This commit is contained in:
seb
2026-10-06 12:50:00 +02:00
parent 0bd12de8d8
commit cab06d96e8
5 changed files with 119 additions and 84 deletions

View File

@@ -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_

View File

@@ -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_

View File

@@ -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`

View File

@@ -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, '')}"`

View File

@@ -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';
}
}