From 12b5857b3f4a4d1e93260c7812a9008e72e7214a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 14 Aug 2026 17:27:07 +0300 Subject: [PATCH] fix(markdown): align image gallery authorization --- .../components/chat/MarkdownImageGallery.tsx | 53 ++++-- .../chat/markdown/markdownImageAssets.test.ts | 55 ++++++- .../chat/markdown/markdownImageAssets.ts | 106 ++++++++++-- .../chat/message/parts/DOCUMENTATION.md | 20 +-- packages/vscode/src/DOCUMENTATION.md | 5 +- .../markdown-image-grants/DOCUMENTATION.md | 8 +- .../lib/markdown-image-grants/routes.js | 151 ++++++++++++++++-- .../lib/markdown-image-grants/routes.test.js | 46 ++++++ 8 files changed, 397 insertions(+), 47 deletions(-) diff --git a/packages/ui/src/components/chat/MarkdownImageGallery.tsx b/packages/ui/src/components/chat/MarkdownImageGallery.tsx index ba529d95..d4b27341 100644 --- a/packages/ui/src/components/chat/MarkdownImageGallery.tsx +++ b/packages/ui/src/components/chat/MarkdownImageGallery.tsx @@ -9,6 +9,7 @@ import { subscribeRuntimeUrlAuthToken, } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; +import { isVSCodeRuntime } from '@/lib/desktop'; import type { ToolPopupContent } from './message/types'; import { extractMarkdownImageCandidates, @@ -20,6 +21,7 @@ import { isLocalMarkdownImageSource, prepareLocalMarkdownImages, resolveMarkdownImageSource, + resolveWorkspaceMarkdownImageSource, type PreparedMarkdownImage, } from './markdown/markdownImageAssets'; @@ -66,8 +68,17 @@ const MarkdownImageThumbnail: React.FC<{ directory: string; assetAuthReady: boolean; assetAuthNonce: number; + useWorkspaceFsBridge: boolean; onShowPopup?: (content: ToolPopupContent) => void; -}> = ({ candidate, preparation, directory, assetAuthReady, assetAuthNonce, onShowPopup }) => { +}> = ({ + candidate, + preparation, + directory, + assetAuthReady, + assetAuthNonce, + useWorkspaceFsBridge, + onShowPopup, +}) => { const { t } = useI18n(); const thumbnailRef = React.useRef(null); const [shouldLoad, setShouldLoad] = React.useState(false); @@ -94,7 +105,19 @@ const MarkdownImageThumbnail: React.FC<{ }, [shouldLoad]); React.useEffect(() => { - if (!shouldLoad || (local && !preparation)) return; + if (!shouldLoad || (local && !useWorkspaceFsBridge && !preparation)) return; + if (local && useWorkspaceFsBridge) { + const controller = new AbortController(); + setImage({ url: '', status: 'loading' }); + void resolveWorkspaceMarkdownImageSource(candidate.source, directory, controller.signal).then((url) => { + if (controller.signal.aborted) return; + setImage({ url, status: 'loading' }); + }).catch(() => { + if (controller.signal.aborted) return; + setImage({ url: '', status: 'error' }); + }); + return () => controller.abort(); + } if (local) { if (preparation?.status !== 'ready') { setImage({ url: '', status: 'error' }); @@ -114,7 +137,7 @@ const MarkdownImageThumbnail: React.FC<{ setImage({ url: '', status: 'error' }); }); return () => controller.abort(); - }, [assetAuthNonce, assetAuthReady, candidate.source, directory, local, preparation, shouldLoad]); + }, [assetAuthNonce, assetAuthReady, candidate.source, directory, local, preparation, shouldLoad, useWorkspaceFsBridge]); const openPreview = React.useCallback(() => { if (image.status === 'error') { @@ -185,16 +208,21 @@ export const MarkdownImageGallery: React.FC<{ const [shouldPrepare, setShouldPrepare] = React.useState(false); const [prepared, setPrepared] = React.useState | null>(null); const [prepareEpoch, setPrepareEpoch] = React.useState(0); + const useWorkspaceFsBridge = isVSCodeRuntime(); const candidates = React.useMemo( () => extractMarkdownImageCandidates(contents, MAX_MARKDOWN_IMAGE_COUNT), [contents], ); - const localSources = React.useMemo( - () => candidates.filter((candidate) => isLocalMarkdownImageSource(candidate.source)).map((candidate) => candidate.source), - [candidates], + const serverPreparationSources = React.useMemo( + () => useWorkspaceFsBridge + ? [] + : candidates + .filter((candidate) => isLocalMarkdownImageSource(candidate.source)) + .map((candidate) => candidate.source), + [candidates, useWorkspaceFsBridge], ); React.useEffect(() => { - if (localSources.length === 0 || shouldPrepare) return; + if (serverPreparationSources.length === 0 || shouldPrepare) return; const gallery = galleryRef.current; if (!gallery || typeof IntersectionObserver === 'undefined') { setShouldPrepare(true); @@ -207,13 +235,13 @@ export const MarkdownImageGallery: React.FC<{ }, { rootMargin: '200px' }); observer.observe(gallery); return () => observer.disconnect(); - }, [localSources.length, shouldPrepare]); + }, [serverPreparationSources.length, shouldPrepare]); React.useEffect(() => { - if (!shouldPrepare || !sessionId || localSources.length === 0) return; + if (!shouldPrepare || !sessionId || serverPreparationSources.length === 0) return; const controller = new AbortController(); void prepareLocalMarkdownImages({ - sources: localSources, + sources: serverPreparationSources, directory, sessionId, messageId, @@ -223,11 +251,11 @@ export const MarkdownImageGallery: React.FC<{ setPrepared(result); }).catch(() => { if (!controller.signal.aborted) { - setPrepared(new Map(localSources.map((source) => [source, { status: 'error' }]))); + setPrepared(new Map(serverPreparationSources.map((source) => [source, { status: 'error' }]))); } }); return () => controller.abort(); - }, [directory, localSources, messageId, prepareEpoch, sessionId, shouldPrepare]); + }, [directory, messageId, prepareEpoch, serverPreparationSources, sessionId, shouldPrepare]); React.useEffect(() => { const nextExpiry = Math.min(...[...(prepared?.values() ?? [])] @@ -257,6 +285,7 @@ export const MarkdownImageGallery: React.FC<{ directory={directory} assetAuthReady={assetAuth.ready} assetAuthNonce={assetAuth.nonce} + useWorkspaceFsBridge={useWorkspaceFsBridge} onShowPopup={onShowPopup} /> ))} diff --git a/packages/ui/src/components/chat/markdown/markdownImageAssets.test.ts b/packages/ui/src/components/chat/markdown/markdownImageAssets.test.ts index 72c5ea3f..50002141 100644 --- a/packages/ui/src/components/chat/markdown/markdownImageAssets.test.ts +++ b/packages/ui/src/components/chat/markdown/markdownImageAssets.test.ts @@ -1,7 +1,22 @@ import { describe, expect, mock, test } from 'bun:test'; let requestCount = 0; -const runtimeFetch = mock(async (_path: string, init?: RequestInit) => { +let requestPaths: string[] = []; +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==', + 'base64', +); +const runtimeFetch = mock(async (path: string, init?: RequestInit & { query?: Record }) => { + requestPaths.push(path); + if (path === '/api/fs/stat') { + return new Response(JSON.stringify({ isFile: true, size: PNG.byteLength }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (path === '/api/fs/raw') { + return new Response(PNG, { status: 200, headers: { 'content-type': 'image/png' } }); + } requestCount += 1; const body = JSON.parse(String(init?.body)) as { sources: string[] }; return new Response(JSON.stringify({ @@ -19,7 +34,30 @@ const resolver = { mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch })); mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => resolver })); -const { getPreparedMarkdownImageUrl, prepareLocalMarkdownImages } = await import('./markdownImageAssets'); +class TestFileReader { + result: string | ArrayBuffer | null = null; + error: DOMException | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + readAsDataURL(blob: Blob) { + void blob.arrayBuffer().then((buffer) => { + this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString('base64')}`; + this.onload?.(); + }).catch((error) => { + this.error = error as DOMException; + this.onerror?.(); + }); + } +} + +globalThis.FileReader = TestFileReader as unknown as typeof FileReader; + +const { + getPreparedMarkdownImageUrl, + prepareLocalMarkdownImages, + resolveWorkspaceMarkdownImageSource, +} = await import('./markdownImageAssets'); describe('Markdown image asset preparation', () => { test('prepares many images in one message-level request', async () => { @@ -65,4 +103,17 @@ describe('Markdown image asset preparation', () => { expect(url).toContain('path=%2Ftmp%2Fopencode%2Fimage.png'); expect(url).toContain('outsideFileGrant=grant-1'); }); + + test('loads a workspace image through the local filesystem bridge', async () => { + requestPaths = []; + + const url = await resolveWorkspaceMarkdownImageSource( + 'screens/image.png', + '/repo', + new AbortController().signal, + ); + + expect(url.startsWith('data:image/png;base64,')).toBe(true); + expect(requestPaths).toEqual(['/api/fs/stat', '/api/fs/raw']); + }); }); diff --git a/packages/ui/src/components/chat/markdown/markdownImageAssets.ts b/packages/ui/src/components/chat/markdown/markdownImageAssets.ts index 0cfc5fef..e2d5d341 100644 --- a/packages/ui/src/components/chat/markdown/markdownImageAssets.ts +++ b/packages/ui/src/components/chat/markdown/markdownImageAssets.ts @@ -1,5 +1,6 @@ import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeUrlResolver, type RuntimeUrlResolver } from '@/lib/runtime-url'; +import { isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils'; const MAX_MARKDOWN_IMAGE_BYTES = 10 * 1024 * 1024; const MAX_PREPARE_CACHE_ENTRIES = 1024; @@ -26,19 +27,60 @@ const throwIfAborted = (signal: AbortSignal): void => { if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError'); }; +const parseLocalImagePath = (source: string): string => { + let value = source; + if (/^file:\/\//i.test(value)) { + try { + const fileUrl = new URL(value); + if (fileUrl.protocol !== 'file:') return ''; + value = fileUrl.host && fileUrl.host !== 'localhost' + ? `//${fileUrl.host}${fileUrl.pathname}` + : fileUrl.pathname; + if (/^\/[A-Za-z]:\//.test(value)) value = value.slice(1); + } catch { + return ''; + } + } + + const path = value.split(/[?#]/, 1)[0] ?? ''; + try { + return decodeURIComponent(path); + } catch { + return path; + } +}; + +const blobToDataUrl = (blob: Blob): Promise => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + } else { + reject(new Error('Unable to encode image')); + } + }; + reader.onerror = () => reject(reader.error ?? new Error('Unable to encode image')); + reader.readAsDataURL(blob); +}); + const hasImageSignature = async (blob: Blob, mimeType: string): Promise => { const bytes = new Uint8Array(await blob.slice(0, 12).arrayBuffer()); const ascii = (start: number, end: number) => String.fromCharCode(...bytes.slice(start, end)); - if (mimeType === 'image/png') { - return bytes[0] === 0x89 && ascii(1, 4) === 'PNG' - && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a; + switch (mimeType) { + case 'image/png': + return bytes[0] === 0x89 && ascii(1, 4) === 'PNG' + && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a; + case 'image/jpeg': + return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; + case 'image/gif': { + const gif = ascii(0, 6); + return gif === 'GIF87a' || gif === 'GIF89a'; + } + case 'image/webp': + return ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP'; + default: + return false; } - if (mimeType === 'image/jpeg') return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; - if (mimeType === 'image/gif') { - const gif = ascii(0, 6); - return gif === 'GIF87a' || gif === 'GIF89a'; - } - return mimeType === 'image/webp' && ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP'; }; const validateImageBlob = async (blob: Blob, mimeType: string): Promise => { @@ -158,6 +200,52 @@ export const resolveMarkdownImageSource = async ( throw new Error('Local image has not been prepared'); }; +/** + * VS Code has no OpenChamber server route for message-scoped temporary-file + * grants. Preserve its existing workspace-only gallery path through the local + * filesystem bridge, including the same size and signature validation. + */ +export const resolveWorkspaceMarkdownImageSource = async ( + source: string, + directory: string, + signal: AbortSignal, +): Promise => { + throwIfAborted(signal); + const localPath = parseLocalImagePath(source); + const absolutePath = toAbsoluteFilePath(directory, localPath); + if (!directory || !localPath || !isFilePathWithinDirectory(absolutePath, directory)) { + throw new Error('Image path is outside the active workspace'); + } + + const statResponse = await runtimeFetch('/api/fs/stat', { + query: { path: absolutePath, directory, optional: 'true' }, + signal, + }); + if (!statResponse.ok) throw new Error(`Unable to inspect image (${statResponse.status})`); + const stat = await statResponse.json() as { isFile?: boolean; size?: number }; + if (!stat.isFile) throw new Error('Image path is not a file'); + if (typeof stat.size === 'number' && stat.size > MAX_MARKDOWN_IMAGE_BYTES) { + throw new Error('Image is too large'); + } + + const response = await runtimeFetch('/api/fs/raw', { + query: { path: absolutePath, directory }, + signal, + }); + if (!response.ok) throw new Error(`Unable to load image (${response.status})`); + + const mimeType = (response.headers.get('content-type') ?? '').split(';', 1)[0]?.toLowerCase() ?? ''; + const contentLength = Number(response.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > MAX_MARKDOWN_IMAGE_BYTES) { + throw new Error('Image is too large'); + } + + const blob = await response.blob(); + await validateImageBlob(blob, mimeType); + throwIfAborted(signal); + return blobToDataUrl(blob); +}; + export const getPreparedMarkdownImageUrl = ( image: Extract, directory: string, diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 3034a28f..edc16029 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -63,17 +63,19 @@ Use this doc when you ask an agent to change tool/header/description behavior. PNG/JPEG/GIF/WebP image candidates into one 100px thumbnail gallery in the message-completion area after all message text and above the turn's changed files. Each muted filename caption includes the shared image-file icon. - HTTP(S) images keep their browser URL. Embedded and workspace-local gallery - images are limited to 10 MiB and validated as PNG/JPEG/GIF/WebP. Local paths - reuse the existing authenticated `/api/fs/raw` asset URL. Chat - Markdown uses the assistant image-label policy without gallery-specific - link rewriting, completion-state switching, or hidden placeholders. A + HTTP(S) images keep their browser URL. Embedded and workspace-local images + are limited to 10 MiB and validated as PNG/JPEG/GIF/WebP. Chat Markdown uses + the assistant image-label policy without gallery-specific link rewriting, + completion-state switching, or hidden placeholders. A completed assistant message hydrates at most 12 unique image candidates, including persisted text parts that omit their optional part-level end time. - A gallery approaching the viewport prepares all local candidates in one - message-level request, while each asset URL loads only when its own thumbnail - approaches the viewport. Mounted historical messages therefore do not - eagerly read every image. + In server-backed runtimes, a gallery approaching the viewport prepares all + local candidates in one message-level request, then reuses the authenticated + `/api/fs/raw` asset route. Each URL loads only when its thumbnail approaches + the viewport. VS Code instead loads workspace-contained images through its + local filesystem bridge and never calls the server grant route; OpenCode + temporary-directory images remain unsupported there. Mounted historical + messages therefore do not eagerly read every image. Gallery clicks do not introduce or alter preview chrome: desktop and mobile both reuse the pre-existing attachment image preview overlay. Workspace-external images receive the existing path-bound `outsideFileGrant` diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 2b80c63b..3a4d3dde 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -45,7 +45,10 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r - `bridge-localfs-proxy-runtime.ts` - Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers. - - Returns an explicit unsupported response for server-owned Markdown image grants instead of forwarding them to OpenCode. + - Workspace-contained Markdown gallery images use these local filesystem + routes without calling the server grant route. Grant requests for OpenCode + temporary-directory images return an explicit unsupported response instead + of being forwarded to OpenCode. - `bridge-proxy-runtime.ts` - Proxy route handlers (`api:proxy`, `api:session:message`) with injected helper dependencies. diff --git a/packages/web/server/lib/markdown-image-grants/DOCUMENTATION.md b/packages/web/server/lib/markdown-image-grants/DOCUMENTATION.md index f92603fe..1b2a390d 100644 --- a/packages/web/server/lib/markdown-image-grants/DOCUMENTATION.md +++ b/packages/web/server/lib/markdown-image-grants/DOCUMENTATION.md @@ -14,6 +14,9 @@ different machine. - `POST /api/openchamber/sessions/:sessionId/markdown-image-grants` prepares up to 12 local images in one message-level request. The server fetches the assistant message once and verifies every exact image source before reading files. +- Authorization recognizes the same common inline and reference-style image + destinations collected by the UI, including balanced parentheses, while + excluding fenced and inline code. - Relative and workspace-contained absolute paths resolve against the active directory. Other absolute paths are accepted only inside `os.tmpdir()/opencode` after `realpath` resolution. @@ -27,4 +30,7 @@ different machine. The routes are OpenChamber-owned and must be registered before the generic OpenCode proxy. Web, Electron, hosted mobile, and Capacitor use the shared -server implementation. VS Code returns an explicit unsupported response. +server implementation. VS Code does not call this route for workspace images; +those use its local filesystem bridge. If called, the grant route returns an +explicit unsupported response because OpenCode temporary images are not +supported there. diff --git a/packages/web/server/lib/markdown-image-grants/routes.js b/packages/web/server/lib/markdown-image-grants/routes.js index 8225666a..25b36c7d 100644 --- a/packages/web/server/lib/markdown-image-grants/routes.js +++ b/packages/web/server/lib/markdown-image-grants/routes.js @@ -42,25 +42,150 @@ const hasImageSignature = (bytes) => { || (header.startsWith('RIFF') && header.slice(8, 12) === 'WEBP'); }; -const markdownImageSources = (message) => { - const sources = new Set(); +const normalizeReferenceLabel = (value) => value.trim().replace(/\s+/g, ' ').toLowerCase(); + +const unescapeMarkdownDestination = (value) => value.replace(/\\([!"#$%&'()*+,\-./:;<=>?@[\]^_`{|}~\\])/g, '$1'); + +const isEscapedAt = (value, index) => { + let slashes = 0; + for (let cursor = index - 1; cursor >= 0 && value[cursor] === '\\'; cursor -= 1) slashes += 1; + return slashes % 2 === 1; +}; + +const findClosingBracket = (value, start) => { + for (let cursor = start; cursor < value.length; cursor += 1) { + if (value[cursor] === ']' && !isEscapedAt(value, cursor)) return cursor; + } + return -1; +}; + +const findInlineImageEnd = (value, start) => { + let cursor = start; + while (/\s/.test(value[cursor] || '')) cursor += 1; + if (value[cursor] === ')') return cursor; + + const opener = value[cursor]; + const closer = opener === '"' ? '"' : opener === "'" ? "'" : opener === '(' ? ')' : ''; + if (!closer) return -1; + cursor += 1; + for (; cursor < value.length; cursor += 1) { + if (value[cursor] !== closer || isEscapedAt(value, cursor)) continue; + cursor += 1; + while (/\s/.test(value[cursor] || '')) cursor += 1; + return value[cursor] === ')' ? cursor : -1; + } + return -1; +}; + +const parseInlineDestination = (value, start) => { + let cursor = start; + while (/\s/.test(value[cursor] || '')) cursor += 1; + if (value[cursor] === '<') { + const end = value.indexOf('>', cursor + 1); + if (end < 0) return null; + const imageEnd = findInlineImageEnd(value, end + 1); + return imageEnd < 0 + ? null + : { source: unescapeMarkdownDestination(value.slice(cursor + 1, end)), end: imageEnd }; + } + + let source = ''; + let depth = 0; + for (; cursor < value.length; cursor += 1) { + const char = value[cursor]; + if (char === '\\' && cursor + 1 < value.length) { + source += char + value[cursor + 1]; + cursor += 1; + continue; + } + if (char === '(') { + depth += 1; + source += char; + continue; + } + if (char === ')') { + if (depth === 0) return { source: unescapeMarkdownDestination(source), end: cursor }; + depth -= 1; + source += char; + continue; + } + if (/\s/.test(char) && depth === 0) { + const imageEnd = findInlineImageEnd(value, cursor); + return imageEnd < 0 ? null : { source: unescapeMarkdownDestination(source), end: imageEnd }; + } + source += char; + } + return null; +}; + +const parseDefinitionDestination = (value) => { + const trimmed = value.trimStart(); + if (trimmed.startsWith('<')) { + const end = trimmed.indexOf('>', 1); + return end < 0 ? '' : unescapeMarkdownDestination(trimmed.slice(1, end)); + } + const match = /^(?:\\.|\S)+/.exec(trimmed); + return match ? unescapeMarkdownDestination(match[0]) : ''; +}; + +const collectMarkdownLinesOutsideCode = (message) => { + const lines = []; for (const part of Array.isArray(message?.parts) ? message.parts : []) { if (part?.type !== 'text' || typeof part.text !== 'string') continue; - // Code examples must never authorize file access, even when they contain image syntax. - let fenced = false; + let fence = null; for (const line of part.text.split('\n')) { - if (/^\s{0,3}(?:```|~~~)/.test(line)) { - fenced = !fenced; + const fenceMatch = /^\s{0,3}(`{3,}|~{3,})/.exec(line); + if (fenceMatch) { + const marker = fenceMatch[1]; + if (!fence) { + fence = { char: marker[0], size: marker.length }; + } else if (marker[0] === fence.char && marker.length >= fence.size) { + fence = null; + } continue; } - if (fenced) continue; - const visible = line.replace(/`+[^`]*`+/g, ''); - const pattern = /(?\n]+)>|([^\s)\n]+))/g; - let match = pattern.exec(visible); - while (match) { - sources.add(match[1] || match[2]); - match = pattern.exec(visible); + if (fence) continue; + lines.push(line.replace(/`+[^`]*`+/g, '')); + } + } + return lines; +}; + +const markdownImageSources = (message) => { + const sources = new Set(); + const markdownLines = collectMarkdownLinesOutsideCode(message); + const definitions = new Map(); + for (const line of markdownLines) { + const match = /^\s{0,3}\[([^\]]+)]\s*:\s*(.*)$/.exec(line); + if (!match) continue; + const source = parseDefinitionDestination(match[2]); + if (source) definitions.set(normalizeReferenceLabel(match[1]), source); + } + + for (const line of markdownLines) { + for (let cursor = 0; cursor < line.length; cursor += 1) { + if (line[cursor] !== '!' || line[cursor + 1] !== '[' || isEscapedAt(line, cursor)) continue; + const altEnd = findClosingBracket(line, cursor + 2); + if (altEnd < 0) continue; + const alt = line.slice(cursor + 2, altEnd); + const next = line[altEnd + 1]; + if (next === '(') { + const parsed = parseInlineDestination(line, altEnd + 2); + if (parsed?.source) sources.add(parsed.source); + cursor = parsed?.end ?? altEnd; + continue; } + let label = alt; + if (next === '[') { + const labelEnd = findClosingBracket(line, altEnd + 2); + if (labelEnd < 0) continue; + label = line.slice(altEnd + 2, labelEnd) || alt; + cursor = labelEnd; + } else { + cursor = altEnd; + } + const source = definitions.get(normalizeReferenceLabel(label)); + if (source) sources.add(source); } } return sources; diff --git a/packages/web/server/lib/markdown-image-grants/routes.test.js b/packages/web/server/lib/markdown-image-grants/routes.test.js index 3a08c691..d44661b2 100644 --- a/packages/web/server/lib/markdown-image-grants/routes.test.js +++ b/packages/web/server/lib/markdown-image-grants/routes.test.js @@ -129,6 +129,52 @@ describe('session image assets', () => { ]); }); + it('authorizes reference-style image syntax using its resolved destination', async () => { + const source = 'reference.png'; + const fixture = await createFixture({ + sources: [source], + markdown: '![screenshot][result]\n\n[result]: reference.png', + }); + await fs.writeFile(path.join(fixture.directory, source), PNG); + + const response = await prepare(fixture.app, fixture.directory, fixture.sources); + + expect(response.body.results).toEqual([ + expect.objectContaining({ source, status: 'ready' }), + ]); + }); + + it('authorizes inline image destinations containing balanced parentheses', async () => { + const source = 'screen(1).png'; + const fixture = await createFixture({ + sources: [source], + markdown: `![screenshot](${source})`, + }); + await fs.writeFile(path.join(fixture.directory, source), PNG); + + const response = await prepare(fixture.app, fixture.directory, fixture.sources); + + expect(response.body.results).toEqual([ + expect.objectContaining({ source, status: 'ready' }), + ]); + }); + + it('requires inline image destinations with titles to close', async () => { + const sources = ['valid.png', 'malformed.png']; + const fixture = await createFixture({ + sources, + markdown: '![valid](valid.png "preview")\n![malformed](malformed.png "preview"', + }); + await Promise.all(sources.map((source) => fs.writeFile(path.join(fixture.directory, source), PNG))); + + const response = await prepare(fixture.app, fixture.directory, sources); + + expect(response.body.results).toEqual([ + expect.objectContaining({ source: 'valid.png', status: 'ready' }), + { source: 'malformed.png', status: 'error' }, + ]); + }); + it('rejects a source that the message does not reference', async () => { const fixture = await createFixture({ markdown: 'No image here.' }); const response = await prepare(fixture.app, fixture.directory, fixture.sources);