Skip to content

Commit 353ddc4

Browse files
committed
feat(attachments): inline PDF preview for application/pdf (lazy-inline)
Rebased onto master and re-implemented on the facilities the inline-audio increment (#300) introduced, rather than the earlier eager approach. - application/pdf is now a LAZY-INLINE viewer (eager: false), mirroring audio: a metadata poster ("Preview" + download) that resolves NOTHING on mount and arms the same fail-closed object-URL resolve via the renderer's content-scoped requestResolve latch only on the first preview intent. Once ready it renders a bounded-height <object type="application/pdf"> of the verified url; loading → spinner; a fail-closed resolve → a broken indicator. - Why lazy over eager: the down-lane already replicates every media block to the local plaintext byte store (§8), so an eager resolve is usually a local HIT — not a re-download or re-decrypt — but it still reads the full (possibly large) bytes into a decrypted object-URL Blob and pins it in memory for the block's lifetime (a note of large PDFs holds them all, even un-viewed), and on iOS Safari (which can't inline-render a PDF) that Blob is pure waste. Lazy also closes the earlier eager version's downloadable-floor gap. - UX: the poster carries an explicit "Preview" affordance (eye pill) so it's clear the row opens the PDF; the expanded preview is collapsible back to the poster (resolve stays armed → instant re-open, no refetch); and the object URL carries #navpanes=0 to hide the native viewer's page sidebar by default (Chrome PDFium open-param, best-effort per browser). - Reuses the audio increment's shared facilities: useMediaDownload + DownloadIconButton (generalized with a `testid` prop). PDF holds the "every attachment is at least downloadable" floor in every state (poster/preview/broken), since application/pdf no longer hits the file download fallback. - mediaBlock: PDF_MIME + isPdfMime (exact, case-insensitive). index: register pdfMediaViewer. No renderer change — a pure facet contribution. Security / XSS (verified empirically in Chromium): the object URL's Blob is pinned to application/pdf by the isPdfMime match, so the browser routes it to its native, out-of-process PDF viewer and never HTML-sniffs hash-verified-but-non-PDF bytes into a same-origin document; PDF-level JS is sandboxed by the viewer itself. A sandboxed iframe is NOT an option for a blob: source: Chromium blocks a sandboxed frame from loading a parent-origin blob: URL — even WITH allow-same-origin (which is honored for an http doc; a separate blob-navigation block still fires, ERR_BLOCKED_BY_CLIENT). The strongest form of "sandbox untrusted HTML" is applied elsewhere: text/html (and any unclaimed mime) is never inline-rendered — it goes to the octet-stream download. design.html §11/§15/scope/banner updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2J741JwXMwT2wGYNM1j5u
1 parent 218d5e5 commit 353ddc4

7 files changed

Lines changed: 407 additions & 58 deletions

File tree

docs/media-attachments/design.html

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ <h1>Media attachments</h1>
8282
now has per-workspace opt-in end-to-end encryption. 2026-06-21.</p>
8383

8484
<aside class="doc-status" style="border:1px solid var(--line); border-left:3px solid var(--info); background:var(--info-soft); border-radius:0 6px 6px 0; padding:8px 14px; margin:12px 0 18px; font-size:0.9em;">
85-
<strong>Status:</strong> partially implemented — phases 1–6, plus the inline-audio increment (§15), have landed on branch
85+
<strong>Status:</strong> partially implemented — phases 1–6, plus the inline-audio and inline-PDF increments (§15), have landed on branch
8686
<code>feat/media-attachments</code>. Phase 1 = the byte-crypto seam (§5/§5.1:
8787
the <code>encb:v1:</code> binary envelope, <code>encodeBytes</code>/<code>decodeBytes</code>, the
8888
read-side content-hash verify) in <code>src/sync/</code>. Phase 2 = the <code>attachments</code> bucket
@@ -97,17 +97,22 @@ <h1>Media attachments</h1>
9797
(returns verified bytes). Phase 4 = the <code>media</code> block type + property schemas and the
9898
<code>MediaBlockRenderer</code> (§11, <code>src/attachments/</code>) — it dispatches a block's
9999
<code>media:mime</code> through a mime→viewer FACET (<code>mediaViewersFacet</code>) that plugins contribute a
100-
viewer to — one per mime family. An EAGER inline viewer (image; PDF later, <code>eager: true</code>)
100+
viewer to — one per mime family. An EAGER inline viewer (image, <code>eager: true</code>)
101101
gets the bytes resolved once on mount into a verified object URL (fetch → verify → Blob → object URL, revoked
102102
on unmount) and a fail-closed resolve shows a broken-asset placeholder, never an unverified source. Audio
103-
(<code>audio/*</code>) is a LAZY-INLINE viewer: it renders a metadata play affordance, resolves nothing on
104-
mount, and arms that SAME object-URL path only on the first play (audio files can be large), then renders a
105-
native <code>&lt;audio controls&gt;</code> at the verified <code>blob:</code> url with a filename + octet-stream
106-
download affordance beside it — same fail-closed guarantee, deferred fetch. The remaining non-image fallback is
103+
(<code>audio/*</code>) and PDF (<code>application/pdf</code>) are LAZY-INLINE viewers: each renders a metadata
104+
poster (play / preview), resolves nothing on mount, and arms that SAME object-URL path only on the first
105+
play/preview intent (audio and PDFs can be large; iOS can't inline-render a PDF at all), then renders a native
106+
<code>&lt;audio controls&gt;</code> / a bounded <code>&lt;object type="application/pdf"&gt;</code> at the verified
107+
<code>blob:</code> url with a filename + octet-stream download affordance beside it — same fail-closed guarantee,
108+
deferred fetch. The PDF blob is typed <code>application/pdf</code> (pinned by the mime that selected the viewer),
109+
so the browser routes it to its PDF viewer and never HTML-sniffs hash-verified-but-non-PDF bytes into a
110+
same-origin navigation; a <code>sandbox</code>ed iframe is not used because an opaque-origin frame can't load a
111+
parent-origin <code>blob:</code> URL (§11). The remaining non-image fallback is
107112
a LAZY download button: it resolves nothing on mount and, on click, fetches the verified bytes and saves them
108113
via a transient, immediately-revoked <em>octet-stream</em> anchor — never a persistent navigable
109114
<code>blob:</code> URL typed with the attacker-influenceable <code>media:mime</code> (a same-origin XSS vector).
110-
Deferring the resolve (audio / download) isn't to save egress (the down-lane replicates every media block for
115+
Deferring the resolve (audio / PDF / download) isn't to save egress (the down-lane replicates every media block for
111116
offline regardless, §8) but to avoid holding a decrypted object-URL Blob in memory for media nobody opened. <code>canRender</code> reads a <code>peek()</code> snapshot so it never throws above the block
112117
ErrorBoundary. Phase 5 = capture + the up-lane (§9/§11): paste/drop mints the asset block,
113118
stages bytes in a durable IndexedDB queue (stage-before-tx / promote-after-commit), and a
@@ -178,15 +183,16 @@ <h2 id="scope">1. What we're adding, and scope</h2>
178183

179184
<p>The app cannot currently insert images. We want paste / drop / file-pick of an image to render
180185
inline, work offline, and sync across a user's devices — and we want the same machinery to extend
181-
to PDFs, audio, video, and generic files later without a content migration.</p>
186+
to audio, video, and other file types later without a content migration.</p>
182187

183188
<p><strong>In scope (v1):</strong> images, via paste / drop / file picker; inline render with a
184189
lightbox; offline-first local caching; cross-device sync; correctness under E2EE workspaces.</p>
185190

186191
<p><strong>Built on the image foundation (§11 / §15):</strong> arbitrary non-image files render a
187-
download affordance through the same content-addressed resolve + a mime→viewer facet, and
188-
<code>audio/*</code> renders an inline <code>&lt;audio&gt;</code> player — each a facet contribution, not a
189-
schema change. A richer viewer (PDF preview, video) is the same shape.</p>
192+
download affordance through the same content-addressed resolve + a mime→viewer facet;
193+
<code>audio/*</code> renders an inline <code>&lt;audio&gt;</code> player and <code>application/pdf</code> a
194+
bounded inline <code>&lt;object&gt;</code> preview — each a facet contribution, not a schema change. A further
195+
viewer (video) is the same shape.</p>
190196

191197
<p><strong>Designed-for but not built (vNext):</strong> the remaining richer inline viewers for non-image
192198
mimes (PDF preview / video); thumbnails / transforms; resumable uploads for large media; public sharing.</p>
@@ -1235,21 +1241,42 @@ <h2 id="renderer">11. The renderer &amp; capture surfaces</h2>
12351241
type and a <code>priority</code> above the default. It dispatches the block's <code>media:mime</code>
12361242
through a mime→viewer FACET (<code>mediaViewersFacet</code>) that plugins contribute a viewer to — one per
12371243
mime family — so the renderer never special-cases a mime; byte access is <em>per-viewer</em>. An EAGER
1238-
inline viewer (image via the <code>MarkdownImage</code> lightbox; PDF later, <code>eager: true</code>)
1244+
inline viewer (image via the <code>MarkdownImage</code> lightbox, <code>eager: true</code>)
12391245
gets the bytes resolved once on mount into the verified object URL the in-thread resolver
1240-
produces (§7.3, revoked on unmount). <code>audio/*</code> is a <strong>LAZY-INLINE</strong> viewer
1241-
(<code>eager: false</code>): it renders a metadata play affordance, resolves nothing on mount, and arms
1242-
that SAME object-URL resolve only on the first play (audio files can be large) — via a one-way
1243-
<code>requestResolve</code> latch the renderer exposes — then renders a native
1244-
<code>&lt;audio controls&gt;</code> at the verified <code>blob:</code> url (typed the block's
1245-
<code>audio/*</code> mime, safe to render, revoked on unmount) with a filename + an octet-stream download
1246-
affordance beside it; bytes that hash-verify but aren't decodable audio fall to the same broken placeholder
1247-
via <code>onError</code>. The remaining non-image fallback is <strong>LAZY</strong> — a metadata-only
1246+
produces (§7.3, revoked on unmount). <code>audio/*</code> and <code>application/pdf</code> are
1247+
<strong>LAZY-INLINE</strong> viewers (<code>eager: false</code>): each renders a metadata poster
1248+
(play / preview), resolves nothing on mount, and arms that SAME object-URL resolve only on the first
1249+
play/preview intent (audio and PDFs can be large; iOS can't inline-render a PDF at all) — via a one-way
1250+
<code>requestResolve</code> latch the renderer exposes and content-scopes (it clears on any content-key
1251+
change so a synced re-capture / undo returns to the poster, never a surprise autoplay/refetch). Audio then
1252+
renders a native <code>&lt;audio controls&gt;</code> at the verified <code>blob:</code> url (typed the block's
1253+
<code>audio/*</code> mime, safe to render, revoked on unmount); bytes that hash-verify but aren't decodable
1254+
audio fall to the same broken placeholder via <code>onError</code>. PDF renders a bounded-height
1255+
<code>&lt;object type="application/pdf"&gt;</code> of the verified url — the Blob is typed
1256+
<code>application/pdf</code> (pinned by the <code>isPdfMime</code> match that selected the viewer), so the
1257+
browser routes it to its native PDF viewer (which sandboxes any PDF-level JS itself) and never HTML-sniffs
1258+
hash-verified-but-non-PDF bytes into an executable same-origin document. A <code>sandbox</code>ed iframe is
1259+
deliberately <em>not</em> used and is <em>not an option</em> for a <code>blob:</code> source: Chromium blocks
1260+
a sandboxed frame from loading a <em>parent-origin</em> <code>blob:</code> URL — verified empirically, and
1261+
<em>not</em> a flag-tuning fix. Even <em>with</em> <code>allow-same-origin</code> (which IS honored for an
1262+
http document — the frame reads the parent origin) a separate blob-navigation block still fires
1263+
(<code>ERR_BLOCKED_BY_CLIENT</code>, frame stays opaque), so any sandbox flag breaks the preview while buying
1264+
nothing the type-pin doesn't already give. (What sandbox <em>would</em> add for a native PDF — blocking a
1265+
malicious PDF's launch-actions / form-POST / popups — it can't, given the blob block; the real path to that
1266+
is pdf.js with scripting off, deferred on bundle cost.) The strongest form of the "sandbox untrusted HTML"
1267+
rule <em>is</em> applied here, elsewhere: we never inline-render <code>text/html</code> (or any unclaimed
1268+
mime) at all — it goes to the octet-stream download. The preview is collapsible back to the poster (the
1269+
resolve stays armed, so re-opening is instant), and the object URL carries <code>#navpanes=0</code> to hide
1270+
the native viewer's page sidebar by default (Chromium best-effort). Both lazy-inline viewers keep a filename
1271+
+ an octet-stream download affordance in <em>every</em> state
1272+
(poster, player/preview, broken), because <code>audio/*</code> and <code>application/pdf</code> no longer
1273+
fall through to the file download fallback and must each hold the "every attachment is at least
1274+
downloadable" floor. The remaining non-image fallback is <strong>LAZY</strong> — a metadata-only
12481275
download button that resolves the verified bytes only on click and saves them through a transient,
12491276
immediately-revoked <em>octet-stream</em> anchor, <em>not</em> a persistent navigable <code>blob:</code>
12501277
URL typed with the attacker-controlled <code>media:mime</code> (that would be same-origin XSS on
12511278
open-in-new-tab, with no CSP backstop, and unreliable-<code>download</code> on iOS). Deferring the
1252-
mount-time resolve (audio / download) is <em>not</em> about egress — the down-lane already replicates every
1279+
mount-time resolve (audio / PDF / download) is <em>not</em> about egress — the down-lane already replicates every
12531280
media block (incl. non-image) to local disk for offline (§8) — but about not holding a decrypted
12541281
object-URL Blob in
12551282
memory for media nobody opened, and not un-throttled demand-fetching ahead of that budgeted lane. A
@@ -1496,8 +1523,22 @@ <h2 id="plan">15. Phased plan</h2>
14961523
fetched / decrypted only on the first play — same fail-closed object-URL path as the image viewer
14971524
(<code>onError</code> → broken placeholder), plus a filename + octet-stream download affordance. No
14981525
renderer branch — a pure facet contribution.</td></tr>
1526+
<tr><td>8 — inline PDF (increment)</td>
1527+
<td>a second lazy-inline viewer on the same facility: an <code>application/pdf</code> viewer that renders a
1528+
bounded-height <code>&lt;object type="application/pdf"&gt;</code> of the verified object URL.
1529+
<strong>Preview-gated</strong> (<code>eager: false</code> + the shared <code>requestResolve</code> latch),
1530+
because PDFs can be large and iOS can't inline-render them anyway — the metadata poster resolves nothing on
1531+
mount; a preview click arms the same fail-closed object-URL path. The Blob type is pinned to
1532+
<code>application/pdf</code> by the viewer's exact-match (no HTML-sniff of hash-verified-but-non-PDF bytes);
1533+
a <code>sandbox</code>ed iframe is not usable (Chromium blocks a sandboxed frame from loading a
1534+
parent-origin <code>blob:</code> URL — even with <code>allow-same-origin</code>, verified). The preview
1535+
hides the native viewer's sidebar (<code>#navpanes=0</code>) and is collapsible back to the poster.
1536+
Filename + octet-stream download beside every state (poster / preview / broken) —
1537+
the downloadable floor, since <code>application/pdf</code> no longer hits the file fallback. No renderer
1538+
branch — a pure facet contribution reusing the audio increment's facilities
1539+
(<code>useMediaDownload</code>, <code>DownloadIconButton</code>, the play/preview latch).</td></tr>
14991540
<tr><td>vNext</td>
1500-
<td>the remaining richer inline viewers for non-image mimes (PDF preview, video) atop the
1541+
<td>the remaining richer inline viewer for non-image mimes (video) atop the
15011542
§11 <code>mediaViewersFacet</code>; thumbnails / transforms; resumable upload; the
15021543
<code>effective_share_ids</code> storage-RLS branch when subtree sharing ships; public-share
15031544
(export / key-in-fragment).</td></tr>

0 commit comments

Comments
 (0)