Files
dashback/src/tools/vfs.js
2026-10-10 06:39:32 +02:00

524 lines
20 KiB
JavaScript

import fs from 'fs';
import path from 'path';
import { getStore } from '../db/store.js';
import env from '../config/env.js';
import { getDefaultSkills } from './skill.js';
import { getDefaultApplets, DEFAULT_APPLETS_VERSION, APPLET_EXT } from './applet.js';
const FULL_HASH_REGEX = /^[a-zA-Z0-9]{8}$/;
const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
export function getMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.html':
case '.htm': return 'text/html; charset=utf-8';
case '.css': return 'text/css; charset=utf-8';
case '.js':
case '.mjs': return 'text/javascript; charset=utf-8';
case '.json':
case '.map': return 'application/json; charset=utf-8';
case '.avif': return 'image/avif';
case '.webp': return 'image/webp';
case '.png': return 'image/png';
case '.jpg':
case '.jpeg': return 'image/jpeg';
case '.gif': return 'image/gif';
case '.svg': return 'image/svg+xml';
case '.ico': return 'image/x-icon';
case '.bmp': return 'image/bmp';
case '.pdf': return 'application/pdf';
case '.txt':
case '.text':
case '.log': return 'text/plain; charset=utf-8';
case '.csv': return 'text/csv; charset=utf-8';
case '.tsv': return 'text/tab-separated-values; charset=utf-8';
case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
case '.xls': return 'application/vnd.ms-excel';
case '.xlsm': return 'application/vnd.ms-excel.sheet.macroEnabled.12';
case '.xltx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.template';
case '.md':
case '.skill': return 'text/markdown; charset=utf-8';
case '.xml': return 'application/xml; charset=utf-8';
case '.mp3': return 'audio/mpeg';
case '.wav': return 'audio/wav';
case '.ogg': return 'audio/ogg';
case '.mp4': return 'video/mp4';
case '.webm': return 'video/webm';
case '.woff': return 'font/woff';
case '.woff2': return 'font/woff2';
case '.ttf': return 'font/ttf';
case '.otf': return 'font/otf';
default: return 'application/octet-stream';
}
}
export function extractFullHash(p) {
if (typeof p !== 'string' || !p.trim()) return null;
const clean = p.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '');
const parts = clean.split('/').filter(Boolean);
if (parts.length === 0) return null;
const first = parts[0].toLowerCase();
const last = parts[parts.length - 1];
const ext = path.extname(last).toLowerCase();
const withoutExt = last.replace(/\.[a-zA-Z0-9]+$/, '');
// Under virtual image folder (e.g. image/aB1cD2eF, images/aB1cD2eF.avif)
if (VIRTUAL_IMAGE_FOLDERS.has(first)) {
if (parts.length === 2 && FULL_HASH_REGEX.test(withoutExt)) {
return withoutExt;
}
}
// Direct hash (e.g. "aB1cD2eF" or "aB1cD2eF.avif")
if (parts.length === 1 && FULL_HASH_REGEX.test(withoutExt) && (!ext || ext === '.avif')) {
return withoutExt;
}
return null;
}
export function isReadOnlyDatabasePath(filePath) {
if (typeof filePath !== 'string' || !filePath.trim()) return false;
const clean = filePath.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
const lower = clean.toLowerCase();
if (lower === 'image' || lower.startsWith('image/')) return true;
if (extractFullHash(clean)) return true;
return false;
}
function assertNotReadOnlyDatabasePath(filePath) {
if (isReadOnlyDatabasePath(filePath)) {
throw new Error(
`Access denied: "${filePath}" matches the read-only database image pattern and cannot be written to. Save artifacts under custom filenames (e.g. "candidate.avif" or "images/candidate.avif").`
);
}
}
/**
* Resolve the on-disk VFS root for the user's CURRENTLY SELECTED company.
*
* This must stay in lockstep with the store, which scopes every `modelvfs` call by
* `google_auth.selected_company` read at call time — see `resolveSelectedCompanyAccess` in
* libdashstore. Callers therefore must NOT pass a `companyId`: a value captured earlier (at
* tool-construction time, or from a request made before a company switch) can disagree with
* what the store will act on, which would hydrate one company's directory from another
* company's rows and delete files from a directory the database never touched.
*
* One authority, read fresh on every call, for both the disk and the database halves.
*
* @param {number} googleAuthId
* @returns {Promise<string>} Absolute `data/vfs/<companyId>` path
* @throws {Error} 'No company selected' when the user has no company selected
*/
export async function resolveVfsRoot(googleAuthId) {
return vfsRootFor(await getStore(), googleAuthId);
}
/**
* Shared implementation of {@link resolveVfsRoot}. Takes an already-resolved store so callers
* that need it anyway (e.g. `prepareVfs`, which hydrates from the same store) do not pay for a
* second lookup.
*
* @param {object} store
* @param {number} googleAuthId
* @returns {Promise<string>}
*/
async function vfsRootFor(store, googleAuthId) {
const row = await store.google_auth.getById(googleAuthId);
const companyId = row?.selectedCompanyId;
if (companyId == null) {
throw new Error('No company selected');
}
return path.resolve(process.cwd(), 'data/vfs', String(companyId));
}
/**
* Loads default SKILL files into the selected company's on-disk VFS.
*
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
* @returns {Promise<Array<string>>} List of loaded skill file names
*/
export async function loadDefaultSkills({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot } = {}) {
const store = passedStore || await getStore();
const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId);
fs.mkdirSync(vfsRoot, { recursive: true });
const defaultSkills = getDefaultSkills();
const loaded = [];
for (const skill of defaultSkills) {
try {
const diskPath = path.resolve(vfsRoot, skill.name);
if (!fs.existsSync(diskPath)) {
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
fs.writeFileSync(diskPath, skill.content, 'utf8');
}
loaded.push(skill.name);
} catch (err) {
console.error(`[vfs] failed to write default skill "${skill.name}" to disk:`, err.message);
}
}
return loaded;
}
/**
* Loads the shipped example `.applet.js` files into the selected company's VFS.
*
* Version-gated: each example is written under a `v1-` prefixed name and only when that exact
* file is absent, so an operator's edits survive and a new example revision ships as a new file.
* Must stay in step with {@link loadDefaultSkills}, which runs alongside it.
*
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
* @returns {Promise<Array<string>>} List of seeded applet file names
*/
export async function loadDefaultApplets({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot } = {}) {
const store = passedStore || await getStore();
const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId);
fs.mkdirSync(vfsRoot, { recursive: true });
const seeded = [];
for (const applet of getDefaultApplets()) {
const name = `${DEFAULT_APPLETS_VERSION}-${applet.name}`;
try {
const diskPath = path.resolve(vfsRoot, name);
if (!fs.existsSync(diskPath)) {
fs.writeFileSync(diskPath, applet.content, 'utf8');
}
seeded.push(name);
} catch (err) {
console.error(`[vfs] failed to write default applet "${name}" to disk:`, err.message);
}
}
return seeded;
}
/**
* Initializes the VFS for the selected company with default SKILL files and example applets.
*
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
* @returns {Promise<string>} vfsRoot directory path
*/
export async function initCompanyVfs({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot } = {}) {
const store = passedStore || await getStore();
const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId);
fs.mkdirSync(vfsRoot, { recursive: true });
await loadDefaultSkills({ googleAuthId, store, vfsRoot });
await loadDefaultApplets({ googleAuthId, store, vfsRoot });
return vfsRoot;
}
/**
* Ensure the local directory for this company's VFS exists and the default skills and example
* applets are loaded.
*
* @param {{ googleAuthId: number }} ctx
* @returns {Promise<string>} vfsRoot directory path
*/
export async function prepareVfs({ googleAuthId }) {
const store = await getStore();
const vfsRoot = await vfsRootFor(store, googleAuthId);
fs.mkdirSync(vfsRoot, { recursive: true });
try {
await loadDefaultSkills({ googleAuthId, store, vfsRoot });
} catch (err) {
console.warn('[vfs] prepare warning:', err.message);
}
try {
await loadDefaultApplets({ googleAuthId, store, vfsRoot });
} catch (err) {
console.warn('[vfs] prepare applet warning:', err.message);
}
return vfsRoot;
}
/**
* Purely disk-based VFS; no database syncing needed. Kept as no-op for backward compatibility.
*
* @param {{ googleAuthId?: number, vfsRoot?: string }} [ctx]
*/
export async function syncVfsToDb({ googleAuthId, vfsRoot } = {}) {
// Purely disk-based VFS; no database syncing.
}
/**
* Creates a sandboxed VFS module object mirroring Node's `fs` and `fs/promises`.
* @param {string} vfsRoot Absolute root, as returned by `prepareVfs`/`resolveVfsRoot`
* @param {{ googleAuthId: number }} [options]
*/
export function createVfs(vfsRoot, options = {}) {
const { googleAuthId } = options;
// Node fs errors embed the REAL absolute on-disk path, which leaks the server's directory
// layout into the model-visible transcript. Rewrite them to use a stable `vfs://` prefix.
const sanitizeFsError = (err, virtualPath) => {
if (!err || typeof err.message !== 'string') return err;
const virtual = typeof virtualPath === 'string' ? virtualPath : String(virtualPath);
const message = err.message
.split(vfsRoot)
.join('vfs://')
.split(path.sep)
.join('/')
.replace(/vfs:\/\/\/+/g, 'vfs:///');
return Object.assign(Object.create(Object.getPrototypeOf(err)), err, {
message,
path: virtual,
});
};
const safeResolve = (p) => {
if (typeof p !== 'string' || !p.trim()) {
throw new Error('Path must be a non-empty string');
}
const cleanVirtual = p.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '');
const resolved = path.resolve(vfsRoot, cleanVirtual);
const relative = path.relative(vfsRoot, resolved);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
}
return resolved;
};
const syncFetchImage = (hash) => {
if (!hash) return null;
try {
// Read from storage or cache if available
const cachePath = path.join(vfsRoot, 'images', `${hash}.avif`);
if (fs.existsSync(cachePath)) return fs.readFileSync(cachePath);
return null;
} catch {
return null;
}
};
const vfs = {
readFileSync: (p, opt) => {
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
if (buf) {
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? buf.toString(encoding) : buf;
}
}
const diskPath = safeResolve(p);
try {
return fs.readFileSync(diskPath, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
readFile: (p, opt, cb) => {
const callback = typeof opt === 'function' ? opt : cb;
const options = typeof opt === 'function' ? undefined : opt;
vfs.promises.readFile(p, options)
.then((data) => {
if (typeof callback === 'function') process.nextTick(callback, null, data);
})
.catch((err) => {
if (typeof callback === 'function') process.nextTick(callback, err);
else throw err;
});
},
createReadStream: (p, opt) => {
const diskPath = safeResolve(p);
if (!fs.existsSync(diskPath)) {
const err = new Error(`ENOENT: no such file or directory, open '${String(p)}'`);
err.code = 'ENOENT';
err.path = String(p);
err.syscall = 'open';
throw sanitizeFsError(err, p);
}
return fs.createReadStream(diskPath, opt);
},
writeFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
try {
return fs.writeFileSync(diskPath, data, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
writeFile: (p, data, opt, cb) => {
const callback = typeof opt === 'function' ? opt : cb;
const options = typeof opt === 'function' ? undefined : opt;
try {
vfs.writeFileSync(p, data, options);
if (typeof callback === 'function') process.nextTick(callback, null);
} catch (err) {
if (typeof callback === 'function') process.nextTick(callback, err);
else throw err;
}
},
createWriteStream: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
return fs.createWriteStream(diskPath, opt);
},
appendFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
try {
return fs.appendFileSync(diskPath, data, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
existsSync: (p) => {
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
if (buf) return true;
}
try {
const diskPath = safeResolve(p);
return fs.existsSync(diskPath);
} catch {
return false;
}
},
statSync: (p, opt) => {
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
if (buf) {
const now = new Date();
return {
isFile: () => true,
isDirectory: () => false,
size: buf.length,
mtime: now,
ctime: now,
atime: now,
};
}
}
const diskPath = safeResolve(p);
return fs.statSync(diskPath, opt);
},
lstatSync: (p, opt) => vfs.statSync(p, opt),
readdirSync: (p = '.', opt) => {
const clean = typeof p === 'string' ? p.trim().replace(/^([a-zA-Z]:|\/)+/, '') : '';
if (VIRTUAL_IMAGE_FOLDERS.has(clean.toLowerCase())) {
return [];
}
const diskPath = safeResolve(p);
return fs.readdirSync(diskPath, opt);
},
mkdirSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
try {
return fs.mkdirSync(diskPath, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
unlinkSync: (p) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.unlinkSync(diskPath);
},
rmdirSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.rmdirSync(diskPath, opt);
},
rmSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.rmSync(diskPath, opt);
},
promises: {
readFile: async (p, opt) => {
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
if (buf) {
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? buf.toString(encoding) : buf;
}
if (googleAuthId) {
try {
const store = await getStore();
const img = await store.pic.getImageByHash(googleAuthId, hash);
if (img?.buffer) {
try {
const cachePath = path.join(vfsRoot, 'images', `${hash}.avif`);
fs.mkdirSync(path.join(vfsRoot, 'images'), { recursive: true });
fs.writeFileSync(cachePath, img.buffer);
} catch { /* ignore cache write error */ }
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? img.buffer.toString(encoding) : img.buffer;
}
} catch {
// fall through
}
}
}
return vfs.readFileSync(p, opt);
},
writeFile: async (p, data, opt) => vfs.writeFileSync(p, data, opt),
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
stat: async (p, opt) => vfs.statSync(p, opt),
readdir: async (p, opt) => vfs.readdirSync(p, opt),
mkdir: async (p, opt) => vfs.mkdirSync(p, opt),
unlink: async (p) => vfs.unlinkSync(p),
rm: async (p, opt) => vfs.rmSync(p, opt),
},
/**
* Resolve a virtual path to an absolute on-disk path for writing, applying the SAME
* containment + read-only guards as `writeFileSync`. Exposed so non-`fs` writers that
* bypass the VFS (e.g. `sharp(...).toFile()`) can be funnelled back inside the sandbox
* boundary instead of writing wherever the process CWD happens to be.
*
* Deliberately does NOT create parent directories: callers such as `sharp().toFile()`
* must behave like `fs.writeFileSync`, which errors with ENOENT when the parent is
* missing. Auto-creating the whole tree turns a typo'd path into silent success.
*/
resolveWritePath: (p) => {
assertNotReadOnlyDatabasePath(p);
return safeResolve(p);
},
/**
* Resolve a virtual path to an absolute on-disk path for reading, applying containment checks.
*/
resolveReadPath: (p) => {
return safeResolve(p);
},
/**
* Sanitize on-disk error paths back to virtual paths for display in sandbox transcripts.
*/
sanitizeError: (err, virtualPath) => sanitizeFsError(err, virtualPath),
};
return vfs;
}