| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "embed" |
| 7 | "io/fs" |
| 8 | "log/slog" |
| 9 | "maps" |
| 10 | "net/http" |
| 11 | "net/url" |
| 12 | "strings" |
| 13 | |
| 14 | "github.com/go-chi/chi/v5" |
| 15 | |
| 16 | "amadan.net/rastrillo/rastrillo" |
| 17 | "amadan.net/rastrillo/rastrillo/auth" |
| 18 | "amadan.net/rastrillo/rastrillo/csrf" |
| 19 | "amadan.net/rastrillo/rastrillo/db" |
| 20 | "amadan.net/rastrillo/rastrillo/locales" |
| 21 | _ "amadan.net/rastrillo/rastrillo/locales/en" |
| 22 | "amadan.net/rastrillo/rastrillo/mail" |
| 23 | "amadan.net/rastrillo/rastrillo/migrate" |
| 24 | "amadan.net/rastrillo/rastrillo/sessions" |
| 25 | "amadan.net/rastrillo/rastrillo/ui" |
| 26 | |
| 27 | "gorm.io/gorm" |
| 28 | |
| 29 | "amadan.net/rastrillo/abono" |
| 30 | ) |
| 31 | |
| 32 | // DefaultPlans are the plans the example sells when Config.Plans is |
| 33 | // empty. The sandbox makes up any price it is asked about: a price whose |
| 34 | // id says annual is yearly, anything else monthly. |
| 35 | const DefaultPlans = "annual=price_annual,monthly=price_monthly" |
| 36 | |
| 37 | // Config is what the example needs. |
| 38 | type Config struct { |
| 39 | DB *db.DB |
| 40 | // Origin is the site's address. With no Key it must be on this |
| 41 | // machine (localhost, 127.0.0.1): the sandbox refuses anything else. |
| 42 | Origin string |
| 43 | // InstanceKey seals sign-in state (auth.Config.InstanceKey). |
| 44 | InstanceKey string |
| 45 | // Mailer sends the sign-in links. Nil logs them, which is the |
| 46 | // development setup: the link to open is in the log. |
| 47 | Mailer mail.Sender |
| 48 | // Owner is the address of the account that may use the owner's pages. |
| 49 | Owner string |
| 50 | // Plans is what is for sale, in abono.ParsePlans' format. |
| 51 | Plans string |
| 52 | // Key and WebhookSecrets are a Stripe account's; without them the |
| 53 | // sandbox stands in for Stripe. |
| 54 | Key string |
| 55 | WebhookSecrets []string |
| 56 | // Abono, when set, adjusts abono's configuration last (tests point |
| 57 | // it at abonotest's Stripe with abonotest.Stripe.Config). |
| 58 | Abono func(abono.Config) abono.Config |
| 59 | // Catalog is the base message catalog; the example's Catalog() when nil. |
| 60 | Catalog rastrillo.Catalog |
| 61 | Logger *slog.Logger |
| 62 | } |
| 63 | |
| 64 | // Options is the example as rastrillo serves it: main adds an address, |
| 65 | // the tests hand it to rastrillo.Handler. |
| 66 | func Options(cfg Config) (rastrillo.Options, error) { |
| 67 | if cfg.Logger == nil { |
| 68 | cfg.Logger = slog.Default() |
| 69 | } |
| 70 | if cfg.Catalog == nil { |
| 71 | cfg.Catalog = Catalog() |
| 72 | } |
| 73 | mux, err := App(cfg) |
| 74 | if err != nil { |
| 75 | return rastrillo.Options{}, err |
| 76 | } |
| 77 | return rastrillo.Options{ |
| 78 | Mux: mux, |
| 79 | Locales: []string{"en"}, |
| 80 | DefaultLocale: "en", |
| 81 | BaseCatalog: cfg.Catalog, |
| 82 | Logger: cfg.Logger, |
| 83 | }, nil |
| 84 | } |
| 85 | |
| 86 | // App wires the example: its schema and abono's, magic-link sign-in, |
| 87 | // abono with the sandbox, the generated pages, and the two machine |
| 88 | // endpoints (Stripe's webhook, carlos's daily tick), which csrf.Protect |
| 89 | // lets through because each checks its own credential. |
| 90 | func App(cfg Config) (*http.ServeMux, error) { |
| 91 | ctx := context.Background() |
| 92 | logger := cfg.Logger |
| 93 | if cfg.Catalog == nil { |
| 94 | cfg.Catalog = Catalog() |
| 95 | } |
| 96 | if _, err := migrate.Apply(ctx, cfg.DB, BootSchema); err != nil { |
| 97 | return nil, err |
| 98 | } |
| 99 | writer, err := cfg.DB.G.DB() |
| 100 | if err != nil { |
| 101 | return nil, err |
| 102 | } |
| 103 | g := cfg.DB.G |
| 104 | |
| 105 | a := &app{db: g, owner: normalEmail(cfg.Owner), logger: logger} |
| 106 | if u, err := url.Parse(cfg.Origin); err == nil { |
| 107 | a.host = u.Host |
| 108 | } |
| 109 | mailer := cfg.Mailer |
| 110 | if mailer == nil { |
| 111 | mailer = mail.Logged(logger) |
| 112 | } |
| 113 | a.auth, err = auth.New(auth.Config{ |
| 114 | DB: writer, |
| 115 | Origin: cfg.Origin, |
| 116 | InstanceKey: cfg.InstanceKey, |
| 117 | Mailer: mailer, |
| 118 | // A verified address signs in as its user, made on first sign-in. |
| 119 | // The address is the only key: a user abono's Holder made for a |
| 120 | // buyer is reached by a link to that buyer's mail, and only so. |
| 121 | SubjectFor: subjectForAddress(g), |
| 122 | SigninScreen: true, |
| 123 | RenderConfirm: func(w http.ResponseWriter, r *http.Request, _ auth.ConfirmPageData) { a.signinPage(w, r) }, |
| 124 | // No keymail federation here: every sign-in is a link by email, |
| 125 | // and no address's domain is ever looked up. |
| 126 | KeymailServers: []string{}, |
| 127 | // The example skips pow's front door to stay small; a real app |
| 128 | // keeps it (see rastrillo's examples/notes). |
| 129 | ProofOff: true, |
| 130 | Logger: logger, |
| 131 | }) |
| 132 | if err != nil { |
| 133 | return nil, err |
| 134 | } |
| 135 | |
| 136 | ac := abono.Config{ |
| 137 | DB: g, |
| 138 | Key: cfg.Key, |
| 139 | WebhookSecrets: cfg.WebhookSecrets, |
| 140 | Origin: cfg.Origin, |
| 141 | Holder: holder, |
| 142 | // A real app moves roles here (Patrix settles a reader's role). |
| 143 | // The example has nothing to move: access is asked of abono. |
| 144 | OnChange: func(ctx context.Context, _ *gorm.DB, subject string) error { |
| 145 | logger.DebugContext(ctx, "example: holdings changed", "subject", subject) |
| 146 | return nil |
| 147 | }, |
| 148 | Logger: logger, |
| 149 | } |
| 150 | if cfg.Abono != nil { |
| 151 | ac = cfg.Abono(ac) |
| 152 | } |
| 153 | ac.Sandbox = ac.Key == "" |
| 154 | svc, err := abono.New(ac) |
| 155 | if err != nil { |
| 156 | return nil, err |
| 157 | } |
| 158 | plans := cfg.Plans |
| 159 | if plans == "" { |
| 160 | plans = DefaultPlans |
| 161 | } |
| 162 | specs, err := abono.ParsePlans(plans) |
| 163 | if err != nil { |
| 164 | return nil, err |
| 165 | } |
| 166 | if err := svc.SeedPlans(ctx, specs); err != nil { |
| 167 | return nil, err |
| 168 | } |
| 169 | // A first reconcile catches up on whatever happened while the app was |
| 170 | // down. The plans page does not need it: Offers reads any price it has |
| 171 | // never read. The daily tick keeps prices fresh. |
| 172 | if _, err := svc.Reconcile(ctx); err != nil { |
| 173 | logger.Warn("example: first reconcile", "err", err) |
| 174 | } |
| 175 | |
| 176 | locCfg, err := locales.CheckConfig("en", []string{"en"}) |
| 177 | if err != nil { |
| 178 | return nil, err |
| 179 | } |
| 180 | if a.pages, err = newPages(locCfg, cfg.Catalog); err != nil { |
| 181 | return nil, err |
| 182 | } |
| 183 | a.abono = &AbonoPages{ |
| 184 | Service: svc, |
| 185 | Render: a.render, |
| 186 | Subject: subject, |
| 187 | IsOwner: a.isOwner, |
| 188 | SubjectForEmail: userByEmail(g), |
| 189 | Label: emailOf(g), |
| 190 | } |
| 191 | |
| 192 | r := chi.NewRouter() |
| 193 | r.Use(csrf.Protect(cfg.Origin, csrf.Exempt(abono.WebhookPath, abono.SyncPath))) |
| 194 | r.Use(a.resolveSession) |
| 195 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, abonoPricingPath, http.StatusFound) }) |
| 196 | r.Get("/signin", a.signinPage) |
| 197 | r.Post("/signin", a.auth.Begin) |
| 198 | r.Post("/signin/forget", a.auth.Forget) |
| 199 | r.Get("/auth/callback", a.auth.Callback) |
| 200 | r.Get("/auth/verify", a.auth.Verify) |
| 201 | r.Post("/auth/verify", a.auth.Verify) |
| 202 | r.Post("/signout", a.auth.Signout) |
| 203 | r.With(a.auth.RequireSession).Get(abonoAccountPath, a.account) |
| 204 | a.abono.Mount(r) |
| 205 | |
| 206 | r.Post(abono.WebhookPath, svc.ServeWebhook) |
| 207 | r.Post(abono.SyncPath, svc.ServeSync) |
| 208 | r.Handle(abono.SandboxPath+"*", svc.SandboxHandler()) |
| 209 | |
| 210 | r.Get("/static/app.css", func(w http.ResponseWriter, _ *http.Request) { |
| 211 | theme, _ := ui.ThemeCSS("day") |
| 212 | w.Header().Set("Content-Type", "text/css; charset=utf-8") |
| 213 | w.Write(ui.TokensCSS()) |
| 214 | w.Write([]byte("\n")) |
| 215 | w.Write(theme) |
| 216 | w.Write([]byte(exampleCSS)) |
| 217 | }) |
| 218 | |
| 219 | mux := http.NewServeMux() |
| 220 | mux.Handle("/", a.pages.Middleware(r)) |
| 221 | return mux, nil |
| 222 | } |
| 223 | |
| 224 | // exampleCSS is what would otherwise be a style attribute, which the |
| 225 | // default CSP blocks. |
| 226 | const exampleCSS = ` |
| 227 | nav form { display: inline; } |
| 228 | ` |
| 229 | |
| 230 | //go:embed templates |
| 231 | var templatesFS embed.FS |
| 232 | |
| 233 | // partialsDir is where abono's partials appear in withPartials. |
| 234 | const partialsDir = "abono-partials/" |
| 235 | |
| 236 | // withPartials is the app's templates with abono's partials beside them |
| 237 | // under partialsDir, so ui.Pages parses both into every page's base. |
| 238 | type withPartials struct{ app, partials fs.FS } |
| 239 | |
| 240 | func (f withPartials) Open(name string) (fs.File, error) { |
| 241 | if rest, ok := strings.CutPrefix(name, partialsDir); ok { |
| 242 | return f.partials.Open(rest) |
| 243 | } |
| 244 | return f.app.Open(name) |
| 245 | } |
| 246 | |
| 247 | // newPages is every page: the example's own, and the generated ones, |
| 248 | // over one layout, with ui's partials, abono's, and the generated |
| 249 | // account section in the base. |
| 250 | func newPages(cfg locales.Config, catalog rastrillo.Catalog) (*ui.Pages, error) { |
| 251 | partials, err := fs.Glob(abono.Templates(), "*.html") |
| 252 | if err != nil { |
| 253 | return nil, err |
| 254 | } |
| 255 | shared := []string{"templates/abono/account.html"} |
| 256 | for _, p := range partials { |
| 257 | shared = append(shared, partialsDir+p) |
| 258 | } |
| 259 | files := map[string]ui.PageFile{} |
| 260 | for _, name := range []string{"signin", "account"} { |
| 261 | files[name] = ui.PageFile{Path: "templates/" + name + ".html"} |
| 262 | } |
| 263 | for _, name := range []string{"pricing", "plan", "done", "owner-members", "owner-end", "owner-offers", "owner-stripe"} { |
| 264 | files["abono/"+name] = ui.PageFile{Path: "templates/abono/" + name + ".html"} |
| 265 | } |
| 266 | return ui.NewPages(ui.PagesConfig{ |
| 267 | App: withPartials{templatesFS, abono.Templates()}, |
| 268 | Layout: "templates/layout.html", |
| 269 | Shared: shared, |
| 270 | Pages: files, |
| 271 | Locale: cfg, |
| 272 | Catalog: catalog, |
| 273 | }) |
| 274 | } |
| 275 | |
| 276 | type app struct { |
| 277 | db *gorm.DB |
| 278 | auth *auth.Auth |
| 279 | pages *ui.Pages |
| 280 | abono *AbonoPages |
| 281 | owner string |
| 282 | host string |
| 283 | logger *slog.Logger |
| 284 | } |
| 285 | |
| 286 | // resolveSession puts the signed-in person, if any, on every request, |
| 287 | // for sessions.Current: public pages (the plans, the layout's nav) want |
| 288 | // to know who is there without requiring anyone. |
| 289 | func (a *app) resolveSession(next http.Handler) http.Handler { |
| 290 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 291 | if id, ok := a.auth.SessionFrom(r); ok { |
| 292 | r = sessions.WithSession(r, sessions.Session{Subject: id.Address, Method: string(id.Method), AuthTime: id.AuthTime, At: id.At}) |
| 293 | } |
| 294 | next.ServeHTTP(w, r) |
| 295 | }) |
| 296 | } |
| 297 | |
| 298 | // subject is the signed-in person's subject, their user's id. |
| 299 | func subject(r *http.Request) (string, bool) { |
| 300 | s, ok := sessions.Current(r) |
| 301 | return s.Subject, ok |
| 302 | } |
| 303 | |
| 304 | // isOwner says whether the signed-in person is the owner, by address. |
| 305 | func (a *app) isOwner(r *http.Request) bool { |
| 306 | s, ok := sessions.Current(r) |
| 307 | return ok && a.owner != "" && emailOf(a.db)(r.Context(), s.Subject) == a.owner |
| 308 | } |
| 309 | |
| 310 | // render executes name with data (a map) plus what the layout reads: the |
| 311 | // localizer, whether someone is signed in, and whether they are the |
| 312 | // owner. It renders into a buffer first, so a template error is a clean |
| 313 | // 500. |
| 314 | func (a *app) render(w http.ResponseWriter, r *http.Request, name string, data any) { |
| 315 | page := map[string]any{} |
| 316 | if m, ok := data.(map[string]any); ok { |
| 317 | maps.Copy(page, m) |
| 318 | } |
| 319 | _, signedIn := sessions.Current(r) |
| 320 | page["L"] = ui.ForRequest(r).L |
| 321 | page["SignedIn"] = signedIn |
| 322 | page["Owner"] = a.isOwner(r) |
| 323 | var buf bytes.Buffer |
| 324 | if err := a.pages.Execute(&buf, name, page); err != nil { |
| 325 | a.logger.ErrorContext(r.Context(), "example: render "+name, "err", err) |
| 326 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) |
| 327 | return |
| 328 | } |
| 329 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 330 | buf.WriteTo(w) |
| 331 | } |
| 332 | |
| 333 | // signinPage is the sign-in screen, ui's signin partial fed by auth: ask |
| 334 | // for an address, say a link was sent, and (as auth's RenderConfirm) the |
| 335 | // page the emailed link lands on, whose button signs in. |
| 336 | func (a *app) signinPage(w http.ResponseWriter, r *http.Request) { |
| 337 | st := a.auth.SigninState(r) |
| 338 | a.auth.PrepareSigninResponse(w, st) |
| 339 | a.render(w, r, "signin", map[string]any{ |
| 340 | "Title": "rastrillo.ui.signin_confirm_submit", |
| 341 | "State": st, |
| 342 | // The site's own address names it: there is no name to translate. |
| 343 | "Brand": map[string]any{"Name": a.host}, |
| 344 | }) |
| 345 | } |
| 346 | |
| 347 | // account is the example's account page: just abono's section. |
| 348 | func (a *app) account(w http.ResponseWriter, r *http.Request) { |
| 349 | data, err := a.abono.Account(r) |
| 350 | if err != nil { |
| 351 | a.logger.ErrorContext(r.Context(), "example: account", "err", err) |
| 352 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) |
| 353 | return |
| 354 | } |
| 355 | a.render(w, r, "account", map[string]any{"Title": "rastrillo.ui.shell_account", "Abono": data}) |
| 356 | } |
| 357 | |