Files
dashback/src/api/routes/vfs.js
2026-10-28 01:46:51 +01:00

1462 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { optimizeImage } from 'wasm-image-optimization';
// #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, '&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 }) {
let meta;
try {
meta = await optimizeImage({ image: buffer, format: 'none' });
} catch {
return null;
}
const origW = meta?.originalWidth;
const origH = meta?.originalHeight;
if (!origW || !origH) return null;
// Downscale to fit inside THUMB_BOX without enlargement, then explicitly upscale
// anything whose larger edge is still under THUMB_MIN_EDGE.
const downScale = Math.min(1, THUMB_BOX / origW, THUMB_BOX / origH);
let outWidth = Math.max(1, Math.round(origW * downScale));
let outHeight = Math.max(1, Math.round(origH * downScale));
const largerEdge = Math.max(outWidth, outHeight);
if (largerEdge < THUMB_MIN_EDGE) {
const factor = THUMB_MIN_EDGE / largerEdge;
outWidth = Math.max(1, Math.round(outWidth * factor));
outHeight = Math.max(1, Math.round(outHeight * factor));
}
const overlay = buildMetaOverlay(
{
width: origW,
height: origH,
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) {
const plain = await optimizeImage({
image: buffer,
width: outWidth,
height: outHeight,
fit: 'fill',
format: THUMB_FORMAT,
quality: THUMB_QUALITY,
});
return {
buffer: Buffer.from(plain.data),
contentType: `image/${THUMB_FORMAT}`,
};
}
const scaledPng = await optimizeImage({
image: buffer,
width: outWidth,
height: outHeight,
fit: 'fill',
format: 'png',
});
const b64 = Buffer.from(scaledPng.data).toString('base64');
const overlayStr = overlay.toString('utf8');
const compositeSvg = overlayStr.replace(
/(<svg[^>]*>)/,
`$1<image href="data:image/png;base64,${b64}" x="0" y="0" width="${outWidth}" height="${outHeight}"/>`
);
const composite = await optimizeImage({
image: compositeSvg,
format: THUMB_FORMAT,
quality: THUMB_QUALITY,
});
return {
buffer: Buffer.from(composite.data),
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/"<timestamp>-<mode>"` 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
}