| 1 | // Package docs is the CARLOS Docs rastrillo app: its database handle, |
| 2 | // router and (for now, one) page. Follows rastrillo's SKILL.md app |
| 3 | // shape (models.go, app.go, handlers.go, render.go, cmd/docs/main.go), |
| 4 | // adapted into a New(cfg) (*App, error) constructor + App.Handler() |
| 5 | // rather than SKILL.md's illustrative App(d, origin, logger) |
| 6 | // (*http.ServeMux, error) function, because the task that specified |
| 7 | // this app also specified the test harness (docs.NewForTest, |
| 8 | // app.Handler().ServeHTTP) that every later task's tests depend on — |
| 9 | // the constructor name and shape follow that harness; everything else |
| 10 | // below follows SKILL.md. |
| 11 | package docs |
| 12 | |
| 13 | import ( |
| 14 | "amadan.net/oficina/estilo.git/sky" |
| 15 | "context" |
| 16 | "errors" |
| 17 | "fmt" |
| 18 | "log/slog" |
| 19 | "net/http" |
| 20 | "net/url" |
| 21 | "strings" |
| 22 | "time" |
| 23 | |
| 24 | "amadan.net/oficina/estilo.git/brand" |
| 25 | "amadan.net/oficina/estilo.git/chrome" |
| 26 | "amadan.net/oficina/estilo.git/theme" |
| 27 | "amadan.net/oficina/home/handoff" |
| 28 | nativeauth "amadan.net/rastrillo/native/server" |
| 29 | "amadan.net/rastrillo/rastrillo" |
| 30 | rauth "amadan.net/rastrillo/rastrillo/auth" |
| 31 | "amadan.net/rastrillo/rastrillo/db" |
| 32 | "amadan.net/rastrillo/rastrillo/mail" |
| 33 | "amadan.net/rastrillo/rastrillo/migrate" |
| 34 | "amadan.net/rastrillo/rastrillo/sessions" |
| 35 | "github.com/carlosframework/docs/internal/docs/linkstore" |
| 36 | "github.com/go-chi/chi/v5" |
| 37 | |
| 38 | "github.com/carlosframework/docs/internal/core" |
| 39 | "github.com/carlosframework/docs/internal/docs/teamstate" |
| 40 | ) |
| 41 | |
| 42 | // Config configures New. |
| 43 | type Config struct { |
| 44 | Home *HomeConfig |
| 45 | // Directory is the hub directory origin ($HOME_DIRECTORY_ORIGIN): the |
| 46 | // app switcher's "All apps". Empty means no switcher. |
| 47 | Directory string |
| 48 | Team *TeamConfig |
| 49 | |
| 50 | // DBPath is the SQLite file New opens via rastrillo's db package. |
| 51 | DBPath string |
| 52 | ContentKey string |
| 53 | |
| 54 | // Env is "development" or "production" ("" defaults to |
| 55 | // "production"). It gates two fail-closed defaults below — Origin |
| 56 | // and (auth.go) InstanceKey/Mailer — and nothing else: the |
| 57 | // development-only stand-in identity that used to live in |
| 58 | // devauth.go is gone (fix round 1 on Task 1 — real sign-in made |
| 59 | // it dead code the moment RequireSession started guarding every |
| 60 | // route, and it was deleted rather than left as an unreachable |
| 61 | // trap for whoever next read "DevAuth" and assumed it still did |
| 62 | // something). cmd/docs/main.go sources this from an explicit |
| 63 | // -env flag, deliberately independent of any identity flag — Env |
| 64 | // choosing "log mail instead of sending it" is not the same |
| 65 | // decision as "who can sign in", and conflating the two is |
| 66 | // exactly what fix round 1 un-did. $DOCS_ENV is consulted too, |
| 67 | // but — fix round 2, after that round's own $DOCS_ENV fallback |
| 68 | // turned out to reopen the same hole — never for "development" |
| 69 | // itself: only the literal flag may ever select it |
| 70 | // (cmd/docs/main.go's chooseEnv has the full reasoning). |
| 71 | Env string |
| 72 | |
| 73 | // Origin is this app's own origin (scheme://host[:port], no path), |
| 74 | // what csrf.Protect (rastrillo SKILL.md §5) uses as its fallback |
| 75 | // same-origin evidence. Fix round 3 corrected a wrong model of |
| 76 | // what that check actually does — see csrf.Protect's own doc |
| 77 | // comment (rastrillo v0.21.0's csrf/csrf.go): it checks |
| 78 | // Sec-Fetch-Site FIRST when a browser sends it, which every |
| 79 | // current browser does on a state-changing request — |
| 80 | // "same-origin"/"none" pass immediately, anything else is refused |
| 81 | // immediately, and Origin is never consulted in that path at all. |
| 82 | // Only when Sec-Fetch-Site is entirely absent (a legacy browser, or |
| 83 | // a non-browser client such as curl) does the check fall back to |
| 84 | // comparing the request's Origin or Referer header against this |
| 85 | // string exactly. So a wrong-but-nonempty Origin does NOT make a |
| 86 | // real browser's genuine same-origin POST fail, and it does NOT |
| 87 | // open up a genuine cross-site request either — both of those are |
| 88 | // decided by Sec-Fetch-Site alone, never by this string. What a |
| 89 | // wrong Origin actually breaks is the legacy fallback path, and it |
| 90 | // breaks it silently: a real client that happens to omit |
| 91 | // Sec-Fetch-Site gets wrongly rejected, or — if that comparison |
| 92 | // were ever loosened or removed — stops being checked at all. |
| 93 | // Neither shows up in ordinary manual testing with a real browser, |
| 94 | // which is exactly why getting this right cannot be verified by |
| 95 | // "the page loaded and the form worked." |
| 96 | // |
| 97 | // Empty is allowed only when Env is "development" (New falls back |
| 98 | // to DefaultOrigin there, for tests and local convenience); with |
| 99 | // any other Env, empty is a startup refusal — see New. |
| 100 | // |
| 101 | // cmd/docs/main.go wires this from $DOCS_ORIGIN, the same |
| 102 | // "<APP>_ORIGIN" convention rastrillo's own `new` scaffold uses |
| 103 | // (cmd/rastrillo/new.go's mainTemplate) — the platform's real |
| 104 | // per-instance origin (spec §9.2's <app>.<sqid>.oncarlos.com) |
| 105 | // is set there at deploy time. On the unix-socket/LISTEN_FDS |
| 106 | // activation path there is no -addr to derive a fallback from, so |
| 107 | // leaving $DOCS_ORIGIN unset must fail loudly rather than quietly |
| 108 | // configuring the app with a string that has nothing to do with |
| 109 | // where it is actually reachable — that is exactly what the |
| 110 | // Env-gated default below refuses to do. |
| 111 | // |
| 112 | // A non-empty value is trimmed and then validated (validateOrigin) |
| 113 | // rather than trusted as-is: whitespace-only is not "empty" by a |
| 114 | // bare == "" check, and would otherwise sail past the guard above |
| 115 | // with nothing catching it until the legacy fallback path above |
| 116 | // tried, and failed, to match it against anything real. |
| 117 | Origin string |
| 118 | |
| 119 | // InstanceKey seals rastrillo/auth's pending-link blob (an HMAC |
| 120 | // key — auth.Config.InstanceKey's own doc comment explains why an |
| 121 | // empty one is dangerous, not just inconvenient: it hashes to one |
| 122 | // fixed, publicly computable value, identical across every |
| 123 | // deployment that made the same mistake). Sourced from |
| 124 | // $DOCS_INSTANCE_KEY (cmd/docs/main.go), the same "read in main, |
| 125 | // validated in New" split Origin above uses. |
| 126 | // |
| 127 | // Empty is allowed only when Env is "development" (New falls back |
| 128 | // to a fixed, non-secret development value — see devInstanceKey in |
| 129 | // auth.go); with any other Env, empty is a startup refusal, the |
| 130 | // same fail-closed shape as Origin. |
| 131 | InstanceKey string |
| 132 | |
| 133 | // Mailer delivers the magic-link email. Nil is fine only in |
| 134 | // development: New wraps rastrillo/mail's logging fallback so the |
| 135 | // link lands in the log, clearly marked, and TestSignIn (testing.go) |
| 136 | // can read it back — no mail server required for |
| 137 | // scripts/run-dev.sh or for tests. Outside development, nil is a |
| 138 | // startup refusal: a sign-in that cannot send its link is not |
| 139 | // sign-in, and silently falling back to logging a live credential |
| 140 | // in a real deployment's log is exactly the mistake that fallback |
| 141 | // exists to flag, not absorb. cmd/docs/main.go builds this from |
| 142 | // $DOCS_SMTP_HOST/$DOCS_SMTP_FROM (and the other |
| 143 | // $DOCS_SMTP_* vars rastrillo/mail.FromEnv reads) when both are |
| 144 | // set, and leaves it nil otherwise — New.buildMailer (auth.go) is |
| 145 | // what turns "nil outside development" into the refusal, the same |
| 146 | // split Origin/InstanceKey use. |
| 147 | Mailer mail.Sender |
| 148 | |
| 149 | // AllowedAddresses is the comma-separated allowlist of the only |
| 150 | // addresses this instance admits — $DOCS_ALLOWED_ADDRESSES |
| 151 | // (cmd/docs/main.go), the same "<APP>_..." env-var convention |
| 152 | // Origin and the SMTP vars above use. Compared case-insensitively, |
| 153 | // each entry trimmed of surrounding whitespace (auth.go's |
| 154 | // normalizeAddress) — a real config file has spaces after its |
| 155 | // commas, and people type capitals into an email field. |
| 156 | // |
| 157 | // The sign-in mail queue restricts email delivery (mailqueue.go, |
| 158 | // listAdmits), and rauth.Config.Authorize |
| 159 | // checks verified identities before minting a session. Classification |
| 160 | // does not depend on admission, so an OAuth start cannot reveal membership. |
| 161 | // |
| 162 | // Empty is allowed only when Env is "development" |
| 163 | // (resolveAllowlist below defaults that to "admit everyone", so |
| 164 | // TestSignIn and scripts/run-dev.sh keep working with no setup of |
| 165 | // their own) — anywhere else, empty is a startup refusal; see |
| 166 | // resolveAllowlist's own doc comment for why that beats the |
| 167 | // alternative of silently admitting nobody. |
| 168 | AllowedAddresses string |
| 169 | |
| 170 | // SampleDocuments opts this instance into seeding a brand-new |
| 171 | // person's very first sign-in with CARLOS Docs' own sample |
| 172 | // documents (seed.go's Seed, six of them) — the fix for "Paul |
| 173 | // signed in to a live instance and saw an empty list": the sample |
| 174 | // documents docs.Seed already wrote were never reachable on a |
| 175 | // deployed instance, because -seed is gated to Env == |
| 176 | // "development" (main.go's own doc comment, unchanged by this |
| 177 | // field) and a deployed instance never runs with that flag. |
| 178 | // Documents are owner-scoped (Document.OwnerID, owned() in |
| 179 | // store.go) and a boot-time seed has no person to own anything, so |
| 180 | // this switches seeding from a boot-time, instance-wide event to a |
| 181 | // per-person one — see Store.EnsurePerson's own doc comment for |
| 182 | // exactly where that happens and why there, not "the list is |
| 183 | // empty" (a person who deletes every sample document must not get |
| 184 | // them back). |
| 185 | // |
| 186 | // Sourced from $DOCS_SAMPLE_DOCUMENTS (cmd/docs/main.go), the same |
| 187 | // "<APP>_..." convention every other setting on this struct uses. |
| 188 | // Parsed by parseSampleDocuments below, which accepts a small, |
| 189 | // case-insensitive truthy set ("1", "true", "yes") and treats |
| 190 | // everything else — unset, empty, or a typo alike — as off. |
| 191 | // Default OFF: a real team instance must never get six invented |
| 192 | // documents about a reading room and an allotment committee just |
| 193 | // because nobody thought to unset something. |
| 194 | // |
| 195 | // Deliberately NOT fail-closed the way Origin/InstanceKey/ |
| 196 | // AllowedAddresses above are: those three guard who can sign in |
| 197 | // and what seals a session, where a value New cannot make sense of |
| 198 | // silently taking the permissive branch would be a real security |
| 199 | // regression, so New refuses to start rather than guess. This |
| 200 | // setting only decides whether a new person's document list starts |
| 201 | // with six sample documents or zero — cosmetic content, not a |
| 202 | // security control — so an unparseable value here is read as "off" |
| 203 | // and New boots anyway. A platform whose env var block has a typo |
| 204 | // in $DOCS_SAMPLE_DOCUMENTS should get an empty list, silently, the |
| 205 | // same outcome as never having set it at all, not a refusal to |
| 206 | // serve any traffic over a setting that was never load-bearing for |
| 207 | // safety in the first place. |
| 208 | SampleDocuments string |
| 209 | |
| 210 | // Logger defaults to slog.Default(). |
| 211 | Logger *slog.Logger |
| 212 | } |
| 213 | |
| 214 | // parseSampleDocuments interprets raw the way Config.SampleDocuments' |
| 215 | // own doc comment describes: a small, case-insensitive truthy set |
| 216 | // means on, and everything else means off. See that doc comment for |
| 217 | // why this is deliberately not fail-closed like resolveInstanceKey/ |
| 218 | // resolveAllowlist (auth.go) just below use for their own settings. |
| 219 | func parseSampleDocuments(raw string) bool { |
| 220 | switch strings.ToLower(strings.TrimSpace(raw)) { |
| 221 | case "1", "true", "yes": |
| 222 | return true |
| 223 | default: |
| 224 | return false |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | // DefaultOrigin is Config.Origin's fallback when empty — see its doc |
| 229 | // comment. Exported so a caller that needs to know what origin this |
| 230 | // app is protecting itself against (NewForTest's callers, notably) |
| 231 | // does not have to duplicate the literal. |
| 232 | const DefaultOrigin = "http://localhost:8080" |
| 233 | |
| 234 | // validateOrigin reports whether origin (already trimmed by the |
| 235 | // caller — New) is something csrf.Protect's legacy Origin/Referer |
| 236 | // fallback can actually use: an absolute http(s) URL naming a scheme |
| 237 | // and a host and nothing else. "Nothing else" is checked |
| 238 | // deliberately, not just "parses as a URL" — url.Parse happily |
| 239 | // accepts a bare path, a value carrying a query string, or one with |
| 240 | // embedded userinfo, none of which a same-origin check has any |
| 241 | // business being handed, and none of which would ever equal a real |
| 242 | // browser's Origin header. An app that accepted one would boot |
| 243 | // looking entirely healthy — a modern browser's genuine same-origin |
| 244 | // traffic passes on Sec-Fetch-Site alone and never reaches this |
| 245 | // string at all (Config.Origin's own doc comment) — and only fail, |
| 246 | // silently, on whatever still uses the legacy fallback. |
| 247 | func validateOrigin(origin string) error { |
| 248 | if origin == "" { |
| 249 | return errors.New("origin is blank") |
| 250 | } |
| 251 | u, err := url.Parse(origin) |
| 252 | if err != nil { |
| 253 | return fmt.Errorf("origin %q does not parse as a URL: %w", origin, err) |
| 254 | } |
| 255 | if u.Scheme != "http" && u.Scheme != "https" { |
| 256 | return fmt.Errorf("origin %q must have an http or https scheme", origin) |
| 257 | } |
| 258 | // u.Host != "" is not enough: fix round 2's own bug. "http://:8099" |
| 259 | // — exactly what -addr :8099 derives (main.go's "http://"+opts.Addr, |
| 260 | // the plan's own example invocation) — has a non-empty Host |
| 261 | // (":8099") but an empty Hostname(); Host merely means "there was |
| 262 | // something between // and the next / (or the end)", not "there is |
| 263 | // a host". A reviewer booted a production-Env binary on this exact |
| 264 | // origin and watched it serve. Hostname() is what actually strips |
| 265 | // the port and answers "is there a host here at all". |
| 266 | if u.Hostname() == "" { |
| 267 | return fmt.Errorf("origin %q has no host", origin) |
| 268 | } |
| 269 | if u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil { |
| 270 | return fmt.Errorf("origin %q must be scheme://host[:port] only, with no path, query, fragment or userinfo", origin) |
| 271 | } |
| 272 | return nil |
| 273 | } |
| 274 | |
| 275 | // App is the CARLOS Docs rastrillo app. |
| 276 | type App struct { |
| 277 | cfg Config |
| 278 | db *db.DB |
| 279 | mux *http.ServeMux |
| 280 | logger *slog.Logger |
| 281 | core *core.Runtime |
| 282 | store *Store |
| 283 | handlers *handlers |
| 284 | mailRecorder *recordingMailer // nil outside development; see auth.go |
| 285 | signinMail *mailQueue // sign-in links, sent after the response |
| 286 | } |
| 287 | |
| 288 | // BootSchema is everything New applies at boot, in apply order. |
| 289 | // migrate.Merge's argument order is apply order (rastrillo SKILL.md |
| 290 | // §2): sessions.Schema before auth.Schema — auth's own backfill |
| 291 | // migration reads the sessions table — before this app's own Schema |
| 292 | // (models.go). |
| 293 | var BootSchema = migrate.Merge(sessions.Schema, authSchema, Schema, linkstore.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff")) |
| 294 | |
| 295 | // New opens the database, applies migrations and wires the router. It |
| 296 | // is rastrillo SKILL.md's app.go shape: db.Open for the SQLite handle, |
| 297 | // migrate.Apply once at boot, a chi router wrapped as an |
| 298 | // *http.ServeMux so httptest exercises the real router, csrf.Protect |
| 299 | // mounted app-wide (SKILL.md §5), rastrillo/auth (auth.go) guarding |
| 300 | // every document route behind a real, per-person magic-link sign-in. |
| 301 | func New(cfg Config) (*App, error) { |
| 302 | logger := cfg.Logger |
| 303 | if logger == nil { |
| 304 | logger = slog.Default() |
| 305 | } |
| 306 | if cfg.Env == "" { |
| 307 | cfg.Env = "production" |
| 308 | } |
| 309 | |
| 310 | // Fail closed, loudly: an empty Origin defaults to DefaultOrigin |
| 311 | // only in development (tests via NewForTest, a person running |
| 312 | // scripts/run-dev.sh). Anywhere else — Env left at its "production" |
| 313 | // default very much included — an empty Origin is a startup |
| 314 | // refusal, not a silent fallback to a placeholder whose only real |
| 315 | // effect is on csrf.Protect's legacy Origin/Referer fallback path |
| 316 | // (Config.Origin's own doc comment explains why that path, not |
| 317 | // "every request", is what a wrong string actually touches). This |
| 318 | // is the same fail-closed shape InstanceKey and Mailer use below |
| 319 | // (auth.go): exactly one string ("development") is ever treated |
| 320 | // as "safe to default", checked before anything else opens. |
| 321 | // |
| 322 | // cfg.Origin == "" alone is not the right test: a whitespace value |
| 323 | // like $DOCS_ORIGIN=" " is non-empty, so it would sail past that |
| 324 | // check. Nothing about a modern browser's own traffic would look |
| 325 | // wrong — Sec-Fetch-Site: same-origin passes without this string |
| 326 | // ever being read — so the only visible symptom would be the |
| 327 | // legacy fallback path failing to match literal whitespace against |
| 328 | // anything real, for whichever client still exercises it. That |
| 329 | // fails safe on that one path, but silently and late (at request |
| 330 | // time, for whoever hits it), not at boot where a malformed origin |
| 331 | // belongs. validateOrigin below rejects that and any other value |
| 332 | // that cannot possibly be what csrf.Protect needs (no scheme, no |
| 333 | // host, a stray path/query/fragment/userinfo) before New ever |
| 334 | // returns a listening app. |
| 335 | trimmedOrigin := strings.TrimSpace(cfg.Origin) |
| 336 | switch { |
| 337 | case trimmedOrigin == "" && cfg.Env != "development": |
| 338 | return nil, fmt.Errorf("docs: Config.Origin is empty and Env is %q, not %q — refusing to start with an unset origin; set $DOCS_ORIGIN to this instance's real origin", cfg.Env, "development") |
| 339 | case trimmedOrigin == "": |
| 340 | cfg.Origin = DefaultOrigin |
| 341 | default: |
| 342 | if err := validateOrigin(trimmedOrigin); err != nil { |
| 343 | return nil, fmt.Errorf("docs: Config.Origin is invalid: %w", err) |
| 344 | } |
| 345 | cfg.Origin = trimmedOrigin |
| 346 | } |
| 347 | |
| 348 | // Resolved before anything else opens — same fail-closed shape as |
| 349 | // Origin above: a deployment that cannot seal a |
| 350 | // pending link, or cannot send one, refuses to start rather than |
| 351 | // booting into a broken or (worse) silently-logging-credentials |
| 352 | // sign-in. See auth.go. |
| 353 | homeSettings, err := validatedHomeConfig(cfg.Home, cfg.Origin) |
| 354 | if err != nil { |
| 355 | return nil, err |
| 356 | } |
| 357 | teamSettings, err := validatedTeamConfig(cfg.Team, homeSettings, cfg.Origin) |
| 358 | if err != nil { |
| 359 | return nil, err |
| 360 | } |
| 361 | instanceKey, err := resolveInstanceKey(cfg) |
| 362 | if err != nil { |
| 363 | return nil, err |
| 364 | } |
| 365 | mailer, mailRecorder, err := buildMailer(cfg, logger) |
| 366 | if err != nil { |
| 367 | return nil, err |
| 368 | } |
| 369 | // Same fail-closed shape again: an instance with nobody configured |
| 370 | // to sign in either refuses to start (outside development) or, in |
| 371 | // development, admits everyone — never a silent "admits nobody" |
| 372 | // that would boot looking healthy. See resolveAllowlist's own doc |
| 373 | // comment for the full reasoning. |
| 374 | list, err := resolveAllowlist(cfg) |
| 375 | if err != nil { |
| 376 | return nil, err |
| 377 | } |
| 378 | |
| 379 | cipher, err := newContentCipher(cfg.ContentKey, cfg.Env) |
| 380 | if err != nil { |
| 381 | return nil, err |
| 382 | } |
| 383 | |
| 384 | d, err := db.Open(cfg.DBPath, logger) |
| 385 | if err != nil { |
| 386 | return nil, fmt.Errorf("docs: open database: %w", err) |
| 387 | } |
| 388 | |
| 389 | required, activated, err := teamstate.Required(context.Background(), d.Writer()) |
| 390 | if err != nil || activated && (teamSettings == nil || required != teamSettings.fingerprint) { |
| 391 | d.Close() |
| 392 | return nil, errTeamConfig |
| 393 | } |
| 394 | schema := BootSchema |
| 395 | if teamSettings != nil { |
| 396 | schema = TeamBootSchema |
| 397 | } |
| 398 | if _, err := migrate.Apply(context.Background(), d, schema); err != nil { |
| 399 | d.Close() |
| 400 | return nil, fmt.Errorf("docs: apply migrations: %w", err) |
| 401 | } |
| 402 | |
| 403 | state, err := readContentSealing(context.Background(), d.G, cipher) |
| 404 | if err == nil && state != nil { |
| 405 | err = requireSealedContent(context.Background(), d.G) |
| 406 | } |
| 407 | if err != nil { |
| 408 | d.Close() |
| 409 | return nil, err |
| 410 | } |
| 411 | if state == nil { |
| 412 | logger.Error("docs: content sealing not activated", "content_encoding", 0, "key_fingerprint", cipher.fingerprint) |
| 413 | } |
| 414 | |
| 415 | // One Runtime for the process (core.New compiles the wasm module |
| 416 | // once — expensive; instantiating a per-document Handle from it is |
| 417 | // not), shared by every document's Store.replay. |
| 418 | rt, err := core.New(context.Background()) |
| 419 | if err != nil { |
| 420 | d.Close() |
| 421 | return nil, fmt.Errorf("docs: start core runtime: %w", err) |
| 422 | } |
| 423 | store := NewStore(d.G, rt, parseSampleDocuments(cfg.SampleDocuments), logger) |
| 424 | if state != nil { |
| 425 | store.content = cipher |
| 426 | } |
| 427 | |
| 428 | // A grant admits its holder. Wired here rather than inside |
| 429 | // resolveAllowlist because the allowlist is resolved before the store |
| 430 | // exists — deliberately, so a misconfigured allowlist refuses to boot |
| 431 | // before anything else is built. See allowlist.granted for why an |
| 432 | // unredeemable invitation is the failure worth avoiding. |
| 433 | // The sample library is for members. A guest admitted by a grant is |
| 434 | // visiting, not moving in. |
| 435 | store.seedOnlyMembers = list.configured |
| 436 | if teamSettings != nil { |
| 437 | store.team, err = newTeamRuntime(context.Background(), teamSettings, d.Writer(), homeSettings) |
| 438 | if err != nil { |
| 439 | rt.Close(context.Background()) |
| 440 | d.Close() |
| 441 | return nil, err |
| 442 | } |
| 443 | } |
| 444 | |
| 445 | list.granted = func(address string) (bool, error) { |
| 446 | // A short deadline rather than context.Background(): this runs on |
| 447 | // the sign-in path and now on every request (requireAdmission), so |
| 448 | // a stalled query must fail rather than hold a request open. |
| 449 | // |
| 450 | // The error is passed up rather than folded into false. Each |
| 451 | // caller wants a different answer to "the check failed": sign-in |
| 452 | // refuses, and a live request answers 503 so the editor's save |
| 453 | // queue retries instead of dropping the batch. |
| 454 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| 455 | defer cancel() |
| 456 | return store.HasGrant(ctx, address) |
| 457 | } |
| 458 | |
| 459 | render, err := newRenderer() |
| 460 | if err != nil { |
| 461 | rt.Close(context.Background()) |
| 462 | d.Close() |
| 463 | return nil, fmt.Errorf("docs: build renderer: %w", err) |
| 464 | } |
| 465 | |
| 466 | // Sign-in links go out after the response, from a worker that asks |
| 467 | // the allowlist first (mailqueue.go). Closed by Close, or below if |
| 468 | // New fails after this point. |
| 469 | signinMail := newMailQueue(mailer, listAdmits(list), nil, logger) |
| 470 | built := false |
| 471 | defer func() { |
| 472 | if !built { |
| 473 | signinMail.close() |
| 474 | } |
| 475 | }() |
| 476 | // h is built below, after the plugin it wraps; the door rastrillo |
| 477 | // calls back into is bound to it late. |
| 478 | var h *handlers |
| 479 | door := signinDoor{ |
| 480 | refused: func(w http.ResponseWriter, r *http.Request) { h.signinRefused(w, r) }, |
| 481 | confirm: func(w http.ResponseWriter, r *http.Request, d rauth.ConfirmPageData) { h.confirmSignin(w, r, d) }, |
| 482 | } |
| 483 | authPlugin, err := newAuthPlugin(cfg, d.Writer(), instanceKey, signinMail, list, logger, door) |
| 484 | if err != nil { |
| 485 | rt.Close(context.Background()) |
| 486 | d.Close() |
| 487 | return nil, fmt.Errorf("docs: build auth: %w", err) |
| 488 | } |
| 489 | |
| 490 | directory, err := validatedDirectory(cfg.Directory) |
| 491 | if err != nil { |
| 492 | rt.Close(context.Background()) |
| 493 | d.Close() |
| 494 | return nil, err |
| 495 | } |
| 496 | h = &handlers{render: render, logger: logger, store: store, hub: newHub(), previews: newPreviews(), origin: cfg.Origin, isMember: list.configured, oneShots: newOneShots(), directory: directory} |
| 497 | render.frame = h.frame |
| 498 | // peek: the viewer on routes no session guards (chrome.go). Standalone |
| 499 | // reads rastrillo/auth's own cookie; with Home, the Home bridge's. |
| 500 | h.peek = func(r *http.Request) (string, bool) { |
| 501 | id, ok := authPlugin.SessionFrom(r) |
| 502 | return id.Address, ok |
| 503 | } |
| 504 | |
| 505 | var home *homeRoutes |
| 506 | if homeSettings != nil { |
| 507 | home, err = newHomeRoutes(homeSettings, cfg.Origin, d.Writer(), authPlugin, h) |
| 508 | if err != nil { |
| 509 | rt.Close(context.Background()) |
| 510 | d.Close() |
| 511 | return nil, err |
| 512 | } |
| 513 | h.homeEnabled = true |
| 514 | h.peek = func(r *http.Request) (string, bool) { |
| 515 | s, ok := home.bridge.sessions.From(r) |
| 516 | if !ok || s.Subject == "" { |
| 517 | return "", false |
| 518 | } |
| 519 | if s.Method == "magiclink" || s.Method == "keymail" { |
| 520 | return s.Subject, true |
| 521 | } |
| 522 | id, err := home.bridge.resolver.resolve(sessions.WithSession(r, s)) |
| 523 | if err != nil { |
| 524 | return "", false |
| 525 | } |
| 526 | return actorAddress(id.Actor), true |
| 527 | } |
| 528 | if store.team != nil { |
| 529 | store.team.bridge = home.bridge |
| 530 | home.bridge.resolver.team = store.team |
| 531 | store.team.readSession, err = newTeamSessionReader(d.Writer(), cfg.Origin, home.bridge.sessionCookie, logger) |
| 532 | if err != nil { |
| 533 | rt.Close(context.Background()) |
| 534 | d.Close() |
| 535 | return nil, err |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | } |
| 540 | // Approval and long-lived streams reuse the same current admission chain. |
| 541 | h.requireCurrent = func(next http.Handler) http.Handler { |
| 542 | if store.team != nil { |
| 543 | next = store.team.admission(next) |
| 544 | } else { |
| 545 | next = requireAdmission(list, "/signin")(next) |
| 546 | } |
| 547 | if home == nil { |
| 548 | return authPlugin.RequireSession(next) |
| 549 | } |
| 550 | current := home.bridge.sessions.Require(home.bridge.resolver.middleware("/signin/local")(next)) |
| 551 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 552 | // A live stream retains its opening context. Require trusts that cached |
| 553 | // session, so re-read the cookie before admitting each stream write. |
| 554 | session, ok := home.bridge.sessions.From(r) |
| 555 | if !ok { |
| 556 | http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusUnauthorized) |
| 557 | return |
| 558 | } |
| 559 | current.ServeHTTP(w, sessions.WithSession(r, session)) |
| 560 | }) |
| 561 | } |
| 562 | nativeSessions, err := newNativeSessions(d.Writer(), cfg.Origin, logger) |
| 563 | if err != nil { |
| 564 | rt.Close(context.Background()) |
| 565 | d.Close() |
| 566 | return nil, err |
| 567 | } |
| 568 | native := nativeauth.New(nativeSessions, cfg.Origin, "Docs", h.requireCurrent) |
| 569 | var standalone *localLinkNavigation |
| 570 | if home == nil { |
| 571 | if standalone, err = standaloneReturn(instanceKey, cfg.Origin); err != nil { |
| 572 | rt.Close(context.Background()) |
| 573 | d.Close() |
| 574 | return nil, err |
| 575 | } |
| 576 | } |
| 577 | if home == nil { |
| 578 | h.remembered = standalone.returnPath |
| 579 | } else { |
| 580 | h.remembered = home.navigation.returnPath |
| 581 | } |
| 582 | r := chi.NewRouter() |
| 583 | // Every route that is not a route is the 404 page for a browser, and a |
| 584 | // page GET to a POST-only path is too (errors.go). |
| 585 | r.NotFound(h.notFound) |
| 586 | r.MethodNotAllowed(h.methodNotAllowed(r)) |
| 587 | r.Use(native.Resume) |
| 588 | // Mounted above every route, per rastrillo SKILL.md §5: refuses a |
| 589 | // cross-origin POST/PUT/PATCH/DELETE by Sec-Fetch-Site/Origin/ |
| 590 | // Referer evidence; GET/HEAD/OPTIONS pass untouched, so this does |
| 591 | // nothing to "/" or "/d/{sqid}" and everything to "/documents", |
| 592 | // "/signin" and "/signout". |
| 593 | r.Use(homeCSRF(cfg.Origin, home)) |
| 594 | r.Post("/native/request", native.ServeHTTP) |
| 595 | r.Post("/native/exchange", native.ServeHTTP) |
| 596 | r.Get("/native/start", native.ServeHTTP) |
| 597 | r.Get("/native/authorize", native.ServeHTTP) |
| 598 | r.Post("/native/authorize", native.ServeHTTP) |
| 599 | // Unauthenticated: the sign-in page and its four handlers, and the |
| 600 | // static assets that page (and the shell around it) needs to |
| 601 | // render at all. |
| 602 | if home == nil { |
| 603 | r.Get("/signin", h.standaloneSignin(standalone)) |
| 604 | r.Get(localLinkCompletePath, standalone.completeStandalone) |
| 605 | r.Get("/signed-out", h.signedOut) |
| 606 | } else { |
| 607 | home.mountPublic(r) |
| 608 | } |
| 609 | // Appearance (estilo chrome): inside the app-wide CSRF check above and |
| 610 | // outside every session guard below, so a session that expired while |
| 611 | // the menu was open cannot turn the click into a sign-in loop. |
| 612 | r.Post("/appearance", chrome.HandleAppearance) |
| 613 | r.Post("/signin", h.beginSignin(authPlugin.Begin)) |
| 614 | // An emailed link opens GET /auth/verify, which only draws the |
| 615 | // confirm door (h.confirmSignin); its Sign in button POSTs the token |
| 616 | // back, and the POST spends it. Without the POST nobody signs in by |
| 617 | // email. Both sit inside the app-wide CSRF check, which the POST must |
| 618 | // pass like any other form. A refused address gets the door in a 403 |
| 619 | // (h.signinRefused), from either route. |
| 620 | r.Get("/auth/callback", localAuthGuard(home, authPlugin.Callback)) |
| 621 | r.Get("/auth/verify", localAuthGuard(home, authPlugin.Verify)) |
| 622 | r.Post("/auth/verify", localAuthGuard(home, authPlugin.Verify)) |
| 623 | // The published document, and the only route here no session guards. |
| 624 | // It is authorized by the token in its own path (links.go) rather |
| 625 | // than by who is asking — which is what a capability is, and why it |
| 626 | // sits above the RequireSession group rather than inside it with an |
| 627 | // exception carved into the middleware. |
| 628 | r.Get("/p/{token}", h.publicShow) |
| 629 | if home == nil { |
| 630 | r.Post("/signout", signoutToSignedOut(authPlugin.Signout)) |
| 631 | } else { |
| 632 | r.Post("/signout", home.signout) |
| 633 | } |
| 634 | // staticHandler (assets.go) composes the committed editor bundle |
| 635 | // with render.assets.Handler()'s vendored design-system tree — |
| 636 | // both live under this one URL prefix, so one composed handler, |
| 637 | // not two competing route registrations. Still unauthenticated, |
| 638 | // still mounted above the RequireSession group below. |
| 639 | r.Handle("/static/*", staticHandler(render.assets.Handler())) |
| 640 | // The suite's app icons, served from estilo (the bar's brand, and the |
| 641 | // switcher's tiles once they render). |
| 642 | r.Handle(brand.Prefix+"*", brand.Handler()) |
| 643 | // Onest, Calma's typeface, served by estilo under its versioned prefix. |
| 644 | r.Handle(theme.FontPrefix+"*", theme.FontHandler()) |
| 645 | // The sky behind the sign-in door: a closed set of SVGs, public. |
| 646 | r.Handle(sky.Prefix+"*", sky.Handler()) |
| 647 | |
| 648 | // Everything else needs a real, signed-in person — rastrillo |
| 649 | // SKILL.md §5/§1's "signed-in routes... in a Group with |
| 650 | // sess.Require", here auth's own RequireSession. A signed-out |
| 651 | // GET/HEAD redirects to /signin; anything else 403s. |
| 652 | |
| 653 | if store.team != nil { |
| 654 | r.Group(func(r chi.Router) { |
| 655 | r.Use(home.bridge.sessions.Require) |
| 656 | r.Use(home.bridge.resolver.middleware("/signin/local")) |
| 657 | r.Use(requireTeamAccountAdmission(list)) |
| 658 | home.mountAccount(r) |
| 659 | }) |
| 660 | } |
| 661 | r.Group(func(r chi.Router) { |
| 662 | if home == nil { |
| 663 | r.Use(requireSessionReturning(authPlugin)) |
| 664 | } else { |
| 665 | r.Use(home.bridge.sessions.Require) |
| 666 | r.Use(home.bridge.resolver.middleware("/signin/local")) |
| 667 | } |
| 668 | // After the session middleware, so a 500 page shows the viewer's |
| 669 | // own chrome (errors.go). |
| 670 | r.Use(h.recoverToErrorPage) |
| 671 | // Admission is re-asked per request, not only at sign-in, so |
| 672 | // revoking a guest's last grant ends their access now rather |
| 673 | // than whenever their 30-day session happens to expire. |
| 674 | if store.team != nil { |
| 675 | r.Use(store.team.admission) |
| 676 | } else { |
| 677 | r.Use(requireAdmission(list, "/signin")) |
| 678 | if home != nil { |
| 679 | home.mountAccount(r) |
| 680 | } |
| 681 | } |
| 682 | r.Get("/", h.list) |
| 683 | r.Post("/documents", h.create) |
| 684 | // Title search for the jump-to palette (search.go). Inside |
| 685 | // this group like every other document route, and reader-scoped |
| 686 | // through Store.Search → Store.List rather than through a |
| 687 | // check of its own — a person's document titles are theirs. |
| 688 | r.Get("/documents/search", h.searchJSON) |
| 689 | r.Get("/d/{sqid}", h.show) |
| 690 | r.Post("/d/{sqid}/title", h.rename) |
| 691 | // Task 5: the update log, what the browser's editor reads to |
| 692 | // build its Yjs doc (GET) and posts new edits back through |
| 693 | // (POST) — see handlers.go's updatesGet/updatesPost. Neither |
| 694 | // is exempted from csrf.Protect above: rastrillo SKILL.md §5's |
| 695 | // origin check is what stops a cross-site page from writing |
| 696 | // to a document through this route. |
| 697 | // Sharing. All three owner-only, and all three answering 404 to |
| 698 | // anybody else — including a writer, who may edit the document and |
| 699 | // still may not decide who else reaches it. |
| 700 | r.Get("/d/{sqid}/share", h.shareGet) |
| 701 | r.Post("/d/{sqid}/share", h.sharePost) |
| 702 | r.Post("/d/{sqid}/share/revoke", h.shareRevoke) |
| 703 | r.Post("/d/{sqid}/share/link", h.shareLink) |
| 704 | |
| 705 | r.Get("/d/{sqid}/updates", h.updatesGet) |
| 706 | r.Post("/d/{sqid}/updates", h.updatesPost) |
| 707 | // Export (export.go). Inside this group, so a signed-out |
| 708 | // request gets the same redirect every other document route |
| 709 | // gives it, and reader-scoped through the same Store.GetFor, so a |
| 710 | // document someone else owns 404s identically to one that does |
| 711 | // not exist. Extensions in the path rather than a ?format= |
| 712 | // query: the URL is what a person sees when they save the |
| 713 | // file, and one that ends in .pdf is one a browser and an |
| 714 | // operating system both already understand. |
| 715 | r.Get("/d/{sqid}/export.md", h.exportMarkdown) |
| 716 | r.Get("/d/{sqid}/export.pdf", h.exportPDF) |
| 717 | // Plan 5: the live-update stream (stream.go). A GET, so |
| 718 | // csrf.Protect passes it through untouched, and reader-scoped |
| 719 | // through the same getDocumentForUpdates helper as the two |
| 720 | // routes above — a stranger gets 404, never 403. |
| 721 | r.Get("/d/{sqid}/stream", h.stream) |
| 722 | }) |
| 723 | |
| 724 | mux := http.NewServeMux() |
| 725 | mux.Handle("/", r) |
| 726 | |
| 727 | built = true |
| 728 | return &App{cfg: cfg, db: d, mux: mux, logger: logger, core: rt, store: store, handlers: h, mailRecorder: mailRecorder, signinMail: signinMail}, nil |
| 729 | } |
| 730 | |
| 731 | // Store returns the app's document store — models.go's Document, |
| 732 | // store.go's Store — over the app's migrated database and shared |
| 733 | // core.Runtime. |
| 734 | func (a *App) Store() *Store { return a.store } |
| 735 | |
| 736 | // Handler is the app's router: rastrillo.Options.Mux for real serving |
| 737 | // (cmd/docs/main.go), and — per rastrillo's own doc comment on |
| 738 | // Handler/Serve — exactly what a test harness should exercise directly, |
| 739 | // so httptest hits the real route table rather than a hand-duplicated |
| 740 | // one. *http.ServeMux, not http.Handler: it satisfies both callers |
| 741 | // without forcing an unchecked type assertion on the main.go side, |
| 742 | // and matches SKILL.md's own App(...) (*http.ServeMux, error) shape. |
| 743 | func (a *App) Handler() *http.ServeMux { return a.mux } |
| 744 | |
| 745 | // Close releases the database handle and the core runtime's compiled |
| 746 | // module. NewForTest registers this as a t.Cleanup. |
| 747 | func (a *App) Close() error { |
| 748 | var errs []error |
| 749 | // First, while the database the allowlist reads is still open. |
| 750 | if a.signinMail != nil { |
| 751 | a.signinMail.close() |
| 752 | } |
| 753 | if a.core != nil { |
| 754 | if err := a.core.Close(context.Background()); err != nil { |
| 755 | errs = append(errs, err) |
| 756 | } |
| 757 | } |
| 758 | if err := a.db.Close(); err != nil { |
| 759 | errs = append(errs, err) |
| 760 | } |
| 761 | return errors.Join(errs...) |
| 762 | } |
| 763 | |