package web import ( "crypto/rand" "embed" "errors" "fmt" "html/template" "io/fs" "net/http" "path" "strings" "time" "tug/internal/platform/config" "tug/internal/platform/spent" transporthttp "tug/internal/transport/http" ) //go:embed templates var templateFS embed.FS const masterKeyLength = 32 type Options struct { Server config.Server Apps []config.App MasterKey config.Secret ExtraOrigins []string AccountOrigin string EnhancementJS bool Now func() time.Time } type subkeys struct { csrf *[]byte flash *[]byte cont *[]byte once *[]byte seed *[]byte } type Kit struct { server config.Server apps map[string]bool ssoAssets assetNames pages map[string]*template.Template assets assetTable origins origins enhancement bool account string now func() time.Time keys subkeys cop *http.CrossOriginProtection spent *spent.Set batchCap int } func New(opts Options) (*Kit, error) { key := opts.MasterKey.Bytes() if len(key) != masterKeyLength { return nil, fmt.Errorf("web: the session master key must be %d bytes", masterKeyLength) } if opts.Server.DomainIDP == "" || opts.Server.DomainAccount == "" || opts.Server.DomainAdmin == "" { return nil, errors.New("web: the three service domains are required") } o, err := newOrigins(opts.Server, opts.ExtraOrigins) if err != nil { return nil, fmt.Errorf("web: %w", err) } account := opts.AccountOrigin if account == "" { account = "https://" + opts.Server.DomainAccount } if v, err := originOf(account); err != nil || v != account || !o[v] { return nil, fmt.Errorf("web: the account origin %q is not one of the navigation origins", account) } pages, err := parsePages(templateFS) if err != nil { return nil, fmt.Errorf("web: %w", err) } assets, err := loadAssets(staticFS) if err != nil { return nil, fmt.Errorf("web: %w", err) } if opts.EnhancementJS && assets.names.JS == "" { return nil, errors.New("web: the enhancement file is enabled but not embedded") } var keys subkeys for _, d := range []struct { dst **[]byte info string }{{&keys.csrf, "tug csrf v1"}, {&keys.flash, "tug flash v1"}, {&keys.cont, "tug continue v1"}, {&keys.once, "tug form nonce v1"}} { if *d.dst, err = deriveKey(key, d.info); err != nil { return nil, fmt.Errorf("web: %w", err) } } seed := make([]byte, onceSeedLength) rand.Read(seed) keys.seed = &seed now := opts.Now if now == nil { now = time.Now } k := &Kit{ server: opts.Server, apps: appDomains(opts.Apps), ssoAssets: assets.names.under(transporthttp.SSOAssetPrefix), pages: pages, assets: assets, origins: o, enhancement: opts.EnhancementJS, account: account, now: now, keys: keys, cop: http.NewCrossOriginProtection(), spent: spent.New(), } est, err := k.measureBatchCap(opts.Server.DomainAccount, opts.Server.SiteName) if err != nil { return nil, fmt.Errorf("web: measure the invitation batch cap: %w", err) } k.batchCap = est.Cap return k, nil } func appDomains(apps []config.App) map[string]bool { m := map[string]bool{} for _, a := range apps { if a.Domain != "" { m[a.Domain] = true } } return m } func parsePages(fsys fs.FS) (map[string]*template.Template, error) { base, err := template.New("layout").Funcs(funcs()).ParseFS(fsys, "templates/layout.html", "templates/partials.html") if err != nil { return nil, err } files, err := fs.Glob(fsys, "templates/pages/*.html") if err != nil { return nil, err } if len(files) == 0 { return nil, errors.New("no page templates") } pages := map[string]*template.Template{} for _, f := range files { clone, err := base.Clone() if err != nil { return nil, err } tpl, err := clone.ParseFS(fsys, f) if err != nil { return nil, err } if err := checkKeys(tpl); err != nil { return nil, fmt.Errorf("%s: %w", f, err) } pages[strings.TrimSuffix(path.Base(f), ".html")] = tpl } return pages, nil }