Raw
  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}