carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

1package todo
2
3import (
4 "context"
5 "errors"
6 "log/slog"
7 "net/http"
8 "time"
9
10 "github.com/go-chi/chi/v5"
11
12 "amadan.net/rastrillo/rastrillo"
13 "amadan.net/rastrillo/rastrillo/db"
14 "amadan.net/rastrillo/rastrillo/migrate"
15 "amadan.net/rastrillo/rastrillo/perf"
16 "amadan.net/rastrillo/rastrillo/sessions"
17)
18
19// visitorTTL is how long an anonymous list lives from its first visit.
20// Sessions' default is 30 days, which suits a sign-in that can be
21// repeated; a list cookie that can't be got back should outlast that.
22const visitorTTL = 400 * 24 * time.Hour
23
24// App wires the whole app: schema, router, static files. origin is the
25// app's own external origin ("https://todo-go-rastrillo.bfj.oncarlos.com",
26// or "http://127.0.0.1:8080" locally): the Origin check's yardstick and
27// what decides the session cookie's Secure and __Host- attributes.
28func App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux, error) {
29 r, err := Router(d, origin, logger)
30 if err != nil {
31 return nil, err
32 }
33 return Mux(r), nil
34}
35
36// Router applies the schema and builds the routes.
37func Router(d *db.DB, origin string, logger *slog.Logger) (chi.Router, error) {
38 migrated, err := migrate.Apply(context.Background(), d, BootSchema)
39 if err != nil {
40 return nil, err
41 }
42 logger.Info("migrate", "applied", migrated.Applied,
43 "skipped", migrated.Skipped, "adopted", migrated.Adopted)
44
45 writer, err := d.G.DB()
46 if err != nil {
47 return nil, err
48 }
49 sess, err := sessions.New(sessions.Config{DB: writer, Origin: origin, TTL: visitorTTL, Logger: logger})
50 if err != nil {
51 return nil, err
52 }
53
54 a := &app{db: d.G, sess: sess, logger: logger}
55
56 r := chi.NewRouter()
57 // App-wide, above every route: the contract's cross-site check.
58 r.Use(originCheck(origin))
59 r.Use(a.visitor)
60 r.Get("/", a.page(filterAll))
61 r.Get("/active", a.page(filterActive))
62 r.Get("/completed", a.page(filterCompleted))
63 r.Route("/todos", func(r chi.Router) {
64 r.Use(parseForm)
65 r.Post("/", a.create)
66 r.Post("/toggle-all", a.toggleAll)
67 r.Post("/clear-completed", a.clearCompleted)
68 r.Post("/{id}/toggle", a.toggle)
69 r.Post("/{id}/edit", a.edit)
70 r.Post("/{id}/destroy", a.destroy)
71 })
72 return r, nil
73}
74
75// Mux mounts the static files beside the router. The stylesheets are
76// served under their bare names too (/static/base.css), byte for byte,
77// which the contract requires; pages link the fingerprinted names.
78func Mux(r http.Handler) *http.ServeMux {
79 mux := http.NewServeMux()
80 mux.Handle("GET /static/", assets.Handler())
81 mux.Handle("/", r)
82 return mux
83}
84
85// Configure is the one place the serving options are set, so main.go
86// and the perf test cannot drift apart.
87func Configure(opts *rastrillo.Options, mux *http.ServeMux, started time.Time) {
88 opts.Mux = mux
89 opts.ErrorPage = ErrorPage
90 // Every page depends on the visitor's cookie, so there is nothing
91 // worth prerendering, and a prerendered GET would mint a list.
92 opts.NoSpeculationRules = true
93 var rec perf.Recorder
94 opts.Wrap = perf.Middleware(&rec, perf.Options{Started: started, Logger: opts.Logger})
95}
96
97// parseForm caps a form post at 1 MiB (room for a title far past the
98// contract's 10,000 characters, and no room for a body meant to fill
99// the disk) and parses it before any handler runs, as form.Parse's own
100// docs ask. A body that is too big or malformed is refused here, so a
101// handler never mistakes an unread field for an empty one and changes
102// data on it: an over-long edit must not read as "delete".
103func parseForm(next http.Handler) http.Handler {
104 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105 r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
106 if err := r.ParseForm(); err != nil {
107 var tooBig *http.MaxBytesError
108 if errors.As(err, &tooBig) {
109 errorPage(w, http.StatusRequestEntityTooLarge, "That is too long to save")
110 return
111 }
112 errorPage(w, http.StatusBadRequest, "That form could not be read")
113 return
114 }
115 next.ServeHTTP(w, r)
116 })
117}
118