u
This commit is contained in:
503
src/api/routes/vfs.js
Normal file
503
src/api/routes/vfs.js
Normal file
@@ -0,0 +1,503 @@
|
||||
import * as db from '../../db/modelvfs.js';
|
||||
import sharp from 'sharp';
|
||||
|
||||
// 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.
|
||||
const THUMB_BOX = 250;
|
||||
// Images whose larger edge is below this are UPSCALED to it, so tiny icons stay legible.
|
||||
const THUMB_MIN_EDGE = 100;
|
||||
// Output format for generated previews. WebP keeps the metadata strip visually lossless at
|
||||
// small sizes without ballooning bytes the way PNG text overlays would.
|
||||
const THUMB_FORMAT = 'webp';
|
||||
const THUMB_QUALITY = 88;
|
||||
|
||||
// Monospace font available on the host (verified: DejaVu Sans Mono ships with the image).
|
||||
const THUMB_FONT = 'DejaVu Sans Mono';
|
||||
const THUMB_FONT_FALLBACK = 'sans-serif';
|
||||
|
||||
// Dark translucent band behind the metadata row so light text stays readable on any image.
|
||||
const THUMB_BG = '#101317';
|
||||
const THUMB_TEXT = '#e8eef5';
|
||||
const THUMB_MUTED = '#a9bccf';
|
||||
|
||||
// Advance width of a monospace glyph as a fraction of font size. DejaVu Sans Mono is 0.6023em;
|
||||
// rounded up so width estimates never underestimate the real glyph advance.
|
||||
const MONO_ADVANCE = 0.61;
|
||||
|
||||
// Legibility limits for the overlay text. The floor matters most: it is what makes metadata
|
||||
// readable on a 100px thumbnail instead of a smear of 8px glyphs.
|
||||
const FONT_MIN_PX = 10;
|
||||
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([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'image/bmp',
|
||||
]);
|
||||
|
||||
function requireSelectedCompany(req) {
|
||||
const companyId = req.user?.selectedCompanyId;
|
||||
if (companyId == null) {
|
||||
return { error: 'No company selected' };
|
||||
}
|
||||
return { companyId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a VFS file reference and decide whether it may be rendered inline.
|
||||
*
|
||||
* Accepts either the plain VFS name (`example.png`, `labels/a.png`) or a URL pointing at this
|
||||
* endpoint (`/api/vfs/example.png`), so a model can hand back the exact URL it was given.
|
||||
* Returns `isInline: false` for anything that is not a known image type — the caller then
|
||||
* sends `Content-Disposition: attachment` so it downloads instead of rendering.
|
||||
*
|
||||
* @param {string} raw
|
||||
* @returns {{ name: string } | { error: string }}
|
||||
*/
|
||||
function parseVfsRef(raw) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
return { error: 'path is required' };
|
||||
}
|
||||
|
||||
let name = raw.trim();
|
||||
|
||||
// Accept absolute URLs to this endpoint: strip origin and the /api/vfs prefix.
|
||||
if (/^https?:\/\//i.test(name)) {
|
||||
try {
|
||||
name = new URL(name).pathname;
|
||||
} catch {
|
||||
return { error: 'path is not a valid URL' };
|
||||
}
|
||||
}
|
||||
name = name.replace(/^\/+/, '');
|
||||
// Strip a leading `api/` so both `/vfs/x.png` and `/api/vfs/x.png` resolve the same way.
|
||||
name = name.replace(/^api\//i, '').replace(/^vfs\//i, '');
|
||||
|
||||
// Reject traversal outright. Never let a caller escape the VFS root via `..` segments,
|
||||
// percent-encoding, or backslashes — this value keys a database lookup, not a disk read,
|
||||
// but normalising here keeps stored names and requested names identical.
|
||||
let decoded = name;
|
||||
try {
|
||||
decoded = decodeURIComponent(name);
|
||||
} catch {
|
||||
return { error: 'path is not valid URL encoding' };
|
||||
}
|
||||
if (decoded.includes('\0') || decoded.includes('\\')) {
|
||||
return { error: 'path contains invalid characters' };
|
||||
}
|
||||
const segments = decoded.split('/').filter((s) => s && s !== '.');
|
||||
if (segments.some((s) => s === '..')) {
|
||||
return { error: 'path escapes virtual filesystem boundary' };
|
||||
}
|
||||
if (segments.length === 0) {
|
||||
return { error: 'path is required' };
|
||||
}
|
||||
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
// 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;
|
||||
if (error.message === 'File not found') return 404;
|
||||
if (error.message === 'File name is required') return 400;
|
||||
return 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable byte size, e.g. `1.4 MB`.
|
||||
* @param {number} bytes
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return 'unknown size';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes / 1024;
|
||||
let i = 0;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** XML-escape for text embedded in the SVG overlay. */
|
||||
function escapeXml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim a monospace string so it fits within `maxPx`, appending an ellipsis when cut.
|
||||
*
|
||||
* SVG `<text>` cannot be measured server-side, so widths are estimated from the font's
|
||||
* monospace advance ratio (slightly conservative, so we never under-estimate).
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {number} maxPx
|
||||
* @param {number} fontSize
|
||||
* @returns {string}
|
||||
*/
|
||||
function fitToWidth(text, maxPx, fontSize) {
|
||||
const value = String(text);
|
||||
const perChar = fontSize * MONO_ADVANCE;
|
||||
if (perChar <= 0) return value;
|
||||
if (value.length * perChar <= maxPx) return value;
|
||||
const room = Math.max(1, Math.floor(maxPx / perChar) - 1);
|
||||
return `${value.slice(0, room)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SVG metadata overlay: a translucent "pill" strip along the bottom edge carrying the
|
||||
* original width, height, file size, MIME type and filename.
|
||||
*
|
||||
* This describes the ORIGINAL file, not the thumbnail — that is the whole point of the strip.
|
||||
*
|
||||
* @param {{ width: number|null, height: number|null, size: number, mime: string, filename: string }} meta
|
||||
* @param {number} outWidth rendered overlay width (image width after scaling)
|
||||
* @param {number} outHeight rendered overlay height (image height after scaling)
|
||||
* @returns {Buffer} SVG bytes
|
||||
*/
|
||||
function buildMetaOverlay({ width, height, size, mime, filename }, outWidth, outHeight) {
|
||||
const dims = width && height ? `${width}×${height} px` : 'unknown dimensions';
|
||||
const fields = [dims, formatBytes(size), mime || 'unknown type'];
|
||||
const SEP = ' · ';
|
||||
|
||||
|
||||
// The band is drawn INSIDE the image, so it must fit within its height. Rather than
|
||||
// shrinking the font until the metadata is unreadable (the earlier approach, which produced
|
||||
// 5px text on a 100px-wide thumbnail), the text WRAPS onto as many rows as the image can
|
||||
// carry, keeping the font at a legible size.
|
||||
const maxBand = Math.max(0, Math.floor(outHeight * BAND_HEIGHT_RATIO));
|
||||
const padX0 = Math.max(3, Math.round(FONT_MIN_PX * 0.55));
|
||||
const usable0 = Math.max(1, outWidth - padX0 * 2);
|
||||
const charsPerLine = Math.max(6, Math.floor(usable0 / (FONT_MIN_PX * MONO_ADVANCE)));
|
||||
|
||||
// Wrap the joined field list into at most `maxRows` lines.
|
||||
const maxRows = Math.max(1, Math.floor((maxBand - FONT_MIN_PX * 0.7) / (FONT_MIN_PX * 1.25)) - 1);
|
||||
const full = fields.join(SEP);
|
||||
let lines = [];
|
||||
if (full.length <= charsPerLine) {
|
||||
lines = [full];
|
||||
} else {
|
||||
lines = [full];
|
||||
while (lines.length < maxRows && lines[lines.length - 1].length > charsPerLine) {
|
||||
const last = lines.pop();
|
||||
lines.push(last.slice(0, charsPerLine - 1) + '…');
|
||||
}
|
||||
// If it still does not fit, break it across the available rows instead of truncating.
|
||||
if (lines.length === 1 && full.length > charsPerLine) {
|
||||
lines = [];
|
||||
for (let i = 0; i < full.length && lines.length < maxRows; i += charsPerLine) {
|
||||
lines.push(full.slice(i, i + charsPerLine));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fontSize = FONT_MIN_PX;
|
||||
const padX = padX0;
|
||||
const lineHeight = Math.round(fontSize * 1.25);
|
||||
const barHeight = lineHeight * (lines.length + 1) + Math.round(fontSize * 0.7);
|
||||
if (barHeight > outHeight) return null;
|
||||
const y = outHeight - barHeight;
|
||||
|
||||
|
||||
|
||||
const nameText = fitToWidth(String(filename), outWidth - padX * 2, fontSize);
|
||||
const lineTexts = lines
|
||||
.map(
|
||||
(line, i) =>
|
||||
`<text x="${padX}" y="${y + lineHeight * (i + 2) - Math.round(fontSize * 0.3)}" ` +
|
||||
`font-family="${THUMB_FONT}, ${THUMB_FONT_FALLBACK}" font-size="${fontSize}" ` +
|
||||
`fill="${THUMB_MUTED}">${escapeXml(line)}</text>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
return Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${outWidth}" height="${outHeight}">` +
|
||||
`<rect x="0" y="${y}" width="${outWidth}" height="${barHeight}" fill="${THUMB_BG}" fill-opacity="0.88"/>` +
|
||||
`<text x="${padX}" y="${y + lineHeight - Math.round(fontSize * 0.35)}" font-family="${THUMB_FONT}, ${THUMB_FONT_FALLBACK}" ` +
|
||||
`font-size="${fontSize}" fill="${THUMB_TEXT}">${escapeXml(nameText)}</text>` +
|
||||
lineTexts +
|
||||
`</svg>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a thumbnail of an image buffer.
|
||||
*
|
||||
* Sizing rule: fit inside a 250x250 box with the aspect ratio preserved (`fit: 'inside'`,
|
||||
* `withoutEnlargement: false` so small images are upscaled), then explicitly upscale anything
|
||||
* whose larger edge is still under 100 px. The metadata overlay reports the ORIGINAL dimensions.
|
||||
*
|
||||
* @param {Buffer} buffer original image bytes
|
||||
* @param {{ mime: string, filename: string }} info
|
||||
* @returns {Promise<{ buffer: Buffer, contentType: string } | null>} null when not rasterisable
|
||||
*/
|
||||
async function renderThumbnail(buffer, { mime, filename }) {
|
||||
const pipeline = sharp(buffer, { animated: false, failOn: 'none' });
|
||||
let meta;
|
||||
try {
|
||||
meta = await pipeline.metadata();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!meta?.width || !meta?.height) return null;
|
||||
|
||||
let scaled = await pipeline
|
||||
.resize({
|
||||
width: THUMB_BOX,
|
||||
height: THUMB_BOX,
|
||||
fit: 'inside',
|
||||
// Downscale only. Upscaling is handled explicitly below, so a 60x40 image ends up at
|
||||
// 100px (the THUMB_MIN_EDGE rule) rather than being blown up to fill the 250 box.
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
let scaledMeta = await sharp(scaled).metadata();
|
||||
|
||||
// Upscale small images so they do not render as an unreadable speck.
|
||||
const largerEdge = Math.max(scaledMeta.width, scaledMeta.height);
|
||||
if (largerEdge < THUMB_MIN_EDGE) {
|
||||
const factor = THUMB_MIN_EDGE / largerEdge;
|
||||
scaled = await sharp(scaled)
|
||||
.resize({
|
||||
width: Math.max(1, Math.round(scaledMeta.width * factor)),
|
||||
height: Math.max(1, Math.round(scaledMeta.height * factor)),
|
||||
fit: 'fill',
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
scaledMeta = await sharp(scaled).metadata();
|
||||
}
|
||||
|
||||
const outWidth = scaledMeta.width;
|
||||
const outHeight = scaledMeta.height;
|
||||
|
||||
const overlay = buildMetaOverlay(
|
||||
{
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
size: buffer.length,
|
||||
mime,
|
||||
filename,
|
||||
},
|
||||
outWidth,
|
||||
outHeight
|
||||
);
|
||||
|
||||
// `null` means the thumbnail is too small to carry a legible metadata band (e.g. an extreme
|
||||
// aspect ratio collapsing to a few pixels tall). Serve the plain scaled image instead.
|
||||
if (!overlay) {
|
||||
return {
|
||||
buffer: await sharp(scaled)[THUMB_FORMAT]({ quality: THUMB_QUALITY }).toBuffer(),
|
||||
contentType: `image/${THUMB_FORMAT}`,
|
||||
};
|
||||
}
|
||||
const composite = await sharp(scaled)
|
||||
.composite([{ input: overlay, top: 0, left: 0 }])
|
||||
[THUMB_FORMAT]({ quality: THUMB_QUALITY })
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
buffer: composite,
|
||||
contentType: `image/${THUMB_FORMAT}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers virtual filesystem file routes (requires auth).
|
||||
*
|
||||
* Serves artifacts the model produced in the `javascript_execute` sandbox so they can be
|
||||
* embedded in chat markdown, e.g. ``.
|
||||
*
|
||||
* @param {import('express').Application} app
|
||||
* @param {string} [basePath='']
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @openapi
|
||||
* /vfs/{*path}:
|
||||
* get:
|
||||
* tags: [Vfs]
|
||||
* summary: Get a virtual filesystem file
|
||||
* description: |
|
||||
* 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:
|
||||
* ``. *
|
||||
* 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.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: VFS file path (e.g. `example.png`, `labels/a.png`)
|
||||
* - in: query
|
||||
* name: download
|
||||
* required: false
|
||||
* 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.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File bytes
|
||||
* headers:
|
||||
* Content-Disposition:
|
||||
* schema:
|
||||
* type: string
|
||||
* Cache-Control:
|
||||
* schema:
|
||||
* type: string
|
||||
* content:
|
||||
* application/octet-stream:
|
||||
* schema:
|
||||
* type: string
|
||||
* format: binary
|
||||
* 400:
|
||||
* description: Invalid path, or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: File not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/vfs/*path`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const raw = Array.isArray(req.params.path) ? req.params.path.join('/') : req.params.path;
|
||||
const parsed = parseVfsRef(raw);
|
||||
if (parsed.error) {
|
||||
return res.status(400).json({ error: parsed.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await db.read(req.user.id, parsed.name);
|
||||
if (!file?.content?.length) {
|
||||
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';
|
||||
|
||||
// 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.
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', file.mime || 'application/octet-stream');
|
||||
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.
|
||||
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
||||
res.setHeader('ETag', `W/"${file.updated_at || file.name}"`);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`${isInline ? 'inline' : 'attachment'}; filename="${fallbackName.replace(/"/g, '')}"`
|
||||
);
|
||||
|
||||
const ifNoneMatch = req.headers['if-none-match'];
|
||||
if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
return res.status(200).send(file.content);
|
||||
} catch (error) {
|
||||
const status = vfsFileErrorStatus(error);
|
||||
if (status === 500) console.error('Get vfs file failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user