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` - Params: `query: lastChangedProduct?: str, limit?: int`
- Returns: `200 obj[]` - Returns: `200 obj[]`
#### Get a virtual filesystem file — `GET /vfs/{*path}` #### 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` - Returns: `200 str`
#### List warehouses — `GET /warehouse` #### List warehouses — `GET /warehouse`
_none_ _none_

View File

@@ -461,7 +461,7 @@ _none_
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }` - 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 } }` - Returns: `200 { unit: { si: str, basesi: str, basesifactor: num, symbol: str, name: str } }`
#### Get a virtual filesystem file — `GET /vfs/{*path}` #### 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` - Returns: `200 str`
#### List warehouses — `GET /warehouse` #### List warehouses — `GET /warehouse`
_none_ _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. (virtual filesystem), for the authenticated user's selected company.
Embed the returned URL in chat markdown to display a generated image: Embed the returned URL in chat markdown to display a generated image:
`![title](/api/vfs/example.png)`. * `![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 Images are returned as a 250x250 aspect-preserving preview unless requested with
edge is upscaled to 100 px. The preview carries a translucent strip along the bottom `download=1` or `raw=1`.
edge listing the ORIGINAL width, height, file size, MIME type and filename. * Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served
Only image MIME types the sandbox produces are rendered inline; every other file inline so they open directly in the browser; other files are sent as downloads.
type is sent with `Content-Disposition: attachment` so it downloads rather than
being interpreted as a document in the app's origin.
## Path parameters ## Path parameters
@@ -28,10 +26,9 @@ being interpreted as a document in the app's origin.
| Param | Required | Type | Description | | Param | Required | Type | Description |
|-------|----------|------|-------------| |-------|----------|------|-------------|
| `download` | no | boolean | Return the original, unmodified file instead of the standard preview. | `download` | no | boolean | Force file download with Content-Disposition: attachment.
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 | `raw` | no | boolean | Return original unmodified image inline without generating a preview thumbnail.
served as-is.
| |
## Response `200` ## Response `200`

View File

@@ -1,5 +1,6 @@
import * as db from '../../db/modelvfs.js'; import * as db from '../../db/modelvfs.js';
import sharp from 'sharp'; 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 // 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. // 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. // Fraction of the thumbnail's height the metadata band may occupy.
const BAND_HEIGHT_RATIO = 0.5; const BAND_HEIGHT_RATIO = 0.5;
// Files the sandbox produces that are safe to render inline in a browser document. // Raster image formats that can be processed into a 250x250 preview thumbnail with metadata strip.
// Anything not listed here (`.html`, `.svg`, `.js`, …) is served as a download so a stored const IMAGE_THUMBNAIL_MIME = new Set([
// artifact can never be loaded as an executable document in the app's origin.
const INLINE_RENDERABLE_MIME = new Set([
'image/png', 'image/png',
'image/jpeg', 'image/jpeg',
'image/gif', 'image/gif',
@@ -44,6 +43,28 @@ const INLINE_RENDERABLE_MIME = new Set([
'image/bmp', '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) { function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId; const companyId = req.user?.selectedCompanyId;
if (companyId == null) { if (companyId == null) {
@@ -104,23 +125,12 @@ function parseVfsRef(raw) {
const normalized = segments.join('/'); const normalized = segments.join('/');
const ext = normalized.slice(normalized.lastIndexOf('.')).toLowerCase(); const mime = getMimeType(normalized);
const isInline = INLINE_RENDERABLE_MIME.has(MIME_BY_EXTENSION[ext] || ''); const canThumbnail = IMAGE_THUMBNAIL_MIME.has(mime.split(';')[0].trim().toLowerCase());
return { name: normalized, isInline }; 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) { function vfsFileErrorStatus(error) {
if (error.message === 'No company selected') return 400; if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403; 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. * (virtual filesystem), for the authenticated user's selected company.
* *
* Embed the returned URL in chat markdown to display a generated image: * Embed the returned URL in chat markdown to display a generated image:
* `![title](/api/vfs/example.png)`. * * `![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 * Images are returned as a 250x250 aspect-preserving preview unless requested with
* edge is upscaled to 100 px. The preview carries a translucent strip along the bottom * `download=1` or `raw=1`.
* edge listing the ORIGINAL width, height, file size, MIME type and filename. * * Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served
* Only image MIME types the sandbox produces are rendered inline; every other file * inline so they open directly in the browser; other files are sent as downloads.
* type is sent with `Content-Disposition: attachment` so it downloads rather than
* being interpreted as a document in the app's origin.
* security: * security:
* - cookieAuth: [] * - cookieAuth: []
* parameters: * parameters:
@@ -380,10 +388,14 @@ export function register(app, basePath = '') {
* schema: * schema:
* type: boolean * type: boolean
* description: | * description: |
* Return the original, unmodified file instead of the standard preview. * Force file download with Content-Disposition: attachment.
* Without this parameter images are always served as a 250x250 aspect-preserving * - in: query
* preview with a metadata strip. Ignored for non-image files, which are always * name: raw
* served as-is. * required: false
* schema:
* type: boolean
* description: |
* Return original unmodified image inline without generating a preview thumbnail.
* responses: * responses:
* 200: * 200:
* description: File bytes * description: File bytes
@@ -442,47 +454,50 @@ export function register(app, basePath = '') {
return res.status(404).json({ error: 'File not found' }); 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 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 const wantsDownload = req.query.download === '1' || req.query.download === 'true';
// explicitly asks for the original with `download=1`. The preview carries the original const wantsRaw = req.query.raw === '1' || req.query.raw === 'true';
// width/height/size/mime/filename in a pill strip, so no metadata is lost. const isInline = isChromeInline(effectiveMime) && !wantsDownload;
if (isInline) {
const rendered = await renderThumbnail(file.content, { // Images are served as a 250x250 aspect-preserving preview for markdown chat embeddings,
mime: file.mime, // unless the caller explicitly asks for the raw file (?raw=1) or a download (?download=1).
filename: fallbackName, const shouldThumbnail = parsed.canThumbnail && !wantsDownload && !wantsRaw;
}).catch((err) => { if (shouldThumbnail) {
console.error('vfs thumbnail render failed:', err.message); const rendered = await renderThumbnail(file.content, {
return null; mime: effectiveMime,
}); filename: fallbackName,
if (rendered) { }).catch((err) => {
res.setHeader('Content-Type', rendered.contentType); console.error('vfs thumbnail render failed:', err.message);
res.setHeader('Content-Length', String(rendered.buffer.length)); return null;
res.setHeader('X-Content-Type-Options', 'nosniff'); });
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox"); if (rendered) {
res.setHeader('Content-Disposition', `inline; filename="${fallbackName.replace(/"/g, '')}"`); res.setHeader('Content-Type', rendered.contentType);
// Previews are derived data — never cache them immutably, the source file can change. res.setHeader('Content-Length', String(rendered.buffer.length));
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('ETag', `W/"thumb-${file.updated_at || file.name}"`); res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
const ifNoneMatch = req.headers['if-none-match']; res.setHeader('Content-Disposition', `inline; filename="${fallbackName.replace(/"/g, '')}"`);
if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) { // Previews are derived data — never cache them immutably, the source file can change.
return res.status(304).end(); res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
} res.setHeader('ETag', `W/"thumb-${file.updated_at || file.name}"`);
return res.status(200).send(rendered.buffer); const ifNoneMatch = req.headers['if-none-match'];
} if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) {
// Rendering failed (unsupported/corrupt image) — fall through and serve the original. 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('Content-Length', String(file.content.length));
res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox"); // Mutable VFS artifacts are cached with revalidation
// 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.
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); 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( res.setHeader(
'Content-Disposition', 'Content-Disposition',
`${isInline ? 'inline' : 'attachment'}; filename="${fallbackName.replace(/"/g, '')}"` `${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) { export function getMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
switch (ext) { 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 '.avif': return 'image/avif';
case '.webp': return 'image/webp'; case '.webp': return 'image/webp';
case '.png': return 'image/png'; case '.png': return 'image/png';
case '.jpg': case '.jpg':
case '.jpeg': return 'image/jpeg'; 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 '.pdf': return 'application/pdf';
case '.txt': return 'text/plain'; case '.txt':
case '.csv': return 'text/csv'; case '.text':
case '.md': return 'text/markdown'; case '.log': return 'text/plain; charset=utf-8';
case '.skill': return 'text/markdown'; 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'; default: return 'application/octet-stream';
} }
} }