feat: PDF export job, screens and API; thumbnails documented; warnings carry slide ids; wasm memory limits; WebP
POST /d/{sqid}/export starts a rastrillo job over Export; the status
page (rastrillo/jobs StatusPage, Fragment and Events under the deck,
in the app's own shell, with a <noscript> refresh while running) lists
the warnings by slide label and offers the file at
/d/{sqid}/export/{job}.pdf. With no object store the PDF is held in
memory for an hour under 50 MiB per deck and 200 MiB per instance; with
one it is uploaded and the download 302s to a presigned GET. The API's
twins are POST /api/v1/decks/{sqid}/export (202) and the two GETs.
Carried over from tasks 3 and 4: layout.Warning names its slide;
WithMemoryLimitPages on both wazero hosts (512 MiB pdfcore, 256 MiB
core) with over-limit-is-an-error tests; golang.org/x/image/webp so a
WebP is re-encoded to PNG rather than dropped; the PDF runtime is the
app's, built lazily and closed with it. docs/api.md documents the .svg
thumbnail route and the export; spec §4.2/§4.3 amended to what shipped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYaKcMy5XrMcsfizRXcRSY
AGENTS.md+71 −14PRODUCT.md+39 −18README.md+16 −5docs/api.md+88 −1docs/superpowers/specs/2026-09-07-slides-design.md+44 −1go.mod+2 −1go.sum+5 −0internal/slides/api.go+6 −0internal/slides/app.go+35 −2internal/slides/assets.go+8 −3internal/slides/core/core.go+34 −2internal/slides/core/core_test.go+40 −0internal/slides/export.go+43 −15internal/slides/export_test.go+69 −15internal/slides/exports.go+1053 −0internal/slides/handlers.go+22 −0internal/slides/layout/layout.go+20 −6internal/slides/layout/layout_test.go+30 −0internal/slides/layout/paint.go+9 −1internal/slides/locales/en.toml+40 −0internal/slides/pdfcore/pdfcore.go+37 −5internal/slides/pdfcore/pdfcore_test.go+32 −0internal/slides/render.go+45 −1internal/slides/svg/svg.go+44 −7internal/slides/svg/svg_test.go+59 −7internal/slides/templates/deck_export.html+24 −0internal/slides/templates/export_status.html+53 −0internal/slides/templates/viewer.html+5 −1internal/slides/thumbs.go+9 −5internal/slides/viewer.go+10 −0internal/slidestest/export_test.go+373 −0internal/slidestest/harness_test.go+10 −1
Intent
<task-notification> <task-id>a09e8f0a49724e84b</task-id> <tool-use-id>toolu_01WeFsA8gj1AT7taW16Tfui4</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a09e8f0a49724e84b.output</output-file> <status>completed</status> <summary>Agent "S3 Task 3: SVG and thumbnails" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Status: done. Working tree clean; nothing of the concurrent agent's touched. Commit: `6c757a4 feat: SVG encoder and thumbnails on every surface; grid gap fix` Tests: `make ci` green; `go test -tags browser ./...` green (browser drive ran, sandbox off — it fails on `socket()` inside the sandbox as predicted). The layout drive now pins the region gap at two viewport widths (43.68 at 1920, 17.745 at 780), which is the assertion that would have caught the original bug. `npm test` in fixtures 138/138. Bundle rebuilt reproducibly (same hash twice). Concerns: an SVG in an `<img>` fetches nothing, so thumbnails set type in whatever face the reader has locally (Liberation is not embedded — ~800KB/thumb would be); WebP cannot be inlined at all (stdlib has no decoder, so the brief's "re-encode to PNG" is impossible under the constraint) and falls back to its URL; inlining reads asset bytes per thumbnail miss; the remote's preview lost its phone reflow and is now the 16:9 picture; `docs/api.md` still lacks the `.svg` API route (task 5). Report: `/home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-08-slice-3-export/task-3-report.md`</result> <usage><subagent_tokens>346211</subagent_tokens><tool_uses>184</tool_uses><duration_ms>1976673</duration_ms></usage> </task-notification>
<task-notification> <task-id>aeeb7cccab22c3bc4</task-id> <tool-use-id>toolu_01URDqYxVFZd1ockjuXCRfY5</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/aeeb7cccab22c3bc4.output</output-file> <status>completed</status> <summary>Agent "Review slice 3 Task 3" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>I have thorough evidence now. Writing the final review. ### Spec Compliance All controller decisions verified against the committed diff (base `0ca08b0` → head `6c757a4`) and confirmed by running the code (`go build ./...` clean; targeted `go test` on `svg`, `slidestest`, `layout` packages all pass; `node --test strip.test.mjs` passes): - `svg.Encode(p, Options{Title, InlineImages, AssetURL})` — matches exactly (`internal/slides/svg/svg.go:61-84`). - SVG 1.1, `viewBox="0 0 1920 1080"`, `role="img"`, `<title>` — present (`svg.go:98-103`). - One `<text>` per line, `<tspan>` per run, each run given its own `x` via `fonts.Advance` — present and tested (`TestEachRunIsPlacedAtItsOwnX`). - Links: http/https/mailto only, tested directly against `javascript:`, `data:`, `file:`, `vbscript:` (`TestOnlyWebAndMailLinksSurvive`). - Shapes with `rx`, arrow `<marker>`s, `fill="none"` written explicitly — present and tested. - Images: data URI when `InlineImages` succeeds (PNG/JPEG/GIF sniffed server-side, never client-declared), else `AssetURL`; `preserveAspectRatio`/`clipPath` for cover — present and tested. - Always well-formed XML: `esc` escapes and strips XML-1.0-illegal characters; every test parses output back with `encoding/xml`; `thumbs_test.go` parses every sample-deck slide (`TestEverySampleSlideIsWellFormedSVG`, verified passing). - Four thumbnail surfaces wired (`/d/…`, `/s/{token}/…`, `/p/{session}/…`, `/api/v1/…`), `image/svg+xml`, CSP, `nosniff`, `ETag` from `(seq, id, w)` with 304, `Cache-Control: private, max-age=0, must-revalidate` — all present, all covered by new `internal/slidestest/thumbs_test.go` (verified passing, including revoked-link 404 and stranger-403/404 cases). - Strip and presenter/remote switched to `<img>`; strip's `?seq=` cache-bust is deck-seq-based and only refetches slides `strip.mark()`ed dirty (per-slide for text/structure edits, deck-wide for theme/meta/asset changes) — correct and matches spec. - `slides.css` gap bug fixed by moving the template grid onto a `[slides-frame]` descendant of the query container; `regions.go`/`sizes.go` and every golden re-measured; browser test now pins the gap at two widths (1920→43.68, 780→17.745), with the arithmetic checked independently against `sizes.go`. - Performance test present, budget 50ms/slide, warm-up pass, skipped under `-short` (verified: matches brief verbatim). ### Strengths - The named-risk items are handled correctly and demonstrably: the 304 check in `serveThumb` happens strictly before `openDeck` (the expensive wasm replay) since the ETag is built from `d.Seq`, a plain SQL column already loaded by the capability helper (`deckForShareLink`/`presentationFor`/`deckFor`) — confirmed by reading `thumbs.go:124-136` and `models.go`'s `Deck.Seq`. - XSS surface is well covered: every text/title/fill/font-family value goes through `esc`; hrefs are allowlisted and escaped; `AssetURL` output is escaped; inlined image bytes are always server-sniffed (`sniffImage`), never client-declared MIME; the one raw-splice path (nested SVG assets) is gated behind the pre-existing `sanitiseSVG` registration-time pass and this package's own additional `<style>`/directive/proc-inst refusal — a defensible, tested design, not a new hole. - Capability resolution is fully reused (not reimplemented) across all four thumbnail routes, so revoked-link→404 and ended-session→410 behavior is inherited from the same helpers the page routes use, and each surface's `AssetURL` fallback is scoped to that surface's own base path — no cross-capability leakage. - Test coverage is unusually thorough: `internal/slidestest/thumbs_test.go` exercises all four surfaces, ETag revalidation, `?w=` bucketing, missing-slide (no stray ETag on 404), share-link revocation closing the thumbnail, and stranger/anonymous access denial — all verified passing. - The report is candid and technically accurate everywhere I checked it against the diff (WebP/GIF handling, per-request asset-read cost, nested-`<style>` refusal, remote losing phone reflow, stale `docs/api.md` line, sample-deck slide count vs. plan's "nine"). ### Issues **Critical:** none found. **Important:** - The presenter's notes window and the remote (`presentation.go`'s `slideThumbImg`) now render the current/next slide as a fixed 16:9 picture instead of `render.Phone`'s reflowed HTML. Spec §3.4 literally calls these "current and next slide thumbnails," so this is textually compliant, and it's a reasonable call — but it is a real readability regression on a phone-sized remote (fixed-width text scaled down like a photo, rather than reflowed larger). This is a product judgment call the report flags itself; I'd want explicit sign-off that "thumbnail" was meant to override the phone-reflow rule here, rather than treat this task's diff as having silently settled it. - `docs/api.md:200` still says "(An SVG thumbnail lands beside it later.)" — now false. Explicitly out of task 3's scope (task 5 brief owns `docs/api.md`), so not a task-3 defect, but flagging so it isn't lost. **Minor:** - `thumbHeaders`' CSP includes `style-src 'unsafe-inline'`, which nothing the encoder emits appears to need (no `style=` attributes or `<style>` elements are ever written — nested SVGs with `<style>` are explicitly refused). Not a vulnerability (doesn't enable script execution), but worth trimming or explaining in a comment. - No dedicated test exercises an *ended* presentation session hitting `/p/{session}/slides/{id}.svg` (410 vs 404) directly, though the underlying `presentationFor`/`presentationOver` logic is shared and presumably covered elsewhere for the page route. - `svg.inlineTypes` includes `image/gif`, but `sniffImage` (pre-existing, `assets.go`) never detects GIF and the app doesn't accept GIF uploads at all (spec §2.7 lists PNG/JPEG/WebP/SVG) — dead code, apparently there to match what `svg2pdf` can decode for task 4's PDF path rather than a thumbnail bug. Worth a one-line comment tying it to task 4 so a future reader doesn't take it as a thumbnail gap. ### Assessment **Approved.** Task 3 meets its brief and the controller decisions in full, matches spec §4.2's letter, and every named risk I checked resolves correctly (XSS surface, capability isolation, ETag-before-openDeck ordering, gap-fix consistency at two widths, per-slide cache-bust granularity, no English literals in JS, XML well-formedness). The two Important items are judgment calls already surfaced honestly in the report (remote phone-reflow tradeoff, stale docs line explicitly deferred to task 5) rather than defects introduced by this task, and the Minor items are cosmetic/forward-compatibility notes. I independently rebuilt the module and ran the SVG/thumbs/layout Go tests plus the JS strip test; all pass.</result> <usage><subagent_tokens>161282</subagent_tokens><tool_uses>64</tool_uses><duration_ms>288716</duration_ms></usage> </task-notification>
<task-notification> <task-id>ac09f0e9faee236ae</task-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/c8f62d65-b2d1-4a65-b79e-6a8e82e1e5c6/tasks/ac09f0e9faee236ae.output</output-file> <status>failed</status> <summary>Agent "S3 Task 5: export job, API, docs" failed: Agent terminated early due to an API error: You've hit your session limit · resets 4:40pm (Europe/Dublin) (error type rate_limit, HTTP 429, request id req_011Cer7iF1JFHgQK1QSaXvtd, model sent to the API: claude-opus-5)</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Now the spec amendments to §4.2/§4.3.</result> </task-notification>
Can you continue, co-ordinating with fable and codex?
diff --git a/AGENTS.md b/AGENTS.md| index 3e17881..ff40176 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -40,9 +40,9 @@ representation of a deck, you have almost certainly taken a wrong turn. |
| ## What is actually built |
| -Slices 1 and 2. A deck is edited in the browser at `/d/{sqid}/edit`, or |
| -driven through the API (`docs/api.md`), or it arrives as the sample deck |
| -seeded on first sign-in. |
| +Slices 1, 2 and 3. A deck is edited in the browser at `/d/{sqid}/edit`, |
| +driven through the API (`docs/api.md`), or arrives as the sample deck |
| +seeded on first sign-in — and comes back out as a PDF. |
| - **Sign-in** is a magic link (`internal/slides/auth.go`), gated by |
| `SLIDES_ALLOWED_ADDRESSES` in front of `POST /signin` as well as in |
| @@ -78,12 +78,25 @@ seeded on first sign-in. |
| - **Assets** (`assets.go`, `svg.go`): presigned PUT browser-to-bucket, |
| registration that re-reads and sniffs what actually landed, SVG |
| sanitised, and three capability-checked routes serving the bytes back. |
| - |
| -Not built, and don't write around their absence: PDF export and the SVG |
| -layout engine, charts from Sheets, video and embeds, an MCP adapter, |
| -aspect ratios other than 16:9, comments, and version history. Those are |
| -slice 3 and the triggers in spec §9. `docs/api.md` is the reference for |
| -the API, written for a person and an agent both. |
| +- **The layout engine and thumbnails** (`layout/`, `svg/`, `fonts/`, |
| + `thumbs.go`): every slide measured into a paint list with the same |
| + embedded Liberation faces the browser sets it in, written as an SVG, |
| + and served as `{surface}/slides/{id}.svg` from all four capabilities. |
| + The editor strip, the presenter's next-slide and the remote are that |
| + picture. |
| +- **PDF export** (`export.go`, `exports.go`, `pdfcore/`, `core/pdf/`): |
| + `POST /d/{sqid}/export` starts a rastrillo job, the status page polls |
| + itself (and works with scripts off), and the file comes back from |
| + `GET /d/{sqid}/export/{job}.pdf`. One page per slide, optionally the |
| + speaker notes as pages of their own, text as embedded glyphs, images |
| + inlined. The API twins are `POST /api/v1/decks/{sqid}/export` and its |
| + two GETs. |
| + |
| +Not built, and don't write around their absence: charts from Sheets, |
| +video and embeds, an MCP adapter, aspect ratios other than 16:9, |
| +comments, and version history. Those are the triggers in spec §9. |
| +`docs/api.md` is the reference for the API, written for a person and an |
| +agent both. |
| **The deployment has no object store bound today** (`carlos store create` |
| is the fix, and nobody has run it). The upload endpoints answer |
| @@ -97,9 +110,12 @@ than letting somebody pick a file and find out afterwards. |
| plus `templates/` — with one file per screen area beside them (`auth.go`, |
| `decks.go`, `viewer.go`, `share.go`, `presentation.go`, `sample.go`, |
| `live.go`, `editor.go`, `snapshot.go`, `assets.go`, `svg.go`, |
| -`assets_embed.go`) and one package per concern nested underneath: `core` |
| -(the wazero host), `deck` (Go types for the core's JSON view), `render` |
| -(slide HTML), `present` (the presentation SSE hub), `live` (the deck's own |
| +`thumbs.go`, `export.go`, `exports.go`, `assets_embed.go`) and one |
| +package per concern nested underneath: `core` (the wazero host), `deck` |
| +(Go types for the core's JSON view), `render` (slide HTML), `layout` |
| +(the paint list), `svg` (the paint list as a picture), `fonts` (the |
| +embedded faces and their metrics), `pdfcore` (the PDF module's wazero |
| +host), `present` (the presentation SSE hub), `live` (the deck's own |
| hub: seqs and presence). `internal/slides/static/` holds the vendored |
| stylesheets and the committed editor bundle. `core/` at the repo root is |
| the Rust crate. `manifest/` and `gen/` are rastrillo's declarative path, |
| @@ -117,6 +133,15 @@ including the notes pages, which are paint lists typeset here with the |
| fonts package. `scripts/build-pdf-wasm.sh` builds the module; the |
| artifact is committed at `internal/slides/pdfcore/testdata/slides_pdf.wasm` |
| and the gate rebuilds and `cmp`s it exactly as it does the CRDT core's. |
| +`internal/slides/exports.go` is everything around that pipeline: the |
| +screens, the job, and the bytes. |
| + |
| +Both wasm hosts cap the memory one instance may take — 512 MiB for |
| +`pdfcore`, 256 MiB for `core` (`defaultMemoryPages` in each) — because |
| +both run a parser over bytes a deck author controls on an instance |
| +shared with everything else. Past the cap the guest's allocator aborts |
| +and the host reports an ordinary error, which is what the two |
| +`…PastTheMemoryLimitIsAnErrorNotACrash` tests hold. |
| `fixtures/` at the repo root is the browser half and the contract that |
| keeps it honest: the editor's ES modules, their `node --test` tests, |
| @@ -201,6 +226,31 @@ sanitised the bytes: `register_asset` is an op any member can apply with |
| any ref, and a presigned PUT lands whatever the browser sent, so the map |
| alone would let an unverified object be served. |
| +`layout.Layout(d, index)` is the other renderer, and the rule about it |
| +is the one in the plan: **the paint list is the only input to SVG and to |
| +PDF.** Nothing under `svg/`, `pdfcore/` or `export.go` reads a deck. |
| +Layout measures with the same embedded Liberation faces the browser sets |
| +the slide in (`fonts/`, served as `@font-face` from `static/fonts/` on |
| +every slide surface), so the two agree on everything but where a line |
| +breaks. Every warning it makes carries the slide it is about |
| +(`layout.Warning.Slide`), because the export's status page lists the |
| +warnings of a whole deck and a line that cannot name its slide is a line |
| +nobody can act on. |
| + |
| +`Export(ctx, view, ExportOptions)` (`export.go`) is that pipeline — |
| +`layout.Layout` → `svg.Encode` with every image inlined → the module's |
| +`Render` — and `exports.go` is everything around it. Two things there |
| +are worth knowing before touching it. The PDF module is compiled once |
| +per app, lazily, and closed with the app (`pdfRuntime`), because |
| +compiling it costs the best part of a second and most processes never |
| +export. And **with no object store bound the finished PDF is held in |
| +memory for an hour**, capped at 50 MiB per deck and 200 MiB per instance: |
| +over either cap the job fails with a sentence the person can act on |
| +rather than the instance failing with an OOM nobody can read. When a |
| +store IS bound the same job uploads it and drops the held bytes, and the |
| +download route 302s to a presigned GET. Both paths are in one handler |
| +(`serveExport`) so neither can rot. |
| + |
| `present.Hub` is the in-memory fan-out for a live presentation. Its events |
| are whole state, never deltas, so a subscriber that falls behind is dropped |
| and reconnects rather than having events skipped — a skipped event leaves a |
| @@ -340,8 +390,15 @@ rather than serving an editor that disagrees with the server. |
| any op change. |
| - **Exactly one directory under `internal/`.** `rastrillo migration check` |
| requires it, which is why every package is nested under |
| - `internal/slides/` (`core`, `deck`, `render`, `present`, `live`) rather |
| - than sitting beside it. |
| + `internal/slides/` (`core`, `deck`, `render`, `layout`, `svg`, `fonts`, |
| + `pdfcore`, `present`, `live`) rather than sitting beside it. |
| +- **A job outlives its request, so it cannot hold one.** The export's |
| + sentences — its name, its progress line, every failure it can report — |
| + are resolved from the catalog while the request is still here |
| + (`exportWords`) and carried into the goroutine. `rastrillo.T` reads the |
| + locale off a request; a job that called it would either be holding a |
| + request it must not hold or answering in the wrong language. The same |
| + goes for the context: use the job's, never the request's. |
| - **A hardcoded English string is a bug.** Every user-visible string is a |
| key in `internal/slides/locales/en.toml`, resolved through `rastrillo.T` |
| at render time. Twelve locales are the suite target; this slice ships |
diff --git a/PRODUCT.md b/PRODUCT.md| index 35fd4c0..9420a11 100644 |
| --- a/PRODUCT.md |
| +++ b/PRODUCT.md |
| @@ -5,11 +5,12 @@ |
| > **How this file was written.** Impeccable's `init` normally interviews |
| > the user before writing anything here. This one was written from |
| > repository evidence instead, during slice 1 and brought up to date at |
| -> the end of slice 2: the design |
| +> the end of each slice since — most recently slice 3: the design |
| > (`docs/superpowers/specs/2026-09-07-slides-design.md`, revision 1), the |
| -> plans (`docs/superpowers/plans/2026-09-07-slice-1-live.md` and |
| -> `2026-09-07-slice-2-editor.md`), `AGENTS.md`, and the shipped code in |
| -> `internal/slides/` and `fixtures/`. Facts traceable to a spec |
| +> plans (`docs/superpowers/plans/2026-09-07-slice-1-live.md`, |
| +> `2026-09-07-slice-2-editor.md` and `2026-09-08-slice-3-export.md`), |
| +> `AGENTS.md`, and the shipped code in `internal/slides/` and |
| +> `fixtures/`. Facts traceable to a spec |
| > ruling or to code are cited. Anything that is a reading rather than a |
| > citation is marked **[inferred — confirm]** and should be corrected |
| > rather than trusted. |
| @@ -86,13 +87,14 @@ rebuilding its foundations: |
| where presenting is just a viewer with no session, which is the |
| rejected alternative recorded in §1 ruling 5. |
| -**What is not shipped and must not be described as though it were:** PDF |
| -export and the SVG layout engine, comments, version history, charts from |
| -Sheets, video and embeds, an MCP adapter, aspect ratios other than 16:9. |
| -Spec §10 puts export in slice 3; §9 gives each of the rest a trigger |
| -rather than a date. And **the first deployment has no object store |
| -bound**, so image upload — which is built, and works against a bucket or |
| -a directory — answers `503` there and says so on the page. |
| +**What is not shipped and must not be described as though it were:** |
| +comments, version history, charts from Sheets, video and embeds, an MCP |
| +adapter, aspect ratios other than 16:9. §9 gives each a trigger rather |
| +than a date. And **the first deployment has no object store bound**, so |
| +image upload — which is built, and works against a bucket or a |
| +directory — answers `503` there and says so on the page, and an exported |
| +PDF is held in the instance's own memory for an hour instead of going to |
| +a bucket. |
| ## Operating Context |
| @@ -107,8 +109,9 @@ a directory — answers `503` there and says so on the page. |
| - **Routes shipped today** (`internal/slides/app.go`): sign-in and the |
| deck list, `/decks/new`, `/d/{sqid}` and its notes, settings, share and |
| delete pages; the editor at `/d/{sqid}/edit` with its slide HTML twin, |
| - its update log, stream and presence, and its upload, register and asset |
| - routes; `/s/{token}` for a share link; `/p/{session}` and its notes, |
| + its `.svg` thumbnail twin, its update log, stream and presence, and its |
| + upload, register and asset routes; `/d/{sqid}/export`, the job's status |
| + page under it and the `.pdf` beside that; `/s/{token}` for a share link; `/p/{session}` and its notes, |
| stream, remote and command; `/r/{code}` to pair a phone. The share, |
| present and remote routes sit outside the session group on purpose — |
| the projector, the phone and the audience each carry their own |
| @@ -157,10 +160,21 @@ browser-to-bucket with SVG sanitising and a deck-scoped check on every |
| byte served, and the JS half of the operation vocabulary with the vectors |
| that hold it to the Rust one. |
| -**Deliberately not built yet** (spec §9, §10): PDF export and the SVG |
| -layout engine, charts from Sheets, video and embeds, an MCP adapter, 4:3 |
| -and custom aspect ratios, comments, and version history. Each has a named |
| -trigger in §9 rather than a date. |
| +**Shipped in slice 3:** the layout engine (`internal/slides/layout`), |
| +which measures a slide into a paint list with the twelve Liberation faces |
| +embedded in the binary and served to every slide surface; the SVG |
| +serialiser over that paint list, and a thumbnail route on all four |
| +capabilities; and PDF export — `svg2pdf` compiled to wasm32 and hosted |
| +under wazero beside the CRDT core (`core/pdf`, |
| +`internal/slides/pdfcore`), driven by a rastrillo job whose status page |
| +polls itself and works with scripts off: one 16:9 page per slide, the |
| +speaker notes as pages of their own when they are asked for, the text as |
| +embedded subset glyphs, the images inlined. The API twins are |
| +`POST /api/v1/decks/{sqid}/export` and its two GETs. |
| + |
| +**Deliberately not built yet** (spec §9): charts from Sheets, video and |
| +embeds, an MCP adapter, 4:3 and custom aspect ratios, comments, and |
| +version history. Each has a named trigger in §9 rather than a date. |
| **Binding technical constraints:** |
| @@ -176,7 +190,7 @@ trigger in §9 rather than a date. |
| - **Only the SHA-256 of a share link is stored**, so the URL is shown |
| exactly once and the page says so at that moment (`share.go`, §5). |
| - **Every user-visible string is a catalog key** resolved through |
| - `rastrillo.T` (`internal/slides/locales/en.toml`, 318 keys today). |
| + `rastrillo.T` (`internal/slides/locales/en.toml`, 339 keys today). |
| Twelve base locales are the suite target; one, `en`, exists. A |
| hardcoded English string is a bug — including in the editor's |
| JavaScript, which reads its strings off `data-text-*` attributes the |
| @@ -251,6 +265,13 @@ trigger in §9 rather than a date. |
| (§0.2). Docs' live-sync design is adopted as written for slice 2 (§3.3). |
| - **API reference:** `docs/api.md`, every endpoint and every operation |
| with an example, written for a person and an agent both. |
| +- **The export, end to end:** `internal/slidestest/export_test.go` walks |
| + the button, the job, the polled fragment, the SSE stream and the file, |
| + and asserts the first heading is extractable with `pdftotext` when the |
| + machine has one (visibly skipped when it does not). |
| + `internal/slides/export_test.go` does the same at the pipeline's own |
| + level, recovering every heading from the PDF's glyph runs through each |
| + font's ToUnicode CMap. |
| - **Sample content:** `internal/slides/sample.go` seeds one deck on first |
| sign-in, covering each flow template, a canvas slide and a phone-reflow |
| slide — which is also the fastest way to see what the renderer does. |
diff --git a/README.md b/README.md| index 0608046..56d0793 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -22,9 +22,19 @@ serve for that slide, fetched from the server, with the text blocks bound |
| for editing in place — which is what keeps a deck the same deck wherever |
| it is seen. |
| -The editor cannot export a PDF, hold a comment, or show you an older |
| -version. And the first deployment has no object store bound, so uploads |
| -there answer `503` and the image field says so on the page. |
| +A deck comes back out as a PDF. Press **Export PDF** on a deck, say |
| +whether the speaker notes should come too, and the export runs as a job |
| +you watch on a page of its own — one 16:9 page per slide, the text as |
| +real text you can search and copy, the pictures embedded. It is the same |
| +paint list the thumbnails are drawn from, set in the same faces the |
| +browser sets a slide in, so the file is the deck rather than a photograph |
| +of it. With no object store bound the file is held for an hour and served |
| +from the instance; with a bucket it goes there and the download is a |
| +signed URL. |
| + |
| +The editor cannot hold a comment or show you an older version. And the |
| +first deployment has no object store bound, so uploads there answer `503` |
| +and the image field says so on the page. |
| A deck can also be built entirely by a program. The drive API in |
| [`docs/api.md`](docs/api.md) has every endpoint and every operation, and an |
| @@ -137,6 +147,7 @@ It is not part of the plain suite or of the gate. |
| The design is `docs/superpowers/specs/2026-09-07-slides-design.md`. The |
| plans are `docs/superpowers/plans/2026-09-07-slice-1-live.md` (sign-in, |
| -viewer, sharing, presenting) and `2026-09-07-slice-2-editor.md` (the |
| -editor and collaboration). `AGENTS.md` is the working file: the seams, the |
| +viewer, sharing, presenting), `2026-09-07-slice-2-editor.md` (the editor |
| +and collaboration) and `2026-09-08-slice-3-export.md` (the layout engine, |
| +thumbnails and PDF export). `AGENTS.md` is the working file: the seams, the |
| traps, how to deploy. Read it before you change anything here. |
diff --git a/docs/api.md b/docs/api.md| index eb09c34..0e94e8c 100644 |
| --- a/docs/api.md |
| +++ b/docs/api.md |
| @@ -74,6 +74,7 @@ Every answer with a body is JSON. Every error is: |
| | 413 | `too_large` | The body is over 1 MiB. | |
| | 415 | `unsupported_media_type` | The body is not `application/json`. | |
| | 422 | `invalid` | The request was read and refused. `message` says why; `op` is the index of the operation at fault, when the failure was about one. A body carrying anything after its JSON value is refused here too, rather than half-read. | |
| +| 429 | `busy` | You already have four exports running. Wait for one to finish; nothing was started. | |
| | 500 | `internal` | Our fault. The server log has a matching line. | |
| --- |
| @@ -197,7 +198,93 @@ curl -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| One `<section>` of HTML — the same markup the viewer renders — and |
| nothing around it. `vp=phone` renders the phone layout; leave it off |
| for the desktop one. This is how you check what you built without a |
| -browser. (An SVG thumbnail lands beside it later.) |
| +browser. |
| + |
| +### `GET /api/v1/decks/{sqid}/slides/{id}.svg` — look at it as a picture |
| + |
| +```sh |
| +curl -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| + 'https://slides.example/api/v1/decks/k7m2xq9dwb/slides/hq3ktna7vc2m.svg' \ |
| + -o slide.svg |
| +``` |
| + |
| +The slide as one 1920 by 1080 SVG: the same paint list the PDF is made |
| +of, so what you see here is what the export will put on a page. Text is |
| +`<text>` with a `<tspan>` per run at its measured position, pictures are |
| +embedded as data URIs where they can be, and nothing in the file |
| +fetches anything. |
| + |
| +`?w=` is a rendering hint and is in the `ETag`; it changes nothing about |
| +the bytes today. The `ETag` is `(seq, slide, w)`, so send |
| +`If-None-Match` and you will get a `304` until somebody edits the deck. |
| + |
| +It is not a substitute for reading the document: a picture is for |
| +looking at what you made, and `GET /api/v1/decks/{sqid}` is for knowing |
| +what is in it. |
| + |
| +### `POST /api/v1/decks/{sqid}/export` — make a PDF |
| + |
| +```sh |
| +curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| + -H "Content-Type: application/json" -d '{"notes": true}' \ |
| + https://slides.example/api/v1/decks/k7m2xq9dwb/export |
| +``` |
| + |
| +```json |
| +{"job": "9Qb1t0RmS3aXk7Zt2Fv8Lw", |
| + "status_url": "/api/v1/decks/k7m2xq9dwb/export/9Qb1t0RmS3aXk7Zt2Fv8Lw", |
| + "pdf_url": "/api/v1/decks/k7m2xq9dwb/export/9Qb1t0RmS3aXk7Zt2Fv8Lw.pdf"} |
| +``` |
| + |
| +`202`, because nothing is ready yet: an export is a job, and what comes |
| +back is where to watch it and where the file will be. The body is |
| +optional; `{"notes": true}` adds the speaker notes as pages of their own |
| +after each slide. |
| + |
| +A deck over 200 slides is a `422` before anything starts. Four exports |
| +running at once for one operator is a `429` — wait for one to finish. |
| + |
| +### `GET /api/v1/decks/{sqid}/export/{job}` — how is it going |
| + |
| +```json |
| +{"state": "running", "progress": "slide 3 of 8"} |
| +``` |
| + |
| +```json |
| +{"state": "done", "progress": "slide 8 of 8", "warnings": [ |
| + {"kind": "asset", "slide": "hq3ktna7vc2m", |
| + "detail": "image \"a1b2c3d4e5f6\" is an SVG with a stylesheet, which cannot be nested into a page; left out of the PDF"}]} |
| +``` |
| + |
| +`state` is `running`, `done` or `failed`, and it is the word to branch |
| +on. A `failed` job carries a `message` written for a person. A `done` |
| +one carries `warnings`: everything worth saying about the file — a block |
| +that ran past its box, a picture that could not go in, a character no |
| +embedded face can draw — each naming the slide it is about. An empty |
| +list means the PDF is the deck. |
| + |
| +Poll it, a second or two apart. `"expired": true` on a `done` job means |
| +the file is no longer there; make another. |
| + |
| +### `GET /api/v1/decks/{sqid}/export/{job}.pdf` — take the file |
| + |
| +```sh |
| +curl -L -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| + https://slides.example/api/v1/decks/k7m2xq9dwb/export/9Qb1t0RmS3aXk7Zt2Fv8Lw.pdf \ |
| + -o deck.pdf |
| +``` |
| + |
| +The bytes, as `application/pdf` — or a `302` to a presigned URL when the |
| +instance has an object store, which is why the `-L` is there. |
| + |
| +**The file lives for an hour.** With no bucket bound it is held in the |
| +instance's memory, which is also why it is capped: an export bigger than |
| +the instance will hold fails with a message saying so. |
| + |
| +An export belongs to the operator, not to the agent that started it — so |
| +a person can watch it from the screen and any of that person's agents can |
| +fetch it. Another operator's job is a `404`, exactly as another |
| +operator's deck is. |
| ### `POST /api/v1/decks/{sqid}/present` — start a talk |
diff --git a/docs/superpowers/specs/2026-09-07-slides-design.md b/docs/superpowers/specs/2026-09-07-slides-design.md| index 13bf259..2500412 100644 |
| --- a/docs/superpowers/specs/2026-09-07-slides-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-slides-design.md |
| @@ -356,6 +356,18 @@ Divergence from the browser is confined to line breaking inside a text |
| block and is small when fonts match, the Docs finding. It is not zero |
| and this spec does not claim it is. |
| +**Amended 2026-09-08, from what slice 3 shipped.** The paint list feeds |
| +exactly two things, the thumbnails and the PDF. Every live surface — the |
| +viewer, the presenter's stage, the editor's stage, the API's `.html` |
| +twin — is still `render.Slide`'s HTML, so §0.1's one tree is rendered |
| +by two renderers and never a third. Images are *inlined* rather than |
| +referenced wherever the bytes can be read, because an SVG inside an |
| +`<img src>` fetches nothing at all — a referenced picture is simply |
| +absent from a thumbnail. The image an inline cannot carry falls back to |
| +a URL, which draws only when the `.svg` is opened as a document. See |
| +§4.3's amendment for the fonts, which is the other half of the same |
| +restriction. |
| + |
| ### 4.3 PDF |
| `core/` (Rust) gains a second module beside the CRDT: `slides_pdf`, |
| @@ -376,6 +388,35 @@ status page polls, the result is a presigned GET from the object store |
| that expires in one hour. A deck over 200 slides is refused with a |
| message. |
| +**Amended 2026-09-08, from what slice 3 shipped.** Four things this |
| +section said differently: |
| + |
| +- The fonts are the twelve Liberation faces (Sans, Serif, Mono in four |
| + styles), embedded in the binary for the layout engine and the PDF and |
| + served as `@font-face` to every slide surface, rather than "the |
| + theme's fonts" per theme. Metrics come from this repo's own TrueType |
| + reader (`internal/slides/fonts`), not `x/image/font/sfnt`. |
| +- The packages are `internal/slides/layout` and `internal/slides/svg` |
| + (§4.2's `internal/layout` and `internal/svg` predate the |
| + one-directory-under-internal rule). |
| +- **A thumbnail carries no embedded fonts.** An SVG in an `<img src>` |
| + fetches nothing, so a thumbnail is set in whatever face the reader's |
| + machine has under the same stack `slides.css` names; the text wraps |
| + where the paint list says either way. Embedding the faces would be |
| + about 800 KB per thumbnail. The PDF is unaffected — the module is |
| + handed the faces directly. |
| +- **With no object store bound the finished PDF is held in memory for an |
| + hour** and served from the instance, capped at 50 MiB per deck and |
| + 200 MiB per instance; with a bucket the job uploads it and the |
| + download route 302s to the presigned GET. Both paths are one handler. |
| + The spike ruled `svg2pdf` under wasm32 viable, so the fallback Go PDF |
| + writer above was not built. |
| + |
| +A WebP image is re-encoded as a PNG on its way into a page |
| +(`golang.org/x/image/webp`), because neither a browser rendering an |
| +`<img src>` SVG nor `svg2pdf` decodes one, and WebP is one of the four |
| +types §2.7 admits. |
| + |
| --- |
| ## 5. Sharing and links |
| @@ -561,5 +602,7 @@ most expensive part is built on it. |
| ## 12. Review record |
| -Revision 1: unreviewed. The review pass is the first item after slice 1 |
| +Revision 1, with §2.6 and ruling 12 rewritten during slice 2 and §4.2 |
| +and §4.3 amended on 2026-09-08 from what slice 3 shipped: unreviewed. |
| +The review pass is the first item after slice 1 |
| ships, so that the reviewer has a running instance and not a document. |
diff --git a/go.mod b/go.mod| index 20f1a76..35da8ee 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -27,8 +27,9 @@ require ( |
| github.com/mattn/go-isatty v0.0.20 // indirect |
| github.com/ncruces/go-strftime v1.0.0 // indirect |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| + golang.org/x/image v0.44.0 // indirect |
| golang.org/x/sys v0.46.0 // indirect |
| - golang.org/x/text v0.20.0 // indirect |
| + golang.org/x/text v0.40.0 // indirect |
| gorm.io/plugin/dbresolver v1.6.2 // indirect |
| modernc.org/libc v1.74.1 // indirect |
| modernc.org/mathutil v1.7.1 // indirect |
diff --git a/go.sum b/go.sum| index e07de66..350a770 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -50,15 +50,20 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1 |
| github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= |
| github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= |
| github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= |
| +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= |
| +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= |
| golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
| golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= |
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= |
| golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= |
| golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= |
| +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= |
| +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= |
| golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= |
diff --git a/internal/slides/api.go b/internal/slides/api.go| index 2221d35..371f63d 100644 |
| --- a/internal/slides/api.go |
| +++ b/internal/slides/api.go |
| @@ -102,6 +102,12 @@ func (a *app) apiRouter() http.Handler { |
| // extension itself and there is one place to add the second one. |
| r.Get(apiPrefix+"/decks/{sqid}/slides/{file}", a.apiSlide) |
| r.Post(apiPrefix+"/decks/{sqid}/present", a.apiPresent) |
| + // Exporting (exports.go, spec §4.3). Three endpoints because it is |
| + // a job: start it, watch it, take the file. {id} carries the .pdf |
| + // itself for the same reason {file} does above — chi matches whole |
| + // segments. |
| + r.Post(apiPrefix+"/decks/{sqid}/export", a.apiStartExport) |
| + r.Get(apiPrefix+"/decks/{sqid}/export/{id}", a.apiExportStatus) |
| // Anything else under the prefix is a 404 in this surface's own |
| // shape, not chi's plain-text one: a caller parsing JSON should |
| // never be handed something else because it mistyped a path. |
diff --git a/internal/slides/app.go b/internal/slides/app.go| index 159338a..af9aafa 100644 |
| --- a/internal/slides/app.go |
| +++ b/internal/slides/app.go |
| @@ -9,6 +9,7 @@ import ( |
| rauth "amadan.net/rastrillo/rastrillo/auth" |
| "amadan.net/rastrillo/rastrillo/csrf" |
| "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/jobs" |
| "amadan.net/rastrillo/rastrillo/migrate" |
| "amadan.net/oficina/slides/internal/slides/core" |
| @@ -135,6 +136,19 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| r.Put("/d/{sqid}/uploads/{ref}", a.putUpload) |
| r.Post("/d/{sqid}/assets", a.registerAsset) |
| r.Get("/d/{sqid}/assets/{ref}", a.deckAsset) |
| + // Exporting to PDF (exports.go, spec §4.3). deckFor's audience, |
| + // like presenting: anybody who can read the deck can take a |
| + // copy of it away. The status page, its fragment and its SSE |
| + // stream are inside this group because rastrillo/jobs answers |
| + // them to the session's owner and nobody else. |
| + r.Get("/d/{sqid}/export", a.exportForm) |
| + r.Post("/d/{sqid}/export", a.startExport) |
| + // One route for the status page and the file: chi matches whole |
| + // segments, so {id} and {id}.pdf cannot both be patterns (see |
| + // exportJobRoute). |
| + r.Get("/d/{sqid}/export/{id}", a.exportJobRoute) |
| + r.Get("/d/{sqid}/export/{id}/fragment", a.exportFragment) |
| + r.Get("/d/{sqid}/export/{id}/events", a.exportEvents) |
| // Presenting starts here and nowhere else: anybody who can |
| // open the deck can read it to a room, so this is deckFor's |
| // audience rather than the owner's alone. |
| @@ -241,7 +255,7 @@ func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) { |
| return nil, nil, err |
| } |
| - return &app{ |
| + a := &app{ |
| db: d, |
| logger: logger, |
| cfg: cfg, |
| @@ -250,5 +264,24 @@ func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) { |
| auth: authPlugin, |
| hub: present.New(), |
| live: live.New(), |
| - }, authPlugin, nil |
| + jobs: jobs.New(logger), |
| + exports: newExportStore(), |
| + pdf: &pdfRuntime{}, |
| + } |
| + |
| + // The export's status page, its polled fragment and its SSE stream |
| + // (exports.go). rastrillo/jobs owns the lifecycle and the |
| + // owner-only lookup; the two renderers are this app's, so the page |
| + // is the app's own shell with the app's own words in it. |
| + pages, err := jobs.NewHandlers(jobs.Config{ |
| + Jobs: a.jobs, |
| + Render: a.renderExportPage, |
| + RenderFragment: a.renderExportFragment, |
| + }) |
| + if err != nil { |
| + rt.Close(context.Background()) |
| + return nil, nil, err |
| + } |
| + a.jobPages = pages |
| + return a, authPlugin, nil |
| } |
diff --git a/internal/slides/assets.go b/internal/slides/assets.go| index f5a2085..9723a5f 100644 |
| --- a/internal/slides/assets.go |
| +++ b/internal/slides/assets.go |
| @@ -37,6 +37,7 @@ package slides |
| import ( |
| "bytes" |
| + "context" |
| "encoding/json" |
| "errors" |
| "io" |
| @@ -312,7 +313,7 @@ func (a *app) registerAsset(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| - raw, err := a.readAsset(r, store, body.Ref) |
| + raw, err := a.readAsset(r.Context(), store, body.Ref) |
| if err != nil { |
| if errors.Is(err, blobs.ErrNotFound) { |
| assetFail(w, r, http.StatusNotFound, "not_found", "missing") |
| @@ -395,8 +396,12 @@ var errAssetTooLarge = errors.New("slides: this object is larger than an asset m |
| // readAsset reads one object out of the store, refusing at the limit |
| // rather than reading whatever is there. The +1 is what tells "exactly |
| // at the limit" from "over it". |
| -func (a *app) readAsset(r *http.Request, store blobs.Store, ref string) ([]byte, error) { |
| - rc, err := store.Get(r.Context(), ref) |
| +// |
| +// A context rather than the request it usually comes from: the export |
| +// job reads assets too (exports.go), and a job outlives the request |
| +// that started it by definition. |
| +func (a *app) readAsset(ctx context.Context, store blobs.Store, ref string) ([]byte, error) { |
| + rc, err := store.Get(ctx, ref) |
| if err != nil { |
| return nil, err |
| } |
diff --git a/internal/slides/core/core.go b/internal/slides/core/core.go| index 1c1116a..87288f2 100644 |
| --- a/internal/slides/core/core.go |
| +++ b/internal/slides/core/core.go |
| @@ -92,11 +92,29 @@ const MaxRetainedUpdates = maxRetainedUpdates |
| // constant's doc comment. |
| var ErrUpdateLogFull = errors.New("update log exceeds the retained limit; the document needs a snapshot before it can accept further updates") |
| +// defaultMemoryPages caps the linear memory ONE document instance may |
| +// grow to: 4096 wasm pages of 64 KiB, which is 256 MiB. |
| +// |
| +// wasm32 memory grows to 4 GiB unless the host says otherwise, and this |
| +// host holds one instance per open document on an instance shared with |
| +// every other request. A document is text and structure — the measured |
| +// working set of a real deck is under a megabyte — so a quarter of a |
| +// gibibyte is far above anything an edit needs and far below anything |
| +// that would take the process down. Half of pdfcore's cap, because a |
| +// document is small and a PDF render is not. |
| +// |
| +// Past the cap `memory.grow` answers -1 inside the module, the Rust |
| +// allocator aborts, and wazero reports a trap: Apply and every other |
| +// call return an ordinary error, the Handle is rebuilt by replay like |
| +// any other trap, and the process is untouched. |
| +const defaultMemoryPages = 4096 |
| + |
| // Option configures a Runtime. See WithGuestCallTimeout. |
| type Option func(*runtimeOptions) |
| type runtimeOptions struct { |
| callTimeout time.Duration |
| + memoryPages uint32 |
| } |
| // WithGuestCallTimeout overrides defaultGuestCallTimeout for one Runtime. |
| @@ -107,6 +125,13 @@ func WithGuestCallTimeout(d time.Duration) Option { |
| return func(o *runtimeOptions) { o.callTimeout = d } |
| } |
| +// WithMemoryLimitPages overrides defaultMemoryPages for one Runtime, so |
| +// the ceiling can be tuned on a bigger host and a test can reach it |
| +// without allocating a quarter of a gibibyte first. |
| +func WithMemoryLimitPages(pages uint32) Option { |
| + return func(o *runtimeOptions) { o.memoryPages = pages } |
| +} |
| + |
| // Runtime owns the compiled module. Compiling is expensive and |
| // instantiating is not, so one Runtime is shared and each document gets its |
| // own instance (instances are not safe for concurrent use). |
| @@ -121,7 +146,7 @@ type Runtime struct { |
| } |
| func New(ctx context.Context, opts ...Option) (*Runtime, error) { |
| - o := runtimeOptions{callTimeout: defaultGuestCallTimeout} |
| + o := runtimeOptions{callTimeout: defaultGuestCallTimeout, memoryPages: defaultMemoryPages} |
| for _, opt := range opts { |
| opt(&o) |
| } |
| @@ -132,7 +157,14 @@ func New(ctx context.Context, opts ...Option) (*Runtime, error) { |
| // With it, wazero itself terminates the call and closes the module |
| // when the context is done, which is what turns a pathologically slow |
| // input into a bounded failure instead of a resource leak. |
| - cfg := wazero.NewRuntimeConfig().WithCloseOnContextDone(true) |
| + // |
| + // WithMemoryLimitPages is the same containment argument about the |
| + // other resource: the timeout bounds how long one call may run, the |
| + // page limit bounds how much memory a document may take with it |
| + // (defaultMemoryPages). |
| + cfg := wazero.NewRuntimeConfig(). |
| + WithCloseOnContextDone(true). |
| + WithMemoryLimitPages(o.memoryPages) |
| rt := wazero.NewRuntimeWithConfig(ctx, cfg) |
| compiled, err := rt.CompileModule(ctx, moduleWASM) |
| if err != nil { |
diff --git a/internal/slides/core/core_test.go b/internal/slides/core/core_test.go| index 0bff4e7..3ad71e3 100644 |
| --- a/internal/slides/core/core_test.go |
| +++ b/internal/slides/core/core_test.go |
| @@ -187,6 +187,46 @@ func TestRejectedOpsSurfaceMessage(t *testing.T) { |
| } |
| } |
| +// TestAnOpPastTheMemoryLimitIsAnErrorNotACrash: the runtime caps one |
| +// document's linear memory (defaultMemoryPages), and a document that |
| +// asks for more than the cap fails the way every other guest failure |
| +// does — an error out of the call, the process untouched. |
| +// |
| +// The cap is dropped to 2 MiB here rather than writing a quarter of a |
| +// gibibyte of text to reach the real one: what is under test is that |
| +// the limit is armed at all and that hitting it is an error, and both |
| +// are the same fact at any ceiling. The same op at the default limit is |
| +// run afterwards, so this cannot pass because the op itself is bad. |
| +func TestAnOpPastTheMemoryLimitIsAnErrorNotACrash(t *testing.T) { |
| + ctx := context.Background() |
| + big := `[{"op":"set_meta","title":"` + strings.Repeat("x", 200_000) + `"}]` |
| + |
| + tight, err := core.New(ctx, core.WithMemoryLimitPages(32)) // 2 MiB |
| + if err != nil { |
| + t.Fatalf("core.New with a 32-page limit: %v", err) |
| + } |
| + defer tight.Close(ctx) |
| + h, err := tight.NewDoc(ctx, "tight", 1) |
| + if err != nil { |
| + t.Fatalf("NewDoc under a 32-page limit: %v", err) |
| + } |
| + defer h.Close(ctx) |
| + if _, err := h.Ops(ctx, []byte(big)); err == nil { |
| + t.Fatal("an op past the memory limit succeeded; the limit is not armed") |
| + } else if core.Rejected(err) { |
| + t.Errorf("running out of memory reads as a refusal of the ops: %v", err) |
| + } else { |
| + t.Logf("over the limit: %v", err) |
| + } |
| + |
| + // The same batch, at the real ceiling, is ordinary work. |
| + _, rt := newRuntime(t) |
| + roomy := newDoc(t, ctx, rt, "roomy", 1) |
| + if _, err := roomy.Ops(ctx, []byte(big)); err != nil { |
| + t.Fatalf("the same op at the default limit: %v", err) |
| + } |
| +} |
| + |
| // TestABIVersion: NewDoc instantiates the embedded module and checks its |
| // reported ABI version — this is the end-to-end proof that the module in |
| // testdata and this host agree (core.New alone never reaches the check). |
diff --git a/internal/slides/export.go b/internal/slides/export.go| index 18ec436..978ee16 100644 |
| --- a/internal/slides/export.go |
| +++ b/internal/slides/export.go |
| @@ -36,7 +36,6 @@ import ( |
| "image/jpeg" |
| "image/png" |
| "strings" |
| - "sync" |
| // The decoders Assets can hand back. Registering them is what makes |
| // image.Decode recognise the bytes; a format nobody registered is |
| @@ -45,6 +44,13 @@ import ( |
| // whole render on it (core/pdf/src/lib.rs). |
| _ "image/gif" |
| + // WebP is one of the four types the upload path admits (§2.7) and |
| + // nothing downstream draws one: registering the decoder is what |
| + // sends it down the decode-and-re-encode path below, where it comes |
| + // out as the PNG svg2pdf can read. Without it a picture somebody |
| + // uploaded from a phone was simply missing from their handout. |
| + _ "golang.org/x/image/webp" |
| + |
| "amadan.net/oficina/slides/internal/slides/deck" |
| "amadan.net/oficina/slides/internal/slides/fonts" |
| "amadan.net/oficina/slides/internal/slides/layout" |
| @@ -91,15 +97,24 @@ type ExportOptions struct { |
| // which is a warning and an omitted image. nil means no image can |
| // be fetched at all: every image is omitted, each with a warning. |
| Assets func(asset string) (data []byte, mime string, ok bool) |
| -} |
| -// pdfRuntime is the one compiled PDF module the process shares: |
| -// compiling is the expensive step and a Runtime is safe for concurrent |
| -// use, with every Render on its own instance. Built on first use so a |
| -// process that never exports never pays for it. |
| -var pdfRuntime = sync.OnceValues(func() (*pdfcore.Runtime, error) { |
| - return pdfcore.New(context.Background()) |
| -}) |
| + // Runtime is the compiled PDF module this export renders through. |
| + // Required. Compiling one costs the best part of a second and a |
| + // Runtime is safe for concurrent use, so it belongs to whatever |
| + // owns the process — the app builds one and closes it (exports.go's |
| + // pdfRuntime), a test builds one per package — and never to a |
| + // single export. Nil is an error rather than a module compiled |
| + // here: a function that quietly compiles a wasm module per call is |
| + // a function whose cost nobody can see. |
| + Runtime *pdfcore.Runtime |
| + |
| + // Progress, when set, is called before each slide is laid out, with |
| + // the slide's 1-based number and the total. It is how an export |
| + // running as a job tells its status page how far along it is. The |
| + // words are the caller's: they are a person's words, and this |
| + // package has no locale. |
| + Progress func(slide, total int) |
| +} |
| // Export renders d as a PDF. The warnings are everything worth telling |
| // the person who asked — a block that overflowed, an image that was |
| @@ -116,6 +131,9 @@ func Export(ctx context.Context, d *deck.Deck, o ExportOptions) ([]byte, []layou |
| if len(d.Slides) == 0 { |
| return nil, nil, errors.New("slides: export: the deck has no slides") |
| } |
| + if o.Runtime == nil { |
| + return nil, nil, errors.New("slides: export: no PDF runtime (ExportOptions.Runtime)") |
| + } |
| x := &exporter{deck: d, opts: o} |
| var pages [][]byte |
| @@ -123,6 +141,13 @@ func Export(ctx context.Context, d *deck.Deck, o ExportOptions) ([]byte, []layou |
| if err := ctx.Err(); err != nil { |
| return nil, x.warnings, err |
| } |
| + // The slide every warning from here on is about — the ones the |
| + // layout engine stamps for itself, and the ones this file makes |
| + // about an image it could not use. |
| + x.slide = d.Slides[i].ID |
| + if o.Progress != nil { |
| + o.Progress(i+1, len(d.Slides)) |
| + } |
| p, warnings, err := layout.Layout(d, i) |
| if err != nil { |
| return nil, x.warnings, fmt.Errorf("slides: export: slide %d: %w", i+1, err) |
| @@ -140,15 +165,11 @@ func Export(ctx context.Context, d *deck.Deck, o ExportOptions) ([]byte, []layou |
| } |
| } |
| - rt, err := pdfRuntime() |
| - if err != nil { |
| - return nil, x.warnings, fmt.Errorf("slides: export: %w", err) |
| - } |
| var faces [][]byte |
| for _, f := range fonts.All() { |
| faces = append(faces, fonts.Data(f)) |
| } |
| - pdf, err := rt.Render(ctx, pages, faces) |
| + pdf, err := o.Runtime.Render(ctx, pages, faces) |
| if err != nil { |
| return nil, x.warnings, fmt.Errorf("slides: export: %w", err) |
| } |
| @@ -162,6 +183,11 @@ type exporter struct { |
| opts ExportOptions |
| warnings []layout.Warning |
| + // slide is the id of the slide being rendered, stamped onto every |
| + // warning made here so the status page can say which slide lost |
| + // its picture (layout.Warning.Slide). |
| + slide string |
| + |
| // inlined caches an asset's inlined form for the length of one |
| // export, so a picture on ten slides is fetched and scaled once. |
| inlined map[string]inlinedImage |
| @@ -174,7 +200,9 @@ type inlinedImage struct { |
| } |
| func (x *exporter) warn(kind, block, detail string) { |
| - x.warnings = append(x.warnings, layout.Warning{Kind: kind, Block: block, Detail: detail}) |
| + x.warnings = append(x.warnings, layout.Warning{ |
| + Kind: kind, Slide: x.slide, Block: block, Detail: detail, |
| + }) |
| } |
| // encode writes one paint list as a self-contained SVG page. |
diff --git a/internal/slides/export_test.go b/internal/slides/export_test.go| index 0d8c49e..6fc08aa 100644 |
| --- a/internal/slides/export_test.go |
| +++ b/internal/slides/export_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "bytes" |
| "compress/zlib" |
| "context" |
| + "encoding/hex" |
| "errors" |
| "fmt" |
| "image" |
| @@ -17,12 +18,14 @@ import ( |
| "sort" |
| "strconv" |
| "strings" |
| + "sync" |
| "testing" |
| "time" |
| "amadan.net/oficina/slides/internal/slides/core" |
| "amadan.net/oficina/slides/internal/slides/deck" |
| "amadan.net/oficina/slides/internal/slides/layout" |
| + "amadan.net/oficina/slides/internal/slides/pdfcore" |
| ) |
| // sampleDeckForExport is the seeded sample deck (sample.go) as a view, |
| @@ -67,12 +70,44 @@ func pdfPages(pdf []byte) int { |
| return bytes.Count(pdf, []byte("/Type /Page")) - bytes.Count(pdf, []byte("/Type /Pages")) |
| } |
| +// exportRuntime is the compiled PDF module these tests render through, |
| +// built once for the whole binary. ExportOptions.Runtime is required |
| +// (the app owns one and closes it, exports.go); compiling costs the |
| +// best part of a second, so a test that built its own per call would be |
| +// paying it a dozen times over. |
| +var exportRuntime = sync.OnceValues(func() (*pdfcore.Runtime, error) { |
| + return pdfcore.New(context.Background()) |
| +}) |
| + |
| +// exportOpts is ExportOptions with the shared runtime already in it — |
| +// every test's starting point, so none of them can accidentally assert |
| +// against a nil runtime's refusal. |
| +func exportOpts(t *testing.T, o ExportOptions) ExportOptions { |
| + t.Helper() |
| + rt, err := exportRuntime() |
| + if err != nil { |
| + t.Fatalf("pdfcore.New: %v", err) |
| + } |
| + o.Runtime = rt |
| + return o |
| +} |
| + |
| +// TestExportNeedsARuntime: the module is the caller's to own, and |
| +// forgetting it is an error rather than a wasm compile hidden inside a |
| +// render. |
| +func TestExportNeedsARuntime(t *testing.T) { |
| + d := sampleDeckForExport(t) |
| + if _, _, err := Export(context.Background(), d, ExportOptions{}); err == nil { |
| + t.Fatal("Export with no Runtime succeeded") |
| + } |
| +} |
| + |
| func TestExportSampleDeck(t *testing.T) { |
| d := sampleDeckForExport(t) |
| ctx, cancel := context.WithTimeout(context.Background(), time.Minute) |
| defer cancel() |
| - pdf, warnings, err := Export(ctx, d, ExportOptions{}) |
| + pdf, warnings, err := Export(ctx, d, exportOpts(t, ExportOptions{})) |
| if err != nil { |
| t.Fatalf("Export: %v", err) |
| } |
| @@ -128,7 +163,7 @@ func TestExportSampleDeck(t *testing.T) { |
| } |
| // With notes: at least one notes page after every slide. |
| - withNotes, _, err := Export(ctx, d, ExportOptions{WithNotes: true}) |
| + withNotes, _, err := Export(ctx, d, exportOpts(t, ExportOptions{WithNotes: true})) |
| if err != nil { |
| t.Fatalf("Export with notes: %v", err) |
| } |
| @@ -157,7 +192,7 @@ func TestLongNotesBreakAcrossPages(t *testing.T) { |
| Blocks: []deck.Block{{ID: "b00000000001", Type: "heading", Region: "heading", Props: map[string]any{"level": float64(1)}, Text: deck.Text{{{T: "One slide"}}}}}, |
| }}, |
| } |
| - pdf, warnings, err := Export(context.Background(), d, ExportOptions{WithNotes: true}) |
| + pdf, warnings, err := Export(context.Background(), d, exportOpts(t, ExportOptions{WithNotes: true})) |
| if err != nil { |
| t.Fatalf("Export: %v", err) |
| } |
| @@ -181,13 +216,13 @@ func TestExportRefusesOver200Slides(t *testing.T) { |
| } |
| return d |
| } |
| - _, _, err := Export(context.Background(), build(MaxExportSlides+1), ExportOptions{}) |
| + _, _, err := Export(context.Background(), build(MaxExportSlides+1), exportOpts(t, ExportOptions{})) |
| if !errors.Is(err, ErrTooManySlides) { |
| t.Fatalf("201 slides: err = %v, want ErrTooManySlides", err) |
| } |
| start := time.Now() |
| - pdf, _, err := Export(context.Background(), build(MaxExportSlides), ExportOptions{}) |
| + pdf, _, err := Export(context.Background(), build(MaxExportSlides), exportOpts(t, ExportOptions{})) |
| if err != nil { |
| t.Fatalf("200 slides: %v", err) |
| } |
| @@ -213,6 +248,20 @@ func pngBytes(t *testing.T, w, h int, c color.Color) []byte { |
| return buf.Bytes() |
| } |
| +// webpBytes is a 4 by 3 lossless WebP of one flat colour. Written as |
| +// hex because Go has no WebP encoder — golang.org/x/image only decodes |
| +// one — and thirty-eight bytes in the test beats a testdata file |
| +// nobody can read. |
| +func webpBytes(t *testing.T) []byte { |
| + t.Helper() |
| + const file = "524946461e000000574542505650384c110000002f0380000007508f2217a5ff8188e87f0000" |
| + raw, err := hex.DecodeString(file) |
| + if err != nil { |
| + t.Fatalf("the WebP fixture is not hex: %v", err) |
| + } |
| + return raw |
| +} |
| + |
| func TestExportInlinesImagesAndDropsWhatItCannot(t *testing.T) { |
| assets := map[string]struct { |
| data []byte |
| @@ -222,8 +271,12 @@ func TestExportInlinesImagesAndDropsWhatItCannot(t *testing.T) { |
| "big": {pngBytes(t, 3000, 1500, color.NRGBA{30, 30, 200, 255}), "image/png"}, |
| "junk": {[]byte("RIFF\x00\x00\x00\x00WEBPnot really"), "image/webp"}, |
| "huge": {make([]byte, maxExportAsset+1), "image/png"}, |
| + // A real WebP — one of the four types the upload path admits. |
| + // svg2pdf cannot read one, so it is decoded and re-encoded on |
| + // the way in rather than dropped (golang.org/x/image/webp). |
| + "webp": {webpBytes(t), "image/webp"}, |
| } |
| - ids := []string{"small", "big", "junk", "huge", "missing"} |
| + ids := []string{"small", "big", "junk", "huge", "missing", "webp"} |
| d := &deck.Deck{ |
| Meta: deck.Meta{Title: "Pictures", Theme: defaultTheme}, |
| Assets: map[string]deck.Asset{}, |
| @@ -237,11 +290,11 @@ func TestExportInlinesImagesAndDropsWhatItCannot(t *testing.T) { |
| }) |
| } |
| var asked []string |
| - opts := ExportOptions{Assets: func(asset string) ([]byte, string, bool) { |
| + opts := exportOpts(t, ExportOptions{Assets: func(asset string) ([]byte, string, bool) { |
| asked = append(asked, asset) |
| a, ok := assets[asset] |
| return a.data, a.mime, ok |
| - }} |
| + }}) |
| pdf, warnings, err := Export(context.Background(), d, opts) |
| if err != nil { |
| @@ -251,12 +304,13 @@ func TestExportInlinesImagesAndDropsWhatItCannot(t *testing.T) { |
| t.Errorf("pages = %d, want %d", got, len(ids)) |
| } |
| - // Two pictures made it in: the small one as it was stored, the big |
| - // one scaled to 2000 on its long edge. |
| - if got := bytes.Count(pdf, []byte("/Subtype /Image")); got != 2 { |
| - t.Errorf("image XObjects = %d, want 2 (small and big)", got) |
| + // Three pictures made it in: the small one as it was stored, the |
| + // big one scaled to 2000 on its long edge, and the WebP re-encoded |
| + // as a PNG at its own 4 by 3. |
| + if got := bytes.Count(pdf, []byte("/Subtype /Image")); got != 3 { |
| + t.Errorf("image XObjects = %d, want 3 (small, big and the WebP)", got) |
| } |
| - for _, want := range []string{"/Width 300", "/Height 200", "/Width 2000", "/Height 1000"} { |
| + for _, want := range []string{"/Width 300", "/Height 200", "/Width 2000", "/Height 1000", "/Width 4", "/Height 3"} { |
| if !bytes.Contains(pdf, []byte(want)) { |
| t.Errorf("PDF lacks %q", want) |
| } |
| @@ -292,12 +346,12 @@ func TestExportInlinesImagesAndDropsWhatItCannot(t *testing.T) { |
| t.Error("the PDF refers to something outside itself") |
| } |
| sort.Strings(asked) |
| - if got := strings.Join(asked, ","); got != "big,huge,junk,missing,small" { |
| + if got := strings.Join(asked, ","); got != "big,huge,junk,missing,small,webp" { |
| t.Errorf("assets asked for: %s", got) |
| } |
| // No way to read assets at all: every picture is left out, loudly. |
| - _, warnings, err = Export(context.Background(), d, ExportOptions{}) |
| + _, warnings, err = Export(context.Background(), d, exportOpts(t, ExportOptions{})) |
| if err != nil { |
| t.Fatalf("Export without Assets: %v", err) |
| } |
diff --git a/internal/slides/exports.go b/internal/slides/exports.go| new file mode 100644 |
| index 0000000..7c99486 |
| --- /dev/null |
| +++ b/internal/slides/exports.go |
| @@ -0,0 +1,1053 @@ |
| +// exports.go is the PDF export as a person meets it (spec §4.3): the |
| +// button, the job behind it, the status page it lands on, and the file |
| +// at the end. export.go is the pipeline it calls; this is everything |
| +// around it. |
| +// |
| +// GET /d/{sqid}/export the little form: with notes or not |
| +// POST /d/{sqid}/export start the job |
| +// GET /d/{sqid}/export/{job} the status page (rastrillo/jobs) |
| +// GET /d/{sqid}/export/{job}/fragment what the shim polls |
| +// GET /d/{sqid}/export/{job}/events the SSE it can ride instead |
| +// GET /d/{sqid}/export/{job}.pdf the file |
| +// |
| +// # Why it is a job |
| +// |
| +// A deck of two hundred slides takes seconds to render, and a request |
| +// that blocks for seconds is a request some proxy will cut. So the POST |
| +// answers immediately with somewhere to watch, exactly as the plan's |
| +// global constraints require, and the work happens in a goroutine under |
| +// rastrillo/jobs' own bounds: four running per person, fifteen minutes |
| +// each, the context expiring with the job. |
| +// |
| +// A job is a goroutine, and a deploy — or the platform hibernating this |
| +// instance — ends one mid-flight (rastrillo skill §6). Nothing here is |
| +// written down, so an interrupted export is simply not there when the |
| +// process comes back and the person presses the button again. That is |
| +// the honest behaviour for work that produces a file and no state: |
| +// re-running it is cheap and produces the same PDF. |
| +// |
| +// # Where the bytes live |
| +// |
| +// With no object store bound — which is this deployment today — the |
| +// finished PDF is held in memory for an hour and served from here. That |
| +// is bounded on purpose and in two directions: 50 MiB of held exports |
| +// per deck, 200 MiB across the instance (exportPerDeckBytes, |
| +// exportPerInstanceBytes). Past either, the job fails with a sentence |
| +// saying so rather than the instance failing with an OOM nobody can |
| +// read. |
| +// |
| +// When a store IS bound the same job uploads the PDF to it and drops |
| +// the held bytes, and the download route 302s to a presigned GET (or |
| +// streams it back from a store that cannot presign, which is what |
| +// blobs.Dir is in development). Both paths are in one handler, so |
| +// neither can rot: exportPDF below. |
| +package slides |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "net/url" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "github.com/go-chi/chi/v5" |
| + |
| + "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/blobs" |
| + "amadan.net/rastrillo/rastrillo/flash" |
| + "amadan.net/rastrillo/rastrillo/jobs" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| + |
| + "amadan.net/oficina/slides/internal/slides/layout" |
| + "amadan.net/oficina/slides/internal/slides/pdfcore" |
| +) |
| + |
| +// How long a finished export is answerable, and how much of it this |
| +// process will hold at once. |
| +// |
| +// The hour is the spec's (§4.3) and the same hour a presigned URL |
| +// lives. The two byte caps are the plan's, and they are the reason |
| +// holding a PDF in memory is a defensible thing to do at all: the worst |
| +// case is bounded and the failure is a sentence rather than a killed |
| +// process. |
| +const ( |
| + exportHoldFor = time.Hour |
| + exportPerDeckBytes = 50 << 20 |
| + exportPerInstanceBytes = 200 << 20 |
| +) |
| + |
| +// exportContentType is what the file is, said once. |
| +const exportContentType = "application/pdf" |
| + |
| +// --- the compiled module --- |
| + |
| +// pdfRuntime is the app's PDF module: compiled at most once, on the |
| +// first export, and closed with the app. |
| +// |
| +// On the app rather than in a package variable, so a test binary that |
| +// builds twenty apps releases twenty runtimes rather than leaking them |
| +// into a package that nothing can close. Lazy rather than built in New, |
| +// unlike the CRDT core: compiling costs the best part of a second and |
| +// most processes — every test app, every instance nobody exports |
| +// from — never render a page. The cost of that choice is that a |
| +// slides_pdf.wasm this host cannot load is a failed export rather than |
| +// a startup refusal; the gate's cmp against a fresh build is what |
| +// actually catches that, and it catches it before the binary is built. |
| +type pdfRuntime struct { |
| + once sync.Once |
| + rt *pdfcore.Runtime |
| + err error |
| +} |
| + |
| +func (p *pdfRuntime) get(ctx context.Context) (*pdfcore.Runtime, error) { |
| + p.once.Do(func() { p.rt, p.err = pdfcore.New(ctx) }) |
| + return p.rt, p.err |
| +} |
| + |
| +// close releases the module if one was ever built. Safe to call on a |
| +// runtime nobody used, which is the common case. |
| +func (p *pdfRuntime) close(ctx context.Context) error { |
| + if p == nil || p.rt == nil { |
| + return nil |
| + } |
| + return p.rt.Close(ctx) |
| +} |
| + |
| +// --- the held bytes --- |
| + |
| +// heldExport is one finished (or running) export: who asked, which |
| +// deck, and where the file is. |
| +// |
| +// It is created when the job starts, so a status page and a download |
| +// URL can be checked against the deck in the path before the file |
| +// exists. bytes is what an instance with no object store holds; hash is |
| +// the store's own key once the bytes are up there, and exactly one of |
| +// the two is ever set. |
| +type heldExport struct { |
| + deck string // the deck's sqid |
| + owner string // the job's owner, as jobs.Get keys it |
| + actor string // person:<id> or agent:<id> — who asked (§6.2) |
| + filename string // what the browser should call the file |
| + bytes []byte |
| + hash string |
| + warnings []layout.Warning |
| + started time.Time |
| + done time.Time // zero while the job is running |
| +} |
| + |
| +// exportStore is every export this process is holding. One mutex, a |
| +// map, and two caps. |
| +// |
| +// Sweeping happens inside its own methods rather than on a timer: there |
| +// is no goroutine to leak and nothing to shut down, and the caps — not |
| +// the sweep — are what bound the memory. The cost is that the last |
| +// export of the day is held until somebody exports again, which is |
| +// bytes and not a correctness problem. |
| +type exportStore struct { |
| + mu sync.Mutex |
| + held map[string]*heldExport |
| + |
| + // perDeck and perInstance are exportPerDeckBytes and |
| + // exportPerInstanceBytes, as fields so a test can prove the refusal |
| + // without making a 50 MiB PDF first. |
| + perDeck, perInstance int64 |
| + ttl time.Duration |
| + now func() time.Time |
| +} |
| + |
| +func newExportStore() *exportStore { |
| + return &exportStore{ |
| + held: map[string]*heldExport{}, |
| + perDeck: exportPerDeckBytes, |
| + perInstance: exportPerInstanceBytes, |
| + ttl: exportHoldFor, |
| + now: time.Now, |
| + } |
| +} |
| + |
| +// SetExportCaps moves the two byte caps for this app. |
| +// |
| +// Exported because the *app New returns is unexported, so the only |
| +// thing outside this package that can reach it is a method — and the |
| +// suite has to be able to prove the refusal without first making a |
| +// 50 MiB PDF, which would be a minute of CPU to assert one sentence. |
| +// It is also where an operator-tunable setting would land if a bigger |
| +// instance ever wanted one. |
| +func (a *app) SetExportCaps(perDeck, perInstance int64) { |
| + a.exports.mu.Lock() |
| + defer a.exports.mu.Unlock() |
| + a.exports.perDeck, a.exports.perInstance = perDeck, perInstance |
| +} |
| + |
| +// begin records a job that has just started. The entry carries no bytes |
| +// yet; what it carries is the deck and the owner, which is what the |
| +// download and status routes check before they answer anything. |
| +func (s *exportStore) begin(id string, e heldExport) []string { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + dropped := s.sweepLocked() |
| + e.started = s.now() |
| + s.held[id] = &e |
| + return dropped |
| +} |
| + |
| +// errExportTooBig is what a job returns when the finished PDF does not |
| +// fit under the caps. The message a person sees is the caller's. |
| +var errExportTooBig = errors.New("slides: the export is bigger than this instance will hold") |
| + |
| +// finish attaches the finished file. It is where the caps are enforced, |
| +// because the size of a PDF is not knowable before it exists: over |
| +// either cap, the entry is dropped and the job fails with a sentence |
| +// instead of the instance filling up quietly. |
| +func (s *exportStore) finish(id string, pdf []byte, warnings []layout.Warning) error { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + e, ok := s.held[id] |
| + if !ok { |
| + return errors.New("slides: this export was swept before it finished") |
| + } |
| + var deckBytes, allBytes int64 |
| + for otherID, other := range s.held { |
| + if otherID == id { |
| + continue |
| + } |
| + allBytes += int64(len(other.bytes)) |
| + if other.deck == e.deck { |
| + deckBytes += int64(len(other.bytes)) |
| + } |
| + } |
| + size := int64(len(pdf)) |
| + if deckBytes+size > s.perDeck || allBytes+size > s.perInstance { |
| + delete(s.held, id) |
| + return errExportTooBig |
| + } |
| + e.bytes = pdf |
| + e.warnings = warnings |
| + e.done = s.now() |
| + return nil |
| +} |
| + |
| +// stored replaces the held bytes with the store's key for them, once |
| +// the upload has happened. The bytes go, because holding a copy of |
| +// something the object store now has is the whole cost this avoids. |
| +func (s *exportStore) stored(id, hash string, warnings []layout.Warning) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + e, ok := s.held[id] |
| + if !ok { |
| + return |
| + } |
| + e.bytes = nil |
| + e.hash = hash |
| + e.warnings = warnings |
| + e.done = s.now() |
| +} |
| + |
| +// forget drops an export whose job failed, so a failure holds nothing. |
| +func (s *exportStore) forget(id string) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + delete(s.held, id) |
| +} |
| + |
| +// get answers one export, sweeping first so an expired one is gone |
| +// rather than merely old. |
| +func (s *exportStore) get(id string) (heldExport, []string, bool) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + dropped := s.sweepLocked() |
| + e, ok := s.held[id] |
| + if !ok { |
| + return heldExport{}, dropped, false |
| + } |
| + return *e, dropped, true |
| +} |
| + |
| +// sweepLocked drops finished exports past the TTL and returns the store |
| +// hashes that no live entry refers to any more, for the caller to |
| +// delete from the bucket. A running job is never swept: its entry is |
| +// what the status page checks the deck against. |
| +func (s *exportStore) sweepLocked() []string { |
| + cutoff := s.now().Add(-s.ttl) |
| + var candidates []string |
| + for id, e := range s.held { |
| + if !e.done.IsZero() && e.done.Before(cutoff) { |
| + if e.hash != "" { |
| + candidates = append(candidates, e.hash) |
| + } |
| + delete(s.held, id) |
| + } |
| + } |
| + // Content addressing means two identical exports share a key, so an |
| + // object is only orphaned once nothing left refers to it. |
| + var orphans []string |
| + for _, hash := range candidates { |
| + used := false |
| + for _, e := range s.held { |
| + if e.hash == hash { |
| + used = true |
| + break |
| + } |
| + } |
| + if !used { |
| + orphans = append(orphans, hash) |
| + } |
| + } |
| + return orphans |
| +} |
| + |
| +// dropObjects deletes swept exports from the object store, best effort: |
| +// a failure here is a file left in a bucket, which is worth a log line |
| +// and nothing more. |
| +func (a *app) dropObjects(ctx context.Context, hashes []string) { |
| + if len(hashes) == 0 || a.cfg.Blobs == nil { |
| + return |
| + } |
| + for _, hash := range hashes { |
| + if err := a.cfg.Blobs.Delete(ctx, hash); err != nil { |
| + a.logger.Error("slides: delete an expired export", "hash", hash, "err", err) |
| + } |
| + } |
| +} |
| + |
| +// --- GET /d/{sqid}/export --- |
| + |
| +// exportPage is the little form, and the notice a refused start lands |
| +// back on. |
| +type exportPage struct { |
| + shell |
| + Title string |
| + DeckTitle string |
| + Body string |
| + NotesLabel string |
| + Submit string |
| + Notice string // a refused start, from the flash cookie |
| + Held string |
| + BackHref string |
| + BackLabel string |
| + Action string |
| +} |
| + |
| +// exportForm is the one screen that starts an export: one job per |
| +// screen, and the checkbox that decides what the PDF contains is on it |
| +// rather than beside a button somewhere else. |
| +func (a *app) exportForm(w http.ResponseWriter, r *http.Request) { |
| + u, ok := a.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + d, ok := a.deckFor(w, r, u) |
| + if !ok { |
| + return |
| + } |
| + notice := "" |
| + if f, has := flash.Take(w, r); has { |
| + notice = f.Message |
| + } |
| + render(w, "deck_export", exportPage{ |
| + shell: shellFor(r, u), |
| + Title: rastrillo.T(r, "slides.export.title"), |
| + DeckTitle: d.Title, |
| + Body: rastrillo.T(r, "slides.export.body"), |
| + NotesLabel: rastrillo.T(r, "slides.export.notes"), |
| + Submit: rastrillo.T(r, "slides.export.submit"), |
| + Held: rastrillo.T(r, "slides.export.held"), |
| + Notice: notice, |
| + BackHref: "/d/" + d.Sqid, |
| + BackLabel: rastrillo.T(r, "slides.deck.back"), |
| + Action: "/d/" + d.Sqid + "/export", |
| + }) |
| +} |
| + |
| +// --- POST /d/{sqid}/export --- |
| + |
| +// exportWords are the sentences an export may have to say, resolved |
| +// from the catalog while the request that started it is still here. |
| +// |
| +// A job outlives its request, and rastrillo.T reads the locale off the |
| +// request, so a job that translated its own progress line would either |
| +// be holding a request it must not hold or answering in the wrong |
| +// language. Resolving them up front is the honest way round: the words |
| +// belong to the person who pressed the button. |
| +type exportWords struct { |
| + name string |
| + progress string |
| + open string |
| + render string |
| + tooMany string |
| + tooBig string |
| + store string |
| +} |
| + |
| +func exportWordsFor(r *http.Request, title string) exportWords { |
| + return exportWords{ |
| + name: rastrillo.Tf(r, "slides.export.job", "title", title), |
| + progress: rastrillo.T(r, "slides.export.progress"), |
| + open: rastrillo.T(r, "slides.export.error.open"), |
| + render: rastrillo.T(r, "slides.export.error.render"), |
| + tooMany: rastrillo.Tf(r, "slides.export.error.toomany", "max", MaxExportSlides), |
| + tooBig: rastrillo.T(r, "slides.export.error.toobig"), |
| + store: rastrillo.T(r, "slides.export.error.store"), |
| + } |
| +} |
| + |
| +// fillWords is rastrillo.Tf's substitution against an already-resolved |
| +// string: {name} replaced wherever it appears. It exists because a job |
| +// has words but no request to translate them with (exportWords). |
| +func fillWords(text string, pairs ...any) string { |
| + for i := 0; i+1 < len(pairs); i += 2 { |
| + key, ok := pairs[i].(string) |
| + if !ok { |
| + continue |
| + } |
| + text = strings.ReplaceAll(text, "{"+key+"}", fmt.Sprint(pairs[i+1])) |
| + } |
| + return text |
| +} |
| + |
| +// startExport is the button. It refuses two things before it starts a |
| +// goroutine — a deck over the slide cap, and a person who already has |
| +// four jobs running — and both refusals are a flash on the form they |
| +// came from rather than an error page: neither is a fault, and the |
| +// answer to both is "try again, differently or later". |
| +func (a *app) startExport(w http.ResponseWriter, r *http.Request) { |
| + u, ok := a.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + d, ok := a.deckFor(w, r, u) |
| + if !ok { |
| + return |
| + } |
| + owner, ok := a.sessionSubject(w, r) |
| + if !ok { |
| + return |
| + } |
| + back := "/d/" + d.Sqid + "/export" |
| + |
| + // The slide cap, before any work: Export refuses the same deck, but |
| + // it would do it inside a job, where the answer is a failed status |
| + // page rather than a sentence on the form. |
| + if n, counted := a.slideCount(r.Context(), d); counted && n > MaxExportSlides { |
| + flash.Set(w, "error", rastrillo.Tf(r, "slides.export.error.toomany", "max", MaxExportSlides)) |
| + http.Redirect(w, r, back, http.StatusSeeOther) |
| + return |
| + } |
| + |
| + withNotes := r.FormValue("notes") != "" |
| + words := exportWordsFor(r, d.Title) |
| + |
| + // The job's own id is not known until Start returns, and Start has |
| + // already begun running the function by then. So the function is |
| + // handed its id down a channel and waits for it: what it does |
| + // first is record the export against this deck, and doing that |
| + // under an id nobody knows yet is not possible. |
| + // |
| + // Location is deliberately empty. jobs.StatusPage 303s a finished |
| + // job to its Location, and the page the owner wants is the one they |
| + // are on — the download link and the warnings are ON the status |
| + // page, so a Location of the status page itself would be a redirect |
| + // loop and a Location of the file would take the warnings away. |
| + id := make(chan string, 1) |
| + job, err := a.jobs.Start(owner, words.name, "", |
| + a.exportJob(id, d, actorFor(u), withNotes, words)) |
| + switch { |
| + case errors.Is(err, jobs.ErrOwnerBusy): |
| + flash.Set(w, "error", rastrillo.T(r, "slides.export.error.busy")) |
| + http.Redirect(w, r, back, http.StatusSeeOther) |
| + return |
| + case err != nil: |
| + a.fail(w, r, "start an export", err) |
| + return |
| + } |
| + a.startedExport(job.ID, d, owner, actorFor(u)) |
| + id <- job.ID |
| + http.Redirect(w, r, back+"/"+job.ID, http.StatusSeeOther) |
| +} |
| + |
| +// exportJob is the work itself, as jobs.Start takes it. |
| +// |
| +// Everything it needs is captured by value here — the deck row, the |
| +// actor, the words — because the request is gone by the time it runs. |
| +// The context is the job's own: it expires after fifteen minutes and is |
| +// honoured at every step, which is the whole of what "honour ctx" means |
| +// for work that is one render. |
| +// |
| +// Its errors are shown to the person who asked, so they are the |
| +// catalog's sentences and never a wrapped internal one; what an |
| +// operator needs is logged here instead. |
| +func (a *app) exportJob(id <-chan string, d Deck, actor string, withNotes bool, words exportWords) func(context.Context, func(string)) error { |
| + return func(ctx context.Context, progress func(string)) error { |
| + var jobID string |
| + select { |
| + case jobID = <-id: |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + } |
| + |
| + h, view, err := a.openDeck(ctx, d) |
| + if err != nil { |
| + a.logger.Error("slides: open a deck to export", "deck", d.Sqid, "err", err) |
| + return errors.New(words.open) |
| + } |
| + defer h.Close(ctx) |
| + if len(view.Slides) > MaxExportSlides { |
| + return errors.New(words.tooMany) |
| + } |
| + |
| + rt, err := a.pdf.get(ctx) |
| + if err != nil { |
| + a.logger.Error("slides: compile the pdf module", "err", err) |
| + return errors.New(words.render) |
| + } |
| + |
| + total := len(view.Slides) |
| + pdf, warnings, err := Export(ctx, view, ExportOptions{ |
| + WithNotes: withNotes, |
| + Runtime: rt, |
| + Assets: a.assetBytes(ctx, d, view), |
| + Progress: func(slide, of int) { |
| + progress(fillWords(words.progress, "n", slide, "total", of)) |
| + }, |
| + }) |
| + if err != nil { |
| + if errors.Is(err, ErrTooManySlides) { |
| + return errors.New(words.tooMany) |
| + } |
| + a.logger.Error("slides: render an export", "deck", d.Sqid, "slides", total, "err", err) |
| + return errors.New(words.render) |
| + } |
| + a.logger.Info("slides: exported", "deck", d.Sqid, "actor", actor, |
| + "slides", total, "notes", withNotes, "bytes", len(pdf), "warnings", len(warnings)) |
| + |
| + // With a store, the bytes belong in it and not in this process. |
| + if store := a.cfg.Blobs; store != nil { |
| + ref, err := store.Put(ctx, bytes.NewReader(pdf), exportContentType) |
| + if err != nil { |
| + a.logger.Error("slides: store an export", "deck", d.Sqid, "err", err) |
| + return errors.New(words.store) |
| + } |
| + a.exports.stored(jobID, ref.Hash, warnings) |
| + return nil |
| + } |
| + if err := a.exports.finish(jobID, pdf, warnings); err != nil { |
| + if errors.Is(err, errExportTooBig) { |
| + return errors.New(words.tooBig) |
| + } |
| + a.logger.Error("slides: hold an export", "deck", d.Sqid, "err", err) |
| + return errors.New(words.render) |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +// --- GET /d/{sqid}/export/{id} and .pdf --- |
| + |
| +// exportJobRoute is the one route behind two things: the status page, |
| +// and the file itself at the same address with `.pdf` on the end. |
| +// |
| +// One handler because chi matches a whole path segment: `{id}` would |
| +// swallow `{id}.pdf` whichever order they were registered in, and a |
| +// second pattern that only sometimes wins is worse than reading the |
| +// extension here, which is what the thumbnail routes do too. |
| +func (a *app) exportJobRoute(w http.ResponseWriter, r *http.Request) { |
| + if id, isPDF := strings.CutSuffix(chi.URLParam(r, "id"), ".pdf"); isPDF { |
| + a.exportPDF(w, r, id) |
| + return |
| + } |
| + if !a.exportJobIsHere(w, r) { |
| + return |
| + } |
| + a.jobPages.StatusPage(w, r) |
| +} |
| + |
| +// exportFragment and exportEvents are the polled twins of the page, |
| +// behind the same check. |
| +func (a *app) exportFragment(w http.ResponseWriter, r *http.Request) { |
| + if !a.exportJobIsHere(w, r) { |
| + return |
| + } |
| + a.jobPages.Fragment(w, r) |
| +} |
| + |
| +func (a *app) exportEvents(w http.ResponseWriter, r *http.Request) { |
| + if !a.exportJobIsHere(w, r) { |
| + return |
| + } |
| + a.jobPages.Events(w, r) |
| +} |
| + |
| +// exportJobIsHere is the deck check rastrillo/jobs cannot make: it |
| +// knows the owner and nothing about a deck, and these URLs name both. |
| +// A job of this person's for ANOTHER deck, addressed under this one, is |
| +// a 404 like every other mismatched capability in this app. |
| +// |
| +// The deck itself is resolved through deckFor, so a person who cannot |
| +// read the deck never reaches the job, and a job whose entry has been |
| +// swept still reaches the status page — jobs.Get is what answers there, |
| +// and its own ten-minute TTL is the one that decides. |
| +func (a *app) exportJobIsHere(w http.ResponseWriter, r *http.Request) bool { |
| + u, ok := a.viewer(w, r) |
| + if !ok { |
| + return false |
| + } |
| + d, ok := a.deckFor(w, r, u) |
| + if !ok { |
| + return false |
| + } |
| + held, dropped, found := a.exports.get(chi.URLParam(r, "id")) |
| + a.dropObjects(r.Context(), dropped) |
| + if found && held.deck != d.Sqid { |
| + a.notFound(w, r) |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// exportPDF serves the file: the held bytes, or a redirect to the |
| +// object store, from one handler so the two paths cannot drift. |
| +// |
| +// Three gates, all of them 404 on failure: the deck has to be one this |
| +// person may read, the export has to be for THIS deck, and it has to be |
| +// theirs — heldExport.owner, compared against the session's subject, |
| +// which is the same string rastrillo/jobs keys a job by. |
| +// |
| +// Its OWN record rather than jobs.Get, and this is the one place the |
| +// two differ on purpose: a finished job is swept from the registry ten |
| +// minutes after it ends, and the file is answerable for an hour, which |
| +// is what the page told the person. Asking the registry would take the |
| +// download away fifty minutes early. |
| +func (a *app) exportPDF(w http.ResponseWriter, r *http.Request, id string) { |
| + u, ok := a.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + d, ok := a.deckFor(w, r, u) |
| + if !ok { |
| + return |
| + } |
| + owner, ok := a.sessionSubject(w, r) |
| + if !ok { |
| + return |
| + } |
| + held, dropped, found := a.exports.get(id) |
| + a.dropObjects(r.Context(), dropped) |
| + if !found || held.deck != d.Sqid || held.owner != owner || held.done.IsZero() { |
| + a.notFound(w, r) |
| + return |
| + } |
| + a.serveExport(w, r, held, func() { a.notFound(w, r) }) |
| +} |
| + |
| +// serveExport writes one finished export, whichever of the two places |
| +// it is in. missing is how the surface asking says "no longer here" — |
| +// the app's 404 page on a screen, the API's JSON on the other side. |
| +func (a *app) serveExport(w http.ResponseWriter, r *http.Request, held heldExport, missing func()) { |
| + if held.hash != "" { |
| + store := a.cfg.Blobs |
| + if store == nil { |
| + missing() |
| + return |
| + } |
| + // A store that can presign hands the browser a URL of its own |
| + // and this process never sees the bytes — the point of putting |
| + // them there. One that cannot (blobs.Dir, in development) is |
| + // streamed back through here, so the same page works either way. |
| + if p, canPresign := store.(presigner); canPresign { |
| + url, err := p.PresignGet(held.hash, presignTTL) |
| + if err != nil { |
| + a.fail(w, r, "presign an export", err) |
| + return |
| + } |
| + http.Redirect(w, r, url, http.StatusFound) |
| + return |
| + } |
| + rc, err := store.Get(r.Context(), held.hash) |
| + if err != nil { |
| + if errors.Is(err, blobs.ErrNotFound) { |
| + missing() |
| + return |
| + } |
| + a.fail(w, r, "read an export", err) |
| + return |
| + } |
| + defer rc.Close() |
| + exportHeaders(w, held.filename) |
| + io.Copy(w, rc) |
| + return |
| + } |
| + if len(held.bytes) == 0 { |
| + missing() |
| + return |
| + } |
| + exportHeaders(w, held.filename) |
| + w.Header().Set("Content-Length", strconv.Itoa(len(held.bytes))) |
| + w.Write(held.bytes) |
| +} |
| + |
| +// exportHeaders says what the file is and what to call it. |
| +// |
| +// Both spellings of the name: the ASCII one every client understands, |
| +// and the RFC 5987 one that carries a deck called "Präsentation" |
| +// intact. no-store because the URL is a job's capability and the file |
| +// is somebody's document — nothing in between should keep a copy. |
| +func exportHeaders(w http.ResponseWriter, filename string) { |
| + w.Header().Set("Content-Type", exportContentType) |
| + w.Header().Set("X-Content-Type-Options", "nosniff") |
| + w.Header().Set("Cache-Control", "private, no-store") |
| + w.Header().Set("Content-Disposition", fmt.Sprintf( |
| + `attachment; filename="%s"; filename*=UTF-8''%s`, |
| + asciiFilename(filename), url.PathEscape(filename))) |
| +} |
| + |
| +// pdfFilename turns a deck's title into something a file manager will |
| +// take: the title's own words, anything else dropped, bounded, and |
| +// never empty. |
| +func pdfFilename(title string) string { |
| + var b strings.Builder |
| + lastSpace := false |
| + for _, r := range strings.TrimSpace(title) { |
| + switch { |
| + case r == '/' || r == '\\' || r == ':' || r == '"' || r < 0x20 || r == 0x7f: |
| + // A path separator, a quote or a control character: dropped |
| + // rather than replaced, because none of them is part of a |
| + // name anybody typed on purpose. |
| + case r == ' ' || r == '\t': |
| + if !lastSpace && b.Len() > 0 { |
| + b.WriteByte(' ') |
| + lastSpace = true |
| + } |
| + default: |
| + b.WriteRune(r) |
| + lastSpace = false |
| + } |
| + if b.Len() > 80 { |
| + break |
| + } |
| + } |
| + name := strings.TrimSpace(b.String()) |
| + if name == "" { |
| + name = "deck" |
| + } |
| + return name + ".pdf" |
| +} |
| + |
| +// asciiFilename is the plain-ASCII fallback of the same name, for the |
| +// unstarred filename parameter — which is a quoted string in a header |
| +// and may hold nothing else. |
| +func asciiFilename(name string) string { |
| + var b strings.Builder |
| + for _, r := range name { |
| + if r < 0x20 || r > 0x7e || r == '"' || r == '\\' { |
| + b.WriteByte('-') |
| + continue |
| + } |
| + b.WriteRune(r) |
| + } |
| + return b.String() |
| +} |
| + |
| +// --- the status page --- |
| + |
| +// exportWarning is one line of the warnings list: the slide it is |
| +// about, and what happened. |
| +type exportWarning struct { |
| + Slide string |
| + Detail string |
| +} |
| + |
| +// exportStatus is the status page and its polled fragment — one struct, |
| +// because they are one rendering seen through two holes (render.go |
| +// builds the same file as a page and as a fragment). |
| +type exportStatus struct { |
| + shell |
| + Title string |
| + DeckTitle string |
| + BackHref string |
| + BackLabel string |
| + |
| + // The job-status partial's own keys. |
| + Name string |
| + Status string |
| + Progress string |
| + Err string |
| + PollURL string |
| + PushURL string |
| + PollSeconds int |
| + |
| + Running bool |
| + Failed bool |
| + |
| + Done string |
| + DownloadHref string |
| + DownloadLabel string |
| + Held string |
| + Gone string |
| + AgainHref string |
| + AgainLabel string |
| + WarningsLabel string |
| + WarningsBody string |
| + Warnings []exportWarning |
| +} |
| + |
| +// renderExportPage and renderExportFragment are jobs.Config's two |
| +// seams. The page is the whole screen; the fragment is the ONE element |
| +// the shim swaps in, which is why the running and finished states are |
| +// alternatives rather than a wrapper around a partial: the shim keeps |
| +// the first element of what it fetches and drops the rest. |
| +func (a *app) renderExportPage(w http.ResponseWriter, r *http.Request, d jobs.PageData) { |
| + u, ok := a.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + page := a.exportStatusFor(r, d) |
| + page.shell = shellFor(r, u) |
| + render(w, "export_status", page) |
| +} |
| + |
| +func (a *app) renderExportFragment(w http.ResponseWriter, r *http.Request, d jobs.PageData) { |
| + renderFragment(w, "export_status", "export-body", a.exportStatusFor(r, d)) |
| +} |
| + |
| +// exportStatusFor is the view data for both, built from the job's own |
| +// snapshot and this app's record of what the job produced. |
| +func (a *app) exportStatusFor(r *http.Request, d jobs.PageData) exportStatus { |
| + sqid := chi.URLParam(r, "sqid") |
| + base := "/d/" + sqid + "/export" |
| + self := base + "/" + d.Job.ID |
| + page := exportStatus{ |
| + Title: rastrillo.T(r, "slides.export.title"), |
| + BackHref: "/d/" + sqid, |
| + BackLabel: rastrillo.T(r, "slides.deck.back"), |
| + Name: d.Job.Name, |
| + Status: string(d.Job.Status), |
| + Progress: d.Job.Progress, |
| + Err: d.Job.Err, |
| + PollURL: self + "/fragment", |
| + PushURL: self + "/events", |
| + PollSeconds: d.PollSeconds, |
| + Running: d.Job.Status == jobs.Running, |
| + Failed: d.Job.Status == jobs.Failed, |
| + AgainHref: base, |
| + AgainLabel: rastrillo.T(r, "slides.export.again"), |
| + } |
| + // FragmentPath and EventsPath off PageData are rastrillo's own |
| + // /jobs/{id}/… mounting, which this app does not use: these pages |
| + // live under the deck they belong to, so the paths are built here. |
| + if page.Running || page.Failed { |
| + return page |
| + } |
| + |
| + held, dropped, found := a.exports.get(d.Job.ID) |
| + a.dropObjects(r.Context(), dropped) |
| + if !found || held.done.IsZero() { |
| + // The job says it finished and the bytes are not here: the hour |
| + // is up, or the process holding them was replaced. |
| + page.Gone = rastrillo.T(r, "slides.export.gone") |
| + return page |
| + } |
| + page.Done = rastrillo.T(r, "slides.export.done") |
| + page.DownloadHref = self + ".pdf" |
| + page.DownloadLabel = rastrillo.T(r, "slides.export.download") |
| + page.Held = rastrillo.T(r, "slides.export.held") |
| + if len(held.warnings) > 0 { |
| + page.WarningsLabel = rastrillo.T(r, "slides.export.warnings") |
| + page.WarningsBody = rastrillo.T(r, "slides.export.warnings.body") |
| + page.Warnings = a.exportWarningsFor(r, held) |
| + } |
| + return page |
| +} |
| + |
| +// exportWarningsFor turns the export's warnings into lines a person can |
| +// act on: the slide's own label rather than its id, and the sentence |
| +// the layout engine or the export wrote. |
| +// |
| +// The deck is opened once for the labels, and a deck that will not open |
| +// costs the labels and not the list — a warning with no slide name is |
| +// still worth reading. |
| +func (a *app) exportWarningsFor(r *http.Request, held heldExport) []exportWarning { |
| + labels := map[string]string{} |
| + var d Deck |
| + if err := a.db.G.WithContext(r.Context()).Where("sqid = ?", held.deck).Take(&d).Error; err == nil { |
| + if h, view, err := a.openDeck(r.Context(), d); err == nil { |
| + defer h.Close(r.Context()) |
| + for i, s := range view.Slides { |
| + labels[s.ID] = slideLabel(r, s, i+1) |
| + } |
| + } |
| + } |
| + out := make([]exportWarning, 0, len(held.warnings)) |
| + for _, warn := range held.warnings { |
| + out = append(out, exportWarning{Slide: labels[warn.Slide], Detail: warn.Detail}) |
| + } |
| + return out |
| +} |
| + |
| +// --- the drive API's twins (spec §7.2) --- |
| +// |
| +// Here rather than in api.go because they are the export, and the |
| +// export is one thing: the same job, the same held bytes, the same |
| +// caps, answered in the API's own shapes. |
| +// |
| +// An agent's export is owned by its OPERATOR — the person the agent |
| +// acts for — spelled as the users row's address, which is the same |
| +// string a session's Subject carries. So an operator watching from the |
| +// API and an operator watching from the screen are watching one job. |
| + |
| +// apiExportOwner is that string. An agent owns nothing (agents.go): a |
| +// token can be revoked and a job it started must not become |
| +// unwatchable because it was. |
| +func apiExportOwner(p principal) string { return p.operator.Email } |
| + |
| +// apiStartExport serves POST /api/v1/decks/{sqid}/export. 202, because |
| +// nothing is ready yet: what comes back is where to watch and where |
| +// the file will be. |
| +func (a *app) apiStartExport(w http.ResponseWriter, r *http.Request) { |
| + p, ok := a.apiPrincipal(w, r) |
| + if !ok { |
| + return |
| + } |
| + d, ok := a.apiDeckFor(w, r, p) |
| + if !ok { |
| + return |
| + } |
| + var body struct { |
| + Notes bool `json:"notes"` |
| + } |
| + // An empty body is the ordinary case ("just export it"), and |
| + // readJSON refuses one, so only a body that IS there is read. |
| + if r.ContentLength > 0 && !a.readJSON(w, r, &body) { |
| + return |
| + } |
| + if n, counted := a.slideCount(r.Context(), d); counted && n > MaxExportSlides { |
| + a.apiInvalid(w, fmt.Sprintf("this deck has %d slides, and the most one export will take is %d", n, MaxExportSlides), -1) |
| + return |
| + } |
| + |
| + owner := apiExportOwner(p) |
| + words := exportWordsFor(r, d.Title) |
| + id := make(chan string, 1) |
| + job, err := a.jobs.Start(owner, words.name, "", a.exportJob(id, d, p.actor(), body.Notes, words)) |
| + switch { |
| + case errors.Is(err, jobs.ErrOwnerBusy): |
| + // 429: this is a rate, not a fault in the request. The caller |
| + // waits for one of its exports to finish. |
| + apiFail(w, http.StatusTooManyRequests, "busy") |
| + return |
| + case err != nil: |
| + a.logger.Error("slides: start an export for the API", "deck", d.Sqid, "err", err) |
| + apiFail(w, http.StatusInternalServerError, errInternal) |
| + return |
| + } |
| + a.startedExport(job.ID, d, owner, p.actor()) |
| + id <- job.ID |
| + |
| + base := fmt.Sprintf("%s/decks/%s/export/%s", apiPrefix, d.Sqid, job.ID) |
| + a.writeJSON(w, http.StatusAccepted, map[string]any{ |
| + "job": job.ID, |
| + "status_url": base, |
| + "pdf_url": base + ".pdf", |
| + }) |
| +} |
| + |
| +// apiExportStatus serves GET /api/v1/decks/{sqid}/export/{id} and, with |
| +// `.pdf` on the end, the file itself — one handler, exportJobRoute's |
| +// reason. |
| +func (a *app) apiExportStatus(w http.ResponseWriter, r *http.Request) { |
| + p, ok := a.apiPrincipal(w, r) |
| + if !ok { |
| + return |
| + } |
| + d, ok := a.apiDeckFor(w, r, p) |
| + if !ok { |
| + return |
| + } |
| + id, wantsPDF := strings.CutSuffix(chi.URLParam(r, "id"), ".pdf") |
| + owner := apiExportOwner(p) |
| + held, dropped, found := a.exports.get(id) |
| + a.dropObjects(r.Context(), dropped) |
| + if found && (held.deck != d.Sqid || held.owner != owner) { |
| + apiFail(w, http.StatusNotFound, errNotFound) |
| + return |
| + } |
| + |
| + // The file outlives the job record (exportPDF says why), so the |
| + // download asks this app's own record and the status asks the |
| + // registry. |
| + if wantsPDF { |
| + if !found || held.done.IsZero() { |
| + apiFail(w, http.StatusNotFound, errNotFound) |
| + return |
| + } |
| + a.serveExport(w, r, held, func() { apiFail(w, http.StatusNotFound, errNotFound) }) |
| + return |
| + } |
| + |
| + job, isTheirs := a.jobs.Get(id, owner) |
| + if !isTheirs { |
| + apiFail(w, http.StatusNotFound, errNotFound) |
| + return |
| + } |
| + |
| + // state, not "status": the word a caller branches on, and the same |
| + // three rastrillo/jobs has. The warnings are the export's own and |
| + // are only there once there is a file to warn about. |
| + out := map[string]any{"state": string(job.Status), "progress": job.Progress} |
| + if job.Err != "" { |
| + out["message"] = job.Err |
| + } |
| + if job.Status == jobs.Done { |
| + warnings := []layout.Warning{} |
| + if found { |
| + warnings = append(warnings, held.warnings...) |
| + } |
| + out["warnings"] = warnings |
| + if !found || held.done.IsZero() { |
| + // Finished, and the file has expired out from under the |
| + // caller. Said plainly rather than as a 404 on this |
| + // endpoint, which would read as "no such job". |
| + out["expired"] = true |
| + } |
| + } |
| + a.writeJSON(w, http.StatusOK, out) |
| +} |
| + |
| +// --- shared bits --- |
| + |
| +// sessionSubject is the string rastrillo/jobs keys a job by: the |
| +// session's Subject, which under rastrillo/auth is an email address. |
| +// Past RequireSession there is always one, so its absence is a 500 and |
| +// not a redirect. |
| +// |
| +// It is the one place in this app that reads a Subject for anything but |
| +// mapping it to a users row (auth.go's currentUser and its warning |
| +// about sessions.UserID): jobs.Get answers the owner and nobody else, |
| +// and the owner it compares against is this string. |
| +func (a *app) sessionSubject(w http.ResponseWriter, r *http.Request) (string, bool) { |
| + s, ok := sessions.Current(r) |
| + if !ok || s.Subject == "" { |
| + a.fail(w, r, "no session subject past the session gate", nil) |
| + return "", false |
| + } |
| + return s.Subject, true |
| +} |
| + |
| +// startedExport is the record this app keeps beside rastrillo's job: it |
| +// exists from the moment the job starts so the status and download |
| +// routes have a deck to check against, and it is where the finished |
| +// bytes land. |
| +func (a *app) startedExport(id string, d Deck, owner, actor string) { |
| + dropped := a.exports.begin(id, heldExport{ |
| + deck: d.Sqid, |
| + owner: owner, |
| + actor: actor, |
| + filename: pdfFilename(d.Title), |
| + }) |
| + a.dropObjects(context.Background(), dropped) |
| +} |
diff --git a/internal/slides/handlers.go b/internal/slides/handlers.go| index f9662d5..728173f 100644 |
| --- a/internal/slides/handlers.go |
| +++ b/internal/slides/handlers.go |
| @@ -6,6 +6,7 @@ import ( |
| rauth "amadan.net/rastrillo/rastrillo/auth" |
| "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/jobs" |
| "amadan.net/oficina/slides/internal/slides/core" |
| "amadan.net/oficina/slides/internal/slides/live" |
| @@ -45,6 +46,21 @@ type app struct { |
| // One per app, because a subscriber list nobody else can reach is |
| // not a fan-out. |
| hub *present.Hub |
| + // jobs is rastrillo's background-work registry and jobPages its |
| + // three handlers (status page, polled fragment, SSE). One per app: |
| + // a job is a goroutine in this process and the registry is how a |
| + // person watches it (exports.go). |
| + jobs *jobs.Jobs |
| + jobPages *jobs.Handlers |
| + // exports is what this app holds beside those jobs: the finished |
| + // PDF, or the object-store key it was uploaded under, per job |
| + // (exports.go). rastrillo/jobs carries a status and a sentence and |
| + // deliberately nothing of the work's own result. |
| + exports *exportStore |
| + // pdf is the compiled PDF module, built on the first export and |
| + // closed with the app. See pdfRuntime for why it is lazy where the |
| + // deck core is not. |
| + pdf *pdfRuntime |
| // live is the in-process fan-out behind every deck being EDITED: |
| // one accepted update announced as a seq, plus who is currently in |
| // the deck (internal/slides/live). Separate from hub above because |
| @@ -77,6 +93,12 @@ func (a *app) Close() error { |
| if a.live != nil { |
| a.live.Close() |
| } |
| + // The PDF module, if any export ever built one. Same argument as |
| + // the deck core's below: a test binary builds an app per test, and |
| + // a compiled wasm module nobody releases is a leak per app. |
| + if err := a.pdf.close(context.Background()); err != nil { |
| + a.logger.Error("slides: close the pdf runtime", "err", err) |
| + } |
| if a.core == nil { |
| return nil |
| } |
diff --git a/internal/slides/layout/layout.go b/internal/slides/layout/layout.go| index c7daab7..56337db 100644 |
| --- a/internal/slides/layout/layout.go |
| +++ b/internal/slides/layout/layout.go |
| @@ -51,6 +51,11 @@ type engine struct { |
| ops []Op |
| warnings []Warning |
| + // slideID is the slide being drawn. Every warning this engine |
| + // makes carries it (see warn), so a caller that laid out a whole |
| + // deck can say which slide each line is about. |
| + slideID string |
| + |
| // block is the id warnings are attributed to while a block is |
| // being measured, and seen keeps a missing glyph from being |
| // reported once per line it appears on. |
| @@ -66,11 +71,12 @@ func newEngine(d *deck.Deck, index int) *engine { |
| theme := render.ThemeID(d.Meta.Theme) |
| e := &engine{ |
| - deck: d, |
| - bg: Fill(vars["--slides-bg"]), |
| - fg: Fill(vars["--slides-fg"]), |
| - code: fonts.Get(theme, fonts.Code, false, false), |
| - seen: map[rune]bool{}, |
| + deck: d, |
| + slideID: d.Slides[index].ID, |
| + bg: Fill(vars["--slides-bg"]), |
| + fg: Fill(vars["--slides-fg"]), |
| + code: fonts.Get(theme, fonts.Code, false, false), |
| + seen: map[rune]bool{}, |
| } |
| e.accent = Fill(vars["--slides-accent"]) |
| e.accent2 = Fill(vars["--slides-accent-2"]) |
| @@ -97,7 +103,15 @@ func (e *engine) faceFor(theme string, role fonts.Role, stack string) fonts.Face |
| return sub |
| } |
| -func (e *engine) warn(w Warning) { e.warnings = append(e.warnings, w) } |
| +// warn records a warning, stamping the slide being drawn onto it so no |
| +// call site has to remember to. A warning that already names a slide |
| +// keeps the one it names. |
| +func (e *engine) warn(w Warning) { |
| + if w.Slide == "" { |
| + w.Slide = e.slideID |
| + } |
| + e.warnings = append(e.warnings, w) |
| +} |
| // noteGlyphs reports runes no face could draw, once each per slide. |
| func (e *engine) noteGlyphs(rs []rune) { |
diff --git a/internal/slides/layout/layout_test.go b/internal/slides/layout/layout_test.go| index e19036c..4f6da8f 100644 |
| --- a/internal/slides/layout/layout_test.go |
| +++ b/internal/slides/layout/layout_test.go |
| @@ -558,6 +558,36 @@ func TestATitleSlideIsCentred(t *testing.T) { |
| } |
| } |
| +// Every warning says which slide it is about. The export's status page |
| +// lists warnings from a whole deck, and a line that cannot name its |
| +// slide is a line nobody can act on. |
| +func TestEveryWarningCarriesItsSlide(t *testing.T) { |
| + d := &deck.Deck{ |
| + Meta: deck.Meta{Theme: "paper", Fonts: &deck.Fonts{Heading: "Nonesuch Grotesk", Body: "Nonesuch Grotesk"}}, |
| + Slides: []deck.Slide{ |
| + {ID: "first", Layout: "flow", Template: "title-body", |
| + Blocks: []deck.Block{{ID: "b1", Type: "text", Region: "body", Text: txt("ok")}}}, |
| + {ID: "second", Layout: "flow", Template: "title-body", |
| + Background: deck.Background{Kind: "image", Value: "never-registered"}, |
| + Blocks: []deck.Block{ |
| + {ID: "b2", Type: "image", Region: "body", Props: map[string]any{"asset": "also-missing"}}, |
| + {ID: "b3", Type: "text", Region: "body", Text: txt("a rune no face draws: \U0001F600")}, |
| + }}, |
| + }, |
| + } |
| + for i, s := range d.Slides { |
| + _, warnings := paintOf(t, d, i) |
| + if len(warnings) == 0 { |
| + t.Errorf("slide %q made no warnings, so this test is asserting nothing about it", s.ID) |
| + } |
| + for _, w := range warnings { |
| + if w.Slide != s.ID { |
| + t.Errorf("slide %q: warning %+v carries slide %q", s.ID, w, w.Slide) |
| + } |
| + } |
| + } |
| +} |
| + |
| func hasWarning(ws []layout.Warning, kind, block string) bool { |
| for _, w := range ws { |
| if w.Kind == kind && w.Block == block { |
diff --git a/internal/slides/layout/paint.go b/internal/slides/layout/paint.go| index 27fb7bd..05a1e5c 100644 |
| --- a/internal/slides/layout/paint.go |
| +++ b/internal/slides/layout/paint.go |
| @@ -171,7 +171,15 @@ func (Text) kind() string { return "text" } |
| // slide is still drawn, and the reason it looks wrong is reported |
| // rather than left for somebody to discover in a printed handout. |
| type Warning struct { |
| - Kind string `json:"kind"` |
| + Kind string `json:"kind"` |
| + // Slide is the id of the slide the warning is about. Layout stamps |
| + // it on everything it reports, so a caller holding the warnings |
| + // from a whole deck — an export's status page — can say WHICH |
| + // slide is wrong. Without it a warning list is a set of true |
| + // sentences about a deck and the person reading it has to open |
| + // every slide to find the picture that went missing. Empty only on |
| + // a warning made outside any slide. |
| + Slide string `json:"slide,omitempty"` |
| Block string `json:"block,omitempty"` |
| Detail string `json:"detail"` |
| } |
diff --git a/internal/slides/locales/en.toml b/internal/slides/locales/en.toml| index c30f50d..8bf3ba8 100644 |
| --- a/internal/slides/locales/en.toml |
| +++ b/internal/slides/locales/en.toml |
| @@ -175,6 +175,9 @@ slides.viewer.notes.page = "All notes" |
| slides.viewer.share = "Share" |
| slides.viewer.present = "Present" |
| slides.viewer.edit = "Edit" |
| +# A link, not a form: the export asks a question first (with the notes |
| +# or without), so the button opens the screen that asks it. |
| +slides.viewer.export = "Export PDF" |
| # A deck whose document has nothing in it. Reachable today only after a |
| # document was emptied by something other than a screen, which is |
| # exactly when a page saying "not found" would read as data loss. |
| @@ -506,3 +509,40 @@ slides.assets.error.notimage = "That file isn't a PNG, JPEG, WebP or SVG image." |
| slides.assets.error.svg = "That SVG carries something we can't make safe — a script, or a reference to another site." |
| slides.assets.error.altrequired = "An image needs alternative text unless it's marked decorative." |
| slides.assets.error.failed = "The image couldn't be stored. Try again." |
| + |
| +# slides.export.* — the PDF export (exports.go, spec §4.3): the little |
| +# form that starts it, the status page it lands on, and everything the |
| +# job may have to say to the person who pressed the button. |
| +# |
| +# The job's sentences are resolved from this catalog while the request |
| +# is still here and carried into the goroutine (exportWords), because a |
| +# job outlives the request and rastrillo.T reads the locale off one. So |
| +# an export answers in the language of whoever started it, which is the |
| +# only person who ever sees it. |
| +# |
| +# progress carries {n} and {total} and is filled in Go, one substitution |
| +# per slide, for the same reason. |
| +slides.export.title = "Export as PDF" |
| +slides.export.body = "Every slide becomes a page, 16:9, with the text as real text you can search and copy." |
| +slides.export.notes = "Add the speaker notes as pages of their own" |
| +slides.export.submit = "Make the PDF" |
| +slides.export.held = "The file is kept for an hour, then it goes. Make another whenever you like." |
| +slides.export.job = "Exporting {title}" |
| +slides.export.progress = "slide {n} of {total}" |
| +slides.export.done = "The PDF is ready." |
| +slides.export.download = "Download the PDF" |
| +slides.export.again = "Export this deck again" |
| +# What the deck looks like in the file, said before somebody finds it |
| +# in a printed handout (§11's degrade loudly). Every line names the |
| +# slide it is about. |
| +slides.export.warnings = "Worth knowing" |
| +slides.export.warnings.body = "The PDF is complete, but these are the places it doesn't match the deck on screen." |
| +# The bytes were held in this process and the process no longer has |
| +# them: the hour is up, or the instance was replaced under it. |
| +slides.export.gone = "This export has expired. Make another one — it takes a few seconds." |
| +slides.export.error.busy = "You already have several exports running. Wait for one to finish and try again." |
| +slides.export.error.toomany = "This deck has more than {max} slides, which is more than one PDF will take." |
| +slides.export.error.open = "This deck couldn't be opened. Try again in a moment." |
| +slides.export.error.render = "The PDF couldn't be made. Try again, and tell us if it keeps happening." |
| +slides.export.error.toobig = "This PDF is larger than we can hold. Export it without the notes, or split the deck." |
| +slides.export.error.store = "The PDF was made but couldn't be stored. Try again." |
diff --git a/internal/slides/pdfcore/pdfcore.go b/internal/slides/pdfcore/pdfcore.go| index e6dfd14..635a386 100644 |
| --- a/internal/slides/pdfcore/pdfcore.go |
| +++ b/internal/slides/pdfcore/pdfcore.go |
| @@ -68,6 +68,23 @@ const defaultGuestDeadline = 60 * time.Second |
| // over. |
| const maxFramesBytes = 1 << 30 |
| +// defaultMemoryPages caps the linear memory ONE instance may grow to: |
| +// 8192 wasm pages of 64 KiB, which is 512 MiB. |
| +// |
| +// wasm32 memory grows to 4 GiB unless the host says otherwise, and this |
| +// host runs a parser (usvg) over bytes a deck author controls, on a |
| +// small instance shared with the database and every other request. The |
| +// cap is what turns "a deck took the process down" into "one export |
| +// failed": past it, `memory.grow` answers -1 inside the module, the |
| +// Rust allocator's handler aborts, and wazero reports an ordinary trap |
| +// from Call which Render wraps like any other failure. |
| +// |
| +// Half a gibibyte rather than something tighter: the measured ceiling |
| +// is a 200-page render with inlined 2000 px images, and svg2pdf holds |
| +// every page's tree while it writes. A render that needs more than |
| +// this is a deck nobody should be waiting on anyway. |
| +const defaultMemoryPages = 8192 |
| + |
| // Option configures a Runtime. See WithGuestDeadline. |
| type Option func(*Runtime) |
| @@ -78,14 +95,23 @@ func WithGuestDeadline(d time.Duration) Option { |
| return func(r *Runtime) { r.deadline = d } |
| } |
| +// WithMemoryLimitPages overrides defaultMemoryPages for one Runtime. |
| +// Exists so the cap can come from configuration on a bigger host, and |
| +// so a test can prove what happens at the ceiling without allocating |
| +// half a gibibyte to get there. |
| +func WithMemoryLimitPages(pages uint32) Option { |
| + return func(r *Runtime) { r.memoryPages = pages } |
| +} |
| + |
| // Runtime owns the compiled module. Compiling is the expensive part |
| // (about three quarters of a second) and instantiating is not, so one |
| // Runtime is shared and every Render gets its own instance. Safe for |
| // concurrent use. |
| type Runtime struct { |
| - rt wazero.Runtime |
| - compiled wazero.CompiledModule |
| - deadline time.Duration |
| + rt wazero.Runtime |
| + compiled wazero.CompiledModule |
| + deadline time.Duration |
| + memoryPages uint32 |
| } |
| // New compiles the embedded module. It refuses a module that imports |
| @@ -93,7 +119,7 @@ type Runtime struct { |
| // no_host_imports test also holds) and one that reports an ABI version |
| // this host does not understand. |
| func New(ctx context.Context, opts ...Option) (*Runtime, error) { |
| - r := &Runtime{deadline: defaultGuestDeadline} |
| + r := &Runtime{deadline: defaultGuestDeadline, memoryPages: defaultMemoryPages} |
| for _, opt := range opts { |
| opt(r) |
| } |
| @@ -102,7 +128,13 @@ func New(ctx context.Context, opts ...Option) (*Runtime, error) { |
| // call only makes the caller stop waiting while the wasm keeps |
| // running to completion. With it, wazero terminates the call and |
| // closes the module when the context is done. |
| - cfg := wazero.NewRuntimeConfig().WithCloseOnContextDone(true) |
| + // |
| + // WithMemoryLimitPages: the other half of the same containment — |
| + // the deadline bounds how long one render may take, this bounds |
| + // how much of the host's memory it may take with it. |
| + cfg := wazero.NewRuntimeConfig(). |
| + WithCloseOnContextDone(true). |
| + WithMemoryLimitPages(r.memoryPages) |
| r.rt = wazero.NewRuntimeWithConfig(ctx, cfg) |
| compiled, err := r.rt.CompileModule(ctx, moduleWASM) |
| if err != nil { |
diff --git a/internal/slides/pdfcore/pdfcore_test.go b/internal/slides/pdfcore/pdfcore_test.go| index da9a99d..cb808bd 100644 |
| --- a/internal/slides/pdfcore/pdfcore_test.go |
| +++ b/internal/slides/pdfcore/pdfcore_test.go |
| @@ -178,6 +178,38 @@ func TestADeadlineIsAnErrorNotAHang(t *testing.T) { |
| } |
| } |
| +// A render that runs out of memory is an error like any other guest |
| +// failure, not a crash: the module's allocator aborts, wazero reports a |
| +// trap, and the instance dies alone. |
| +// |
| +// 24 pages is 1.5 MiB — just above the module's own static memory and |
| +// far below what one page of svg2pdf needs, so the ceiling is reached |
| +// on the first render instead of after allocating half a gibibyte to |
| +// find the real one (defaultMemoryPages). The same page at the default |
| +// limit is rendered afterwards, so this cannot pass on a broken SVG. |
| +func TestARenderPastTheMemoryLimitIsAnErrorNotACrash(t *testing.T) { |
| + ctx := context.Background() |
| + page := slide("Over the top", "Liberation Sans", 400) |
| + |
| + tight, err := pdfcore.New(ctx, pdfcore.WithMemoryLimitPages(24)) // 1.5 MiB |
| + if err != nil { |
| + t.Fatalf("New with a 24-page limit: %v", err) |
| + } |
| + defer tight.Close(ctx) |
| + out, err := tight.Render(ctx, [][]byte{page}, allFonts()) |
| + if err == nil { |
| + t.Fatalf("rendered %d bytes under a 24-page memory limit; the limit is not armed", len(out)) |
| + } |
| + if pdfcore.Rejected(err) { |
| + t.Errorf("running out of memory reads as a refusal of the deck: %v", err) |
| + } |
| + t.Logf("over the limit: %v", err) |
| + |
| + if _, err := runtime(t).Render(ctx, [][]byte{page}, allFonts()); err != nil { |
| + t.Fatalf("the same page at the default limit: %v", err) |
| + } |
| +} |
| + |
| func TestACancelledContextIsAnError(t *testing.T) { |
| rt := runtime(t) |
| ctx, cancel := context.WithCancel(context.Background()) |
diff --git a/internal/slides/render.go b/internal/slides/render.go| index 9699dd7..d770b54 100644 |
| --- a/internal/slides/render.go |
| +++ b/internal/slides/render.go |
| @@ -55,7 +55,18 @@ var publicPages = []string{"signin", "errors"} |
| // with templates/account.html parsed after the layout, so the shell's |
| // account menu is defined once rather than in each page (see that |
| // file's own comment). |
| -var signedInPages = []string{"decks", "viewer", "editor", "deck_notes", "deck_new", "deck_settings", "deck_delete", "share", "agents"} |
| +var signedInPages = []string{"decks", "viewer", "editor", "deck_notes", "deck_new", "deck_settings", "deck_delete", "deck_export", "export_status", "share", "agents"} |
| + |
| +// fragmentPages are the templates that are ALSO rendered without the |
| +// layout: rastrillo's polling shim replaces one element with what it |
| +// fetches, so the polled endpoint answers a partial and not a page |
| +// (jobs.Config.RenderFragment — "render a whole page there and the |
| +// layout nests inside itself on the next poll"). |
| +// |
| +// One file, two builds, exactly as viewer.html is built twice: the |
| +// status page and the fragment inside it cannot say different things |
| +// about the same job if they are one template. |
| +var fragmentPages = []string{"export_status"} |
| // linkPages are screens rendered from a signed-in page's own template |
| // for somebody who is not signed in — today, exactly one: the viewer, |
| @@ -100,6 +111,25 @@ func init() { |
| for name, shared := range capabilityPages { |
| pages[name] = buildPage(name, shared...) |
| } |
| + for _, name := range fragmentPages { |
| + fragments[name] = buildFragment(name) |
| + } |
| +} |
| + |
| +// fragments is one *template.Template per template that is also |
| +// rendered on its own, without the layout around it. |
| +var fragments = map[string]*template.Template{} |
| + |
| +// buildFragment parses a page's file with ui's partials and NOTHING |
| +// else — no layout, no shell — so executing one of its defines writes |
| +// the partial by itself. |
| +func buildFragment(name string) *template.Template { |
| + return template.Must( |
| + template.Must(template.New("fragment"). |
| + Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))). |
| + Funcs(template.FuncMap{"asset": assets.Path}). |
| + ParseFS(ui.Templates(), "*.html")). |
| + ParseFS(appFS, "templates/"+name+".html")) |
| } |
| // buildPage parses one page: ui's partials, then the layout, then any |
| @@ -142,6 +172,20 @@ func render(w http.ResponseWriter, name string, data any) { |
| buf.WriteTo(w) |
| } |
| +// renderFragment executes one define out of a fragment build — the |
| +// markup a polled endpoint answers with, and nothing around it. Same |
| +// buffer-first rule as render: a template error is a clean 500 rather |
| +// than half a fragment swapped into somebody's page. |
| +func renderFragment(w http.ResponseWriter, name, define string, data any) { |
| + var buf bytes.Buffer |
| + if err := fragments[name].ExecuteTemplate(&buf, define, data); err != nil { |
| + http.Error(w, "something went wrong", http.StatusInternalServerError) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + buf.WriteTo(w) |
| +} |
| + |
| // ErrorPage renders the "errors" page — templates/errors.html, which |
| // wraps ui's error-page partial in the layout — for status inside ref |
| // (rastrillo.NewRef, or empty when there is none to show). It is the |
diff --git a/internal/slides/svg/svg.go b/internal/slides/svg/svg.go| index 482278b..5fba59e 100644 |
| --- a/internal/slides/svg/svg.go |
| +++ b/internal/slides/svg/svg.go |
| @@ -20,7 +20,10 @@ |
| // knowingly not the slide, and why it is a thumbnail and not the |
| // export. |
| // - The PDF, where the same bytes go through svg2pdf, which reads |
| -// PNG, JPEG and GIF and no WebP. |
| +// PNG, JPEG and GIF and no WebP. A WebP asset is decoded and |
| +// re-encoded as a PNG on its way in (webpAsPNG), so a picture a |
| +// person uploaded in the format their phone writes is in both the |
| +// thumbnail and the PDF rather than in neither. |
| // |
| // # What comes out |
| // |
| @@ -44,6 +47,7 @@ import ( |
| "encoding/xml" |
| "errors" |
| "fmt" |
| + "image/png" |
| "io" |
| "math" |
| "net/url" |
| @@ -51,6 +55,8 @@ import ( |
| "strconv" |
| "strings" |
| + "golang.org/x/image/webp" |
| + |
| "amadan.net/oficina/slides/internal/slides/fonts" |
| "amadan.net/oficina/slides/internal/slides/layout" |
| ) |
| @@ -70,10 +76,11 @@ type Options struct { |
| // missing asset, a store this deployment does not have, a type |
| // this cannot use — and the picture falls back to AssetURL. |
| // |
| - // Only PNG, JPEG, GIF and a sanitised SVG are used. WebP is |
| - // deliberately not: there is no decoder for it in the standard |
| - // library and svg2pdf will not read one either, so a caller that |
| - // offers one is answered with the URL instead. |
| + // PNG, JPEG, GIF and a sanitised SVG go in as they are. A WebP is |
| + // decoded here and re-encoded as a PNG (golang.org/x/image/webp), |
| + // because nothing downstream reads one: a browser rendering an |
| + // `<img src>` SVG fetches nothing, and svg2pdf has no WebP decoder |
| + // either. Anything else falls back to AssetURL. |
| InlineImages func(asset string) (data []byte, mime string, ok bool) |
| // AssetURL is where an asset's bytes are served from on THIS |
| @@ -304,10 +311,36 @@ func safeHref(raw string) string { |
| // ── Images ─────────────────────────────────────────────────────────── |
| // inlineTypes are the raster types that go in as bytes: the three every |
| -// renderer in this pipeline reads. WebP is not one of them — see |
| -// Options.InlineImages. |
| +// renderer in this pipeline reads. A WebP is not one of them and is |
| +// converted to the first of them instead (webpAsPNG). |
| var inlineTypes = []string{"image/png", "image/jpeg", "image/gif"} |
| +// webpAsPNG decodes a WebP and writes it back out as a PNG, which is |
| +// what both consumers of this package can actually draw. |
| +// |
| +// Re-encoding rather than refusing, because the alternative is a |
| +// picture that is simply missing: WebP is one of the four types the |
| +// upload path admits (assets.go's assetTypes), it is what a phone and |
| +// a modern image editor write by default, and neither a browser |
| +// rendering an `<img src>` SVG nor svg2pdf will decode one. The cost is |
| +// a bigger data URI — PNG is lossless and WebP usually is not — which |
| +// is paid once per export, against a picture that would otherwise not |
| +// be on the slide at all. |
| +// |
| +// A file that does not decode answers ok=false and the caller falls |
| +// back to the URL, exactly as an unknown type does. |
| +func webpAsPNG(data []byte) ([]byte, bool) { |
| + img, err := webp.Decode(bytes.NewReader(data)) |
| + if err != nil { |
| + return nil, false |
| + } |
| + var out bytes.Buffer |
| + if err := png.Encode(&out, img); err != nil { |
| + return nil, false |
| + } |
| + return out.Bytes(), true |
| +} |
| + |
| func (w *writer) image(im layout.Image) { |
| // preserveAspectRatio is `object-fit` said in SVG: slice fills the |
| // box and overflows it, meet fits inside it. |
| @@ -323,6 +356,10 @@ func (w *writer) image(im layout.Image) { |
| switch { |
| case slices.Contains(inlineTypes, mime): |
| href = "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data) |
| + case mime == "image/webp": |
| + if pngBytes, converted := webpAsPNG(data); converted { |
| + href = "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes) |
| + } |
| case mime == "image/svg+xml": |
| nested = nestableSVG(data) |
| } |
diff --git a/internal/slides/svg/svg_test.go b/internal/slides/svg/svg_test.go| index d58ae1d..dfdf613 100644 |
| --- a/internal/slides/svg/svg_test.go |
| +++ b/internal/slides/svg/svg_test.go |
| @@ -3,7 +3,9 @@ package svg_test |
| import ( |
| "bytes" |
| "encoding/base64" |
| + "encoding/hex" |
| "encoding/xml" |
| + "image/png" |
| "io" |
| "strings" |
| "testing" |
| @@ -459,11 +461,7 @@ func TestAnImageFallsBackToTheSurfacesOwnURL(t *testing.T) { |
| } |
| } |
| -// The three raster types svg2pdf reads. A WebP has no decoder in the |
| -// standard library and no place in a PDF, so a caller that hands one |
| -// over is telling this package something it cannot use: the picture |
| -// falls back to its URL rather than being written as bytes nothing will |
| -// draw. |
| +// The three raster types svg2pdf reads go in as they are. |
| func TestOnlyTypesEverythingCanReadAreInlined(t *testing.T) { |
| for _, mime := range []string{"image/png", "image/jpeg", "image/gif"} { |
| doc := parse(t, svg.Encode(frame(layout.Image{W: 10, H: 10, Asset: "asset0001aaa"}), |
| @@ -473,14 +471,68 @@ func TestOnlyTypesEverythingCanReadAreInlined(t *testing.T) { |
| } |
| } |
| doc := parse(t, svg.Encode(frame(layout.Image{W: 10, H: 10, Asset: "asset0001aaa"}), svg.Options{ |
| - InlineImages: inline("image/webp", onePNG), |
| + InlineImages: inline("image/octet-stream", onePNG), |
| AssetURL: func(string) string { return "/assets/x" }, |
| })) |
| if got := doc.first(t, "image").attr["href"]; got != "/assets/x" { |
| - t.Errorf("a WebP inlined as %q; it has no decoder here", got) |
| + t.Errorf("a type nothing here decodes inlined as %q; it should fall back to the URL", got) |
| } |
| } |
| +// aWebP is a 4 by 3 lossless WebP, one flat colour: the smallest real |
| +// file of the one raster type nothing downstream can draw. Written as |
| +// hex rather than as a testdata file because it is thirty-eight bytes |
| +// and the test reads better with its own fixture in it. |
| +const aWebP = "524946461e000000574542505650384c110000002f0380000007508f2217a5ff8188e87f0000" |
| + |
| +// A WebP is re-encoded as a PNG on its way into the picture. It is one |
| +// of the four types the upload path admits and neither consumer of this |
| +// package can draw one, so the choice is convert it or lose it. |
| +func TestAWebPIsInlinedAsAPNG(t *testing.T) { |
| + data, err := hex.DecodeString(aWebP) |
| + if err != nil { |
| + t.Fatalf("the fixture is not hex: %v", err) |
| + } |
| + doc := parse(t, svg.Encode(frame(layout.Image{W: 10, H: 10, Asset: "asset0001aaa"}), svg.Options{ |
| + InlineImages: inline("image/webp", data), |
| + AssetURL: func(string) string { return "/assets/x" }, |
| + })) |
| + href := doc.first(t, "image").attr["href"] |
| + prefix := "data:image/png;base64," |
| + if !strings.HasPrefix(href, prefix) { |
| + t.Fatalf("a WebP came out as %q, want a PNG data URI", trunc(href)) |
| + } |
| + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(href, prefix)) |
| + if err != nil { |
| + t.Fatalf("the data URI is not base64: %v", err) |
| + } |
| + img, err := png.Decode(bytes.NewReader(raw)) |
| + if err != nil { |
| + t.Fatalf("the inlined bytes are not a PNG: %v", err) |
| + } |
| + if b := img.Bounds(); b.Dx() != 4 || b.Dy() != 3 { |
| + t.Errorf("the PNG is %d by %d, want the WebP's 4 by 3", b.Dx(), b.Dy()) |
| + } |
| + |
| + // Bytes that claim to be a WebP and are not fall back to the URL, |
| + // like any other picture this package cannot read. |
| + broken := parse(t, svg.Encode(frame(layout.Image{W: 10, H: 10, Asset: "asset0001aaa"}), svg.Options{ |
| + InlineImages: inline("image/webp", []byte("RIFF\x00\x00\x00\x00WEBPnot really")), |
| + AssetURL: func(string) string { return "/assets/x" }, |
| + })) |
| + if got := broken.first(t, "image").attr["href"]; got != "/assets/x" { |
| + t.Errorf("junk claiming to be a WebP inlined as %q", trunc(got)) |
| + } |
| +} |
| + |
| +// trunc keeps a failure message about a data URI readable. |
| +func trunc(s string) string { |
| + if len(s) > 80 { |
| + return s[:80] + "…" |
| + } |
| + return s |
| +} |
| + |
| func TestAnImageNothingCanResolveIsNotDrawn(t *testing.T) { |
| doc := parse(t, svg.Encode(frame(layout.Image{W: 10, H: 10, Asset: "gone"}), svg.Options{})) |
| if got := len(doc.all("image")); got != 0 { |
diff --git a/internal/slides/templates/deck_export.html b/internal/slides/templates/deck_export.html| new file mode 100644 |
| index 0000000..8f69163 |
| --- /dev/null |
| +++ b/internal/slides/templates/deck_export.html |
| @@ -0,0 +1,24 @@ |
| +{{/* deck_export — GET /d/{sqid}/export, the one screen that starts an |
| + export. |
| + |
| + A page of its own rather than a button on the viewer, because |
| + pressing it starts work and asks a question first: with the |
| + speaker notes, or without. One job per screen (SKILL.md §7). |
| + |
| + The notice is where a refused start lands: a deck over the slide |
| + cap, or a person who already has four exports running. Neither is |
| + a fault, so neither is an error page. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +{{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}} |
| +{{template "page-header" dict "Title" .Title "Sub" .DeckTitle}} |
| +{{template "form-error" .Notice}} |
| +<form method="post" action="{{.Action}}"> |
| + <div rst-box> |
| + <p>{{.Body}}</p> |
| + {{template "field-check" dict "Name" "notes" "Label" .NotesLabel "Value" "1"}} |
| + <p rst-text-muted>{{.Held}}</p> |
| + </div> |
| + {{template "form-foot" dict "Submit" .Submit "CancelHref" .BackHref "CancelLabel" .BackLabel}} |
| +</form> |
| +{{end}} |
diff --git a/internal/slides/templates/export_status.html b/internal/slides/templates/export_status.html| new file mode 100644 |
| index 0000000..9c8ee5f |
| --- /dev/null |
| +++ b/internal/slides/templates/export_status.html |
| @@ -0,0 +1,53 @@ |
| +{{/* export_status — GET /d/{sqid}/export/{job}, where the button lands. |
| + |
| + Built twice (render.go): as a page, and as the bare "export-body" |
| + the shim polls. That is why the running and finished states are |
| + ALTERNATIVES rather than a result appended under a spinner — the |
| + shim keeps the first element of what it fetches and drops the |
| + rest, so a fragment has to be one element, and the element that |
| + replaces the spinner is the result. |
| + |
| + While it is running the element is ui's own job-status partial, |
| + which carries data-poll (and data-poll-push for a browser that can |
| + take server-sent events) and drops it when the job is done, which |
| + is what stops the loop. With scripts off the meta refresh below |
| + does the same job more slowly — and only while the job is running, |
| + or a finished page would refresh for ever. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "page-head"}}{{if .Running}}<noscript><meta http-equiv="refresh" content="{{.PollSeconds}}"></noscript>{{end}}{{end}} |
| + |
| +{{define "export-body"}} |
| +{{- if .Running}} |
| +{{template "job-status" dict |
| + "Name" .Name "Status" .Status "Progress" .Progress |
| + "PollURL" .PollURL "PollSeconds" .PollSeconds "PushURL" .PushURL}} |
| +{{- else}} |
| +<div slides-export> |
| + {{- if .Failed}} |
| + {{template "callout" dict "Tone" "negative" "Title" .Name "Body" .Err "Alert" true}} |
| + {{- else if .Gone}} |
| + {{template "callout" dict "Tone" "warning" "Title" .Name "Body" .Gone}} |
| + {{- else}} |
| + <p>{{.Done}}</p> |
| + <p><a rst-btn="primary lg" href="{{.DownloadHref}}">{{.DownloadLabel}}</a></p> |
| + <p rst-text-muted>{{.Held}}</p> |
| + {{- if .Warnings}} |
| + <h2>{{.WarningsLabel}}</h2> |
| + <p>{{.WarningsBody}}</p> |
| + <ul slides-export-warnings> |
| + {{- range .Warnings}} |
| + <li>{{with .Slide}}<strong>{{.}}</strong> — {{end}}{{.Detail}}</li> |
| + {{- end}} |
| + </ul> |
| + {{- end}} |
| + {{- end}} |
| + <p><a href="{{.AgainHref}}">{{.AgainLabel}}</a></p> |
| +</div> |
| +{{- end}} |
| +{{end}} |
| + |
| +{{define "content"}} |
| +{{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}} |
| +{{template "page-header" dict "Title" .Title "Sub" .Name}} |
| +{{template "export-body" .}} |
| +{{end}} |
diff --git a/internal/slides/templates/viewer.html b/internal/slides/templates/viewer.html| index 89bf9f9..076ac80 100644 |
| --- a/internal/slides/templates/viewer.html |
| +++ b/internal/slides/templates/viewer.html |
| @@ -85,12 +85,16 @@ |
| <div slides-viewer data-vp="{{.VP}}"> |
| {{if .BackHref}}{{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}}{{end}} |
| {{template "page-header" dict "Title" .Title}} |
| -{{if or .EditHref .SettingsHref .ShareHref .NotesHref .PresentHref .PresentAction .FollowHref}} |
| +{{if or .EditHref .SettingsHref .ShareHref .NotesHref .ExportHref .PresentHref .PresentAction .FollowHref}} |
| <nav slides-tools aria-label="{{.ToolsLabel}}"> |
| {{if .EditHref}}<a rst-btn="primary" href="{{.EditHref}}">{{.EditLabel}}</a>{{end}} |
| {{if .SettingsHref}}<a rst-btn href="{{.SettingsHref}}">{{.SettingsLabel}}</a>{{end}} |
| {{if .ShareHref}}<a rst-btn href="{{.ShareHref}}">{{.ShareLabel}}</a>{{end}} |
| {{if .NotesHref}}<a rst-btn href="{{.NotesHref}}">{{.NotesPageLabel}}</a>{{end}} |
| + {{/* Export is a LINK to a screen of its own, not a form button: it |
| + asks whether the speaker notes go in the file before it starts |
| + anything (exports.go). */}} |
| + {{if .ExportHref}}<a rst-btn href="{{.ExportHref}}">{{.ExportLabel}}</a>{{end}} |
| {{/* Present is a FORM on the signed-in screens, because pressing it |
| writes: it opens a session and mints the remote's capability |
| (presentation.go). Over a share link it stays a link to the same |
diff --git a/internal/slides/thumbs.go b/internal/slides/thumbs.go| index b5f7042..c320190 100644 |
| --- a/internal/slides/thumbs.go |
| +++ b/internal/slides/thumbs.go |
| @@ -38,6 +38,7 @@ |
| package slides |
| import ( |
| + "context" |
| "net/http" |
| "strconv" |
| "strings" |
| @@ -181,7 +182,7 @@ func (a *app) thumbnail(r *http.Request, d Deck, view *deck.Deck, index int, bas |
| assets := assetBaseFor(base) |
| return svg.Encode(paint, svg.Options{ |
| Title: slideLabel(r, view.Slides[index], index+1), |
| - InlineImages: a.thumbImages(r, d, view), |
| + InlineImages: a.assetBytes(r.Context(), d, view), |
| AssetURL: func(id string) string { |
| asset, ok := view.Assets[id] |
| if !ok || !assetRefPattern.MatchString(asset.Ref) { |
| @@ -192,7 +193,7 @@ func (a *app) thumbnail(r *http.Request, d Deck, view *deck.Deck, index int, bas |
| }), nil |
| } |
| -// thumbImages reads an asset's bytes for the serialiser to inline. |
| +// assetBytes reads an asset's bytes for the serialiser to inline. |
| // |
| // It has to inline. A thumbnail is shown in an `<img src>`, and a |
| // browser renders an SVG referenced that way in a mode that fetches |
| @@ -212,13 +213,16 @@ func (a *app) thumbnail(r *http.Request, d Deck, view *deck.Deck, index int, bas |
| // Returns nil when this deployment has no object store, which turns |
| // InlineImages off in the serialiser rather than making it ask nine |
| // times. |
| -func (a *app) thumbImages(r *http.Request, d Deck, view *deck.Deck) func(string) ([]byte, string, bool) { |
| +// |
| +// A context rather than the request: the PDF export uses this same |
| +// reader (exports.go), and a job outlives its request. |
| +func (a *app) assetBytes(ctx context.Context, d Deck, view *deck.Deck) func(string) ([]byte, string, bool) { |
| store := a.cfg.Blobs |
| if store == nil { |
| return nil |
| } |
| var verified []string |
| - if err := a.db.G.WithContext(r.Context()).Model(&DeckAsset{}). |
| + if err := a.db.G.WithContext(ctx).Model(&DeckAsset{}). |
| Where("deck_id = ?", d.ID).Pluck("ref", &verified).Error; err != nil { |
| a.logger.Error("slides: list verified assets for a thumbnail", "deck", d.Sqid, "err", err) |
| return nil |
| @@ -241,7 +245,7 @@ func (a *app) thumbImages(r *http.Request, d Deck, view *deck.Deck) func(string) |
| } |
| raw, read := seen[asset.Ref] |
| if !read { |
| - b, err := a.readAsset(r, store, asset.Ref) |
| + b, err := a.readAsset(ctx, store, asset.Ref) |
| if err != nil { |
| // Not a failure of the request: the slide is drawn |
| // without the picture, which is what the browser shows |
diff --git a/internal/slides/viewer.go b/internal/slides/viewer.go| index 08b0896..9375d3d 100644 |
| --- a/internal/slides/viewer.go |
| +++ b/internal/slides/viewer.go |
| @@ -99,6 +99,11 @@ type viewerPage struct { |
| ShareLabel string |
| NotesHref string |
| NotesPageLabel string |
| + // ExportHref opens the export screen (exports.go). Owner and |
| + // member alike, like the editor and presenting: anybody who can |
| + // read the deck can take a copy of it away. |
| + ExportHref string |
| + ExportLabel string |
| // PresentAction is the form that opens a presentation session |
| // (presentation.go). A form and not a link, because pressing it |
| // WRITES: it mints a session and a remote capability. |
| @@ -193,6 +198,8 @@ func (a *app) renderViewer(w http.ResponseWriter, r *http.Request, d Deck, mode, |
| page.ShareLabel = rastrillo.T(r, "slides.viewer.share") |
| page.NotesHref = base + "/notes" |
| page.NotesPageLabel = rastrillo.T(r, "slides.viewer.notes.page") |
| + page.ExportHref = base + "/export" |
| + page.ExportLabel = rastrillo.T(r, "slides.viewer.export") |
| page.PresentAction = base + "/present" |
| page.PresentLabel = rastrillo.T(r, "slides.viewer.present") |
| case modeMember: |
| @@ -200,6 +207,9 @@ func (a *app) renderViewer(w http.ResponseWriter, r *http.Request, d Deck, mode, |
| page.EditLabel = rastrillo.T(r, "slides.viewer.edit") |
| page.NotesHref = base + "/notes" |
| page.NotesPageLabel = rastrillo.T(r, "slides.viewer.notes.page") |
| + // A member exports too, for the same reason. |
| + page.ExportHref = base + "/export" |
| + page.ExportLabel = rastrillo.T(r, "slides.viewer.export") |
| // A member presents too: reading a deck to a room is reading |
| // it (presentation.go's startPresenting). |
| page.PresentAction = base + "/present" |
diff --git a/internal/slidestest/export_test.go b/internal/slidestest/export_test.go| new file mode 100644 |
| index 0000000..f250029 |
| --- /dev/null |
| +++ b/internal/slidestest/export_test.go |
| @@ -0,0 +1,373 @@ |
| +package slidestest |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// exportJobPath finds the status page a started export redirected to: |
| +// /d/{sqid}/export/{job}, the job id being rastrillo's own base64url. |
| +var exportJobPath = regexp.MustCompile(`/d/[23456789abcdefghjkmnpqrstuvwxyz]{10}/export/[A-Za-z0-9_-]{22}`) |
| + |
| +// startExport posts the export form and returns the status page it |
| +// redirected to. |
| +func startExport(t *testing.T, a *testApp, deck string, cookies []*http.Cookie, form url.Values) string { |
| + t.Helper() |
| + rec := post(t, a, deck+"/export", form, cookies...) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST %s/export: status %d, want 303\n%s", deck, rec.Code, rec.Body) |
| + } |
| + loc := rec.Header().Get("Location") |
| + if !exportJobPath.MatchString(loc) { |
| + t.Fatalf("POST %s/export: Location = %q, want a job's status page", deck, loc) |
| + } |
| + return loc |
| +} |
| + |
| +// waitForExport polls the status page's own fragment — the endpoint the |
| +// shim polls, so this waits exactly as a browser does — until the job |
| +// stops running. Bounded by a context deadline rather than a count of |
| +// sleeps, so a slow machine waits longer and a hung job still fails. |
| +func waitForExport(t *testing.T, a *testApp, status string, cookies []*http.Cookie) string { |
| + t.Helper() |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) |
| + defer cancel() |
| + for { |
| + rec := mustGet(t, a, status+"/fragment", http.StatusOK, cookies) |
| + body := rec.Body.String() |
| + if !strings.Contains(body, "data-poll=") { |
| + return body |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + t.Fatalf("the export was still running after %v:\n%s", 2*time.Minute, body) |
| + case <-time.After(50 * time.Millisecond): |
| + } |
| + } |
| +} |
| + |
| +// TestExportJobProducesADownload is the whole flow as a person walks |
| +// it: the form, the POST, the status page while it works, the fragment |
| +// the shim polls, and the file at the end. |
| +func TestExportJobProducesADownload(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + deck := sampleDeck(t, a, cookies) |
| + |
| + // The viewer offers it, to the person who can read the deck. |
| + page := mustGet(t, a, deck, http.StatusOK, cookies).Body.String() |
| + if !strings.Contains(page, `href="`+deck+`/export"`) { |
| + t.Errorf("the viewer has no export button:\n%s", page) |
| + } |
| + |
| + form := mustGet(t, a, deck+"/export", http.StatusOK, cookies).Body.String() |
| + if !strings.Contains(form, `name="notes"`) { |
| + t.Errorf("the export form has no with-notes checkbox:\n%s", form) |
| + } |
| + if !strings.Contains(form, `method="post"`) { |
| + t.Errorf("the export form does not post:\n%s", form) |
| + } |
| + |
| + status := startExport(t, a, deck, cookies, url.Values{"notes": {"1"}}) |
| + |
| + // The status page is a page in its own right, and while the job |
| + // runs it says so in a way that works with scripts off. |
| + first := mustGet(t, a, status, http.StatusOK, cookies).Body.String() |
| + if strings.Contains(first, "data-poll=") && !strings.Contains(first, "http-equiv=\"refresh\"") { |
| + t.Errorf("the running status page polls but has no <noscript> refresh:\n%s", first) |
| + } |
| + |
| + fragment := waitForExport(t, a, status, cookies) |
| + if strings.Contains(fragment, "rst-tone=\"negative\"") { |
| + t.Fatalf("the export failed:\n%s", fragment) |
| + } |
| + download := status + ".pdf" |
| + if !strings.Contains(fragment, download) { |
| + t.Fatalf("the finished fragment offers no download of %s:\n%s", download, fragment) |
| + } |
| + |
| + // The finished page says the same thing as the fragment: one |
| + // template, two holes. |
| + done := mustGet(t, a, status, http.StatusOK, cookies).Body.String() |
| + if !strings.Contains(done, download) { |
| + t.Errorf("the finished status page offers no download:\n%s", done) |
| + } |
| + if strings.Contains(done, "data-poll=") { |
| + t.Errorf("the finished page is still polling:\n%s", done) |
| + } |
| + if strings.Contains(done, "http-equiv=\"refresh\"") { |
| + t.Errorf("the finished page still refreshes itself:\n%s", done) |
| + } |
| + |
| + // The stream a browser rides instead of the timer says the same |
| + // thing and closes: it carries no markup, only "go and look". |
| + events := mustGet(t, a, status+"/events", http.StatusOK, cookies) |
| + if got := events.Header().Get("Content-Type"); got != "text/event-stream" { |
| + t.Errorf("the events stream's Content-Type is %q", got) |
| + } |
| + if !strings.Contains(events.Body.String(), "event: done") { |
| + t.Errorf("the events stream of a finished job does not say so:\n%s", events.Body) |
| + } |
| + |
| + rec := mustGet(t, a, download, http.StatusOK, cookies) |
| + pdf := rec.Body.Bytes() |
| + if !strings.HasPrefix(string(pdf), "%PDF-") { |
| + t.Fatalf("the download is not a PDF: %q", pdf[:min(16, len(pdf))]) |
| + } |
| + if got := rec.Header().Get("Content-Type"); got != "application/pdf" { |
| + t.Errorf("Content-Type = %q", got) |
| + } |
| + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { |
| + t.Errorf("X-Content-Type-Options = %q", got) |
| + } |
| + disposition := rec.Header().Get("Content-Disposition") |
| + if !strings.HasPrefix(disposition, "attachment; ") || !strings.Contains(disposition, sampleDeckTitle+".pdf") { |
| + t.Errorf("Content-Disposition = %q, want an attachment named after the deck", disposition) |
| + } |
| + |
| + // Notes were asked for, so there are at least two pages per slide. |
| + pages := strings.Count(string(pdf), "/Type /Page") - strings.Count(string(pdf), "/Type /Pages") |
| + if pages < 16 { |
| + t.Errorf("the PDF has %d pages; the sample deck with notes should have at least 16", pages) |
| + } |
| + |
| + // A real extractor, when the machine has one. |
| + if path, err := exec.LookPath("pdftotext"); err == nil { |
| + file := filepath.Join(t.TempDir(), "export.pdf") |
| + if err := os.WriteFile(file, pdf, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + out, err := exec.Command(path, file, "-").Output() |
| + if err != nil { |
| + t.Fatalf("pdftotext: %v", err) |
| + } |
| + if !strings.Contains(string(out), sampleDeckTitle) { |
| + t.Errorf("pdftotext did not find the first heading %q; got:\n%s", sampleDeckTitle, out) |
| + } |
| + } else { |
| + t.Log("pdftotext is not on PATH: the extracted-text assertion was skipped") |
| + } |
| +} |
| + |
| +// TestExportRefusesWhenOverTheHeldCap: with no object store, the |
| +// finished PDF is held in this process, and that is bounded. Over the |
| +// cap the job fails with a sentence the owner can act on, and nothing |
| +// is held. |
| +func TestExportRefusesWhenOverTheHeldCap(t *testing.T) { |
| + a := newApp(t) |
| + // A cap of one byte: every real export is over it, and none of |
| + // them has to be big to prove the rule. |
| + a.app.SetExportCaps(1, 1) |
| + cookies := signedIn(t, a, allowedAddress) |
| + deck := sampleDeck(t, a, cookies) |
| + |
| + status := startExport(t, a, deck, cookies, nil) |
| + fragment := waitForExport(t, a, status, cookies) |
| + if !strings.Contains(fragment, "larger than we can hold") { |
| + t.Errorf("a refused export does not say why:\n%s", fragment) |
| + } |
| + // And the file is not there — a job that failed holds nothing. |
| + if rec := get(t, a, status+".pdf", cookies...); rec.Code != http.StatusNotFound { |
| + t.Errorf("GET the pdf of a failed export: status %d, want 404", rec.Code) |
| + } |
| +} |
| + |
| +// TestExportGoesToTheStoreWhenThereIsOne: with a bucket bound, the |
| +// bytes belong in it and not in this process. A store that can presign |
| +// hands the browser a URL of its own; one that cannot — blobs.Dir, in |
| +// development — is streamed back through the same route, so the page |
| +// works either way. |
| +func TestExportGoesToTheStoreWhenThereIsOne(t *testing.T) { |
| + dir := dirStore(t) |
| + a := newAppWithStore(t, dir) |
| + // A cap of one byte, which the held-bytes path would refuse: with |
| + // a store there is nothing held, so this is also the proof that |
| + // the upload path really is the one being taken. |
| + a.app.SetExportCaps(1, 1) |
| + cookies := signedIn(t, a, allowedAddress) |
| + deck := sampleDeck(t, a, cookies) |
| + |
| + status := startExport(t, a, deck, cookies, nil) |
| + fragment := waitForExport(t, a, status, cookies) |
| + if strings.Contains(fragment, "rst-tone=\"negative\"") { |
| + t.Fatalf("the export failed:\n%s", fragment) |
| + } |
| + rec := mustGet(t, a, status+".pdf", http.StatusOK, cookies) |
| + if !strings.HasPrefix(rec.Body.String(), "%PDF-") { |
| + t.Errorf("the streamed export is not a PDF") |
| + } |
| + |
| + // The same deck through a store that presigns: a redirect, and the |
| + // bytes never come through this process at all. |
| + presigning := &presigningStore{Store: dirStore(t)} |
| + b := newAppWithStore(t, presigning) |
| + bCookies := signedIn(t, b, allowedAddress) |
| + bDeck := sampleDeck(t, b, bCookies) |
| + bStatus := startExport(t, b, bDeck, bCookies, nil) |
| + waitForExport(t, b, bStatus, bCookies) |
| + redirect := get(t, b, bStatus+".pdf", bCookies...) |
| + if redirect.Code != http.StatusFound { |
| + t.Fatalf("GET the pdf with a presigning store: status %d, want 302\n%s", redirect.Code, redirect.Body) |
| + } |
| + if loc := redirect.Header().Get("Location"); !strings.HasPrefix(loc, "https://bucket.test/") { |
| + t.Errorf("Location = %q, want the presigned GET", loc) |
| + } |
| +} |
| + |
| +// TestExportPdfIsOwnersOnly: an export belongs to the person who |
| +// started it. Somebody else — even somebody who can read the same deck |
| +// — gets the same 404 a stranger gets, because a job id is a |
| +// capability and a wrong owner must be indistinguishable from a wrong |
| +// id. |
| +func TestExportPdfIsOwnersOnly(t *testing.T) { |
| + a := newSharedApp(t) |
| + mine := signedIn(t, a, allowedAddress) |
| + theirs := signedIn(t, a, otherAddress) |
| + |
| + deck := createDeck(t, a, mine, "Shared with the team") |
| + // On the team rung, so the other person can read it and could |
| + // start an export of their own. |
| + rec := post(t, a, deck+"/settings", url.Values{ |
| + "title": {"Shared with the team"}, "theme": {"ink"}, "rung": {"team"}, |
| + }, mine...) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST %s/settings: status %d\n%s", deck, rec.Code, rec.Body) |
| + } |
| + mustGet(t, a, deck, http.StatusOK, theirs) |
| + |
| + status := startExport(t, a, deck, mine, nil) |
| + waitForExport(t, a, status, mine) |
| + |
| + for _, path := range []string{status, status + ".pdf", status + "/fragment"} { |
| + if rec := get(t, a, path, theirs...); rec.Code != http.StatusNotFound { |
| + t.Errorf("GET %s as the other person: status %d, want 404", path, rec.Code) |
| + } |
| + } |
| + // Signed out, it is not even a page. |
| + if rec := get(t, a, status+".pdf"); rec.Code != http.StatusSeeOther { |
| + t.Errorf("GET the pdf signed out: status %d, want a redirect to sign in", rec.Code) |
| + } |
| +} |
| + |
| +// TestExportRefusesADeckOverTheSlideCap: 200 slides is the most one |
| +// PDF will take (spec §4.3), and the refusal happens on the form |
| +// BEFORE a goroutine is started — a person is told, not shown a failed |
| +// job. |
| +func TestExportRefusesADeckOverTheSlideCap(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + token := mintAgent(t, a, cookies, "filler") |
| + deck := createDeck(t, a, cookies, "Too many slides") |
| + sqid := sqidOf(deck) |
| + |
| + // 201 slides in one batch through the API: the fastest way to a |
| + // deck of this size, and the same op path a person's editor uses. |
| + ops := make([]map[string]any, 0, 201) |
| + for i := 0; i < 201; i++ { |
| + ops = append(ops, map[string]any{"op": "add_slide", "layout": "flow", "template": "section"}) |
| + } |
| + body, err := json.Marshal(map[string]any{"ops": ops}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, string(body), http.StatusOK) |
| + |
| + rec := post(t, a, deck+"/export", nil, cookies...) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST %s/export: status %d, want 303\n%s", deck, rec.Code, rec.Body) |
| + } |
| + if loc := rec.Header().Get("Location"); loc != deck+"/export" { |
| + t.Errorf("a refused export redirected to %q, want the form at %q", loc, deck+"/export") |
| + } |
| + // The reason travels in a flash cookie and is on the form. |
| + form := mustGet(t, a, deck+"/export", http.StatusOK, append(cookies, flashCookie(t, rec))).Body.String() |
| + if !strings.Contains(form, "more than 200 slides") { |
| + t.Errorf("the form does not say why the export was refused:\n%s", form) |
| + } |
| +} |
| + |
| +// flashCookie picks the flash out of a response, so the next request in |
| +// a test carries it exactly as a browser would. |
| +func flashCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { |
| + t.Helper() |
| + for _, c := range rec.Result().Cookies() { |
| + if c.Name == "rastrillo_flash" { |
| + return c |
| + } |
| + } |
| + t.Fatal("no flash cookie on the refusal") |
| + return nil |
| +} |
| + |
| +// TestExportAPITwins: the same export, driven by an agent. 202 with |
| +// somewhere to poll, a status document, and the bytes at the end. |
| +func TestExportAPITwins(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + token := mintAgent(t, a, cookies, "claude") |
| + sqid := sqidOf(sampleDeck(t, a, cookies)) |
| + |
| + started := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/export", token, |
| + `{"notes": false}`, http.StatusAccepted) |
| + job, _ := started["job"].(string) |
| + statusURL, _ := started["status_url"].(string) |
| + pdfURL, _ := started["pdf_url"].(string) |
| + if job == "" || statusURL == "" || pdfURL == "" { |
| + t.Fatalf("POST export: %v, want job, status_url and pdf_url", started) |
| + } |
| + if want := fmt.Sprintf("/api/v1/decks/%s/export/%s", sqid, job); statusURL != want { |
| + t.Errorf("status_url = %q, want %q", statusURL, want) |
| + } |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) |
| + defer cancel() |
| + var state string |
| + for state != "done" { |
| + body := apiJSON(t, a, http.MethodGet, statusURL, token, "", http.StatusOK) |
| + state, _ = body["state"].(string) |
| + if state == "failed" { |
| + t.Fatalf("the export failed: %v", body) |
| + } |
| + if state == "done" { |
| + if _, has := body["warnings"]; !has { |
| + t.Errorf("a finished export reports no warnings key: %v", body) |
| + } |
| + break |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + t.Fatalf("the export was still %q after two minutes", state) |
| + case <-time.After(50 * time.Millisecond): |
| + } |
| + } |
| + |
| + req := httptest.NewRequest(http.MethodGet, pdfURL, nil) |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + rec := httptest.NewRecorder() |
| + a.ServeHTTP(rec, req) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET %s: status %d\n%s", pdfURL, rec.Code, rec.Body) |
| + } |
| + if !strings.HasPrefix(rec.Body.String(), "%PDF-") { |
| + t.Errorf("the API's download is not a PDF") |
| + } |
| + if got := rec.Header().Get("Content-Type"); got != "application/pdf" { |
| + t.Errorf("Content-Type = %q", got) |
| + } |
| + |
| + // A token that is not one of this instance's is not a way in. |
| + if body := apiJSON(t, a, http.MethodGet, statusURL, "sl_"+strings.Repeat("x", 40), "", http.StatusUnauthorized); body["error"] != "unauthorized" { |
| + t.Errorf("a bad token: %v", body) |
| + } |
| +} |
diff --git a/internal/slidestest/harness_test.go b/internal/slidestest/harness_test.go| index 2217c61..ed773a1 100644 |
| --- a/internal/slidestest/harness_test.go |
| +++ b/internal/slidestest/harness_test.go |
| @@ -83,6 +83,15 @@ type testApp struct { |
| http.Handler |
| mailer *fakeMailer |
| db *db.DB |
| + // app is the built app itself, reachable only through its exported |
| + // methods — slides.New returns an unexported type on purpose, so |
| + // this names the shape rather than the type. Today: Close, and the |
| + // export caps, which a test moves so it can prove the refusal |
| + // without first making a 50 MiB PDF. |
| + app interface { |
| + Close() error |
| + SetExportCaps(perDeck, perInstance int64) |
| + } |
| } |
| // newConfig is the fully-configured Config every test starts from: |
| @@ -121,7 +130,7 @@ func newAppWith(t *testing.T, cfg slides.Config, mailer *fakeMailer) *testApp { |
| // Releases the compiled deck core's wazero runtime; every test app |
| // built over slides.New leaks one without this (task 3 fix round 1). |
| t.Cleanup(func() { a.Close() }) |
| - return &testApp{Handler: localised(t, mux), mailer: mailer, db: d} |
| + return &testApp{Handler: localised(t, mux), mailer: mailer, db: d, app: a} |
| } |
| // openTestDB opens a fresh SQLite file for one test, closed on |