Files
dashback/src/api/routes/vfs.js
2026-10-07 13:06:00 +02:00

732 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import fs from 'fs';
import path from 'path';
import multer from 'multer';
import { emit } from '../sse.js';
import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath } from '../../tools/vfs.js';
import * as db from '../../db/modelvfs.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.
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 }}
*/
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 };
}
function vfsFileErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'File not found') return 404;
if (error.message === 'File name is required') return 400;
return 500;
}
/**
* Human-readable byte size, e.g. `1.4 MB`.
* @param {number} bytes
*/
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) return 'unknown size';
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes / 1024;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i += 1;
}
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`;
}
/** XML-escape for text embedded in the SVG overlay. */
function escapeXml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Trim a monospace string so it fits within `maxPx`, appending an ellipsis when cut.
*
* SVG `<text>` cannot be measured server-side, so widths are estimated from the font's
* monospace advance ratio (slightly conservative, so we never under-estimate).
*
* @param {string} text
* @param {number} maxPx
* @param {number} fontSize
* @returns {string}
*/
function fitToWidth(text, maxPx, fontSize) {
const value = String(text);
const perChar = fontSize * MONO_ADVANCE;
if (perChar <= 0) return value;
if (value.length * perChar <= maxPx) return value;
const room = Math.max(1, Math.floor(maxPx / perChar) - 1);
return `${value.slice(0, room)}…`;
}
/**
* Build the SVG metadata overlay: a translucent "pill" strip along the bottom edge carrying the
* original width, height, file size, MIME type and filename.
*
* This describes the ORIGINAL file, not the thumbnail — that is the whole point of the strip.
*
* @param {{ width: number|null, height: number|null, size: number, mime: string, filename: string }} meta
* @param {number} outWidth rendered overlay width (image width after scaling)
* @param {number} outHeight rendered overlay height (image height after scaling)
* @returns {Buffer} SVG bytes
*/
function buildMetaOverlay({ width, height, size, mime, filename }, outWidth, outHeight) {
const dims = width && height ? `${width}×${height} px` : 'unknown dimensions';
const fields = [dims, formatBytes(size), mime || 'unknown type'];
const SEP = ' · ';
// The band is drawn INSIDE the image, so it must fit within its height. Rather than
// shrinking the font until the metadata is unreadable (the earlier approach, which produced
// 5px text on a 100px-wide thumbnail), the text WRAPS onto as many rows as the image can
// carry, keeping the font at a legible size.
const maxBand = Math.max(0, Math.floor(outHeight * BAND_HEIGHT_RATIO));
const padX0 = Math.max(3, Math.round(FONT_MIN_PX * 0.55));
const usable0 = Math.max(1, outWidth - padX0 * 2);
const charsPerLine = Math.max(6, Math.floor(usable0 / (FONT_MIN_PX * MONO_ADVANCE)));
// Wrap the joined field list into at most `maxRows` lines.
const maxRows = Math.max(1, Math.floor((maxBand - FONT_MIN_PX * 0.7) / (FONT_MIN_PX * 1.25)) - 1);
const full = fields.join(SEP);
let lines = [];
if (full.length <= charsPerLine) {
lines = [full];
} else {
lines = [full];
while (lines.length < maxRows && lines[lines.length - 1].length > charsPerLine) {
const last = lines.pop();
lines.push(last.slice(0, charsPerLine - 1) + '…');
}
// If it still does not fit, break it across the available rows instead of truncating.
if (lines.length === 1 && full.length > charsPerLine) {
lines = [];
for (let i = 0; i < full.length && lines.length < maxRows; i += charsPerLine) {
lines.push(full.slice(i, i + charsPerLine));
}
}
}
const fontSize = FONT_MIN_PX;
const padX = padX0;
const lineHeight = Math.round(fontSize * 1.25);
const barHeight = lineHeight * (lines.length + 1) + Math.round(fontSize * 0.7);
if (barHeight > outHeight) return null;
const y = outHeight - barHeight;
const nameText = fitToWidth(String(filename), outWidth - padX * 2, fontSize);
const lineTexts = lines
.map(
(line, i) =>
`<text x="${padX}" y="${y + lineHeight * (i + 2) - Math.round(fontSize * 0.3)}" ` +
`font-family="${THUMB_FONT}, ${THUMB_FONT_FALLBACK}" font-size="${fontSize}" ` +
`fill="${THUMB_MUTED}">${escapeXml(line)}</text>`
)
.join('');
return Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${outWidth}" height="${outHeight}">` +
`<rect x="0" y="${y}" width="${outWidth}" height="${barHeight}" fill="${THUMB_BG}" fill-opacity="0.88"/>` +
`<text x="${padX}" y="${y + lineHeight - Math.round(fontSize * 0.35)}" font-family="${THUMB_FONT}, ${THUMB_FONT_FALLBACK}" ` +
`font-size="${fontSize}" fill="${THUMB_TEXT}">${escapeXml(nameText)}</text>` +
lineTexts +
`</svg>`
);
}
/**
* Produce a thumbnail of an image buffer.
*
* Sizing rule: fit inside a 250x250 box with the aspect ratio preserved (`fit: 'inside'`,
* `withoutEnlargement: false` so small images are upscaled), then explicitly upscale anything
* whose larger edge is still under 100 px. The metadata overlay reports the ORIGINAL dimensions.
*
* @param {Buffer} buffer original image bytes
* @param {{ mime: string, filename: string }} info
* @returns {Promise<{ buffer: Buffer, contentType: string } | null>} null when not rasterisable
*/
async function renderThumbnail(buffer, { mime, filename }) {
const pipeline = sharp(buffer, { animated: false, failOn: 'none' });
let meta;
try {
meta = await pipeline.metadata();
} catch {
return null;
}
if (!meta?.width || !meta?.height) return null;
let scaled = await pipeline
.resize({
width: THUMB_BOX,
height: THUMB_BOX,
fit: 'inside',
// Downscale only. Upscaling is handled explicitly below, so a 60x40 image ends up at
// 100px (the THUMB_MIN_EDGE rule) rather than being blown up to fill the 250 box.
withoutEnlargement: true,
})
.png()
.toBuffer();
let scaledMeta = await sharp(scaled).metadata();
// Upscale small images so they do not render as an unreadable speck.
const largerEdge = Math.max(scaledMeta.width, scaledMeta.height);
if (largerEdge < THUMB_MIN_EDGE) {
const factor = THUMB_MIN_EDGE / largerEdge;
scaled = await sharp(scaled)
.resize({
width: Math.max(1, Math.round(scaledMeta.width * factor)),
height: Math.max(1, Math.round(scaledMeta.height * factor)),
fit: 'fill',
})
.png()
.toBuffer();
scaledMeta = await sharp(scaled).metadata();
}
const outWidth = scaledMeta.width;
const outHeight = scaledMeta.height;
const overlay = buildMetaOverlay(
{
width: meta.width,
height: meta.height,
size: buffer.length,
mime,
filename,
},
outWidth,
outHeight
);
// `null` means the thumbnail is too small to carry a legible metadata band (e.g. an extreme
// aspect ratio collapsing to a few pixels tall). Serve the plain scaled image instead.
if (!overlay) {
return {
buffer: await sharp(scaled)[THUMB_FORMAT]({ quality: THUMB_QUALITY }).toBuffer(),
contentType: `image/${THUMB_FORMAT}`,
};
}
const composite = await sharp(scaled)
.composite([{ input: overlay, top: 0, left: 0 }])
[THUMB_FORMAT]({ quality: THUMB_QUALITY })
.toBuffer();
return {
buffer: composite,
contentType: `image/${THUMB_FORMAT}`,
};
}
/**
* Registers virtual filesystem file routes (requires auth).
*
* Serves artifacts the model produced in the `javascript_execute` sandbox so they can be
* embedded in chat markdown, e.g. `![title](/api/vfs/example.png)`.
*
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function isTextMime(mime, filePath = '') {
if (!mime) return false;
const lower = mime.toLowerCase();
if (lower.startsWith('text/')) return true;
if (lower === 'application/json' || lower === 'application/javascript' || lower === 'application/xml') return true;
const ext = path.extname(filePath).toLowerCase();
return ['.txt', '.text', '.md', '.csv', '.tsv', '.json', '.js', '.mjs', '.css', '.html', '.htm', '.xml', '.skill', '.sql', '.env', '.log', '.yaml', '.yml', '.ini'].includes(ext);
}
const vfsUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 100 * 1024 * 1024 },
});
export function register(app, basePath = '') {
/**
* @openapi
* /vfs/{*path}:
* get:
* tags: [Vfs]
* summary: Get a virtual filesystem file
* 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.
* 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.
* responses:
* 200:
* description: File bytes
* headers:
* Content-Disposition:
* schema:
* type: string
* Cache-Control:
* schema:
* type: string
* content:
* application/octet-stream:
* schema:
* type: string
* format: binary
* 400:
* description: 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'
* 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) {
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 });
}
});
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;
const absPath = path.resolve(vfsRoot, parsed.name);
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);
await db.write(req.user.id, {
name: parsed.name,
mime: effectiveMime,
content: f.buffer,
});
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 });
}
});
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);
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,
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 });
}
});
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 (fs.existsSync(absPath)) {
try { fs.unlinkSync(absPath); } catch {}
}
await db.remove(req.user.id, parsed.name);
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 });
}
});
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 file = await db.read(req.user.id, parsed.name);
if (!file?.content?.length) {
return res.status(404).json({ error: 'File not found' });
}
const fallbackName = parsed.name.split('/').pop() || 'download';
const effectiveMime = (file.mime && file.mime !== 'application/octet-stream')
? file.mime
: 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(file.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-${file.updated_at || file.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(file.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(
'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(file.content);
} catch (error) {
const status = vfsFileErrorStatus(error);
if (status === 500) console.error('Get vfs file failed:', error);
res.status(status).json({ error: error.message });
}
});
}