package main import ( "bytes" "context" "embed" "io/fs" "log/slog" "maps" "net/http" "net/url" "strings" "github.com/go-chi/chi/v5" "amadan.net/rastrillo/rastrillo" "amadan.net/rastrillo/rastrillo/auth" "amadan.net/rastrillo/rastrillo/csrf" "amadan.net/rastrillo/rastrillo/db" "amadan.net/rastrillo/rastrillo/locales" _ "amadan.net/rastrillo/rastrillo/locales/en" "amadan.net/rastrillo/rastrillo/mail" "amadan.net/rastrillo/rastrillo/migrate" "amadan.net/rastrillo/rastrillo/sessions" "amadan.net/rastrillo/rastrillo/ui" "gorm.io/gorm" "amadan.net/rastrillo/abono" ) // DefaultPlans are the plans the example sells when Config.Plans is // empty. The sandbox makes up any price it is asked about: a price whose // id says annual is yearly, anything else monthly. const DefaultPlans = "annual=price_annual,monthly=price_monthly" // Config is what the example needs. type Config struct { DB *db.DB // Origin is the site's address. With no Key it must be on this // machine (localhost, 127.0.0.1): the sandbox refuses anything else. Origin string // InstanceKey seals sign-in state (auth.Config.InstanceKey). InstanceKey string // Mailer sends the sign-in links. Nil logs them, which is the // development setup: the link to open is in the log. Mailer mail.Sender // Owner is the address of the account that may use the owner's pages. Owner string // Plans is what is for sale, in abono.ParsePlans' format. Plans string // Key and WebhookSecrets are a Stripe account's; without them the // sandbox stands in for Stripe. Key string WebhookSecrets []string // Abono, when set, adjusts abono's configuration last (tests point // it at abonotest's Stripe with abonotest.Stripe.Config). Abono func(abono.Config) abono.Config // Catalog is the base message catalog; the example's Catalog() when nil. Catalog rastrillo.Catalog Logger *slog.Logger } // Options is the example as rastrillo serves it: main adds an address, // the tests hand it to rastrillo.Handler. func Options(cfg Config) (rastrillo.Options, error) { if cfg.Logger == nil { cfg.Logger = slog.Default() } if cfg.Catalog == nil { cfg.Catalog = Catalog() } mux, err := App(cfg) if err != nil { return rastrillo.Options{}, err } return rastrillo.Options{ Mux: mux, Locales: []string{"en"}, DefaultLocale: "en", BaseCatalog: cfg.Catalog, Logger: cfg.Logger, }, nil } // App wires the example: its schema and abono's, magic-link sign-in, // abono with the sandbox, the generated pages, and the two machine // endpoints (Stripe's webhook, carlos's daily tick), which csrf.Protect // lets through because each checks its own credential. func App(cfg Config) (*http.ServeMux, error) { ctx := context.Background() logger := cfg.Logger if cfg.Catalog == nil { cfg.Catalog = Catalog() } if _, err := migrate.Apply(ctx, cfg.DB, BootSchema); err != nil { return nil, err } writer, err := cfg.DB.G.DB() if err != nil { return nil, err } g := cfg.DB.G a := &app{db: g, owner: normalEmail(cfg.Owner), logger: logger} if u, err := url.Parse(cfg.Origin); err == nil { a.host = u.Host } mailer := cfg.Mailer if mailer == nil { mailer = mail.Logged(logger) } a.auth, err = auth.New(auth.Config{ DB: writer, Origin: cfg.Origin, InstanceKey: cfg.InstanceKey, Mailer: mailer, // A verified address signs in as its user, made on first sign-in. // The address is the only key: a user abono's Holder made for a // buyer is reached by a link to that buyer's mail, and only so. SubjectFor: subjectForAddress(g), SigninScreen: true, RenderConfirm: func(w http.ResponseWriter, r *http.Request, _ auth.ConfirmPageData) { a.signinPage(w, r) }, // No keymail federation here: every sign-in is a link by email, // and no address's domain is ever looked up. KeymailServers: []string{}, // The example skips pow's front door to stay small; a real app // keeps it (see rastrillo's examples/notes). ProofOff: true, Logger: logger, }) if err != nil { return nil, err } ac := abono.Config{ DB: g, Key: cfg.Key, WebhookSecrets: cfg.WebhookSecrets, Origin: cfg.Origin, Holder: holder, // A real app moves roles here (Patrix settles a reader's role). // The example has nothing to move: access is asked of abono. OnChange: func(ctx context.Context, _ *gorm.DB, subject string) error { logger.DebugContext(ctx, "example: holdings changed", "subject", subject) return nil }, Logger: logger, } if cfg.Abono != nil { ac = cfg.Abono(ac) } ac.Sandbox = ac.Key == "" svc, err := abono.New(ac) if err != nil { return nil, err } plans := cfg.Plans if plans == "" { plans = DefaultPlans } specs, err := abono.ParsePlans(plans) if err != nil { return nil, err } if err := svc.SeedPlans(ctx, specs); err != nil { return nil, err } // A first reconcile catches up on whatever happened while the app was // down. The plans page does not need it: Offers reads any price it has // never read. The daily tick keeps prices fresh. if _, err := svc.Reconcile(ctx); err != nil { logger.Warn("example: first reconcile", "err", err) } locCfg, err := locales.CheckConfig("en", []string{"en"}) if err != nil { return nil, err } if a.pages, err = newPages(locCfg, cfg.Catalog); err != nil { return nil, err } a.abono = &AbonoPages{ Service: svc, Render: a.render, Subject: subject, IsOwner: a.isOwner, SubjectForEmail: userByEmail(g), Label: emailOf(g), } r := chi.NewRouter() r.Use(csrf.Protect(cfg.Origin, csrf.Exempt(abono.WebhookPath, abono.SyncPath))) r.Use(a.resolveSession) r.Get("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, abonoPricingPath, http.StatusFound) }) r.Get("/signin", a.signinPage) r.Post("/signin", a.auth.Begin) r.Post("/signin/forget", a.auth.Forget) r.Get("/auth/callback", a.auth.Callback) r.Get("/auth/verify", a.auth.Verify) r.Post("/auth/verify", a.auth.Verify) r.Post("/signout", a.auth.Signout) r.With(a.auth.RequireSession).Get(abonoAccountPath, a.account) a.abono.Mount(r) r.Post(abono.WebhookPath, svc.ServeWebhook) r.Post(abono.SyncPath, svc.ServeSync) r.Handle(abono.SandboxPath+"*", svc.SandboxHandler()) r.Get("/static/app.css", func(w http.ResponseWriter, _ *http.Request) { theme, _ := ui.ThemeCSS("day") w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Write(ui.TokensCSS()) w.Write([]byte("\n")) w.Write(theme) w.Write([]byte(exampleCSS)) }) mux := http.NewServeMux() mux.Handle("/", a.pages.Middleware(r)) return mux, nil } // exampleCSS is what would otherwise be a style attribute, which the // default CSP blocks. const exampleCSS = ` nav form { display: inline; } ` //go:embed templates var templatesFS embed.FS // partialsDir is where abono's partials appear in withPartials. const partialsDir = "abono-partials/" // withPartials is the app's templates with abono's partials beside them // under partialsDir, so ui.Pages parses both into every page's base. type withPartials struct{ app, partials fs.FS } func (f withPartials) Open(name string) (fs.File, error) { if rest, ok := strings.CutPrefix(name, partialsDir); ok { return f.partials.Open(rest) } return f.app.Open(name) } // newPages is every page: the example's own, and the generated ones, // over one layout, with ui's partials, abono's, and the generated // account section in the base. func newPages(cfg locales.Config, catalog rastrillo.Catalog) (*ui.Pages, error) { partials, err := fs.Glob(abono.Templates(), "*.html") if err != nil { return nil, err } shared := []string{"templates/abono/account.html"} for _, p := range partials { shared = append(shared, partialsDir+p) } files := map[string]ui.PageFile{} for _, name := range []string{"signin", "account"} { files[name] = ui.PageFile{Path: "templates/" + name + ".html"} } for _, name := range []string{"pricing", "plan", "done", "owner-members", "owner-end", "owner-offers", "owner-stripe"} { files["abono/"+name] = ui.PageFile{Path: "templates/abono/" + name + ".html"} } return ui.NewPages(ui.PagesConfig{ App: withPartials{templatesFS, abono.Templates()}, Layout: "templates/layout.html", Shared: shared, Pages: files, Locale: cfg, Catalog: catalog, }) } type app struct { db *gorm.DB auth *auth.Auth pages *ui.Pages abono *AbonoPages owner string host string logger *slog.Logger } // resolveSession puts the signed-in person, if any, on every request, // for sessions.Current: public pages (the plans, the layout's nav) want // to know who is there without requiring anyone. func (a *app) resolveSession(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if id, ok := a.auth.SessionFrom(r); ok { r = sessions.WithSession(r, sessions.Session{Subject: id.Address, Method: string(id.Method), AuthTime: id.AuthTime, At: id.At}) } next.ServeHTTP(w, r) }) } // subject is the signed-in person's subject, their user's id. func subject(r *http.Request) (string, bool) { s, ok := sessions.Current(r) return s.Subject, ok } // isOwner says whether the signed-in person is the owner, by address. func (a *app) isOwner(r *http.Request) bool { s, ok := sessions.Current(r) return ok && a.owner != "" && emailOf(a.db)(r.Context(), s.Subject) == a.owner } // render executes name with data (a map) plus what the layout reads: the // localizer, whether someone is signed in, and whether they are the // owner. It renders into a buffer first, so a template error is a clean // 500. func (a *app) render(w http.ResponseWriter, r *http.Request, name string, data any) { page := map[string]any{} if m, ok := data.(map[string]any); ok { maps.Copy(page, m) } _, signedIn := sessions.Current(r) page["L"] = ui.ForRequest(r).L page["SignedIn"] = signedIn page["Owner"] = a.isOwner(r) var buf bytes.Buffer if err := a.pages.Execute(&buf, name, page); err != nil { a.logger.ErrorContext(r.Context(), "example: render "+name, "err", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") buf.WriteTo(w) } // signinPage is the sign-in screen, ui's signin partial fed by auth: ask // for an address, say a link was sent, and (as auth's RenderConfirm) the // page the emailed link lands on, whose button signs in. func (a *app) signinPage(w http.ResponseWriter, r *http.Request) { st := a.auth.SigninState(r) a.auth.PrepareSigninResponse(w, st) a.render(w, r, "signin", map[string]any{ "Title": "rastrillo.ui.signin_confirm_submit", "State": st, // The site's own address names it: there is no name to translate. "Brand": map[string]any{"Name": a.host}, }) } // account is the example's account page: just abono's section. func (a *app) account(w http.ResponseWriter, r *http.Request) { data, err := a.abono.Account(r) if err != nil { a.logger.ErrorContext(r.Context(), "example: account", "err", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } a.render(w, r, "account", map[string]any{"Title": "rastrillo.ui.shell_account", "Abono": data}) }