carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

1package movement
2
3import (
4 "context"
5 "net/http"
6
7 "github.com/go-chi/chi/v5"
8 "github.com/go-chi/chi/v5/middleware"
9
10 "github.com/carlosframework/rastrillo/csrf"
11 "github.com/carlosframework/rastrillo/db"
12 "github.com/carlosframework/rastrillo/mail"
13 "github.com/carlosframework/rastrillo/migrate"
14)
15
16// App wires the whole app: schema, router, static files. It returns a
17// *http.ServeMux because rastrillo.Options.Mux is typed that way — the
18// chi router mounts inside it. origin is the app's external origin
19// ("https://app.example.com") — the CSRF check's yardstick, and, once
20// the app grows accounts, sessions.Config.Origin too.
21//
22// Growing the app is SKILL.md's five-file shape: models in models.go,
23// handlers in handlers.go, and — for a multi-user app — the sessions
24// core plus an identity plugin wired right here (examples/notes in the
25// rastrillo repo is the worked example to copy). Adding a subsystem
26// also means adding its Schema to migrations.go's BootSchema — see
27// the comment there.
28// App takes the whole Env rather than a bare origin, because the origin is
29// no longer the only thing wiring depends on: the instance key seals
30// challenges, the allowlist gates admin, and the origin decides whether
31// unreviewed copy is allowed to boot.
32func App(d *db.DB, env Env) (*http.ServeMux, error) {
33 // Unreviewed copy can be reviewed on a canary; it must never answer on
34 // the canonical host. The check is here, at boot, against the origin —
35 // not at build time — because there is only one build and the canary
36 // serves the same binary production would.
37 if err := CheckPlaceholders(env.Origin, CanonicalHost); err != nil {
38 return nil, err
39 }
40 // Apply BootSchema, not Schema: BootSchema is everything this
41 // app needs at boot (its own migrations plus any subsystem's),
42 // while Schema (migrations.go) stays just this app's own so the
43 // generator and its check never see a subsystem's tables and try
44 // to drop them.
45 migrated, err := migrate.Apply(context.Background(), d, BootSchema)
46 if err != nil {
47 return nil, err
48 }
49 // One line per boot. Result exists so this can be logged, and
50 // until something does, a fleet-wide adoption — every database
51 // stamped, no DDL run, exactly once, never again — produces no
52 // output at all, which is the last event you want to be silent.
53 Migrated(migrated.Applied, migrated.Skipped, migrated.Adopted)
54
55 if len(Placeholders()) > 0 {
56 Warn("this build carries unreviewed placeholder copy")
57 }
58
59 sender := env.Mailer
60 if sender == nil {
61 sender = mail.FromEnv("CARLOS", slogFor())
62 }
63 a := &app{
64 db: d,
65 env: env,
66 challengeKey: ChallengeKey(env.InstanceKey),
67 // Always passed explicitly wherever a Mailer is wanted. auth.New
68 // substitutes mail.Logged for a nil one, which writes the link and
69 // the address into carlos logs — the same leak config.go refuses at
70 // boot, arriving through another door.
71 mailer: BudgetedSender(sender, d.G),
72 }
73
74 r := chi.NewRouter()
75 // App-wide, from day one: every state-changing route added below
76 // is born covered. Origin-checking, not tokens — nothing to mint
77 // or forget in a form.
78 // chi routes HEAD only if a handler is registered for it, so without this
79 // every GET route answers 405 to a HEAD request — which is what uptime
80 // checks, link unfurlers and a good deal of crawler traffic send. Found by
81 // curl -I against the deployed host, not by any test.
82 r.Use(middleware.GetHead)
83 r.Use(csrf.Protect(env.Origin))
84 r.Get("/", a.index)
85 // One route per reviewed slug rather than a wildcard, so an unknown path
86 // stays a 404 from the router instead of reaching a handler.
87 for _, s := range Stances {
88 r.Get("/"+s.Slug(), a.stancePage(s))
89 }
90 r.Post("/signup", a.handleSignup)
91 r.Get("/confirm", a.handleConfirmPage)
92 r.Post("/confirm", a.handleConfirm)
93
94 if err := a.mountAdmin(r); err != nil {
95 return nil, err
96 }
97
98 mux := http.NewServeMux()
99 // The app serves its own static files — the framework never does.
100 // They are embedded (render.go) and fingerprinted: templates link
101 // them via {{asset ...}}, which returns a content-hashed URL the
102 // handler serves cacheable-forever.
103 mux.Handle("GET /static/", assets.Handler())
104 // Outside the chi router, and therefore outside csrf.Protect. The
105 // platform's scheduler sends no Origin, Referer or Sec-Fetch-Site, and
106 // csrf.SameOrigin refuses a POST carrying none of them — so mounted
107 // inside, this would 403 forever and the platform would stop retrying.
108 // carlos.Tick's bearer check is what guards it instead.
109 mux.Handle("POST /tick/sweep", http.HandlerFunc(a.handleSweep))
110 mux.Handle("/", r)
111 return mux, nil
112}
113