oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1package slides
2
3import (
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.
47func 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.
278func 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