1457 lines
56 KiB
JavaScript
1457 lines
56 KiB
JavaScript
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, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
/**
|
||
* Trim a monospace string so it fits within `maxPx`, appending an ellipsis when cut.
|
||
*
|
||
* SVG `<text>` cannot be measured server-side, so widths are estimated from the font's
|
||
* monospace advance ratio (slightly conservative, so we never under-estimate).
|
||
*
|
||
* @param {string} text
|
||
* @param {number} maxPx
|
||
* @param {number} fontSize
|
||
* @returns {string}
|
||
*/
|
||
function fitToWidth(text, maxPx, fontSize) {
|
||
const value = String(text);
|
||
const perChar = fontSize * MONO_ADVANCE;
|
||
if (perChar <= 0) return value;
|
||
if (value.length * perChar <= maxPx) return value;
|
||
const room = Math.max(1, Math.floor(maxPx / perChar) - 1);
|
||
return `${value.slice(0, room)}…`;
|
||
}
|
||
|
||
/**
|
||
* Build the SVG metadata overlay: a translucent "pill" strip along the bottom edge carrying the
|
||
* original width, height, file size, MIME type and filename.
|
||
*
|
||
* This describes the ORIGINAL file, not the thumbnail — that is the whole point of the strip.
|
||
*
|
||
* @param {{ width: number|null, height: number|null, size: number, mime: string, filename: string }} meta
|
||
* @param {number} outWidth rendered overlay width (image width after scaling)
|
||
* @param {number} outHeight rendered overlay height (image height after scaling)
|
||
* @returns {Buffer} SVG bytes
|
||
*/
|
||
function buildMetaOverlay({ width, height, size, mime, filename }, outWidth, outHeight) {
|
||
const dims = width && height ? `${width}×${height} px` : 'unknown dimensions';
|
||
const fields = [dims, formatBytes(size), mime || 'unknown type'];
|
||
const SEP = ' · ';
|
||
|
||
|
||
// The band is drawn INSIDE the image, so it must fit within its height. Rather than
|
||
// shrinking the font until the metadata is unreadable (the earlier approach, which produced
|
||
// 5px text on a 100px-wide thumbnail), the text WRAPS onto as many rows as the image can
|
||
// carry, keeping the font at a legible size.
|
||
const maxBand = Math.max(0, Math.floor(outHeight * BAND_HEIGHT_RATIO));
|
||
const padX0 = Math.max(3, Math.round(FONT_MIN_PX * 0.55));
|
||
const usable0 = Math.max(1, outWidth - padX0 * 2);
|
||
const charsPerLine = Math.max(6, Math.floor(usable0 / (FONT_MIN_PX * MONO_ADVANCE)));
|
||
|
||
// Wrap the joined field list into at most `maxRows` lines.
|
||
const maxRows = Math.max(1, Math.floor((maxBand - FONT_MIN_PX * 0.7) / (FONT_MIN_PX * 1.25)) - 1);
|
||
const full = fields.join(SEP);
|
||
let lines = [];
|
||
if (full.length <= charsPerLine) {
|
||
lines = [full];
|
||
} else {
|
||
lines = [full];
|
||
while (lines.length < maxRows && lines[lines.length - 1].length > charsPerLine) {
|
||
const last = lines.pop();
|
||
lines.push(last.slice(0, charsPerLine - 1) + '…');
|
||
}
|
||
// If it still does not fit, break it across the available rows instead of truncating.
|
||
if (lines.length === 1 && full.length > charsPerLine) {
|
||
lines = [];
|
||
for (let i = 0; i < full.length && lines.length < maxRows; i += charsPerLine) {
|
||
lines.push(full.slice(i, i + charsPerLine));
|
||
}
|
||
}
|
||
}
|
||
|
||
const fontSize = FONT_MIN_PX;
|
||
const padX = padX0;
|
||
const lineHeight = Math.round(fontSize * 1.25);
|
||
const barHeight = lineHeight * (lines.length + 1) + Math.round(fontSize * 0.7);
|
||
if (barHeight > outHeight) return null;
|
||
const y = outHeight - barHeight;
|
||
|
||
|
||
|
||
const nameText = fitToWidth(String(filename), outWidth - padX * 2, fontSize);
|
||
const lineTexts = lines
|
||
.map(
|
||
(line, i) =>
|
||
`<text x="${padX}" y="${y + lineHeight * (i + 2) - Math.round(fontSize * 0.3)}" ` +
|
||
`font-family="${THUMB_FONT}, ${THUMB_FONT_FALLBACK}" font-size="${fontSize}" ` +
|
||
`fill="${THUMB_MUTED}">${escapeXml(line)}</text>`
|
||
)
|
||
.join('');
|
||
|
||
return Buffer.from(
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="${outWidth}" height="${outHeight}">` +
|
||
`<rect x="0" y="${y}" width="${outWidth}" height="${barHeight}" fill="${THUMB_BG}" fill-opacity="0.88"/>` +
|
||
`<text x="${padX}" y="${y + lineHeight - Math.round(fontSize * 0.35)}" font-family="${THUMB_FONT}, ${THUMB_FONT_FALLBACK}" ` +
|
||
`font-size="${fontSize}" fill="${THUMB_TEXT}">${escapeXml(nameText)}</text>` +
|
||
lineTexts +
|
||
`</svg>`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Produce a thumbnail of an image buffer.
|
||
*
|
||
* Sizing rule: fit inside a 250x250 box with the aspect ratio preserved (`fit: 'inside'`,
|
||
* `withoutEnlargement: false` so small images are upscaled), then explicitly upscale anything
|
||
* whose larger edge is still under 100 px. The metadata overlay reports the ORIGINAL dimensions.
|
||
*
|
||
* @param {Buffer} buffer original image bytes
|
||
* @param {{ mime: string, filename: string }} info
|
||
* @returns {Promise<{ buffer: Buffer, contentType: string } | null>} null when not rasterisable
|
||
*/
|
||
async function renderThumbnail(buffer, { mime, filename }) {
|
||
const pipeline = sharp(buffer, { animated: false, failOn: 'none' });
|
||
let meta;
|
||
try {
|
||
meta = await pipeline.metadata();
|
||
} catch {
|
||
return null;
|
||
}
|
||
if (!meta?.width || !meta?.height) return null;
|
||
|
||
let scaled = await pipeline
|
||
.resize({
|
||
width: THUMB_BOX,
|
||
height: THUMB_BOX,
|
||
fit: 'inside',
|
||
// Downscale only. Upscaling is handled explicitly below, so a 60x40 image ends up at
|
||
// 100px (the THUMB_MIN_EDGE rule) rather than being blown up to fill the 250 box.
|
||
withoutEnlargement: true,
|
||
})
|
||
.png()
|
||
.toBuffer();
|
||
|
||
let scaledMeta = await sharp(scaled).metadata();
|
||
|
||
// Upscale small images so they do not render as an unreadable speck.
|
||
const largerEdge = Math.max(scaledMeta.width, scaledMeta.height);
|
||
if (largerEdge < THUMB_MIN_EDGE) {
|
||
const factor = THUMB_MIN_EDGE / largerEdge;
|
||
scaled = await sharp(scaled)
|
||
.resize({
|
||
width: Math.max(1, Math.round(scaledMeta.width * factor)),
|
||
height: Math.max(1, Math.round(scaledMeta.height * factor)),
|
||
fit: 'fill',
|
||
})
|
||
.png()
|
||
.toBuffer();
|
||
scaledMeta = await sharp(scaled).metadata();
|
||
}
|
||
|
||
const outWidth = scaledMeta.width;
|
||
const outHeight = scaledMeta.height;
|
||
|
||
const overlay = buildMetaOverlay(
|
||
{
|
||
width: meta.width,
|
||
height: meta.height,
|
||
size: buffer.length,
|
||
mime,
|
||
filename,
|
||
},
|
||
outWidth,
|
||
outHeight
|
||
);
|
||
|
||
// `null` means the thumbnail is too small to carry a legible metadata band (e.g. an extreme
|
||
// aspect ratio collapsing to a few pixels tall). Serve the plain scaled image instead.
|
||
if (!overlay) {
|
||
return {
|
||
buffer: await sharp(scaled)[THUMB_FORMAT]({ quality: THUMB_QUALITY }).toBuffer(),
|
||
contentType: `image/${THUMB_FORMAT}`,
|
||
};
|
||
}
|
||
const composite = await sharp(scaled)
|
||
.composite([{ input: overlay, top: 0, left: 0 }])
|
||
[THUMB_FORMAT]({ quality: THUMB_QUALITY })
|
||
.toBuffer();
|
||
|
||
return {
|
||
buffer: composite,
|
||
contentType: `image/${THUMB_FORMAT}`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Registers virtual filesystem file routes (requires auth).
|
||
*
|
||
* Serves artifacts the model produced in the `javascript_execute` sandbox so they can be
|
||
* embedded in chat markdown, e.g. ``.
|
||
*
|
||
* @param {import('express').Application} app
|
||
* @param {string} [basePath='']
|
||
*/
|
||
|
||
export function 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
|
||
} |