You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
<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
86
86
<code>feat/media-attachments</code>. Phase 1 = the byte-crypto seam (§5/§5.1:
87
87
the <code>encb:v1:</code> binary envelope, <code>encodeBytes</code>/<code>decodeBytes</code>, the
88
88
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>
97
97
(returns verified bytes). Phase 4 = the <code>media</code> block type + property schemas and the
98
98
<code>MediaBlockRenderer</code> (§11, <code>src/attachments/</code>) — it dispatches a block's
99
99
<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>)
101
101
gets the bytes resolved once on mount into a verified object URL (fetch → verify → Blob → object URL, revoked
102
102
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><audio controls></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><audio controls></code> / a bounded <code><object type="application/pdf"></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
107
112
a LAZY download button: it resolves nothing on mount and, on click, fetches the verified bytes and saves them
108
113
via a transient, immediately-revoked <em>octet-stream</em> anchor — never a persistent navigable
109
114
<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
111
116
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
112
117
ErrorBoundary. Phase 5 = capture + the up-lane (§9/§11): paste/drop mints the asset block,
113
118
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>
178
183
179
184
<p>The app cannot currently insert images. We want paste / drop / file-pick of an image to render
180
185
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>
182
187
183
188
<p><strong>In scope (v1):</strong> images, via paste / drop / file picker; inline render with a
184
189
lightbox; offline-first local caching; cross-device sync; correctness under E2EE workspaces.</p>
185
190
186
191
<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><audio></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><audio></code> player and <code>application/pdf</code> a
194
+
bounded inline <code><object></code> preview — each a facet contribution, not a schema change. A further
195
+
viewer (video) is the same shape.</p>
190
196
191
197
<p><strong>Designed-for but not built (vNext):</strong> the remaining richer inline viewers for non-image
192
198
mimes (PDF preview / video); thumbnails / transforms; resumable uploads for large media; public sharing.</p>
@@ -1235,21 +1241,42 @@ <h2 id="renderer">11. The renderer & capture surfaces</h2>
1235
1241
type and a <code>priority</code> above the default. It dispatches the block's <code>media:mime</code>
1236
1242
through a mime→viewer FACET (<code>mediaViewersFacet</code>) that plugins contribute a viewer to — one per
1237
1243
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>)
1239
1245
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><audio controls></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><audio controls></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><object type="application/pdf"></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
1248
1275
download button that resolves the verified bytes only on click and saves them through a transient,
1249
1276
immediately-revoked <em>octet-stream</em> anchor, <em>not</em> a persistent navigable <code>blob:</code>
1250
1277
URL typed with the attacker-controlled <code>media:mime</code> (that would be same-origin XSS on
1251
1278
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
1253
1280
media block (incl. non-image) to local disk for offline (§8) — but about not holding a decrypted
1254
1281
object-URL Blob in
1255
1282
memory for media nobody opened, and not un-throttled demand-fetching ahead of that budgeted lane. A
0 commit comments