1package web
2
3import (
4 "crypto/rand"
5 "embed"
6 "errors"
7 "fmt"
8 "html/template"
9 "io/fs"
10 "net/http"
11 "path"
12 "strings"
13 "time"
14
15 "tug/internal/platform/config"
16 "tug/internal/platform/spent"
17 transporthttp "tug/internal/transport/http"
18)
19
20//go:embed templates
21var templateFS embed.FS
22
23const masterKeyLength = 32
24
25type Options struct {
26 Server config.Server
27 Apps []config.App
28 MasterKey config.Secret
29 ExtraOrigins []string
30 AccountOrigin string
31 EnhancementJS bool
32 Now func() time.Time
33}
34
35type subkeys struct {
36 csrf *[]byte
37 flash *[]byte
38 cont *[]byte
39 once *[]byte
40 seed *[]byte
41}
42
43type Kit struct {
44 server config.Server
45 apps map[string]bool
46 ssoAssets assetNames
47 pages map[string]*template.Template
48 assets assetTable
49 origins origins
50 enhancement bool
51 account string
52 now func() time.Time
53 keys subkeys
54 cop *http.CrossOriginProtection
55 spent *spent.Set
56 batchCap int
57}
58
59func New(opts Options) (*Kit, error) {
60 key := opts.MasterKey.Bytes()
61 if len(key) != masterKeyLength {
62 return nil, fmt.Errorf("web: the session master key must be %d bytes", masterKeyLength)
63 }
64 if opts.Server.DomainIDP == "" || opts.Server.DomainAccount == "" || opts.Server.DomainAdmin == "" {
65 return nil, errors.New("web: the three service domains are required")
66 }
67 o, err := newOrigins(opts.Server, opts.ExtraOrigins)
68 if err != nil {
69 return nil, fmt.Errorf("web: %w", err)
70 }
71 account := opts.AccountOrigin
72 if account == "" {
73 account = "https://" + opts.Server.DomainAccount
74 }
75 if v, err := originOf(account); err != nil || v != account || !o[v] {
76 return nil, fmt.Errorf("web: the account origin %q is not one of the navigation origins", account)
77 }
78 pages, err := parsePages(templateFS)
79 if err != nil {
80 return nil, fmt.Errorf("web: %w", err)
81 }
82 assets, err := loadAssets(staticFS)
83 if err != nil {
84 return nil, fmt.Errorf("web: %w", err)
85 }
86 if opts.EnhancementJS && assets.names.JS == "" {
87 return nil, errors.New("web: the enhancement file is enabled but not embedded")
88 }
89 var keys subkeys
90 for _, d := range []struct {
91 dst **[]byte
92 info string
93 }{{&keys.csrf, "tug csrf v1"}, {&keys.flash, "tug flash v1"}, {&keys.cont, "tug continue v1"}, {&keys.once, "tug form nonce v1"}} {
94 if *d.dst, err = deriveKey(key, d.info); err != nil {
95 return nil, fmt.Errorf("web: %w", err)
96 }
97 }
98 seed := make([]byte, onceSeedLength)
99 rand.Read(seed)
100 keys.seed = &seed
101 now := opts.Now
102 if now == nil {
103 now = time.Now
104 }
105 k := &Kit{
106 server: opts.Server,
107 apps: appDomains(opts.Apps),
108 ssoAssets: assets.names.under(transporthttp.SSOAssetPrefix),
109 pages: pages,
110 assets: assets,
111 origins: o,
112 enhancement: opts.EnhancementJS,
113 account: account,
114 now: now,
115 keys: keys,
116 cop: http.NewCrossOriginProtection(),
117 spent: spent.New(),
118 }
119 est, err := k.measureBatchCap(opts.Server.DomainAccount, opts.Server.SiteName)
120 if err != nil {
121 return nil, fmt.Errorf("web: measure the invitation batch cap: %w", err)
122 }
123 k.batchCap = est.Cap
124 return k, nil
125}
126
127func appDomains(apps []config.App) map[string]bool {
128 m := map[string]bool{}
129 for _, a := range apps {
130 if a.Domain != "" {
131 m[a.Domain] = true
132 }
133 }
134 return m
135}
136
137func parsePages(fsys fs.FS) (map[string]*template.Template, error) {
138 base, err := template.New("layout").Funcs(funcs()).ParseFS(fsys, "templates/layout.html", "templates/partials.html")
139 if err != nil {
140 return nil, err
141 }
142 files, err := fs.Glob(fsys, "templates/pages/*.html")
143 if err != nil {
144 return nil, err
145 }
146 if len(files) == 0 {
147 return nil, errors.New("no page templates")
148 }
149 pages := map[string]*template.Template{}
150 for _, f := range files {
151 clone, err := base.Clone()
152 if err != nil {
153 return nil, err
154 }
155 tpl, err := clone.ParseFS(fsys, f)
156 if err != nil {
157 return nil, err
158 }
159 if err := checkKeys(tpl); err != nil {
160 return nil, fmt.Errorf("%s: %w", f, err)
161 }
162 pages[strings.TrimSuffix(path.Base(f), ".html")] = tpl
163 }
164 return pages, nil
165}