u
This commit is contained in:
@@ -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:
|
||||
* ``.
|
||||
*
|
||||
* 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);
|
||||
|
||||
Reference in New Issue
Block a user