fix(markdown): align image gallery authorization

This commit is contained in:
Bohdan Triapitsyn
2026-08-14 17:27:07 +03:00
parent 90780258cd
commit 12b5857b3f
8 changed files with 397 additions and 47 deletions
@@ -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<string, unknown> }) => {
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']);
});
});
@@ -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<string> => 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<boolean> => {
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<void> => {
@@ -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<string> => {
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<PreparedMarkdownImage, { status: 'ready' }>,
directory: string,