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