| 1 | package slides |
| 2 | |
| 3 | import ( |
| 4 | "amadan.net/oficina/estilo.git/sky" |
| 5 | "context" |
| 6 | "net/http" |
| 7 | "time" |
| 8 | |
| 9 | "amadan.net/oficina/estilo.git/brand" |
| 10 | "amadan.net/oficina/estilo.git/chrome" |
| 11 | "amadan.net/oficina/estilo.git/theme" |
| 12 | "amadan.net/oficina/home/connect" |
| 13 | "amadan.net/oficina/home/handoff" |
| 14 | "amadan.net/oficina/slides/internal/slides/core" |
| 15 | "amadan.net/oficina/slides/internal/slides/live" |
| 16 | "amadan.net/oficina/slides/internal/slides/present" |
| 17 | rauth "amadan.net/rastrillo/rastrillo/auth" |
| 18 | "amadan.net/rastrillo/rastrillo/csrf" |
| 19 | "amadan.net/rastrillo/rastrillo/db" |
| 20 | "amadan.net/rastrillo/rastrillo/jobs" |
| 21 | "amadan.net/rastrillo/rastrillo/migrate" |
| 22 | "amadan.net/rastrillo/rastrillo/sessions" |
| 23 | "github.com/go-chi/chi/v5" |
| 24 | ) |
| 25 | |
| 26 | // New wires the whole app: schema, sign-in, router, static files. It |
| 27 | // returns a *http.ServeMux because rastrillo.Options.Mux is typed that |
| 28 | // way — the chi router mounts inside it. |
| 29 | // |
| 30 | // It is SKILL.md §1's app.go shape with one change: the origin and |
| 31 | // logger arguments the scaffold took have grown into Config (config.go), |
| 32 | // because sign-in needs four more settings and each of them is a |
| 33 | // startup refusal when it is missing outside development. Every one of |
| 34 | // those refusals happens before the database is touched, so a |
| 35 | // misconfigured instance never reaches the point of serving a request. |
| 36 | // |
| 37 | // The database handle is the caller's (cmd/slides/main.go's db.Open), |
| 38 | // not one this opens: SKILL.md §1's Resolve/Serve split depends on |
| 39 | // exactly one connection to the file. |
| 40 | // |
| 41 | // The second return value is the built *app, unexported so callers |
| 42 | // outside this package can only ever hold one (via :=) and call its |
| 43 | // exported methods — today, just Close, which releases the deck core's |
| 44 | // runtime. Returning it is what lets cmd/slides/main.go and the test |
| 45 | // harness release that resource on shutdown without this package |
| 46 | // exposing anything more of the app than it already did. |
| 47 | func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| 48 | a, authPlugin, err := newApp(d, cfg) |
| 49 | if err != nil { |
| 50 | return nil, nil, err |
| 51 | } |
| 52 | |
| 53 | r := chi.NewRouter() |
| 54 | // App-wide, from day one: every state-changing route added below |
| 55 | // is born covered. Origin-checking, not tokens — nothing to mint |
| 56 | // or forget in a form. a.cfg.Origin, not the caller's cfg.Origin: |
| 57 | // what this protects has to be the origin the app resolved. |
| 58 | if a.homeRoutes != nil { |
| 59 | r.Use(a.homeRoutes.Middleware) |
| 60 | } else { |
| 61 | r.Use(csrf.Protect(a.cfg.Origin)) |
| 62 | } |
| 63 | |
| 64 | // A panic anywhere in the router is the 500 page with the viewer's |
| 65 | // own chrome (errors.go); an unknown address is the 404 page, not |
| 66 | // chi's plain text; a GET of a POST-only path is that 404 too. |
| 67 | r.Use(a.recoverToErrorPage) |
| 68 | r.NotFound(a.routeNotFound) |
| 69 | r.MethodNotAllowed(a.methodNotAllowed(r)) |
| 70 | |
| 71 | r.Get("/help/open-source", a.credits) |
| 72 | // The account menu's Appearance (estilo chrome). Inside the app-wide |
| 73 | // CSRF check above and outside the session group below: a session |
| 74 | // that expired mid-page must not turn the click into a sign-in loop. |
| 75 | r.Post("/appearance", chrome.HandleAppearance) |
| 76 | |
| 77 | // Unauthenticated: the front door and rauth's handlers. The sign-in |
| 78 | // page has to be reachable signed out or nobody could ever get in. |
| 79 | // |
| 80 | // Verify answers GET with the confirm door (renderConfirm), which |
| 81 | // spends nothing, and POST with the redemption, so it is mounted for |
| 82 | // both methods in both modes, wrapped alike: without the POST, the |
| 83 | // door's Sign in button gets a 405 and nobody signs in. A verified |
| 84 | // address that is not admitted is answered by rauth's Refused hook |
| 85 | // (refused), inside Callback and Verify. |
| 86 | if a.homeRoutes == nil { |
| 87 | r.Get(signinPath, a.signin) |
| 88 | r.Post(signinPath, a.newBegin(authPlugin.Begin)) |
| 89 | r.Get("/auth/callback", authPlugin.Callback) |
| 90 | r.Get("/auth/verify", authPlugin.Verify) |
| 91 | r.Post("/auth/verify", authPlugin.Verify) |
| 92 | r.Get(landedPath, a.landed) |
| 93 | r.Post("/signout", signedOutAfter(authPlugin.Signout)) |
| 94 | r.Get("/signed-out", a.signedOut) |
| 95 | } else { |
| 96 | h := a.homeRoutes |
| 97 | r.Get(signinPath, a.homeSignin) |
| 98 | r.Get("/signin/local", a.signin) |
| 99 | r.Post(signinPath, h.LocalGuard(a.newBegin(authPlugin.Begin))) |
| 100 | r.Get("/auth/callback", h.LocalGuard(authPlugin.Callback)) |
| 101 | r.Get("/auth/verify", h.LocalGuard(authPlugin.Verify)) |
| 102 | r.Post("/auth/verify", h.LocalGuard(authPlugin.Verify)) |
| 103 | r.Post("/signout", h.Signout) |
| 104 | r.Get("/signed-out", h.SignedOut) |
| 105 | r.Get(connect.LocalCompletePath, h.LocalComplete) |
| 106 | r.Get(handoff.StartPath, a.home.Start) |
| 107 | r.Post(handoff.CallbackPath, a.home.Callback) |
| 108 | r.Get(handoff.FinishPath, a.home.Finish) |
| 109 | r.Get("/account/home", h.Account) |
| 110 | r.Post("/account/home", h.LinkStart) |
| 111 | r.Get("/account/home/confirm", a.home.ConfirmPage) |
| 112 | r.Post("/account/home/confirm", h.ConfirmPost) |
| 113 | } |
| 114 | |
| 115 | // The share link, and the one screen this app serves to somebody |
| 116 | // with no session at all (spec §5). Outside the group below on |
| 117 | // purpose: the token IS the capability, so requiring a session |
| 118 | // would defeat the whole feature. It is still inside the chi |
| 119 | // router, so csrf.Protect and every other app-wide middleware |
| 120 | // covers it. |
| 121 | r.Get("/s/{token}", a.sharedDeck) |
| 122 | // The pictures on that page, behind the same capability and no |
| 123 | // other: the renderer writes their src as /s/{token}/assets/{ref} |
| 124 | // (assets.go's own comment says why this is three routes rather |
| 125 | // than one). |
| 126 | r.Get("/s/{token}/assets/{ref}", a.sharedAsset) |
| 127 | // And the thumbnails, for the same reason (thumbs.go). |
| 128 | r.Get("/s/{token}/slides/{file}", a.sharedSlideThumb) |
| 129 | // And the presentation stream, when the link's deck is being |
| 130 | // presented and its holder chose to follow along (share.go's |
| 131 | // sharedStream, spec §3.4). Here rather than on /p/{session} for |
| 132 | // the reason presentation.go's own comment gives: an audience |
| 133 | // stays behind the capability it arrived with, so revoking the |
| 134 | // link ends the follow. |
| 135 | r.Get("/s/{token}/stream", a.sharedStream) |
| 136 | |
| 137 | // Presenting (presentation.go, spec §3.4). Outside the group for |
| 138 | // the share link's reason and then some: the projector has no |
| 139 | // session, the phone in the presenter's hand has no session, and |
| 140 | // the audience following along has no session. Each screen carries |
| 141 | // its own capability — the session sqid to watch, the remote token |
| 142 | // to drive — and answers 404 to anything else. |
| 143 | r.Get("/p/{session}", a.presentSurface) |
| 144 | r.Get("/p/{session}/notes", a.presenterNotes) |
| 145 | r.Get("/p/{session}/stream", a.presentStream) |
| 146 | r.Get("/p/{session}/remote", a.remote) |
| 147 | r.Get("/p/{session}/assets/{ref}", a.presentAsset) |
| 148 | r.Get("/p/{session}/slides/{file}", a.presentSlideThumb) |
| 149 | r.Post("/p/{session}/command", a.presentCommand) |
| 150 | // The phone's way in: a scanned QR carries the code and the key, |
| 151 | // a typed code gets a form asking for the key. |
| 152 | r.Get("/r/{code}", a.remotePair) |
| 153 | |
| 154 | // Everything else needs a real, signed-in person (SKILL.md §5). A |
| 155 | // signed-out GET/HEAD redirects to /signin; anything else 403s. |
| 156 | r.Group(func(r chi.Router) { |
| 157 | r.Use(a.requireSession(authPlugin.RequireSession)) |
| 158 | r.Get("/", a.decks) |
| 159 | // One screen, one job (SKILL.md §7): the list links to a page |
| 160 | // for creating, and that page is the only place a deck is made. |
| 161 | r.Get("/decks/new", a.newDeck) |
| 162 | r.Post("/decks", a.createDeck) |
| 163 | // /d/{sqid} is the deck's own short URL — short because it is |
| 164 | // what a person pastes into a chat window. |
| 165 | r.Get("/d/{sqid}", a.showDeck) |
| 166 | // Every slide's notes on one page — the crib sheet somebody |
| 167 | // presents from with scripts off (spec §8). |
| 168 | r.Get("/d/{sqid}/notes", a.deckNotes) |
| 169 | // Live sync (live.go, spec §3.3). All four are the deck's |
| 170 | // readers-and-writers — deckFor's audience, not the owner's |
| 171 | // alone — because a team deck is edited by the team. |
| 172 | // |
| 173 | // Inside the group and therefore inside csrf.Protect, unlike |
| 174 | // the drive API: these are made by a browser, which sends the |
| 175 | // same-origin evidence csrf.Protect looks for. |
| 176 | r.Get("/d/{sqid}/updates", a.updatesGet) |
| 177 | r.Post("/d/{sqid}/updates", a.updatesPost) |
| 178 | r.Get("/d/{sqid}/stream", a.deckStream) |
| 179 | r.Post("/d/{sqid}/presence", a.deckPresence) |
| 180 | // The editor (editor.go, spec §3.1): deckFor's audience, like |
| 181 | // the live-sync routes it drives — a team deck is edited by the |
| 182 | // team. The slide HTML its stage and strip fetch is the |
| 183 | // session-cookie twin of the API's own. |
| 184 | r.Get("/d/{sqid}/edit", a.deckEditor) |
| 185 | r.Get("/d/{sqid}/slides/{file}", a.deckSlide) |
| 186 | // Images (assets.go, spec §2.7). Sign, register, serve — the |
| 187 | // deck's readers-and-writers, like the live-sync routes: a team |
| 188 | // deck is illustrated by the team. The PUT is the development |
| 189 | // receiving end for a store that cannot presign (blobs.Dir) and |
| 190 | // refuses itself on one that can. |
| 191 | r.Post("/d/{sqid}/uploads", a.signUpload) |
| 192 | r.Put("/d/{sqid}/uploads/{ref}", a.putUpload) |
| 193 | r.Post("/d/{sqid}/assets", a.registerAsset) |
| 194 | r.Get("/d/{sqid}/assets/{ref}", a.deckAsset) |
| 195 | // Exporting to PDF (exports.go, spec §4.3). deckFor's audience, |
| 196 | // like presenting: anybody who can read the deck can take a |
| 197 | // copy of it away. The status page, its fragment and its SSE |
| 198 | // stream are inside this group because rastrillo/jobs answers |
| 199 | // them to the session's owner and nobody else. |
| 200 | r.Get("/d/{sqid}/export", a.exportForm) |
| 201 | r.Post("/d/{sqid}/export", a.startExport) |
| 202 | // One route for the status page and the file: chi matches whole |
| 203 | // segments, so {id} and {id}.pdf cannot both be patterns (see |
| 204 | // exportJobRoute). |
| 205 | r.Get("/d/{sqid}/export/{id}", a.exportJobRoute) |
| 206 | r.Get("/d/{sqid}/export/{id}/fragment", a.exportFragment) |
| 207 | r.Get("/d/{sqid}/export/{id}/events", a.exportEvents) |
| 208 | // Presenting starts here and nowhere else: anybody who can |
| 209 | // open the deck can read it to a room, so this is deckFor's |
| 210 | // audience rather than the owner's alone. |
| 211 | r.Post("/d/{sqid}/present", a.startPresenting) |
| 212 | // Sharing is the owner's, all three of these (share.go says |
| 213 | // why a team member who can read the deck still cannot mint a |
| 214 | // link to it). |
| 215 | r.Get("/d/{sqid}/share", a.deckShare) |
| 216 | r.Post("/d/{sqid}/share", a.mintShareLink) |
| 217 | r.Post("/d/{sqid}/share/{id}/revoke", a.revokeShareLink) |
| 218 | r.Get("/d/{sqid}/settings", a.deckSettings) |
| 219 | r.Post("/d/{sqid}/settings", a.saveDeckSettings) |
| 220 | // Deleting gets its own URL and its own page, never a modal |
| 221 | // and never a button on the settings form (AGENTS.md's |
| 222 | // destructive-action convention). The GET changes nothing. |
| 223 | r.Get("/d/{sqid}/delete", a.confirmDeleteDeck) |
| 224 | r.Post("/d/{sqid}/delete", a.deleteDeck) |
| 225 | // Agent tokens (agents.go, spec §7.1). A person's own, like |
| 226 | // their decks: minting one is handing a program the ability to |
| 227 | // write everything they can write. |
| 228 | r.Get("/agents", a.agents) |
| 229 | r.Post("/agents", a.createAgent) |
| 230 | r.Post("/agents/{id}/revoke", a.revokeAgent) |
| 231 | }) |
| 232 | |
| 233 | mux := http.NewServeMux() |
| 234 | // The app serves its own static files — the framework never does. |
| 235 | // They are embedded (render.go) and fingerprinted: templates link |
| 236 | // them via {{asset ...}}, which returns a content-hashed URL the |
| 237 | // handler serves cacheable-forever. Mounted outside the chi router |
| 238 | // so the sign-in page can load its stylesheet signed out. |
| 239 | mux.HandleFunc("GET /static/credits.css", serveCreditsCSS) |
| 240 | // The suite's app icons (the bar's brand, the switcher's tiles), |
| 241 | // served from estilo rather than vendored. |
| 242 | mux.Handle("GET "+brand.Prefix, brand.Handler()) |
| 243 | // Onest, Calma's typeface, served by estilo under its versioned prefix. |
| 244 | mux.Handle("GET "+theme.FontPrefix, theme.FontHandler()) |
| 245 | // The sky behind the sign-in door: a closed set of SVGs, public. |
| 246 | mux.Handle("GET "+sky.Prefix, sky.Handler()) |
| 247 | mux.Handle("GET /static/", assets.Handler()) |
| 248 | // The drive surface (api.go, spec §7), mounted HERE rather than in |
| 249 | // the chi router above, and this is the one deliberate exception in |
| 250 | // the whole app's routing. |
| 251 | // |
| 252 | // csrf.Protect refuses a POST that carries no Sec-Fetch-Site, no |
| 253 | // Origin and no Referer (csrf.SameOrigin's last clause), which is |
| 254 | // precisely what an agent's HTTP client sends: it is not a browser |
| 255 | // and has no such evidence to offer. Inside the chi router, every |
| 256 | // write over the API would be a 403 with an HTML body. |
| 257 | // |
| 258 | // Nothing is given up by moving it out. The API's whole gate is an |
| 259 | // `Authorization: Bearer` header, and a browser never attaches one |
| 260 | // on its own — so no page on any origin can make a request this |
| 261 | // surface will authenticate, which is what CSRF is an attack on. |
| 262 | // api.go's package comment says the same thing at length, and |
| 263 | // docs/api.md says it to the person reading the API. |
| 264 | mux.Handle(apiPrefix+"/", a.apiRouter()) |
| 265 | mux.Handle("/", r) |
| 266 | return mux, a, nil |
| 267 | } |
| 268 | |
| 269 | // newApp is everything New does before the router: resolve the config |
| 270 | // fail-closed, apply the schema, build the sign-in plugin. |
| 271 | // |
| 272 | // Separate from New so this package's own tests can assert on the |
| 273 | // resolved *app — that a.cfg carries the origin the app is really |
| 274 | // protecting rather than the empty string a development caller passed |
| 275 | // in (config_test.go). New's own *app return is unexported, so a |
| 276 | // caller outside this package can hold one and Close it but never |
| 277 | // spell its type or read a.cfg; only a package-internal test can. |
| 278 | func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) { |
| 279 | // Fail closed, loudly, and before anything opens: Origin, |
| 280 | // InstanceKey, Mailer and AllowedAddresses each decide whether |
| 281 | // sign-in is safe, and each may default only when Env is exactly |
| 282 | // "development" (config.go's package comment). |
| 283 | cfg, list, err := resolve(cfg) |
| 284 | if err != nil { |
| 285 | return nil, nil, err |
| 286 | } |
| 287 | logger := cfg.Logger |
| 288 | |
| 289 | // Apply BootSchema, not Schema: BootSchema is everything this |
| 290 | // app needs at boot (its own migrations plus sessions' and auth's), |
| 291 | // while Schema (migrations.go) stays just this app's own so the |
| 292 | // generator and its check never see a subsystem's tables and try |
| 293 | // to drop them. |
| 294 | homeConfig, err := connect.DecodeCredentials(cfg.Home, cfg.Origin) |
| 295 | if err != nil { |
| 296 | return nil, nil, err |
| 297 | } |
| 298 | boot := BootSchema |
| 299 | if homeConfig != nil { |
| 300 | boot = migrate.Merge(BootSchema, homeSchema) |
| 301 | } |
| 302 | migrated, err := migrate.Apply(context.Background(), d, boot) |
| 303 | if err != nil { |
| 304 | return nil, nil, err |
| 305 | } |
| 306 | // One line per boot. Result exists so this can be logged, and |
| 307 | // until something does, a fleet-wide adoption — every database |
| 308 | // stamped, no DDL run, exactly once, never again — produces no |
| 309 | // output at all, which is the last event you want to be silent. |
| 310 | logger.Info("migrate", "applied", migrated.Applied, |
| 311 | "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| 312 | |
| 313 | // The sign-in mailer: links are queued and sent after the response, |
| 314 | // to permitted addresses only (mailqueue.go). Closed by app.Close. |
| 315 | mailer := newMailQueue(cfg.Mailer, list.allows, nil, logger) |
| 316 | // a exists before the plugin so rauth can hold its Refused and |
| 317 | // RenderConfirm pages; it is filled in below, once everything it |
| 318 | // carries has been built. |
| 319 | a := &app{} |
| 320 | authPlugin, err := newAuthPlugin(cfg, d.Writer(), list, mailer, a) |
| 321 | if err != nil { |
| 322 | mailer.close() |
| 323 | return nil, nil, err |
| 324 | } |
| 325 | returnSeal, err := newReturnSealer(cfg.InstanceKey) |
| 326 | if err != nil { |
| 327 | mailer.close() |
| 328 | return nil, nil, err |
| 329 | } |
| 330 | |
| 331 | // One Runtime for the process: it owns the compiled wasm module, |
| 332 | // which is expensive to build and cheap to instantiate, and every |
| 333 | // deck opened gets its own instance from it (decks.go's openDeck). |
| 334 | // Built here rather than lazily on first use so a module that will |
| 335 | // not compile — a stale or truncated .wasm — is a startup refusal |
| 336 | // like every other one in this function, not a 500 on whichever |
| 337 | // request happens to open the first deck. |
| 338 | rt, err := core.New(context.Background()) |
| 339 | if err != nil { |
| 340 | mailer.close() |
| 341 | return nil, nil, err |
| 342 | } |
| 343 | |
| 344 | *a = app{ |
| 345 | db: d, |
| 346 | logger: logger, |
| 347 | cfg: cfg, |
| 348 | allowed: list, |
| 349 | returnSeal: returnSeal, |
| 350 | returnNow: time.Now, |
| 351 | core: rt, |
| 352 | auth: authPlugin, |
| 353 | mail: mailer, |
| 354 | hub: present.New(), |
| 355 | live: live.New(), |
| 356 | jobs: jobs.New(logger), |
| 357 | exports: newExportStore(), |
| 358 | pdf: &pdfRuntime{}, |
| 359 | } |
| 360 | |
| 361 | // The optional session read behind every page outside the signed-in |
| 362 | // group (chrome.go's optionalViewer). The same table and cookie as |
| 363 | // rauth's own store. |
| 364 | sess, err := sessions.New(sessions.Config{DB: d.Writer(), Origin: cfg.Origin, Logger: logger}) |
| 365 | if err != nil { |
| 366 | rt.Close(context.Background()) |
| 367 | mailer.close() |
| 368 | return nil, nil, err |
| 369 | } |
| 370 | a.sessions = sess |
| 371 | |
| 372 | if err := a.initHome(homeConfig); err != nil { |
| 373 | rt.Close(context.Background()) |
| 374 | mailer.close() |
| 375 | return nil, nil, err |
| 376 | } |
| 377 | |
| 378 | // The export's status page, its polled fragment and its SSE stream |
| 379 | // (exports.go). rastrillo/jobs owns the lifecycle and the |
| 380 | // owner-only lookup; the two renderers are this app's, so the page |
| 381 | // is the app's own shell with the app's own words in it. |
| 382 | pages, err := jobs.NewHandlers(jobs.Config{ |
| 383 | Jobs: a.jobs, |
| 384 | Render: a.renderExportPage, |
| 385 | RenderFragment: a.renderExportFragment, |
| 386 | }) |
| 387 | if err != nil { |
| 388 | rt.Close(context.Background()) |
| 389 | mailer.close() |
| 390 | return nil, nil, err |
| 391 | } |
| 392 | a.jobPages = pages |
| 393 | return a, authPlugin, nil |
| 394 | } |
| 395 | |