u
This commit is contained in:
@@ -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_
|
||||||
|
|||||||
@@ -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_
|
||||||
|
|||||||
@@ -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:
|
||||||
``. *
|
``.
|
||||||
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`
|
||||||
|
|||||||
@@ -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:
|
||||||
* ``. *
|
* ``.
|
||||||
* 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, '')}"`
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user