import fs from 'fs'; import path from 'path'; import multer from 'multer'; import { emit } from '../sse.js'; import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath } from '../../tools/vfs.js'; 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. 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; // 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', 'image/webp', 'image/avif', '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) { 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 mime = getMimeType(normalized); const canThumbnail = IMAGE_THUMBNAIL_MIME.has(mime.split(';')[0].trim().toLowerCase()); const isInline = isChromeInline(mime); return { name: normalized, mime, canThumbnail, isInline }; } 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, '''); } /** * Trim a monospace string so it fits within `maxPx`, appending an ellipsis when cut. * * SVG `` 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) => `${escapeXml(line)}` ) .join(''); return Buffer.from( `` + `` + `${escapeXml(nameText)}` + lineTexts + `` ); } /** * 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 isTextMime(mime, filePath = '') { if (!mime) return false; const lower = mime.toLowerCase(); if (lower.startsWith('text/')) return true; if (lower === 'application/json' || lower === 'application/javascript' || lower === 'application/xml') return true; const ext = path.extname(filePath).toLowerCase(); return ['.txt', '.text', '.md', '.csv', '.tsv', '.json', '.js', '.mjs', '.css', '.html', '.htm', '.xml', '.skill', '.sql', '.env', '.log', '.yaml', '.yml', '.ini'].includes(ext); } const vfsUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 100 * 1024 * 1024 }, }); 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 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: * - 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: | * 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 * 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`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const vfsRoot = await prepareVfs({ googleAuthId: req.user.id }); function walk(dir, rel = '') { if (!fs.existsSync(dir)) return []; const entries = fs.readdirSync(dir, { withFileTypes: true }); const list = []; for (const e of entries) { if (e.name.startsWith('.')) continue; const relPath = rel ? `${rel}/${e.name}` : e.name; const fullPath = path.join(dir, e.name); if (e.isDirectory()) { list.push(...walk(fullPath, relPath)); } else if (e.isFile()) { try { const stat = fs.statSync(fullPath); const mime = getMimeType(relPath); const isText = isTextMime(mime, relPath); const isImage = IMAGE_THUMBNAIL_MIME.has(mime.split(';')[0].trim().toLowerCase()); const isPdf = mime === 'application/pdf' || relPath.toLowerCase().endsWith('.pdf'); list.push({ name: relPath, size: stat.size, mime, updated_at: stat.mtime.toISOString(), created_at: stat.birthtime ? stat.birthtime.toISOString() : stat.mtime.toISOString(), isText, isImage, isPdf, }); } catch {} } } return list; } const files = walk(vfsRoot).sort((a, b) => a.name.localeCompare(b.name)); return res.json({ files }); } catch (error) { const status = vfsFileErrorStatus(error); if (status === 500) console.error('List vfs files failed:', error); return res.status(status).json({ error: error.message }); } }); app.post(`${basePath}/vfs/upload`, vfsUpload.array('files'), async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const files = req.files || (req.file ? [req.file] : []); if (!files.length) { return res.status(400).json({ error: 'No files uploaded' }); } try { const vfsRoot = await resolveVfsRoot(req.user.id); const targetDir = typeof req.body?.targetDir === 'string' ? req.body.targetDir.trim().replace(/^\/+|\/+$/g, '') : ''; const uploaded = []; for (const f of files) { const origName = (f.originalname || 'upload').replace(/[\0\\]/g, '/'); const relPath = targetDir ? `${targetDir}/${origName}` : origName; const parsed = parseVfsRef(relPath); if (parsed.error) continue; if (isReadOnlyDatabasePath(parsed.name)) continue; const absPath = path.resolve(vfsRoot, parsed.name); fs.mkdirSync(path.dirname(absPath), { recursive: true }); fs.writeFileSync(absPath, f.buffer); const effectiveMime = f.mimetype && f.mimetype !== 'application/octet-stream' ? f.mimetype : getMimeType(parsed.name); await db.write(req.user.id, { name: parsed.name, mime: effectiveMime, content: f.buffer, }); uploaded.push({ name: parsed.name, size: f.buffer.length, mime: effectiveMime, }); } emit('vfs', { companyId: selected.companyId }); return res.json({ success: true, uploaded }); } catch (error) { const status = vfsFileErrorStatus(error); if (status === 500) console.error('Upload vfs files failed:', error); return res.status(status).json({ error: error.message }); } }); app.put(`${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 }); } if (isReadOnlyDatabasePath(parsed.name)) { return res.status(403).json({ error: 'Cannot modify read-only database files' }); } let buffer; let mime = parsed.mime; if (typeof req.body?.content === 'string') { buffer = Buffer.from(req.body.content, 'utf8'); if (req.body.mime) mime = req.body.mime; } else if (Buffer.isBuffer(req.body)) { buffer = req.body; if (req.headers['content-type'] && req.headers['content-type'] !== 'application/octet-stream') { mime = req.headers['content-type']; } } else if (typeof req.body === 'string') { buffer = Buffer.from(req.body, 'utf8'); } else { return res.status(400).json({ error: 'No content provided' }); } try { const vfsRoot = await resolveVfsRoot(req.user.id); const absPath = path.resolve(vfsRoot, parsed.name); fs.mkdirSync(path.dirname(absPath), { recursive: true }); fs.writeFileSync(absPath, buffer); await db.write(req.user.id, { name: parsed.name, mime, content: buffer, }); emit('vfs', { companyId: selected.companyId }); return res.json({ success: true, file: { name: parsed.name, size: buffer.length, mime, updated_at: new Date().toISOString(), }, }); } catch (error) { const status = vfsFileErrorStatus(error); if (status === 500) console.error('Write vfs file failed:', error); return res.status(status).json({ error: error.message }); } }); app.delete(`${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 }); } if (isReadOnlyDatabasePath(parsed.name)) { return res.status(403).json({ error: 'Cannot delete read-only database files' }); } try { const vfsRoot = await resolveVfsRoot(req.user.id); const absPath = path.resolve(vfsRoot, parsed.name); if (fs.existsSync(absPath)) { try { fs.unlinkSync(absPath); } catch {} } await db.remove(req.user.id, parsed.name); emit('vfs', { companyId: selected.companyId }); return res.json({ success: true, deleted: parsed.name }); } catch (error) { const status = vfsFileErrorStatus(error); if (status === 500) console.error('Delete vfs file failed:', error); return res.status(status).json({ error: error.message }); } }); 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 fallbackName = parsed.name.split('/').pop() || 'download'; const effectiveMime = (file.mime && file.mime !== 'application/octet-stream') ? file.mime : getMimeType(parsed.name); 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', effectiveMime); res.setHeader('Content-Length', String(file.content.length)); res.setHeader('X-Content-Type-Options', 'nosniff'); // 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}-${isInline ? 'i' : 'a'}"`); 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 }); } }); }