This commit is contained in:
seb
2026-10-04 02:14:03 +02:00
parent 9a7f345d8b
commit e5ff4ef307
15 changed files with 1178 additions and 62 deletions

View File

@@ -1542,8 +1542,25 @@ export function register(app, basePath = '') {
* Returns every article reachable via the company's category tree.
* Designed for client-side search / autocomplete preload.
* Auth via company access.
*
* REQUIRES `windowSize`. Without it the response is always an empty list — the full
* index can be very large and is not useful to a caller that did not ask for a
* specific window. Pass the number of entries the client actually intends to preload.
* security:
* - cookieAuth: []
* parameters:
* - in: query
* name: windowSize
* required: true
* schema:
* type: integer
* minimum: 1
* maximum: 1000
* description: |
* Number of articles to return (window size). Required; omitting it returns an
* empty `articles` array. `truncated` is true when more entries exist than were
* returned, signalling the client should request a larger window or narrow its
* query.
* responses:
* 200:
* description: Search index
@@ -1572,6 +1589,9 @@ export function register(app, basePath = '') {
* type: array
* items:
* type: integer
* truncated:
* type: boolean
* description: True when the window was smaller than the total number of matching articles.
* 400:
* description: No company selected
* content:
@@ -1601,11 +1621,28 @@ export function register(app, basePath = '') {
const companyId = req.user.selectedCompanyId;
if (companyId == null) {
return res.status(400).json({ error: 'No company selected' });
}
// A window size must be given explicitly. Without one the index can be arbitrarily large,
// and returning the entire result set to a caller that never asked for a window is
// exactly the "fetch everything" behaviour this guard exists to prevent.
const rawWindowSize = req.query.windowSize;
if (rawWindowSize === undefined || rawWindowSize === '') {
return res.status(200).json({ articles: [], truncated: false });
}
const windowSize = parseInt(rawWindowSize, 10);
if (!Number.isInteger(windowSize) || windowSize < 1 || windowSize > 1000) {
return res.status(400).json({ error: 'windowSize must be an integer between 1 and 1000' });
}
try {
const articles = await db.listSearchIndex(req.user.id);
res.status(200).json({ articles });
const all = await db.listSearchIndex(req.user.id);
// `length === windowSize` is the same truncation signal used by
// GET /article/{sku}/history: the window is exactly full, so more may exist.
const truncated = all.length > windowSize;
const articles = truncated ? all.slice(0, windowSize) : all;
res.status(200).json({ articles, truncated });
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('List article search index failed:', error);

503
src/api/routes/vfs.js Normal file
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 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. `![title](/api/vfs/example.png)`.
*
* @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:
* `![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.
* 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 });
}
});
}