rastrillo / abono Public

Clone
git clone https://amadan.net/rastrillo/abono

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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.
35const DefaultPlans = "annual=price_annual,monthly=price_monthly"
36
37// Config is what the example needs.
38type 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.
66func 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.
90func 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.
226const exampleCSS = `
227nav form { display: inline; }
228`
229
230//go:embed templates
231var templatesFS embed.FS
232
233// partialsDir is where abono's partials appear in withPartials.
234const 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.
238type withPartials struct{ app, partials fs.FS }
239
240func (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.
250func 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
276type 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.
289func (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.
299func 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.
305func (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.
314func (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.
336func (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.
348func (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