Skip to content

Commit 4629f2d

Browse files
committed
feat(attachments): inline PDF preview for application/pdf media blocks
Adds a `pdf` eager viewer contribution on the mime→viewer facet so an `application/pdf` media block renders an inline, bounded-height preview instead of the download-only file fallback. - mediaBlock.ts: PDF_MIME + isPdfMime (case-insensitive exact match). - mediaViewers.tsx: PdfViewer — a bounded-height <object type="application/pdf"> of the verified object URL (useAssetObjectUrl, revoked on unmount), with an always-visible neutral octet-stream download and a can't-preview fallback (e.g. iOS Safari). Fail-closed resolve → the broken-asset placeholder, never an unverified source (§5.1/§7.3). Extracted a shared useLazyDownload hook so the file fallback and PDF download reuse one octet-stream save path. - index.ts: register pdfMediaViewer on mediaViewersFacet. Security: the inline <object> is safe because the Blob's type is pinned to application/pdf — the mime that selected the viewer — so the browser hands a known non-text/* type to its PDF viewer and never HTML-sniffs hash-verified-but-non-PDF bytes into an executable navigation; the download stays octet-stream (matching the file-fallback posture). Docs: design.html §11 / §15 / scope / status banner move PDF from vNext to shipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2J741JwXMwT2wGYNM1j5u
1 parent 1176c4d commit 4629f2d

7 files changed

Lines changed: 221 additions & 49 deletions

File tree

docs/media-attachments/design.html

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,13 @@ <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 INLINE viewer (image today; PDF/audio later, <code>eager: true</code>)
101-
gets the bytes resolved once into a verified object URL (fetch → verify → Blob → object URL, revoked on
102-
unmount) and a fail-closed resolve shows a broken-asset placeholder, never an unverified source. The
100+
viewer to — one per mime family. An INLINE viewer (image + inline PDF today; audio/video later,
101+
<code>eager: true</code>) gets the bytes resolved once into a verified object URL (fetch → verify → Blob →
102+
object URL, revoked on unmount) and a fail-closed resolve shows a broken-asset placeholder, never an
103+
unverified source. The PDF viewer renders a bounded-height <code>&lt;object type="application/pdf"&gt;</code>
104+
of that object URL (the Blob's type is pinned to <code>application/pdf</code> by the mime that selected the
105+
viewer, so a known non-<code>text/*</code> type is handed to the browser's PDF viewer and never HTML-sniffed
106+
into an executable navigation) with an always-visible neutral octet-stream download. The
103107
non-image fallback is a LAZY download button: it resolves nothing on mount and, on click, fetches the
104108
verified bytes and saves them via a transient, immediately-revoked <em>octet-stream</em> anchor — never a
105109
persistent navigable <code>blob:</code> URL typed with the attacker-influenceable <code>media:mime</code> (a
@@ -121,7 +125,7 @@ <h1>Media attachments</h1>
121125
deferred</strong> — until it lands, a never-promoted <code>staged</code> orphan and its OPFS bytes are
122126
retained (never reaped), and a <code>failed</code> upload record is recoverable only by re-paste (no §9
123127
recovery actor yet).
124-
Last verified against code 2026-07-01. Treat as current intent. The least-settled part is the byte-lane
128+
Last verified against code 2026-07-02. Treat as current intent. The least-settled part is the byte-lane
125129
lifecycle / failure-recovery machinery (§8–§9, §16): the happy path and the crypto / threat model are
126130
solid; the recovery edges are where the spec is still hardening.
127131
</aside>
@@ -181,11 +185,12 @@ <h2 id="scope">1. What we're adding, and scope</h2>
181185
lightbox; offline-first local caching; cross-device sync; correctness under E2EE workspaces.</p>
182186

183187
<p><strong>Built on the image foundation (§11 / §15):</strong> arbitrary non-image files render a
184-
download affordance through the same content-addressed resolve + a mime→viewer facet — so a richer
185-
inline viewer (PDF preview, <code>&lt;audio&gt;</code>) is a new facet contribution, not a schema change.</p>
188+
download affordance through the same content-addressed resolve + a mime→viewer facet, and
189+
<code>application/pdf</code> renders an inline bounded-height preview — each a facet contribution, not a
190+
schema change, so a further inline viewer (<code>&lt;audio&gt;</code>, video) is the same one-viewer add.</p>
186191

187-
<p><strong>Designed-for but not built (vNext):</strong> those richer inline viewers for non-image mimes
188-
(PDF / audio / video); thumbnails / transforms; resumable uploads for large media; public sharing.</p>
192+
<p><strong>Designed-for but not built (vNext):</strong> inline viewers for the remaining non-image mimes
193+
(audio / video); thumbnails / transforms; resumable uploads for large media; public sharing.</p>
189194

190195
<p><strong>Explicitly out of scope:</strong> server-side media features for E2EE workspaces (OCR,
191196
thumbnails, transcode) — the server has no key; this is the existing E2EE tradeoff
@@ -1231,9 +1236,15 @@ <h2 id="renderer">11. The renderer &amp; capture surfaces</h2>
12311236
type and a <code>priority</code> above the default. It dispatches the block's <code>media:mime</code>
12321237
through a mime→viewer FACET (<code>mediaViewersFacet</code>) that plugins contribute a viewer to — one per
12331238
mime family — so the renderer never special-cases a mime; byte access is <em>per-viewer</em>. An EAGER
1234-
inline viewer (image via the <code>MarkdownImage</code> lightbox today; PDF/audio later,
1235-
<code>eager: true</code>) gets the bytes resolved once into the verified object URL the in-thread resolver
1236-
produces (§7.3, revoked on unmount). The non-image fallback is <strong>LAZY</strong> — a metadata-only
1239+
inline viewer (<code>eager: true</code>) gets the bytes resolved once into the verified object URL the
1240+
in-thread resolver produces (§7.3, revoked on unmount): today the <code>image</code> viewer feeds it to the
1241+
<code>MarkdownImage</code> lightbox and the <code>pdf</code> viewer renders it in a bounded-height
1242+
<code>&lt;object type="application/pdf"&gt;</code> (audio/video later). The PDF <code>&lt;object&gt;</code>
1243+
is safe because the object URL's Blob type is pinned to <code>application/pdf</code> — the very mime that
1244+
selected the viewer — so the browser hands a known non-<code>text/*</code> type to its PDF viewer and never
1245+
HTML-sniffs hash-verified-but-non-PDF bytes into an executable navigation; its download affordance (and the
1246+
<code>&lt;object&gt;</code> can't-preview fallback, e.g. iOS Safari) reuses the same neutral octet-stream
1247+
path as the fallback below. The non-image fallback is <strong>LAZY</strong> — a metadata-only
12371248
download button that resolves the verified bytes only on click and saves them through a transient,
12381249
immediately-revoked <em>octet-stream</em> anchor, <em>not</em> a persistent navigable <code>blob:</code>
12391250
URL typed with the attacker-controlled <code>media:mime</code> (that would be same-origin XSS on
@@ -1465,7 +1476,10 @@ <h2 id="plan">15. Phased plan</h2>
14651476
<code>mediaViewersFacet</code> registry: an EAGER image viewer contribution resolves the verified
14661477
object URL for the lightbox; the built-in fallback is a LAZY, metadata-only download button that
14671478
resolves on click and saves via a transient octet-stream anchor (filename/size from metadata). External
1468-
<code>![](url)</code> images keep the existing <code>MarkdownImage</code>, §11.</td></tr>
1479+
<code>![](url)</code> images keep the existing <code>MarkdownImage</code>, §11. A second EAGER
1480+
contribution — the <code>pdf</code> viewer — landed on this foundation with no renderer change: a
1481+
bounded-height <code>&lt;object type="application/pdf"&gt;</code> of the object URL + a neutral
1482+
octet-stream download (and a can't-preview fallback), §11.</td></tr>
14691483
<tr><td>5 — capture + up-lane</td>
14701484
<td>paste / drop / file-pick arms (through <code>pasteDecisionVerb</code>); deterministic content-derived
14711485
block id via <code>createOrRestoreTargetBlock</code> (dedup is automatic, §11); optimistic write to the durable byte
@@ -1477,7 +1491,7 @@ <h2 id="plan">15. Phased plan</h2>
14771491
<em>budget</em> / active-workspace <em>scope</em> / <em>politeness</em> controls; and
14781492
<code>navigator.storage.persist()</code> so the byte store isn't best-effort-evicted.</td></tr>
14791493
<tr><td>vNext</td>
1480-
<td>richer inline viewers for non-image mimes (PDF preview, <code>&lt;audio&gt;</code>, video) atop the
1494+
<td>inline viewers for the remaining non-image mimes (<code>&lt;audio&gt;</code>, video) atop the
14811495
§11 <code>mediaViewersFacet</code>; thumbnails / transforms; resumable upload; the
14821496
<code>effective_share_ids</code> storage-RLS branch when subtree sharing ships; public-share
14831497
(export / key-in-fragment).</td></tr>

src/plugins/attachments/MediaBlockRenderer.test.tsx

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,12 @@ vi.mock('./useAssetObjectUrl.js', () => ({
3030
}))
3131
vi.mock('./assetResolver.js', () => ({ getAssetResolver: () => ({ resolve: h.resolve }) }))
3232
vi.mock('@/utils/downloadBlob.js', () => ({ downloadBlob: h.downloadBlob }))
33-
// The runtime resolves the media-viewer facet to just the (real) image viewer, so an
34-
// image mime dispatches to ImageViewer and everything else to the download fallback.
33+
// The runtime resolves the media-viewer facet to the (real) image + PDF viewers, so an
34+
// image mime dispatches to ImageViewer, application/pdf to PdfViewer, and everything
35+
// else to the download fallback.
3536
vi.mock('@/extensions/runtimeContext.js', async () => {
36-
const { imageMediaViewer } = await import('./mediaViewers.js')
37-
return { useAppRuntime: () => ({ read: () => [imageMediaViewer] }) }
37+
const { imageMediaViewer, pdfMediaViewer } = await import('./mediaViewers.js')
38+
return { useAppRuntime: () => ({ read: () => [imageMediaViewer, pdfMediaViewer] }) }
3839
})
3940

4041
const { MediaBlockRenderer, MediaContentRenderer } = await import('./MediaBlockRenderer.js')
@@ -91,8 +92,10 @@ describe('MediaContentRenderer — image branch', () => {
9192
})
9293

9394
describe('MediaContentRenderer — non-image (file) branch', () => {
95+
// A mime claimed by NO registered viewer (not image/*, not application/pdf) so it hits
96+
// the download fallback — PDF now has its own inline viewer, so it can't stand in here.
9497
const fileProps = (extra: Record<string, unknown> = {}) => {
95-
h.props = { 'media:hash': 'sha256:ab', 'media:mime': 'application/pdf', 'media:filename': 'doc.pdf', ...extra }
98+
h.props = { 'media:hash': 'sha256:ab', 'media:mime': 'application/zip', 'media:filename': 'doc.zip', ...extra }
9699
}
97100

98101
it('renders a METADATA-ONLY download button — no eager resolve — for a non-image MIME', () => {
@@ -101,7 +104,7 @@ describe('MediaContentRenderer — non-image (file) branch', () => {
101104
renderContent()
102105
const btn = screen.getByTestId('media-file')
103106
expect(btn.tagName).toBe('BUTTON')
104-
expect(btn).toHaveTextContent('doc.pdf')
107+
expect(btn).toHaveTextContent('doc.zip')
105108
expect(btn).toHaveTextContent('2 MB')
106109
expect(screen.queryByRole('img')).toBeNull() // not the image lightbox
107110
// The download fallback is LAZY: the renderer must gate the eager resolve OFF for it,
@@ -116,7 +119,7 @@ describe('MediaContentRenderer — non-image (file) branch', () => {
116119
fireEvent.click(screen.getByTestId('media-file'))
117120
await waitFor(() => expect(h.downloadBlob).toHaveBeenCalledTimes(1))
118121
const [blob, name] = h.downloadBlob.mock.calls[0]
119-
expect(name).toBe('doc.pdf')
122+
expect(name).toBe('doc.zip')
120123
// NEVER the attacker-influenceable media:mime — a neutral type so a navigated blob URL
121124
// downloads instead of rendering (no same-origin XSS via media:mime = text/html).
122125
expect(blob.type).toBe('application/octet-stream')
@@ -146,6 +149,65 @@ describe('MediaContentRenderer — non-image (file) branch', () => {
146149
})
147150
})
148151

152+
describe('MediaContentRenderer — PDF branch', () => {
153+
const pdfProps = (extra: Record<string, unknown> = {}) => {
154+
h.props = { 'media:hash': 'sha256:ab', 'media:mime': 'application/pdf', 'media:filename': 'doc.pdf', ...extra }
155+
}
156+
157+
it('renders a bounded inline <object> of the VERIFIED object URL, typed application/pdf', () => {
158+
pdfProps()
159+
h.urlState = { status: 'ready', url: 'blob:pdf/1' }
160+
const { container } = renderContent()
161+
expect(screen.getByTestId('media-pdf')).toBeInTheDocument()
162+
const object = container.querySelector('object')
163+
expect(object).toHaveAttribute('data', 'blob:pdf/1')
164+
// Pinned to application/pdf so the browser hands it to its PDF viewer, never HTML-sniffs it.
165+
expect(object).toHaveAttribute('type', 'application/pdf')
166+
// The PDF viewer is EAGER: the renderer resolves the object URL up front for the inline preview.
167+
expect(h.useAssetObjectUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), { enabled: true })
168+
})
169+
170+
it('shows the loading placeholder while resolving', () => {
171+
pdfProps()
172+
h.urlState = { status: 'loading' }
173+
renderContent()
174+
expect(screen.getByTestId('media-loading')).toBeInTheDocument()
175+
})
176+
177+
it('shows the broken-asset placeholder (and NO object) on a fail-closed resolve', () => {
178+
pdfProps()
179+
h.urlState = { status: 'error', reason: 'hash-mismatch' }
180+
const { container } = renderContent()
181+
expect(screen.getByTestId('media-broken')).toBeInTheDocument()
182+
// The load-bearing assertion: nothing is ever served for an unverified asset.
183+
expect(container.querySelector('object')).toBeNull()
184+
})
185+
186+
it('downloads the VERIFIED bytes as a NEUTRAL octet-stream blob on the download click', async () => {
187+
pdfProps()
188+
h.urlState = { status: 'ready', url: 'blob:pdf/1' }
189+
renderContent()
190+
fireEvent.click(screen.getByTestId('media-pdf-download'))
191+
await waitFor(() => expect(h.downloadBlob).toHaveBeenCalledTimes(1))
192+
const [blob, name] = h.downloadBlob.mock.calls[0]
193+
expect(name).toBe('doc.pdf')
194+
// NEVER a navigable blob typed with the attacker-influenceable media:mime.
195+
expect(blob.type).toBe('application/octet-stream')
196+
})
197+
198+
it('fails the download closed on a failed resolve — nothing downloaded, retryable', async () => {
199+
pdfProps()
200+
h.urlState = { status: 'ready', url: 'blob:pdf/1' }
201+
h.resolve.mockResolvedValue({ ok: false, reason: 'fetch-failed' })
202+
renderContent()
203+
const btn = screen.getByTestId('media-pdf-download')
204+
fireEvent.click(btn)
205+
await waitFor(() => expect(btn).toHaveTextContent('Retry'))
206+
expect(h.downloadBlob).not.toHaveBeenCalled()
207+
expect(btn).toBeEnabled() // still clickable to retry a transient failure
208+
})
209+
})
210+
149211
describe('MediaBlockRenderer.canRender', () => {
150212
// canRender must gate on a LOADED snapshot via peek() — `block.hasType()` reads
151213
// block.data, which THROWS for a not-yet-loaded / missing row, and useRenderer

src/plugins/attachments/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { systemToggle } from '@/facets/togglable.js'
1717
import { diagnosticsFacet } from '@/plugins/diagnostics/facet.js'
1818
import { mobileKeyboardToolbarItemsFacet } from '@/plugins/mobile-keyboard-toolbar/facet.js'
1919
import { MediaBlockRenderer } from './MediaBlockRenderer.js'
20-
import { imageMediaViewer } from './mediaViewers.js'
20+
import { imageMediaViewer, pdfMediaViewer } from './mediaViewers.js'
2121
import { mediaViewersFacet } from './mediaViewersFacet.js'
2222
import { MediaDownLaneReplicator } from './MediaDownLaneReplicator.js'
2323
import { MediaUploadReconciler } from './MediaUploadReconciler.js'
@@ -43,9 +43,11 @@ export const attachmentsPlugin: AppExtension = systemToggle({
4343
typesFacet.of(ASSETS_TYPE_CONTRIBUTION, { source: 'attachments' }),
4444
MEDIA_PROPERTY_SCHEMAS.map((schema) => propertySchemasFacet.of(schema, { source: 'attachments' })),
4545
blockRenderersFacet.of({ id: 'media', renderer: MediaBlockRenderer }, { source: 'attachments' }),
46-
// The image mime-family viewer. A PDF / audio / video plugin registers its own viewer
47-
// on this facet (eager: true) and the renderer dispatches to it — no renderer change.
46+
// The mime-family viewers. Each is an eager inline viewer; the renderer dispatches by
47+
// mime over the facet (no renderer change per mime). An audio / video plugin registers
48+
// its own viewer here the same way.
4849
mediaViewersFacet.of(imageMediaViewer, { source: 'attachments' }),
50+
mediaViewersFacet.of(pdfMediaViewer, { source: 'attachments' }),
4951
// The capture path: DECIDE a file paste is media (decorator) + ACT on it (the
5052
// captureMediaVerb impl). Both gated here, so disabling the plugin disables capture
5153
// (a file paste falls through to a text paste, and the verb is a no-op).

src/plugins/attachments/mediaBlock.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
MEDIA_TYPE,
55
MEDIA_TYPE_CONTRIBUTION,
66
isImageMime,
7+
isPdfMime,
78
mediaHashProp,
89
resolveCaptureMime,
910
sniffImageMime,
@@ -33,6 +34,17 @@ describe('isImageMime', () => {
3334
})
3435
})
3536

37+
describe('isPdfMime', () => {
38+
it('is true only for application/pdf, case-insensitively', () => {
39+
expect(isPdfMime('application/pdf')).toBe(true)
40+
expect(isPdfMime('APPLICATION/PDF')).toBe(true)
41+
expect(isPdfMime('image/png')).toBe(false)
42+
expect(isPdfMime('application/x-pdf')).toBe(false) // not the registered type
43+
expect(isPdfMime('')).toBe(false)
44+
expect(isPdfMime(undefined)).toBe(false)
45+
})
46+
})
47+
3648
describe('sniffImageMime', () => {
3749
it('recognizes common raster image magic, null otherwise', () => {
3850
expect(sniffImageMime(png())).toBe('image/png')

src/plugins/attachments/mediaBlock.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ export const MEDIA_TYPE_CONTRIBUTION: TypeContribution = defineBlockType({
6969
export const isImageMime = (mime: string | undefined): boolean =>
7070
typeof mime === 'string' && mime.toLowerCase().startsWith('image/')
7171

72+
/** The PDF MIME — its inline-preview viewer (§11 PDF branch). */
73+
export const PDF_MIME = 'application/pdf'
74+
75+
/** Does a MIME type render as an inline PDF (§11 PDF branch)? Case-insensitive per
76+
* RFC 2045; an exact match — `application/pdf` has no sub-family the way `image/*` does. */
77+
export const isPdfMime = (mime: string | undefined): boolean =>
78+
typeof mime === 'string' && mime.toLowerCase() === PDF_MIME
79+
7280
/** The fallback MIME for a file with no declared type. */
7381
export const GENERIC_MIME = 'application/octet-stream'
7482

