oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// exports.go is the PDF export as a person meets it (spec §4.3): the
2// button, the job behind it, the status page it lands on, and the file
3// at the end. export.go is the pipeline it calls; this is everything
4// around it.
5//
6// GET /d/{sqid}/export the little form: with notes or not
7// POST /d/{sqid}/export start the job
8// GET /d/{sqid}/export/{job} the status page (rastrillo/jobs)
9// GET /d/{sqid}/export/{job}/fragment what the shim polls
10// GET /d/{sqid}/export/{job}/events the SSE it can ride instead
11// GET /d/{sqid}/export/{job}.pdf the file
12//
13// # Why it is a job
14//
15// A deck of two hundred slides takes seconds to render, and a request
16// that blocks for seconds is a request some proxy will cut. So the POST
17// answers immediately with somewhere to watch, exactly as the plan's
18// global constraints require, and the work happens in a goroutine under
19// rastrillo/jobs' own bounds: four running per person, fifteen minutes
20// each, the context expiring with the job.
21//
22// A job is a goroutine, and a deploy — or the platform hibernating this
23// instance — ends one mid-flight (rastrillo skill §6). Nothing here is
24// written down, so an interrupted export is simply not there when the
25// process comes back and the person presses the button again. That is
26// the honest behaviour for work that produces a file and no state:
27// re-running it is cheap and produces the same PDF.
28//
29// # Where the bytes live
30//
31// With no object store bound — which is this deployment today — the
32// finished PDF is held in memory for an hour and served from here. That
33// is bounded on purpose and in two directions: 50 MiB of held exports
34// per deck, 200 MiB across the instance (exportPerDeckBytes,
35// exportPerInstanceBytes). Past either, the job fails with a sentence
36// saying so rather than the instance failing with an OOM nobody can
37// read.
38//
39// When a store IS bound the same job uploads the PDF to it and drops
40// the held bytes, and the download route 302s to a presigned GET (or
41// streams it back from a store that cannot presign, which is what
42// blobs.Dir is in development). Both paths are in one handler, so
43// neither can rot: exportPDF below.
44package slides
45
46import (
47 "bytes"
48 "context"
49 "errors"
50 "fmt"
51 "io"
52 "net/http"
53 "net/url"
54 "strconv"
55 "strings"
56 "sync"
57 "time"
58
59 "github.com/go-chi/chi/v5"
60
61 "amadan.net/rastrillo/rastrillo"
62 "amadan.net/rastrillo/rastrillo/blobs"
63 "amadan.net/rastrillo/rastrillo/flash"
64 "amadan.net/rastrillo/rastrillo/jobs"
65 "amadan.net/rastrillo/rastrillo/sessions"
66
67 "amadan.net/oficina/slides/internal/slides/layout"
68 "amadan.net/oficina/slides/internal/slides/pdfcore"
69)
70
71// How long a finished export is answerable, and how much of it this
72// process will hold at once.
73//
74// The hour is the spec's (§4.3) and the same hour a presigned URL
75// lives. The two byte caps are the plan's, and they are the reason
76// holding a PDF in memory is a defensible thing to do at all: the worst
77// case is bounded and the failure is a sentence rather than a killed
78// process.
79const (
80 exportHoldFor = time.Hour
81 exportPerDeckBytes = 50 << 20
82 exportPerInstanceBytes = 200 << 20
83)
84
85// exportContentType is what the file is, said once.
86const exportContentType = "application/pdf"
87
88// --- the compiled module ---
89
90// pdfRuntime is the app's PDF module: compiled at most once, on the
91// first export, and closed with the app.
92//
93// On the app rather than in a package variable, so a test binary that
94// builds twenty apps releases twenty runtimes rather than leaking them
95// into a package that nothing can close. Lazy rather than built in New,
96// unlike the CRDT core: compiling costs the best part of a second and
97// most processes — every test app, every instance nobody exports
98// from — never render a page. The cost of that choice is that a
99// slides_pdf.wasm this host cannot load is a failed export rather than
100// a startup refusal; the gate's cmp against a fresh build is what
101// actually catches that, and it catches it before the binary is built.
102type pdfRuntime struct {
103 once sync.Once
104 rt *pdfcore.Runtime
105 err error
106}
107
108func (p *pdfRuntime) get(ctx context.Context) (*pdfcore.Runtime, error) {
109 p.once.Do(func() { p.rt, p.err = pdfcore.New(ctx) })
110 return p.rt, p.err
111}
112
113// close releases the module if one was ever built. Safe to call on a
114// runtime nobody used, which is the common case.
115func (p *pdfRuntime) close(ctx context.Context) error {
116 if p == nil || p.rt == nil {
117 return nil
118 }
119 return p.rt.Close(ctx)
120}
121
122// --- the held bytes ---
123
124// heldExport is one finished (or running) export: who asked, which
125// deck, and where the file is.
126//
127// It is created when the job starts, so a status page and a download
128// URL can be checked against the deck in the path before the file
129// exists. bytes is what an instance with no object store holds; hash is
130// the store's own key once the bytes are up there, and exactly one of
131// the two is ever set.
132type heldExport struct {
133 deck string // the deck's sqid
134 owner string // the job's owner, as jobs.Get keys it
135 actor string // person:<id> or agent:<id> — who asked (§6.2)
136 name string // the job's own label (exportFallback shows it)
137 filename string // what the browser should call the file
138 bytes []byte
139 hash string
140 warnings []layout.Warning
141 started time.Time
142 done time.Time // zero while the job is running
143}
144
145// exportStore is every export this process is holding. One mutex, a
146// map, and two caps.
147//
148// Sweeping happens inside its own methods rather than on a timer: there
149// is no goroutine to leak and nothing to shut down, and the caps — not
150// the sweep — are what bound the memory. The cost is that the last
151// export of the day is held until somebody exports again, which is
152// bytes and not a correctness problem.
153type exportStore struct {
154 mu sync.Mutex
155 held map[string]*heldExport
156
157 // perDeck and perInstance are exportPerDeckBytes and
158 // exportPerInstanceBytes, as fields so a test can prove the refusal
159 // without making a 50 MiB PDF first.
160 perDeck, perInstance int64
161 ttl time.Duration
162 now func() time.Time
163}
164
165func newExportStore() *exportStore {
166 return &exportStore{
167 held: map[string]*heldExport{},
168 perDeck: exportPerDeckBytes,
169 perInstance: exportPerInstanceBytes,
170 ttl: exportHoldFor,
171 now: time.Now,
172 }
173}
174
175// SetExportCaps moves the two byte caps for this app.
176//
177// Exported because the *app New returns is unexported, so the only
178// thing outside this package that can reach it is a method — and the
179// suite has to be able to prove the refusal without first making a
180// 50 MiB PDF, which would be a minute of CPU to assert one sentence.
181// It is also where an operator-tunable setting would land if a bigger
182// instance ever wanted one.
183func (a *app) SetExportCaps(perDeck, perInstance int64) {
184 a.exports.mu.Lock()
185 defer a.exports.mu.Unlock()
186 a.exports.perDeck, a.exports.perInstance = perDeck, perInstance
187}
188
189// ForgetExportJobs gives this app an empty job registry, leaving the
190// exports it is holding alone.
191//
192// Exported for SetExportCaps's reason: rastrillo/jobs sweeps a finished
193// job ten minutes after it ends and offers no way to bring that
194// forward, so the suite proves exportFallback by handing the app a
195// registry that has forgotten — which is what the sweep leaves behind,
196// and what a restarted process has. Both the registry and the handlers
197// built over it are replaced, because the handlers hold their own
198// reference to it.
199//
200// Nothing else may call this: replacing the registry while a job is
201// running orphans it.
202func (a *app) ForgetExportJobs() error {
203 a.jobs = jobs.New(a.logger)
204 pages, err := jobs.NewHandlers(jobs.Config{
205 Jobs: a.jobs,
206 Render: a.renderExportPage,
207 RenderFragment: a.renderExportFragment,
208 })
209 if err != nil {
210 return err
211 }
212 a.jobPages = pages
213 return nil
214}
215
216// begin records a job that has just started. The entry carries no bytes
217// yet; what it carries is the deck and the owner, which is what the
218// download and status routes check before they answer anything.
219func (s *exportStore) begin(id string, e heldExport) []string {
220 s.mu.Lock()
221 defer s.mu.Unlock()
222 dropped := s.sweepLocked()
223 e.started = s.now()
224 s.held[id] = &e
225 return dropped
226}
227
228// errExportTooBig is what a job returns when the finished PDF does not
229// fit under the caps. The message a person sees is the caller's.
230var errExportTooBig = errors.New("slides: the export is bigger than this instance will hold")
231
232// finish attaches the finished file. It is where the caps are enforced,
233// because the size of a PDF is not knowable before it exists: over
234// either cap, the entry is dropped and the job fails with a sentence
235// instead of the instance filling up quietly.
236func (s *exportStore) finish(id string, pdf []byte, warnings []layout.Warning) error {
237 s.mu.Lock()
238 defer s.mu.Unlock()
239 e, ok := s.held[id]
240 if !ok {
241 return errors.New("slides: this export was swept before it finished")
242 }
243 var deckBytes, allBytes int64
244 for otherID, other := range s.held {
245 if otherID == id {
246 continue
247 }
248 allBytes += int64(len(other.bytes))
249 if other.deck == e.deck {
250 deckBytes += int64(len(other.bytes))
251 }
252 }
253 size := int64(len(pdf))
254 if deckBytes+size > s.perDeck || allBytes+size > s.perInstance {
255 delete(s.held, id)
256 return errExportTooBig
257 }
258 e.bytes = pdf
259 e.warnings = warnings
260 e.done = s.now()
261 return nil
262}
263
264// stored replaces the held bytes with the store's key for them, once
265// the upload has happened. The bytes go, because holding a copy of
266// something the object store now has is the whole cost this avoids.
267func (s *exportStore) stored(id, hash string, warnings []layout.Warning) {
268 s.mu.Lock()
269 defer s.mu.Unlock()
270 e, ok := s.held[id]
271 if !ok {
272 return
273 }
274 e.bytes = nil
275 e.hash = hash
276 e.warnings = warnings
277 e.done = s.now()
278}
279
280// forget drops an export whose job failed, so a failure holds nothing.
281func (s *exportStore) forget(id string) {
282 s.mu.Lock()
283 defer s.mu.Unlock()
284 delete(s.held, id)
285}
286
287// get answers one export, sweeping first so an expired one is gone
288// rather than merely old.
289func (s *exportStore) get(id string) (heldExport, []string, bool) {
290 s.mu.Lock()
291 defer s.mu.Unlock()
292 dropped := s.sweepLocked()
293 e, ok := s.held[id]
294 if !ok {
295 return heldExport{}, dropped, false
296 }
297 return *e, dropped, true
298}
299
300// sweepLocked drops finished exports past the TTL and returns the store
301// hashes that no live entry refers to any more, for the caller to
302// delete from the bucket. A running job is never swept: its entry is
303// what the status page checks the deck against.
304func (s *exportStore) sweepLocked() []string {
305 cutoff := s.now().Add(-s.ttl)
306 var candidates []string
307 for id, e := range s.held {
308 if !e.done.IsZero() && e.done.Before(cutoff) {
309 if e.hash != "" {
310 candidates = append(candidates, e.hash)
311 }
312 delete(s.held, id)
313 }
314 }
315 // Content addressing means two identical exports share a key, so an
316 // object is only orphaned once nothing left refers to it.
317 var orphans []string
318 for _, hash := range candidates {
319 used := false
320 for _, e := range s.held {
321 if e.hash == hash {
322 used = true
323 break
324 }
325 }
326 if !used {
327 orphans = append(orphans, hash)
328 }
329 }
330 return orphans
331}
332
333// dropObjects deletes swept exports from the object store, best effort:
334// a failure here is a file left in a bucket, which is worth a log line
335// and nothing more.
336func (a *app) dropObjects(ctx context.Context, hashes []string) {
337 if len(hashes) == 0 || a.cfg.Blobs == nil {
338 return
339 }
340 for _, hash := range hashes {
341 if err := a.cfg.Blobs.Delete(ctx, hash); err != nil {
342 a.logger.Error("slides: delete an expired export", "hash", hash, "err", err)
343 }
344 }
345}
346
347// --- GET /d/{sqid}/export ---
348
349// exportPage is the little form, and the notice a refused start lands
350// back on.
351type exportPage struct {
352 shell
353 Title string
354 DeckTitle string
355 Body string
356 NotesLabel string
357 Submit string
358 Notice string // a refused start, from the flash cookie
359 Held string
360 BackHref string
361 BackLabel string
362 Action string
363}
364
365// exportForm is the one screen that starts an export: one job per
366// screen, and the checkbox that decides what the PDF contains is on it
367// rather than beside a button somewhere else.
368func (a *app) exportForm(w http.ResponseWriter, r *http.Request) {
369 u, ok := a.viewer(w, r)
370 if !ok {
371 return
372 }
373 d, ok := a.deckFor(w, r, u)
374 if !ok {
375 return
376 }
377 notice := ""
378 if f, has := flash.Take(w, r); has {
379 notice = f.Message
380 }
381 render(w, "deck_export", exportPage{
382 shell: a.shellFor(r, u, onDeck(rastrillo.T(r, "slides.export.title"), d.Title)),
383 Title: rastrillo.T(r, "slides.export.title"),
384 DeckTitle: d.Title,
385 Body: rastrillo.T(r, "slides.export.body"),
386 NotesLabel: rastrillo.T(r, "slides.export.notes"),
387 Submit: rastrillo.T(r, "slides.export.submit"),
388 Held: rastrillo.T(r, "slides.export.held"),
389 Notice: notice,
390 BackHref: "/d/" + d.Sqid,
391 BackLabel: rastrillo.T(r, "slides.deck.back"),
392 Action: "/d/" + d.Sqid + "/export",
393 })
394}
395
396// --- POST /d/{sqid}/export ---
397
398// exportWords are the sentences an export may have to say, resolved
399// from the catalog while the request that started it is still here.
400//
401// A job outlives its request, and rastrillo.T reads the locale off the
402// request, so a job that translated its own progress line would either
403// be holding a request it must not hold or answering in the wrong
404// language. Resolving them up front is the honest way round: the words
405// belong to the person who pressed the button.
406type exportWords struct {
407 name string
408 progress string
409 open string
410 render string
411 tooMany string
412 tooBig string
413 store string
414}
415
416func exportWordsFor(r *http.Request, title string) exportWords {
417 return exportWords{
418 name: rastrillo.Tf(r, "slides.export.job", "title", title),
419 progress: rastrillo.T(r, "slides.export.progress"),
420 open: rastrillo.T(r, "slides.export.error.open"),
421 render: rastrillo.T(r, "slides.export.error.render"),
422 tooMany: rastrillo.Tf(r, "slides.export.error.toomany", "max", MaxExportSlides),
423 tooBig: rastrillo.T(r, "slides.export.error.toobig"),
424 store: rastrillo.T(r, "slides.export.error.store"),
425 }
426}
427
428// fillWords is rastrillo.Tf's substitution against an already-resolved
429// string: {name} replaced wherever it appears. It exists because a job
430// has words but no request to translate them with (exportWords).
431func fillWords(text string, pairs ...any) string {
432 for i := 0; i+1 < len(pairs); i += 2 {
433 key, ok := pairs[i].(string)
434 if !ok {
435 continue
436 }
437 text = strings.ReplaceAll(text, "{"+key+"}", fmt.Sprint(pairs[i+1]))
438 }
439 return text
440}
441
442// startExport is the button. It refuses two things before it starts a
443// goroutine — a deck over the slide cap, and a person who already has
444// four jobs running — and both refusals are a flash on the form they
445// came from rather than an error page: neither is a fault, and the
446// answer to both is "try again, differently or later".
447func (a *app) startExport(w http.ResponseWriter, r *http.Request) {
448 u, ok := a.viewer(w, r)
449 if !ok {
450 return
451 }
452 d, ok := a.deckFor(w, r, u)
453 if !ok {
454 return
455 }
456 owner, ok := a.sessionSubject(w, r)
457 if !ok {
458 return
459 }
460 back := "/d/" + d.Sqid + "/export"
461
462 // The slide cap, before any work: Export refuses the same deck, but
463 // it would do it inside a job, where the answer is a failed status
464 // page rather than a sentence on the form.
465 if n, counted := a.slideCount(r.Context(), d); counted && n > MaxExportSlides {
466 flash.Set(w, "error", rastrillo.Tf(r, "slides.export.error.toomany", "max", MaxExportSlides))
467 http.Redirect(w, r, back, http.StatusSeeOther)
468 return
469 }
470
471 withNotes := r.FormValue("notes") != ""
472 words := exportWordsFor(r, d.Title)
473
474 // The job's own id is not known until Start returns, and Start has
475 // already begun running the function by then. So the function is
476 // handed its id down a channel and waits for it: what it does
477 // first is record the export against this deck, and doing that
478 // under an id nobody knows yet is not possible.
479 //
480 // Location is deliberately empty. jobs.StatusPage 303s a finished
481 // job to its Location, and the page the owner wants is the one they
482 // are on — the download link and the warnings are ON the status
483 // page, so a Location of the status page itself would be a redirect
484 // loop and a Location of the file would take the warnings away.
485 id := make(chan string, 1)
486 job, err := a.jobs.Start(owner, words.name, "",
487 a.exportJob(id, d, actorFor(u), withNotes, words))
488 switch {
489 case errors.Is(err, jobs.ErrOwnerBusy):
490 flash.Set(w, "error", rastrillo.T(r, "slides.export.error.busy"))
491 http.Redirect(w, r, back, http.StatusSeeOther)
492 return
493 case err != nil:
494 a.fail(w, r, "start an export", err)
495 return
496 }
497 a.startedExport(job.ID, d, owner, actorFor(u), words.name)
498 id <- job.ID
499 http.Redirect(w, r, back+"/"+job.ID, http.StatusSeeOther)
500}
501
502// exportJob is the work itself, as jobs.Start takes it.
503//
504// Everything it needs is captured by value here — the deck row, the
505// actor, the words — because the request is gone by the time it runs.
506// The context is the job's own: it expires after fifteen minutes and is
507// honoured at every step, which is the whole of what "honour ctx" means
508// for work that is one render.
509//
510// Its errors are shown to the person who asked, so they are the
511// catalog's sentences and never a wrapped internal one; what an
512// operator needs is logged here instead.
513func (a *app) exportJob(id <-chan string, d Deck, actor string, withNotes bool, words exportWords) func(context.Context, func(string)) error {
514 return func(ctx context.Context, progress func(string)) error {
515 var jobID string
516 select {
517 case jobID = <-id:
518 case <-ctx.Done():
519 return ctx.Err()
520 }
521
522 h, view, err := a.openDeck(ctx, d)
523 if err != nil {
524 a.logger.Error("slides: open a deck to export", "deck", d.Sqid, "err", err)
525 return errors.New(words.open)
526 }
527 defer h.Close(ctx)
528 if len(view.Slides) > MaxExportSlides {
529 return errors.New(words.tooMany)
530 }
531
532 rt, err := a.pdf.get(ctx)
533 if err != nil {
534 a.logger.Error("slides: compile the pdf module", "err", err)
535 return errors.New(words.render)
536 }
537
538 total := len(view.Slides)
539 pdf, warnings, err := Export(ctx, view, ExportOptions{
540 WithNotes: withNotes,
541 Runtime: rt,
542 Assets: a.assetBytes(ctx, d, view),
543 Progress: func(slide, of int) {
544 progress(fillWords(words.progress, "n", slide, "total", of))
545 },
546 })
547 if err != nil {
548 if errors.Is(err, ErrTooManySlides) {
549 return errors.New(words.tooMany)
550 }
551 a.logger.Error("slides: render an export", "deck", d.Sqid, "slides", total, "err", err)
552 return errors.New(words.render)
553 }
554 a.logger.Info("slides: exported", "deck", d.Sqid, "actor", actor,
555 "slides", total, "notes", withNotes, "bytes", len(pdf), "warnings", len(warnings))
556
557 // With a store, the bytes belong in it and not in this process.
558 if store := a.cfg.Blobs; store != nil {
559 ref, err := store.Put(ctx, bytes.NewReader(pdf), exportContentType)
560 if err != nil {
561 a.logger.Error("slides: store an export", "deck", d.Sqid, "err", err)
562 return errors.New(words.store)
563 }
564 a.exports.stored(jobID, ref.Hash, warnings)
565 return nil
566 }
567 if err := a.exports.finish(jobID, pdf, warnings); err != nil {
568 if errors.Is(err, errExportTooBig) {
569 return errors.New(words.tooBig)
570 }
571 a.logger.Error("slides: hold an export", "deck", d.Sqid, "err", err)
572 return errors.New(words.render)
573 }
574 return nil
575 }
576}
577
578// --- GET /d/{sqid}/export/{id} and .pdf ---
579
580// exportJobRoute is the one route behind two things: the status page,
581// and the file itself at the same address with `.pdf` on the end.
582//
583// One handler because chi matches a whole path segment: `{id}` would
584// swallow `{id}.pdf` whichever order they were registered in, and a
585// second pattern that only sometimes wins is worse than reading the
586// extension here, which is what the thumbnail routes do too.
587func (a *app) exportJobRoute(w http.ResponseWriter, r *http.Request) {
588 if id, isPDF := strings.CutSuffix(chi.URLParam(r, "id"), ".pdf"); isPDF {
589 a.exportPDF(w, r, id)
590 return
591 }
592 if !a.exportJobIsHere(w, r) {
593 return
594 }
595 if d, ok := a.exportFallback(r); ok {
596 a.renderExportPage(w, r, d)
597 return
598 }
599 a.jobPages.StatusPage(w, a.exportPrincipal(r))
600}
601
602// exportFragment and exportEvents are the polled twins of the page,
603// behind the same check.
604func (a *app) exportFragment(w http.ResponseWriter, r *http.Request) {
605 if !a.exportJobIsHere(w, r) {
606 return
607 }
608 if d, ok := a.exportFallback(r); ok {
609 a.renderExportFragment(w, r, d)
610 return
611 }
612 a.jobPages.Fragment(w, a.exportPrincipal(r))
613}
614
615// exportFallback is what the status page and its fragment answer with
616// once rastrillo/jobs has forgotten the job but this app is still
617// holding the file. ok is false in the ordinary case — the registry
618// still has the job — and jobs.StatusPage answers as it always did.
619//
620// The two records have deliberately different lifetimes, and this is
621// where they are made to agree. The registry sweeps a finished job ten
622// minutes after it ends; the bytes stay answerable for an hour, which
623// is what the page itself told the person ("held for an hour"). Left
624// alone, the page 404s for the last fifty of those minutes while
625// `.pdf` at the same address still hands over the file — the page and
626// the download disagreeing about whether the export exists, which is
627// what somebody who left the tab open over lunch would meet. The other
628// way to make them agree was to shorten the hold to the registry's ten
629// minutes, and that throws away a download that was promised and buys
630// nothing. So the page falls back to the record that outlives the
631// registry.
632//
633// Nothing is invented: the name, both times and Done all come off the
634// held record, which this app wrote when it started the job and
635// finished it. A job that failed leaves no finished record, so a
636// failure still expires into a 404 rather than reappearing as a
637// success.
638// It reads the session itself rather than through sessionSubject: a
639// missing one is jobs.StatusPage's 403 to write, not a 500 here on top
640// of it.
641func (a *app) exportFallback(r *http.Request) (jobs.PageData, bool) {
642 r = a.exportPrincipal(r)
643 s, ok := sessions.Current(r)
644 if !ok || s.Subject == "" {
645 return jobs.PageData{}, false
646 }
647 owner := s.Subject
648 id := chi.URLParam(r, "id")
649 if _, live := a.jobs.Get(id, owner); live {
650 return jobs.PageData{}, false
651 }
652 held, dropped, found := a.exports.get(id)
653 a.dropObjects(r.Context(), dropped)
654 if !found || held.owner != owner || held.done.IsZero() {
655 return jobs.PageData{}, false
656 }
657 // PollSeconds is left at zero: the template reads it only while the
658 // job is running, and this job is finished by construction.
659 return jobs.PageData{Job: jobs.Job{
660 ID: id,
661 Owner: owner,
662 Name: held.name,
663 Status: jobs.Done,
664 StartedAt: held.started,
665 FinishedAt: held.done,
666 }}, true
667}
668
669func (a *app) exportEvents(w http.ResponseWriter, r *http.Request) {
670 if !a.exportJobIsHere(w, r) {
671 return
672 }
673 a.jobPages.Events(w, a.exportPrincipal(r))
674}
675
676// exportJobIsHere is the deck check rastrillo/jobs cannot make: it
677// knows the owner and nothing about a deck, and these URLs name both.
678// A job of this person's for ANOTHER deck, addressed under this one, is
679// a 404 like every other mismatched capability in this app.
680//
681// The deck itself is resolved through deckFor, so a person who cannot
682// read the deck never reaches the job. A job the registry has swept
683// still reaches the status page: exportFallback answers it from this
684// app's own record, which is the one whose TTL the page quoted.
685func (a *app) exportJobIsHere(w http.ResponseWriter, r *http.Request) bool {
686 u, ok := a.viewer(w, r)
687 if !ok {
688 return false
689 }
690 d, ok := a.deckFor(w, r, u)
691 if !ok {
692 return false
693 }
694 held, dropped, found := a.exports.get(chi.URLParam(r, "id"))
695 a.dropObjects(r.Context(), dropped)
696 if found && held.deck != d.Sqid {
697 a.notFound(w, r)
698 return false
699 }
700 return true
701}
702
703// exportPDF serves the file: the held bytes, or a redirect to the
704// object store, from one handler so the two paths cannot drift.
705//
706// Three gates, all of them 404 on failure: the deck has to be one this
707// person may read, the export has to be for THIS deck, and it has to be
708// theirs — heldExport.owner, compared against the session's subject,
709// which is the same string rastrillo/jobs keys a job by.
710//
711// Its OWN record rather than jobs.Get: a finished job is swept from the
712// registry ten minutes after it ends, and the file is answerable for an
713// hour, which is what the page told the person. Asking the registry
714// would take the download away fifty minutes early. The status page
715// reads the same record for the same fifty minutes (exportFallback), so
716// the page and the file agree about whether the export is still there.
717func (a *app) exportPDF(w http.ResponseWriter, r *http.Request, id string) {
718 u, ok := a.viewer(w, r)
719 if !ok {
720 return
721 }
722 d, ok := a.deckFor(w, r, u)
723 if !ok {
724 return
725 }
726 owner, ok := a.sessionSubject(w, r)
727 if !ok {
728 return
729 }
730 held, dropped, found := a.exports.get(id)
731 a.dropObjects(r.Context(), dropped)
732 if !found || held.deck != d.Sqid || held.owner != owner || held.done.IsZero() {
733 a.notFound(w, r)
734 return
735 }
736 a.serveExport(w, r, held, func() { a.notFound(w, r) })
737}
738
739// serveExport writes one finished export, whichever of the two places
740// it is in. missing is how the surface asking says "no longer here" —
741// the app's 404 page on a screen, the API's JSON on the other side.
742func (a *app) serveExport(w http.ResponseWriter, r *http.Request, held heldExport, missing func()) {
743 if held.hash != "" {
744 store := a.cfg.Blobs
745 if store == nil {
746 missing()
747 return
748 }
749 // A store that can presign hands the browser a URL of its own
750 // and this process never sees the bytes — the point of putting
751 // them there. One that cannot (blobs.Dir, in development) is
752 // streamed back through here, so the same page works either way.
753 if p, canPresign := store.(presigner); canPresign {
754 url, err := p.PresignGet(held.hash, presignTTL)
755 if err != nil {
756 a.fail(w, r, "presign an export", err)
757 return
758 }
759 http.Redirect(w, r, url, http.StatusFound)
760 return
761 }
762 rc, err := store.Get(r.Context(), held.hash)
763 if err != nil {
764 if errors.Is(err, blobs.ErrNotFound) {
765 missing()
766 return
767 }
768 a.fail(w, r, "read an export", err)
769 return
770 }
771 defer rc.Close()
772 exportHeaders(w, held.filename)
773 io.Copy(w, rc)
774 return
775 }
776 if len(held.bytes) == 0 {
777 missing()
778 return
779 }
780 exportHeaders(w, held.filename)
781 w.Header().Set("Content-Length", strconv.Itoa(len(held.bytes)))
782 w.Write(held.bytes)
783}
784
785// exportHeaders says what the file is and what to call it.
786//
787// Both spellings of the name: the ASCII one every client understands,
788// and the RFC 5987 one that carries a deck called "Präsentation"
789// intact. no-store because the URL is a job's capability and the file
790// is somebody's document — nothing in between should keep a copy.
791func exportHeaders(w http.ResponseWriter, filename string) {
792 w.Header().Set("Content-Type", exportContentType)
793 w.Header().Set("X-Content-Type-Options", "nosniff")
794 w.Header().Set("Cache-Control", "private, no-store")
795 w.Header().Set("Content-Disposition", fmt.Sprintf(
796 `attachment; filename="%s"; filename*=UTF-8''%s`,
797 asciiFilename(filename), url.PathEscape(filename)))
798}
799
800// pdfFilename turns a deck's title into something a file manager will
801// take: the title's own words, anything else dropped, bounded, and
802// never empty.
803func pdfFilename(title string) string {
804 var b strings.Builder
805 lastSpace := false
806 for _, r := range strings.TrimSpace(title) {
807 switch {
808 case r == '/' || r == '\\' || r == ':' || r == '"' || r < 0x20 || r == 0x7f:
809 // A path separator, a quote or a control character: dropped
810 // rather than replaced, because none of them is part of a
811 // name anybody typed on purpose.
812 case r == ' ' || r == '\t':
813 if !lastSpace && b.Len() > 0 {
814 b.WriteByte(' ')
815 lastSpace = true
816 }
817 default:
818 b.WriteRune(r)
819 lastSpace = false
820 }
821 if b.Len() > 80 {
822 break
823 }
824 }
825 name := strings.TrimSpace(b.String())
826 if name == "" {
827 name = "deck"
828 }
829 return name + ".pdf"
830}
831
832// asciiFilename is the plain-ASCII fallback of the same name, for the
833// unstarred filename parameter — which is a quoted string in a header
834// and may hold nothing else.
835func asciiFilename(name string) string {
836 var b strings.Builder
837 for _, r := range name {
838 if r < 0x20 || r > 0x7e || r == '"' || r == '\\' {
839 b.WriteByte('-')
840 continue
841 }
842 b.WriteRune(r)
843 }
844 return b.String()
845}
846
847// --- the status page ---
848
849// exportWarning is one line of the warnings list: the slide it is
850// about, and what happened.
851type exportWarning struct {
852 Slide string
853 Detail string
854}
855
856// exportStatus is the status page and its polled fragment — one struct,
857// because they are one rendering seen through two holes (render.go
858// builds the same file as a page and as a fragment).
859type exportStatus struct {
860 shell
861 Title string
862 DeckTitle string
863 BackHref string
864 BackLabel string
865
866 // The job-status partial's own keys.
867 Name string
868 Status string
869 Progress string
870 Err string
871 PollURL string
872 PushURL string
873 PollSeconds int
874
875 Running bool
876 Failed bool
877
878 Done string
879 DownloadHref string
880 DownloadLabel string
881 Held string
882 Gone string
883 AgainHref string
884 AgainLabel string
885 WarningsLabel string
886 WarningsBody string
887 Warnings []exportWarning
888}
889
890// renderExportPage and renderExportFragment are jobs.Config's two
891// seams. The page is the whole screen; the fragment is the ONE element
892// the shim swaps in, which is why the running and finished states are
893// alternatives rather than a wrapper around a partial: the shim keeps
894// the first element of what it fetches and drops the rest.
895func (a *app) renderExportPage(w http.ResponseWriter, r *http.Request, d jobs.PageData) {
896 r = restoreExportPrincipal(r)
897 u, ok := a.viewer(w, r)
898 if !ok {
899 return
900 }
901 page := a.exportStatusFor(r, d)
902 page.shell = a.shellFor(r, u, titled(page.Title))
903 render(w, "export_status", page)
904}
905
906func (a *app) renderExportFragment(w http.ResponseWriter, r *http.Request, d jobs.PageData) {
907 r = restoreExportPrincipal(r)
908 renderFragment(w, "export_status", "export-body", a.exportStatusFor(r, d))
909}
910
911// exportStatusFor is the view data for both, built from the job's own
912// snapshot and this app's record of what the job produced.
913func (a *app) exportStatusFor(r *http.Request, d jobs.PageData) exportStatus {
914 sqid := chi.URLParam(r, "sqid")
915 base := "/d/" + sqid + "/export"
916 self := base + "/" + d.Job.ID
917 page := exportStatus{
918 Title: rastrillo.T(r, "slides.export.title"),
919 BackHref: "/d/" + sqid,
920 BackLabel: rastrillo.T(r, "slides.deck.back"),
921 Name: d.Job.Name,
922 Status: string(d.Job.Status),
923 Progress: d.Job.Progress,
924 Err: d.Job.Err,
925 PollURL: self + "/fragment",
926 PushURL: self + "/events",
927 PollSeconds: d.PollSeconds,
928 Running: d.Job.Status == jobs.Running,
929 Failed: d.Job.Status == jobs.Failed,
930 AgainHref: base,
931 AgainLabel: rastrillo.T(r, "slides.export.again"),
932 }
933 // FragmentPath and EventsPath off PageData are rastrillo's own
934 // /jobs/{id}/… mounting, which this app does not use: these pages
935 // live under the deck they belong to, so the paths are built here.
936 if page.Running || page.Failed {
937 return page
938 }
939
940 held, dropped, found := a.exports.get(d.Job.ID)
941 a.dropObjects(r.Context(), dropped)
942 if !found || held.done.IsZero() {
943 // The job says it finished and the bytes are not here: the hour
944 // is up, or the process holding them was replaced.
945 page.Gone = rastrillo.T(r, "slides.export.gone")
946 return page
947 }
948 page.Done = rastrillo.T(r, "slides.export.done")
949 page.DownloadHref = self + ".pdf"
950 page.DownloadLabel = rastrillo.T(r, "slides.export.download")
951 page.Held = rastrillo.T(r, "slides.export.held")
952 if len(held.warnings) > 0 {
953 page.WarningsLabel = rastrillo.T(r, "slides.export.warnings")
954 page.WarningsBody = rastrillo.T(r, "slides.export.warnings.body")
955 page.Warnings = a.exportWarningsFor(r, held)
956 }
957 return page
958}
959
960// exportWarningsFor turns the export's warnings into lines a person can
961// act on: the slide's own label rather than its id, and the sentence
962// the layout engine or the export wrote.
963//
964// The deck is opened once for the labels, and a deck that will not open
965// costs the labels and not the list — a warning with no slide name is
966// still worth reading.
967func (a *app) exportWarningsFor(r *http.Request, held heldExport) []exportWarning {
968 labels := map[string]string{}
969 var d Deck
970 if err := a.db.G.WithContext(r.Context()).Where("sqid = ?", held.deck).Take(&d).Error; err == nil {
971 if h, view, err := a.openDeck(r.Context(), d); err == nil {
972 defer h.Close(r.Context())
973 for i, s := range view.Slides {
974 labels[s.ID] = slideLabel(r, s, i+1)
975 }
976 }
977 }
978 out := make([]exportWarning, 0, len(held.warnings))
979 for _, warn := range held.warnings {
980 out = append(out, exportWarning{Slide: labels[warn.Slide], Detail: warn.Detail})
981 }
982 return out
983}
984
985// --- the drive API's twins (spec §7.2) ---
986//
987// Here rather than in api.go because they are the export, and the
988// export is one thing: the same job, the same held bytes, the same
989// caps, answered in the API's own shapes.
990//
991// An agent's export is owned by its OPERATOR — the person the agent
992// acts for — spelled as the users row's address, which is the same
993// string a session's Subject carries. So an operator watching from the
994// API and an operator watching from the screen are watching one job.
995
996// apiExportOwner is that string. An agent owns nothing (agents.go): a
997// token can be revoked and a job it started must not become
998// unwatchable because it was.
999func apiExportOwner(p principal) string { return p.operator.Email }
1000
1001// apiStartExport serves POST /api/v1/decks/{sqid}/export. 202, because
1002// nothing is ready yet: what comes back is where to watch and where
1003// the file will be.
1004func (a *app) apiStartExport(w http.ResponseWriter, r *http.Request) {
1005 p, ok := a.apiPrincipal(w, r)
1006 if !ok {
1007 return
1008 }
1009 d, ok := a.apiDeckFor(w, r, p)
1010 if !ok {
1011 return
1012 }
1013 var body struct {
1014 Notes bool `json:"notes"`
1015 }
1016 // An empty body is the ordinary case ("just export it"), and
1017 // readJSON refuses one, so only a body that IS there is read.
1018 if r.ContentLength > 0 && !a.readJSON(w, r, &body) {
1019 return
1020 }
1021 if n, counted := a.slideCount(r.Context(), d); counted && n > MaxExportSlides {
1022 a.apiInvalid(w, fmt.Sprintf("this deck has %d slides, and the most one export will take is %d", n, MaxExportSlides), -1)
1023 return
1024 }
1025
1026 owner := apiExportOwner(p)
1027 words := exportWordsFor(r, d.Title)
1028 id := make(chan string, 1)
1029 job, err := a.jobs.Start(owner, words.name, "", a.exportJob(id, d, p.actor(), body.Notes, words))
1030 switch {
1031 case errors.Is(err, jobs.ErrOwnerBusy):
1032 // 429: this is a rate, not a fault in the request. The caller
1033 // waits for one of its exports to finish.
1034 apiFail(w, http.StatusTooManyRequests, "busy")
1035 return
1036 case err != nil:
1037 a.logger.Error("slides: start an export for the API", "deck", d.Sqid, "err", err)
1038 apiFail(w, http.StatusInternalServerError, errInternal)
1039 return
1040 }
1041 a.startedExport(job.ID, d, owner, p.actor(), words.name)
1042 id <- job.ID
1043
1044 base := fmt.Sprintf("%s/decks/%s/export/%s", apiPrefix, d.Sqid, job.ID)
1045 a.writeJSON(w, http.StatusAccepted, map[string]any{
1046 "job": job.ID,
1047 "status_url": base,
1048 "pdf_url": base + ".pdf",
1049 })
1050}
1051
1052// apiExportStatus serves GET /api/v1/decks/{sqid}/export/{id} and, with
1053// `.pdf` on the end, the file itself — one handler, exportJobRoute's
1054// reason.
1055func (a *app) apiExportStatus(w http.ResponseWriter, r *http.Request) {
1056 p, ok := a.apiPrincipal(w, r)
1057 if !ok {
1058 return
1059 }
1060 d, ok := a.apiDeckFor(w, r, p)
1061 if !ok {
1062 return
1063 }
1064 id, wantsPDF := strings.CutSuffix(chi.URLParam(r, "id"), ".pdf")
1065 owner := apiExportOwner(p)
1066 held, dropped, found := a.exports.get(id)
1067 a.dropObjects(r.Context(), dropped)
1068 if found && (held.deck != d.Sqid || held.owner != owner) {
1069 apiFail(w, http.StatusNotFound, errNotFound)
1070 return
1071 }
1072
1073 // The file outlives the job record (exportPDF says why), so both
1074 // halves of this endpoint ask this app's own record — the download
1075 // for the bytes, the status for the state once the registry has
1076 // forgotten the job. A caller polling here must not be told the job
1077 // never existed while `.pdf` at the same address still answers.
1078 if wantsPDF {
1079 if !found || held.done.IsZero() {
1080 apiFail(w, http.StatusNotFound, errNotFound)
1081 return
1082 }
1083 a.serveExport(w, r, held, func() { apiFail(w, http.StatusNotFound, errNotFound) })
1084 return
1085 }
1086
1087 job, isTheirs := a.jobs.Get(id, owner)
1088 if !isTheirs {
1089 // exportFallback's answer in the API's shape: a finished export
1090 // this app is still holding, rebuilt from that record.
1091 if !found || held.done.IsZero() {
1092 apiFail(w, http.StatusNotFound, errNotFound)
1093 return
1094 }
1095 job = jobs.Job{
1096 ID: id,
1097 Owner: owner,
1098 Name: held.name,
1099 Status: jobs.Done,
1100 StartedAt: held.started,
1101 FinishedAt: held.done,
1102 }
1103 }
1104
1105 // state, not "status": the word a caller branches on, and the same
1106 // three rastrillo/jobs has. The warnings are the export's own and
1107 // are only there once there is a file to warn about.
1108 out := map[string]any{"state": string(job.Status), "progress": job.Progress}
1109 if job.Err != "" {
1110 out["message"] = job.Err
1111 }
1112 if job.Status == jobs.Done {
1113 warnings := []layout.Warning{}
1114 if found {
1115 warnings = append(warnings, held.warnings...)
1116 }
1117 out["warnings"] = warnings
1118 if !found || held.done.IsZero() {
1119 // Finished, and the file has expired out from under the
1120 // caller. Said plainly rather than as a 404 on this
1121 // endpoint, which would read as "no such job".
1122 out["expired"] = true
1123 }
1124 }
1125 a.writeJSON(w, http.StatusOK, out)
1126}
1127
1128// --- shared bits ---
1129
1130// sessionSubject is the string rastrillo/jobs keys a job by: the
1131// session's Subject, which under rastrillo/auth is an email address.
1132// Past RequireSession there is always one, so its absence is a 500 and
1133// not a redirect.
1134//
1135// It is the one place in this app that reads a Subject for anything but
1136// mapping it to a users row (auth.go's currentUser and its warning
1137// about sessions.UserID): jobs.Get answers the owner and nobody else,
1138// and the owner it compares against is this string.
1139func (a *app) sessionSubject(w http.ResponseWriter, r *http.Request) (string, bool) {
1140 r = a.exportPrincipal(r)
1141 s, ok := sessions.Current(r)
1142 if !ok || s.Subject == "" {
1143 a.fail(w, r, "no session subject past the session gate", nil)
1144 return "", false
1145 }
1146 return s.Subject, true
1147}
1148
1149// startedExport is the record this app keeps beside rastrillo's job: it
1150// exists from the moment the job starts so the status and download
1151// routes have a deck to check against, and it is where the finished
1152// bytes land.
1153func (a *app) startedExport(id string, d Deck, owner, actor, name string) {
1154 dropped := a.exports.begin(id, heldExport{
1155 deck: d.Sqid,
1156 owner: owner,
1157 actor: actor,
1158 name: name,
1159 filename: pdfFilename(d.Title),
1160 })
1161 a.dropObjects(context.Background(), dropped)
1162}
1163