diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index b252735..a4b8c58 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -576,7 +576,7 @@ _none_ - Params: `query: lastChangedProduct?: str, limit?: int` - Returns: `200 obj[]` #### Get a virtual filesystem file — `GET /vfs/{*path}` -- Params: `path: str, query: download?: bool` +- Params: `path: str, query: download?: bool, raw?: bool` - Returns: `200 str` #### List warehouses — `GET /warehouse` _none_ diff --git a/docs/api/routes.md b/docs/api/routes.md index 995010d..23b074e 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -461,7 +461,7 @@ _none_ - Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }` - Returns: `200 { unit: { si: str, basesi: str, basesifactor: num, symbol: str, name: str } }` #### Get a virtual filesystem file — `GET /vfs/{*path}` -- Params: `path: str, query: download?: bool` +- Params: `path: str, query: download?: bool, raw?: bool` - Returns: `200 str` #### List warehouses — `GET /warehouse` _none_ diff --git a/docs/api/routes/vfs/_*path.md b/docs/api/routes/vfs/_*path.md index 657c8e4..a292a87 100644 --- a/docs/api/routes/vfs/_*path.md +++ b/docs/api/routes/vfs/_*path.md @@ -8,14 +8,12 @@ Returns a file the model produced in the `javascript_execute` sandbox (virtual filesystem), for the authenticated user's selected company. Embed the returned URL in chat markdown to display a generated image: -`![title](/api/vfs/example.png)`. * -Images are ALWAYS returned as a preview: scaled to fit inside a 250x250 box with the -aspect ratio preserved (never stretched), and anything smaller than 100 px on its larger -edge is upscaled to 100 px. The preview carries a translucent strip along the bottom -edge listing the ORIGINAL width, height, file size, MIME type and filename. * -Only image MIME types the sandbox produces are rendered inline; every other file -type is sent with `Content-Disposition: attachment` so it downloads rather than -being interpreted as a document in the app's origin. +`![title](/api/vfs/example.png)`. + +Images are returned as a 250x250 aspect-preserving preview unless requested with +`download=1` or `raw=1`. +Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served +inline so they open directly in the browser; other files are sent as downloads. ## Path parameters @@ -28,10 +26,9 @@ being interpreted as a document in the app's origin. | Param | Required | Type | Description | |-------|----------|------|-------------| -| `download` | no | boolean | Return the original, unmodified file instead of the standard preview. -Without this parameter images are always served as a 250x250 aspect-preserving -preview with a metadata strip. Ignored for non-image files, which are always -served as-is. +| `download` | no | boolean | Force file download with Content-Disposition: attachment. + | +| `raw` | no | boolean | Return original unmodified image inline without generating a preview thumbnail. | ## Response `200` diff --git a/src/api/routes/vfs.js b/src/api/routes/vfs.js index 622f536..b9d474d 100644 --- a/src/api/routes/vfs.js +++ b/src/api/routes/vfs.js @@ -1,5 +1,6 @@ import * as db from '../../db/modelvfs.js'; import sharp from 'sharp'; +import { getMimeType } from '../../tools/vfs.js'; // Thumbnail box. The image is fitted inside this box preserving aspect ratio; it is never // stretched, so a 2000x100 image becomes 250x13 and not 250x250. @@ -32,10 +33,8 @@ const FONT_MAX_PX = 13; // Fraction of the thumbnail's height the metadata band may occupy. const BAND_HEIGHT_RATIO = 0.5; -// Files the sandbox produces that are safe to render inline in a browser document. -// Anything not listed here (`.html`, `.svg`, `.js`, …) is served as a download so a stored -// artifact can never be loaded as an executable document in the app's origin. -const INLINE_RENDERABLE_MIME = new Set([ +// Raster image formats that can be processed into a 250x250 preview thumbnail with metadata strip. +const IMAGE_THUMBNAIL_MIME = new Set([ 'image/png', 'image/jpeg', 'image/gif', @@ -44,6 +43,28 @@ const INLINE_RENDERABLE_MIME = new Set([ 'image/bmp', ]); +/** + * Check if a MIME type can be viewed/rendered inline in Chrome and modern browsers. + * @param {string} mime + * @returns {boolean} + */ +export function isChromeInline(mime) { + if (!mime) return false; + const base = mime.split(';')[0].trim().toLowerCase(); + return ( + base.startsWith('text/') || + base.startsWith('image/') || + base.startsWith('audio/') || + base.startsWith('video/') || + base.startsWith('font/') || + base === 'application/pdf' || + base === 'application/json' || + base === 'application/javascript' || + base === 'application/xml' || + base === 'application/wasm' + ); +} + function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { @@ -104,23 +125,12 @@ function parseVfsRef(raw) { const normalized = segments.join('/'); - const ext = normalized.slice(normalized.lastIndexOf('.')).toLowerCase(); - const isInline = INLINE_RENDERABLE_MIME.has(MIME_BY_EXTENSION[ext] || ''); - return { name: normalized, isInline }; + const mime = getMimeType(normalized); + const canThumbnail = IMAGE_THUMBNAIL_MIME.has(mime.split(';')[0].trim().toLowerCase()); + const isInline = isChromeInline(mime); + return { name: normalized, mime, canThumbnail, isInline }; } -// Only the extensions the sandbox actually writes (see getMimeType in tools/vfs.js). -const MIME_BY_EXTENSION = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.avif': 'image/avif', - '.bmp': 'image/bmp', - '.svg': 'image/svg+xml', -}; - function vfsFileErrorStatus(error) { if (error.message === 'No company selected') return 400; if (error.message === 'Not authorized') return 403; @@ -357,14 +367,12 @@ export function register(app, basePath = '') { * (virtual filesystem), for the authenticated user's selected company. * * Embed the returned URL in chat markdown to display a generated image: - * `![title](/api/vfs/example.png)`. * - * Images are ALWAYS returned as a preview: scaled to fit inside a 250x250 box with the - * aspect ratio preserved (never stretched), and anything smaller than 100 px on its larger - * edge is upscaled to 100 px. The preview carries a translucent strip along the bottom - * edge listing the ORIGINAL width, height, file size, MIME type and filename. * - * Only image MIME types the sandbox produces are rendered inline; every other file - * type is sent with `Content-Disposition: attachment` so it downloads rather than - * being interpreted as a document in the app's origin. + * `![title](/api/vfs/example.png)`. + * + * Images are returned as a 250x250 aspect-preserving preview unless requested with + * `download=1` or `raw=1`. + * Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served + * inline so they open directly in the browser; other files are sent as downloads. * security: * - cookieAuth: [] * parameters: @@ -380,10 +388,14 @@ export function register(app, basePath = '') { * schema: * type: boolean * description: | - * Return the original, unmodified file instead of the standard preview. - * Without this parameter images are always served as a 250x250 aspect-preserving - * preview with a metadata strip. Ignored for non-image files, which are always - * served as-is. + * Force file download with Content-Disposition: attachment. + * - in: query + * name: raw + * required: false + * schema: + * type: boolean + * description: | + * Return original unmodified image inline without generating a preview thumbnail. * responses: * 200: * description: File bytes @@ -442,47 +454,50 @@ export function register(app, basePath = '') { return res.status(404).json({ error: 'File not found' }); } -const wantsDownload = req.query.download === '1' || req.query.download === 'true'; - const isInline = parsed.isInline && !wantsDownload; const fallbackName = parsed.name.split('/').pop() || 'download'; + const effectiveMime = (file.mime && file.mime !== 'application/octet-stream') + ? file.mime + : getMimeType(parsed.name); - // Images are ALWAYS served as a 250x250 aspect-preserving preview unless the caller - // explicitly asks for the original with `download=1`. The preview carries the original - // width/height/size/mime/filename in a pill strip, so no metadata is lost. - if (isInline) { -const rendered = await renderThumbnail(file.content, { - mime: file.mime, - filename: fallbackName, - }).catch((err) => { - console.error('vfs thumbnail render failed:', err.message); - return null; - }); - if (rendered) { - res.setHeader('Content-Type', rendered.contentType); - res.setHeader('Content-Length', String(rendered.buffer.length)); - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox"); - res.setHeader('Content-Disposition', `inline; filename="${fallbackName.replace(/"/g, '')}"`); - // Previews are derived data — never cache them immutably, the source file can change. - res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); - res.setHeader('ETag', `W/"thumb-${file.updated_at || file.name}"`); - const ifNoneMatch = req.headers['if-none-match']; - if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) { -return res.status(304).end(); - } - return res.status(200).send(rendered.buffer); - } - // Rendering failed (unsupported/corrupt image) — fall through and serve the original. + const wantsDownload = req.query.download === '1' || req.query.download === 'true'; + const wantsRaw = req.query.raw === '1' || req.query.raw === 'true'; + const isInline = isChromeInline(effectiveMime) && !wantsDownload; + + // Images are served as a 250x250 aspect-preserving preview for markdown chat embeddings, + // unless the caller explicitly asks for the raw file (?raw=1) or a download (?download=1). + const shouldThumbnail = parsed.canThumbnail && !wantsDownload && !wantsRaw; + if (shouldThumbnail) { + const rendered = await renderThumbnail(file.content, { + mime: effectiveMime, + filename: fallbackName, + }).catch((err) => { + console.error('vfs thumbnail render failed:', err.message); + return null; + }); + if (rendered) { + res.setHeader('Content-Type', rendered.contentType); + res.setHeader('Content-Length', String(rendered.buffer.length)); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox"); + res.setHeader('Content-Disposition', `inline; filename="${fallbackName.replace(/"/g, '')}"`); + // Previews are derived data — never cache them immutably, the source file can change. + res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); + res.setHeader('ETag', `W/"thumb-${file.updated_at || file.name}"`); + const ifNoneMatch = req.headers['if-none-match']; + if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) { + return res.status(304).end(); + } + return res.status(200).send(rendered.buffer); + } + // Rendering failed (unsupported/corrupt image) — fall through and serve the original. } - res.setHeader('Content-Type', file.mime || 'application/octet-stream'); + res.setHeader('Content-Type', effectiveMime); res.setHeader('Content-Length', String(file.content.length)); res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox"); - // VFS artifacts are mutable (a re-run can overwrite a name), so they must NOT be - // cached immutably the way content-addressed /image/{hash} is. + // Mutable VFS artifacts are cached with revalidation res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); - res.setHeader('ETag', `W/"${file.updated_at || file.name}"`); + res.setHeader('ETag', `W/"${file.updated_at || file.name}-${isInline ? 'i' : 'a'}"`); res.setHeader( 'Content-Disposition', `${isInline ? 'inline' : 'attachment'}; filename="${fallbackName.replace(/"/g, '')}"` diff --git a/src/tools/vfs.js b/src/tools/vfs.js index 5e3ad2a..c1676e0 100644 --- a/src/tools/vfs.js +++ b/src/tools/vfs.js @@ -10,17 +10,40 @@ const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']); export function getMimeType(filePath) { const ext = path.extname(filePath).toLowerCase(); switch (ext) { - case '.json': return 'application/json'; + case '.html': + case '.htm': return 'text/html; charset=utf-8'; + case '.css': return 'text/css; charset=utf-8'; + case '.js': + case '.mjs': return 'text/javascript; charset=utf-8'; + case '.json': + case '.map': return 'application/json; charset=utf-8'; case '.avif': return 'image/avif'; case '.webp': return 'image/webp'; case '.png': return 'image/png'; case '.jpg': case '.jpeg': return 'image/jpeg'; + case '.gif': return 'image/gif'; + case '.svg': return 'image/svg+xml'; + case '.ico': return 'image/x-icon'; + case '.bmp': return 'image/bmp'; case '.pdf': return 'application/pdf'; - case '.txt': return 'text/plain'; - case '.csv': return 'text/csv'; - case '.md': return 'text/markdown'; - case '.skill': return 'text/markdown'; + case '.txt': + case '.text': + case '.log': return 'text/plain; charset=utf-8'; + case '.csv': return 'text/csv; charset=utf-8'; + case '.tsv': return 'text/tab-separated-values; charset=utf-8'; + case '.md': + case '.skill': return 'text/markdown; charset=utf-8'; + case '.xml': return 'application/xml; charset=utf-8'; + case '.mp3': return 'audio/mpeg'; + case '.wav': return 'audio/wav'; + case '.ogg': return 'audio/ogg'; + case '.mp4': return 'video/mp4'; + case '.webm': return 'video/webm'; + case '.woff': return 'font/woff'; + case '.woff2': return 'font/woff2'; + case '.ttf': return 'font/ttf'; + case '.otf': return 'font/otf'; default: return 'application/octet-stream'; } }