package todo import ( "context" "errors" "log/slog" "net/http" "time" "github.com/go-chi/chi/v5" "amadan.net/rastrillo/rastrillo" "amadan.net/rastrillo/rastrillo/db" "amadan.net/rastrillo/rastrillo/migrate" "amadan.net/rastrillo/rastrillo/perf" "amadan.net/rastrillo/rastrillo/sessions" ) // visitorTTL is how long an anonymous list lives from its first visit. // Sessions' default is 30 days, which suits a sign-in that can be // repeated; a list cookie that can't be got back should outlast that. const visitorTTL = 400 * 24 * time.Hour // App wires the whole app: schema, router, static files. origin is the // app's own external origin ("https://todo-go-rastrillo.bfj.oncarlos.com", // or "http://127.0.0.1:8080" locally): the Origin check's yardstick and // what decides the session cookie's Secure and __Host- attributes. func App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux, error) { r, err := Router(d, origin, logger) if err != nil { return nil, err } return Mux(r), nil } // Router applies the schema and builds the routes. func Router(d *db.DB, origin string, logger *slog.Logger) (chi.Router, error) { migrated, err := migrate.Apply(context.Background(), d, BootSchema) if err != nil { return nil, err } logger.Info("migrate", "applied", migrated.Applied, "skipped", migrated.Skipped, "adopted", migrated.Adopted) writer, err := d.G.DB() if err != nil { return nil, err } sess, err := sessions.New(sessions.Config{DB: writer, Origin: origin, TTL: visitorTTL, Logger: logger}) if err != nil { return nil, err } a := &app{db: d.G, sess: sess, logger: logger} r := chi.NewRouter() // App-wide, above every route: the contract's cross-site check. r.Use(originCheck(origin)) r.Use(a.visitor) r.Get("/", a.page(filterAll)) r.Get("/active", a.page(filterActive)) r.Get("/completed", a.page(filterCompleted)) r.Route("/todos", func(r chi.Router) { r.Use(parseForm) r.Post("/", a.create) r.Post("/toggle-all", a.toggleAll) r.Post("/clear-completed", a.clearCompleted) r.Post("/{id}/toggle", a.toggle) r.Post("/{id}/edit", a.edit) r.Post("/{id}/destroy", a.destroy) }) return r, nil } // Mux mounts the static files beside the router. The stylesheets are // served under their bare names too (/static/base.css), byte for byte, // which the contract requires; pages link the fingerprinted names. func Mux(r http.Handler) *http.ServeMux { mux := http.NewServeMux() mux.Handle("GET /static/", assets.Handler()) mux.Handle("/", r) return mux } // Configure is the one place the serving options are set, so main.go // and the perf test cannot drift apart. func Configure(opts *rastrillo.Options, mux *http.ServeMux, started time.Time) { opts.Mux = mux opts.ErrorPage = ErrorPage // Every page depends on the visitor's cookie, so there is nothing // worth prerendering, and a prerendered GET would mint a list. opts.NoSpeculationRules = true var rec perf.Recorder opts.Wrap = perf.Middleware(&rec, perf.Options{Started: started, Logger: opts.Logger}) } // parseForm caps a form post at 1 MiB (room for a title far past the // contract's 10,000 characters, and no room for a body meant to fill // the disk) and parses it before any handler runs, as form.Parse's own // docs ask. A body that is too big or malformed is refused here, so a // handler never mistakes an unread field for an empty one and changes // data on it: an over-long edit must not read as "delete". func parseForm(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 1<<20) if err := r.ParseForm(); err != nil { var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { errorPage(w, http.StatusRequestEntityTooLarge, "That is too long to save") return } errorPage(w, http.StatusBadRequest, "That form could not be read") return } next.ServeHTTP(w, r) }) }