| 1 | package movement |
| 2 | |
| 3 | import ( |
| 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. |
| 32 | func 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 | |