import fs from 'fs'; import path from 'path'; import multer from 'multer'; import { emit } from '../sse.js'; import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath, getMimeType, restoreDefaultSkills, restoreDefaultApplets, } from '../../tools/vfs.js'; import sharp from 'sharp'; // #region Helpers // 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 }} */ export 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 }; } export 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 }, }); // #endregion export function register(app, basePath = '') { /** * @openapi * /vfs: * get: * tags: [Vfs] * summary: List virtual filesystem files * description: | * Lists all virtual filesystem files for the authenticated user's selected company. * Scans directories recursively (ignoring hidden dotfiles) and returns file * metadata including path, byte size, detected MIME type, timestamps, and classification flags. * security: * - cookieAuth: [] * x-markdown: vfs/list.md * responses: * 200: * description: List of virtual filesystem files * content: * application/json: * schema: * type: object * required: [files] * properties: * files: * type: array * items: * $ref: '#/components/schemas/VfsFile' * 400: * description: 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' */ // #region GET /vfs 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 }); } }); // #endregion /** * @openapi * /vfs/upload: * post: * tags: [Vfs] * summary: Upload files to virtual filesystem * description: | * Uploads one or more files to the virtual filesystem for the authenticated user's selected company. * Files are written to the company's VFS root directory and saved to the database. * An optional `targetDir` form field specifies a subfolder within the VFS. * * Writing to read-only database paths (`image/*` or filenames containing 8-character image hashes) is * forbidden and automatically skipped during upload. * security: * - cookieAuth: [] * x-signals: [vfs] * x-markdown: vfs/upload.md * requestBody: * required: true * content: * multipart/form-data: * schema: * type: object * required: [files] * properties: * files: * type: array * items: * type: string * format: binary * description: One or more files to upload (form field name `files`) * targetDir: * type: string * description: Optional destination subdirectory within VFS (e.g. `reports` or `charts/q1`) * responses: * 200: * description: Successfully uploaded files * content: * application/json: * schema: * type: object * required: [success, uploaded] * properties: * success: * type: boolean * uploaded: * type: array * items: * type: object * required: [name, size, mime] * properties: * name: * type: string * description: Stored relative path in VFS * size: * type: integer * description: Size in bytes * mime: * type: string * description: Effective MIME type * 400: * description: No files uploaded, 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' */ // #region POST /vfs/upload 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; if (parseDraftPath(parsed.name).isFolder || parseDraftPath(parsed.name).isDraft) continue; const absPath = path.resolve(vfsRoot, parsed.name); if (!absPath.startsWith(vfsRoot)) continue; 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); 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 }); } }); // #endregion /** * @openapi * /vfs/mkdir: * post: * tags: [Vfs] * summary: Create directory in virtual filesystem * description: | * Creates a directory (and any necessary parent directories) in the virtual filesystem * for the authenticated user's selected company. * security: * - cookieAuth: [] * x-signals: [vfs] * x-markdown: vfs/mkdir.md * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [path] * properties: * path: * type: string * description: Directory path to create (e.g. `reports/2026/q1`) * responses: * 200: * description: Directory created successfully * content: * application/json: * schema: * type: object * required: [success, dir] * properties: * success: * type: boolean * dir: * type: string * description: Created directory path * 400: * description: Path is required, invalid characters, traversal, 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, or cannot create directory inside read-only database paths * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /vfs/mkdir app.post(`${basePath}/vfs/mkdir`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const raw = req.body?.path || req.body?.dir || req.body?.name; 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 create directories in read-only database paths' }); } try { const vfsRoot = await resolveVfsRoot(req.user.id); const absPath = path.resolve(vfsRoot, parsed.name); if (!absPath.startsWith(vfsRoot)) { return res.status(400).json({ error: 'path escapes virtual filesystem boundary' }); } fs.mkdirSync(absPath, { recursive: true }); emit('vfs', { companyId: selected.companyId }); return res.json({ success: true, dir: parsed.name }); } catch (error) { const status = vfsFileErrorStatus(error); if (status === 500) console.error('Create vfs directory failed:', error); return res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /vfs/rename: * post: * tags: [Vfs] * summary: Rename or move a virtual filesystem file or directory * description: | * Renames or moves a file or directory within the virtual filesystem for the authenticated user's selected company. * Parent directories for the destination path are created automatically if needed. * security: * - cookieAuth: [] * x-signals: [vfs] * x-markdown: vfs/rename.md * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [oldPath, newPath] * properties: * oldPath: * type: string * description: Existing VFS file or directory path (e.g. `draft.txt`) * newPath: * type: string * description: New destination VFS path (e.g. `archive/final.txt`) * responses: * 200: * description: File or directory successfully renamed * content: * application/json: * schema: * type: object * required: [success, oldPath, newPath] * properties: * success: * type: boolean * oldPath: * type: string * newPath: * type: string * 400: * description: Path is required, invalid characters, traversal, 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, or cannot rename read-only database paths * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Source file or directory not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /vfs/rename app.post(`${basePath}/vfs/rename`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const rawOld = req.body?.oldPath || req.body?.from || req.body?.source; const rawNew = req.body?.newPath || req.body?.to || req.body?.target; const parsedOld = parseVfsRef(rawOld); if (parsedOld.error) { return res.status(400).json({ error: `oldPath: ${parsedOld.error}` }); } const parsedNew = parseVfsRef(rawNew); if (parsedNew.error) { return res.status(400).json({ error: `newPath: ${parsedNew.error}` }); } if (isReadOnlyDatabasePath(parsedOld.name) || isReadOnlyDatabasePath(parsedNew.name)) { return res.status(403).json({ error: 'Cannot rename read-only database paths' }); } try { const vfsRoot = await resolveVfsRoot(req.user.id); const absOld = path.resolve(vfsRoot, parsedOld.name); const absNew = path.resolve(vfsRoot, parsedNew.name); if (!absOld.startsWith(vfsRoot) || !absNew.startsWith(vfsRoot)) { return res.status(400).json({ error: 'path escapes virtual filesystem boundary' }); } if (!fs.existsSync(absOld)) { return res.status(404).json({ error: 'Source file or directory not found' }); } fs.mkdirSync(path.dirname(absNew), { recursive: true }); fs.renameSync(absOld, absNew); emit('vfs', { companyId: selected.companyId }); return res.json({ success: true, oldPath: parsedOld.name, newPath: parsedNew.name, }); } catch (error) { const status = vfsFileErrorStatus(error); if (status === 500) console.error('Rename vfs file failed:', error); return res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /vfs/{*path}: * put: * tags: [Vfs] * summary: Create or overwrite a virtual filesystem file * description: | * Creates or overwrites a file at the specified virtual filesystem path for the authenticated user's selected company. * Supports multiple request payload formats: * - JSON body: `{"content": "text content", "mime": "optional/mime"}` * - Binary body: raw binary buffer with optional `Content-Type` header * - Plain text: raw UTF-8 string * * Modifying read-only database paths (`image/*` or filenames containing 8-character image hashes) is blocked with HTTP 403. * security: * - cookieAuth: [] * x-signals: [vfs] * x-markdown: vfs/write.md * parameters: * - in: path * name: path * required: true * schema: * type: string * description: Destination VFS file path (e.g. `notes.txt`, `data/metrics.json`) * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [content] * properties: * content: * type: string * description: File text content * mime: * type: string * description: Optional MIME type override * application/octet-stream: * schema: * type: string * format: binary * description: Raw binary payload * text/plain: * schema: * type: string * description: Raw text payload * responses: * 200: * description: File successfully written * content: * application/json: * schema: * type: object * required: [success, file] * properties: * success: * type: boolean * file: * type: object * required: [name, size, mime, updated_at] * properties: * name: * type: string * description: VFS file path * size: * type: integer * description: Written byte length * mime: * type: string * description: Stored MIME type * updated_at: * type: string * format: date-time * description: ISO timestamp of update * 400: * description: Path is required, invalid characters/traversal, no content provided, 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, or cannot modify read-only database files * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region PUT /vfs/*path 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); if (!absPath.startsWith(vfsRoot)) { return res.status(400).json({ error: 'path escapes virtual filesystem boundary' }); } fs.mkdirSync(path.dirname(absPath), { recursive: true }); fs.writeFileSync(absPath, 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 }); } }); // #endregion /** * @openapi * /vfs/{*path}: * delete: * tags: [Vfs] * summary: Delete a virtual filesystem file * description: | * Deletes a file from both the on-disk virtual filesystem and database for the authenticated user's selected company. * Deleting read-only database files (`image/*` or filenames containing 8-character image hashes) is blocked with HTTP 403. * security: * - cookieAuth: [] * x-signals: [vfs] * x-markdown: vfs/delete.md * parameters: * - in: path * name: path * required: true * schema: * type: string * description: VFS file path to delete (e.g. `temp.png`, `notes.txt`) * responses: * 200: * description: File successfully deleted * content: * application/json: * schema: * type: object * required: [success, deleted] * properties: * success: * type: boolean * deleted: * type: string * description: Path of deleted file * 400: * description: Invalid path, path traversal, 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, or cannot delete read-only database files * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region DELETE /vfs/*path 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 (!absPath.startsWith(vfsRoot)) { return res.status(400).json({ error: 'path escapes virtual filesystem boundary' }); } if (!fs.existsSync(absPath)) { return res.status(404).json({ error: 'File not found' }); } const stat = fs.statSync(absPath); if (stat.isDirectory()) { fs.rmSync(absPath, { recursive: true, force: true }); } else { fs.unlinkSync(absPath); } 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 }); } }); // #endregion /** * @openapi * /vfs/{*path}: * get: * tags: [Vfs] * summary: Get a virtual filesystem file * description: | * Returns a file from the virtual filesystem for the authenticated user's selected company. * Commonly used to retrieve artifacts generated by the model in the `javascript_execute` sandbox or uploaded by users. * * ### URL Format * Accepts: * - Relative VFS paths: `example.png`, `reports/summary.pdf` * - Full endpoint paths: `/vfs/example.png`, `/api/vfs/example.png` (leading `api/` and `vfs/` prefixes are automatically stripped) * - Absolute URLs: `https://.../api/vfs/example.png` * - Traversal (`..`), null bytes (`\0`), and backslashes (`\`) are strictly forbidden and rejected with HTTP 400. * * ### Image Previews & Thumbnails * Supported raster images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`, `image/bmp`) * are automatically transformed into a **250×250 aspect-preserving preview thumbnail** (WebP format, quality 88): * - Scaled to fit within a 250×250 box without distortion (e.g. 2000×100 becomes 250×13). * - Images whose larger edge is below 100px are upscaled to 100px for icon legibility. * - A translucent dark metadata strip (`#101317`, max 50% height) with monospace text (`DejaVu Sans Mono`) * is rendered along the bottom edge, displaying original dimensions and file size. * - Served with `Content-Security-Policy: default-src 'none'; sandbox`. * - Use `?raw=1` (or `?raw=true`) to bypass preview generation and return the original unmodified image inline. * * ### Inline vs Attachment * - Browser-compatible types (HTML, CSS, JS, JSON, PDF, text, images, audio, video, fonts, WASM) * are served with `Content-Disposition: inline; filename="..."` so they open directly in modern browsers. * - Non-browser/binary types default to `Content-Disposition: attachment; filename="..."`. * - Use `?download=1` (or `?download=true`) to force file download with `Content-Disposition: attachment`. * * ### Caching & Conditional Requests * - Generates weak ETags (`W/"thumb-..."` for previews, `W/"-"` for originals). * - Sets `Cache-Control: private, max-age=0, must-revalidate`. * - Supports `If-None-Match` conditional requests returning HTTP `304 Not Modified`. * security: * - cookieAuth: [] * x-markdown: vfs/_*path.md * parameters: * - in: path * name: path * required: true * schema: * type: string * description: VFS file path (e.g. `example.png`, `charts/graph.svg`) * - in: query * name: download * required: false * schema: * type: boolean * description: 'Force file download with Content-Disposition: attachment (1 or true).' * - in: query * name: raw * required: false * schema: * type: boolean * description: 'Return original unmodified image inline without generating a preview thumbnail (1 or true).' * responses: * 200: * description: | * File bytes. Served inline for browser-renderable formats or as a download. * Includes headers: * - `Content-Type`: MIME type of file, or `image/webp` for previews * - `Content-Length`: Size of served payload in bytes * - `Content-Disposition`: `inline; filename="..."` or `attachment; filename="..."` * - `Cache-Control`: `private, max-age=0, must-revalidate` * - `ETag`: Weak ETag identifier * - `X-Content-Type-Options`: `nosniff` * - `Content-Security-Policy`: `default-src 'none'; sandbox` (for preview thumbnails) * headers: * Content-Type: * schema: * type: string * Content-Length: * schema: * type: string * Content-Disposition: * schema: * type: string * Cache-Control: * schema: * type: string * ETag: * schema: * type: string * X-Content-Type-Options: * schema: * type: string * Content-Security-Policy: * schema: * type: string * content: * application/octet-stream: * schema: * type: string * format: binary * 304: * description: Not modified (If-None-Match matched ETag) * 400: * description: Invalid path, path escapes VFS boundary, 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' */ // #region GET /vfs/*path 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 vfsRoot = await resolveVfsRoot(req.user.id); const absPath = path.resolve(vfsRoot, parsed.name); if (!absPath.startsWith(vfsRoot) || !fs.existsSync(absPath)) { return res.status(404).json({ error: 'File not found' }); } const stat = fs.statSync(absPath); if (stat.isDirectory()) { return res.status(400).json({ error: 'Cannot download a directory directly' }); } const content = fs.readFileSync(absPath); const updatedAt = stat.mtime.toISOString(); const fallbackName = parsed.name.split('/').pop() || 'download'; const effectiveMime = 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(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-${updatedAt || parsed.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(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/"${updatedAt || parsed.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(content); } catch (error) { const status = vfsFileErrorStatus(error); if (status === 500) console.error('Get vfs file failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /vfs/skills/restore: * post: * tags: [Vfs] * summary: Restore system default skills * description: | * Restores all shipped system default SKILL files into the active virtual filesystem (VFS) * for the authenticated user's selected company, overwriting existing system skills with their upstream definitions. * security: * - cookieAuth: [] * x-signals: [vfs] * x-markdown: vfs/skills-restore.md * responses: * 200: * description: System skills restored successfully * content: * application/json: * schema: * type: object * required: [success, restored, count] * properties: * success: * type: boolean * restored: * type: array * items: * type: string * count: * type: integer * 400: * description: 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' * 500: * description: Restore failed * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /vfs/skills/restore app.post(`${basePath}/vfs/skills/restore`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const restored = await restoreDefaultSkills({ googleAuthId: req.user.id }); emit('vfs', { companyId: selected.companyId }); return res.json({ success: true, restored, count: restored.length, }); } catch (error) { const status = vfsFileErrorStatus(error); if (status === 500) console.error('Restore default skills failed:', error); return res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /vfs/applets/restore: * post: * tags: [Vfs] * summary: Restore system default applets * description: | * Restores all shipped system default example applets (.applet.js) into the active virtual * filesystem (VFS) for the authenticated user's selected company, overwriting existing system applets with their upstream definitions. * security: * - cookieAuth: [] * x-signals: [vfs] * x-markdown: vfs/applets-restore.md * responses: * 200: * description: System applets restored successfully * content: * application/json: * schema: * type: object * required: [success, restored, count] * properties: * success: * type: boolean * restored: * type: array * items: * type: string * count: * type: integer * 400: * description: 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' * 500: * description: Restore failed * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /vfs/applets/restore app.post(`${basePath}/vfs/applets/restore`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const restored = await restoreDefaultApplets({ googleAuthId: req.user.id }); emit('vfs', { companyId: selected.companyId }); return res.json({ success: true, restored, count: restored.length, }); } catch (error) { const status = vfsFileErrorStatus(error); if (status === 500) console.error('Restore default applets failed:', error); return res.status(status).json({ error: error.message }); } }); // #endregion }