src/plugins/attachments/mediaViewers.test.tsx

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,17 @@
11
import { describe, expect, it } from 'vitest'
2-
import { FILE_VIEWER_FALLBACK, formatByteSize, imageMediaViewer, pickMediaViewer } from './mediaViewers.js'
3-
import type { MediaViewerContribution } from './mediaViewersFacet.js'
4-
5-
const fakePdf: MediaViewerContribution = {
6-
id: 'pdf',
7-
match: (m) => m === 'application/pdf',
8-
Component: () => null,
9-
eager: true,
10-
}
2+
import { FILE_VIEWER_FALLBACK, formatByteSize, imageMediaViewer, pdfMediaViewer, pickMediaViewer } from './mediaViewers.js'
113

124
describe('pickMediaViewer', () => {
135
it('returns the first viewer whose match() accepts the mime (list is precedence-ordered)', () => {
14-
const viewers = [imageMediaViewer, fakePdf]
6+
const viewers = [imageMediaViewer, pdfMediaViewer]
157
expect(pickMediaViewer(viewers, 'image/png')).toBe(imageMediaViewer)
168
expect(pickMediaViewer(viewers, 'IMAGE/PNG')).toBe(imageMediaViewer) // MIME is case-insensitive (RFC 2045)
17-
expect(pickMediaViewer(viewers, 'application/pdf')).toBe(fakePdf)
9+
expect(pickMediaViewer(viewers, 'application/pdf')).toBe(pdfMediaViewer)
10+
expect(pickMediaViewer(viewers, 'APPLICATION/PDF')).toBe(pdfMediaViewer) // case-insensitive too
1811
})
1912

2013
it('falls back to the download viewer when no registered viewer claims the mime', () => {
21-
expect(pickMediaViewer([imageMediaViewer], 'audio/mpeg')).toBe(FILE_VIEWER_FALLBACK)
14+
expect(pickMediaViewer([imageMediaViewer, pdfMediaViewer], 'audio/mpeg')).toBe(FILE_VIEWER_FALLBACK)
2215
// Empty facet → still downloadable (the fallback is a hardcoded floor, not a contribution).
2316
expect(pickMediaViewer([], 'application/pdf')).toBe(FILE_VIEWER_FALLBACK)
2417
})

0 commit comments

Comments
 (0)