This commit is contained in:
seb
2026-10-07 20:36:50 +02:00
parent 857963b1c3
commit a29ad50b74
18 changed files with 1024 additions and 220 deletions

View File

@@ -2,10 +2,8 @@ import fs from 'fs';
import path from 'path';
import multer from 'multer';
import { emit } from '../sse.js';
import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath } from '../../tools/vfs.js';
import * as db from '../../db/modelvfs.js';
import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath, getMimeType } from '../../tools/vfs.js';
import sharp from 'sharp';
import { getMimeType } from '../../tools/vfs.js';
// Thumbnail box. The image is fitted inside this box preserving aspect ratio; it is never
// stretched, so a 2000x100 image becomes 250x13 and not 250x250.
@@ -379,61 +377,32 @@ export function register(app, basePath = '') {
/**
* @openapi
* /vfs/{*path}:
* /vfs:
* get:
* tags: [Vfs]
* summary: Get a virtual filesystem file
* summary: List virtual filesystem files
* description: |
* Returns a file the model produced in the `javascript_execute` sandbox
* (virtual filesystem), for the authenticated user's selected company.
*
* Embed the returned URL in chat markdown to display a generated image:
* `![title](/api/vfs/example.png)`.
*
* Images are returned as a 250x250 aspect-preserving preview unless requested with
* `download=1` or `raw=1`.
* Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served
* inline so they open directly in the browser; other files are sent as downloads.
* 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: []
* parameters:
* - in: path
* name: path
* required: true
* schema:
* type: string
* description: VFS file path (e.g. `example.png`, `labels/a.png`)
* - in: query
* name: download
* required: false
* schema:
* type: boolean
* description: |
* Force file download with Content-Disposition: attachment.
* - in: query
* name: raw
* required: false
* schema:
* type: boolean
* description: |
* Return original unmodified image inline without generating a preview thumbnail.
* x-markdown: vfs/list.md
* responses:
* 200:
* description: File bytes
* headers:
* Content-Disposition:
* schema:
* type: string
* Cache-Control:
* schema:
* type: string
* description: List of virtual filesystem files
* content:
* application/octet-stream:
* application/json:
* schema:
* type: string
* format: binary
* type: object
* required: [files]
* properties:
* files:
* type: array
* items:
* $ref: '#/components/schemas/VfsFile'
* 400:
* description: Invalid path, or no company selected
* description: No company selected
* content:
* application/json:
* schema:
@@ -450,14 +419,7 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: File not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.get(`${basePath}/vfs`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -506,6 +468,85 @@ export function register(app, basePath = '') {
}
});
/**
* @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 64-character hex 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'
*/
app.post(`${basePath}/vfs/upload`, vfsUpload.array('files'), async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -531,6 +572,7 @@ export function register(app, basePath = '') {
if (isReadOnlyDatabasePath(parsed.name)) 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);
@@ -538,12 +580,6 @@ export function register(app, basePath = '') {
? f.mimetype
: getMimeType(parsed.name);
await db.write(req.user.id, {
name: parsed.name,
mime: effectiveMime,
content: f.buffer,
});
uploaded.push({
name: parsed.name,
size: f.buffer.length,
@@ -560,6 +596,305 @@ export function register(app, basePath = '') {
}
});
/**
* @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'
*/
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 });
}
});
/**
* @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'
*/
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 });
}
});
/**
* @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 64-character hex 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'
*/
app.put(`${basePath}/vfs/*path`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -593,15 +928,12 @@ export function register(app, basePath = '') {
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);
await db.write(req.user.id, {
name: parsed.name,
mime,
content: buffer,
});
emit('vfs', { companyId: selected.companyId });
return res.json({
success: true,
@@ -619,6 +951,59 @@ export function register(app, basePath = '') {
}
});
/**
* @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 64-character hex 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'
*/
app.delete(`${basePath}/vfs/*path`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -636,10 +1021,18 @@ export function register(app, basePath = '') {
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name);
if (fs.existsSync(absPath)) {
try { fs.unlinkSync(absPath); } catch {}
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);
}
await db.remove(req.user.id, parsed.name);
emit('vfs', { companyId: selected.companyId });
return res.json({ success: true, deleted: parsed.name });
@@ -650,6 +1043,131 @@ export function register(app, basePath = '') {
}
});
/**
* @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'
*/
app.get(`${basePath}/vfs/*path`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -663,15 +1181,20 @@ export function register(app, basePath = '') {
}
try {
const file = await db.read(req.user.id, parsed.name);
if (!file?.content?.length) {
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 = (file.mime && file.mime !== 'application/octet-stream')
? file.mime
: getMimeType(parsed.name);
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';
@@ -681,7 +1204,7 @@ export function register(app, basePath = '') {
// unless the caller explicitly asks for the raw file (?raw=1) or a download (?download=1).
const shouldThumbnail = parsed.canThumbnail && !wantsDownload && !wantsRaw;
if (shouldThumbnail) {
const rendered = await renderThumbnail(file.content, {
const rendered = await renderThumbnail(content, {
mime: effectiveMime,
filename: fallbackName,
}).catch((err) => {
@@ -696,7 +1219,7 @@ export function register(app, basePath = '') {
res.setHeader('Content-Disposition', `inline; filename="${fallbackName.replace(/"/g, '')}"`);
// Previews are derived data — never cache them immutably, the source file can change.
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
res.setHeader('ETag', `W/"thumb-${file.updated_at || file.name}"`);
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();
@@ -707,11 +1230,11 @@ export function register(app, basePath = '') {
}
res.setHeader('Content-Type', effectiveMime);
res.setHeader('Content-Length', String(file.content.length));
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/"${file.updated_at || file.name}-${isInline ? 'i' : 'a'}"`);
res.setHeader('ETag', `W/"${updatedAt || parsed.name}-${isInline ? 'i' : 'a'}"`);
res.setHeader(
'Content-Disposition',
`${isInline ? 'inline' : 'attachment'}; filename="${fallbackName.replace(/"/g, '')}"`
@@ -722,7 +1245,7 @@ export function register(app, basePath = '') {
return res.status(304).end();
}
return res.status(200).send(file.content);
return res.status(200).send(content);
} catch (error) {
const status = vfsFileErrorStatus(error);
if (status === 500) console.error('Get vfs file failed:', error);