| 1 | package todo |
| 2 | |
| 3 | import ( |
| 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. |
| 22 | const 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. |
| 28 | func 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. |
| 37 | func 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. |
| 78 | func 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. |
| 87 | func 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". |
| 103 | func 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 | |