| 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. |
| 44 | package slides |
| 45 | |
| 46 | import ( |
| 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. |
| 79 | const ( |
| 80 | exportHoldFor = time.Hour |
| 81 | exportPerDeckBytes = 50 << 20 |
| 82 | exportPerInstanceBytes = 200 << 20 |
| 83 | ) |
| 84 | |
| 85 | // exportContentType is what the file is, said once. |
| 86 | const 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. |
| 102 | type pdfRuntime struct { |
| 103 | once sync.Once |
| 104 | rt *pdfcore.Runtime |
| 105 | err error |
| 106 | } |
| 107 | |
| 108 | func (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. |
| 115 | func (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. |
| 132 | type 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. |
| 153 | type 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 | |
| 165 | func 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. |
| 183 | func (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. |
| 202 | func (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. |
| 219 | func (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. |
| 230 | var 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. |
| 236 | func (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. |
| 267 | func (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. |
| 281 | func (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. |
| 289 | func (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. |
| 304 | func (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. |
| 336 | func (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. |
| 351 | type 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. |
| 368 | func (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. |
| 406 | type 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 | |
| 416 | func 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). |
| 431 | func 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". |
| 447 | func (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. |
| 513 | func (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. |
| 587 | func (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. |
| 604 | func (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. |
| 641 | func (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 | |
| 669 | func (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. |
| 685 | func (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. |
| 717 | func (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. |
| 742 | func (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. |
| 791 | func 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. |
| 803 | func 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. |
| 835 | func 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. |
| 851 | type 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). |
| 859 | type 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. |
| 895 | func (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 | |
| 906 | func (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. |
| 913 | func (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. |
| 967 | func (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. |
| 999 | func 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. |
| 1004 | func (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. |
| 1055 | func (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. |
| 1139 | func (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. |
| 1153 | func (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 | |