800 lines
28 KiB
JavaScript
800 lines
28 KiB
JavaScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import { getStore } from '../db/store.js';
|
|
import { requireSelectedCompanyAdmin } from '../db/companyuser.js';
|
|
import { getDefaultSkills } from './skill.js';
|
|
import { getDefaultApplets, DEFAULT_APPLETS_VERSION } from './applet.js';
|
|
|
|
// #region Constants & Configuration
|
|
// MARK: - Constants & Configuration
|
|
|
|
const FULL_HASH_REGEX = /^[a-zA-Z0-9]{8}$/;
|
|
const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
|
|
|
|
const MIME_TYPES = Object.freeze({
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.htm': 'text/html; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.mjs': 'text/javascript; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.map': 'application/json; charset=utf-8',
|
|
'.avif': 'image/avif',
|
|
'.webp': 'image/webp',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
'.ico': 'image/x-icon',
|
|
'.bmp': 'image/bmp',
|
|
'.pdf': 'application/pdf',
|
|
'.txt': 'text/plain; charset=utf-8',
|
|
'.text': 'text/plain; charset=utf-8',
|
|
'.log': 'text/plain; charset=utf-8',
|
|
'.csv': 'text/csv; charset=utf-8',
|
|
'.tsv': 'text/tab-separated-values; charset=utf-8',
|
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'.xls': 'application/vnd.ms-excel',
|
|
'.xlsm': 'application/vnd.ms-excel.sheet.macroEnabled.12',
|
|
'.xltx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
|
|
'.md': 'text/markdown; charset=utf-8',
|
|
'.skill': 'text/markdown; charset=utf-8',
|
|
'.xml': 'application/xml; charset=utf-8',
|
|
'.mp3': 'audio/mpeg',
|
|
'.wav': 'audio/wav',
|
|
'.ogg': 'audio/ogg',
|
|
'.mp4': 'video/mp4',
|
|
'.webm': 'video/webm',
|
|
'.woff': 'font/woff',
|
|
'.woff2': 'font/woff2',
|
|
'.ttf': 'font/ttf',
|
|
'.otf': 'font/otf',
|
|
});
|
|
|
|
// #endregion
|
|
|
|
// #region MIME & Path Helpers
|
|
// MARK: - MIME & Path Helpers
|
|
|
|
export function getMimeType(filePath) {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
}
|
|
|
|
function cleanVirtualPath(p, stripDotSlash = false) {
|
|
let clean = p.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '');
|
|
if (stripDotSlash) clean = clean.replace(/^\.\/+/, '');
|
|
return clean;
|
|
}
|
|
|
|
export function extractFullHash(p) {
|
|
if (typeof p !== 'string' || !p.trim()) return null;
|
|
const parts = cleanVirtualPath(p).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) && 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 = cleanVirtualPath(filePath, true);
|
|
const lower = clean.toLowerCase();
|
|
return lower === 'image' || lower.startsWith('image/') || Boolean(extractFullHash(clean));
|
|
}
|
|
|
|
export function isProtectedAdminVfsPath(filePath) {
|
|
if (typeof filePath !== 'string' || !filePath.trim()) return false;
|
|
const clean = cleanVirtualPath(filePath, true);
|
|
const lower = clean.toLowerCase();
|
|
return (
|
|
lower.endsWith('.skill') ||
|
|
lower.endsWith('.applet.js') ||
|
|
lower.endsWith('.apple.js') ||
|
|
lower === 'apple.js' ||
|
|
lower.endsWith('/apple.js')
|
|
);
|
|
}
|
|
|
|
export function directoryContainsProtectedAdminVfsFile(absDirPath) {
|
|
try {
|
|
if (!fs.existsSync(absDirPath)) return false;
|
|
const stat = fs.statSync(absDirPath);
|
|
if (!stat.isDirectory()) return isProtectedAdminVfsPath(absDirPath);
|
|
const entries = fs.readdirSync(absDirPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(absDirPath, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (directoryContainsProtectedAdminVfsFile(fullPath)) return true;
|
|
} else if (isProtectedAdminVfsPath(entry.name)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
} catch {
|
|
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").`
|
|
);
|
|
}
|
|
}
|
|
|
|
// #endregion
|
|
|
|
// #region VFS Root Resolution
|
|
// MARK: - VFS Root Resolution
|
|
|
|
/**
|
|
* Resolve the on-disk VFS root for the user's CURRENTLY SELECTED company.
|
|
*
|
|
* @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}.
|
|
*
|
|
* @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));
|
|
}
|
|
|
|
// #endregion
|
|
|
|
// #region Default Skills & Applets Seeding
|
|
// MARK: - Default Skills & Applets Seeding
|
|
|
|
/**
|
|
* Internal helper to write or overwrite default files into the company's VFS.
|
|
*/
|
|
async function seedVfsFiles({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot }, items, { overwrite, label }) {
|
|
const store = passedStore || await getStore();
|
|
const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId);
|
|
fs.mkdirSync(vfsRoot, { recursive: true });
|
|
|
|
const written = [];
|
|
for (const { name, content } of items) {
|
|
try {
|
|
const diskPath = path.resolve(vfsRoot, name);
|
|
if (overwrite || !fs.existsSync(diskPath)) {
|
|
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
|
fs.writeFileSync(diskPath, content, 'utf8');
|
|
written.push(name);
|
|
}
|
|
} catch (err) {
|
|
const action = overwrite ? `restore default ${label}` : `write default ${label}`;
|
|
const suffix = overwrite ? '' : ' to disk';
|
|
console.error(`[vfs] failed to ${action} "${name}"${suffix}:`, err.message);
|
|
}
|
|
}
|
|
return written;
|
|
}
|
|
|
|
function getVersionedApplets() {
|
|
return getDefaultApplets().map((a) => ({
|
|
name: `${DEFAULT_APPLETS_VERSION}-${a.name}`,
|
|
content: a.content,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Loads default SKILL files into the selected company's on-disk VFS.
|
|
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
|
|
* @returns {Promise<Array<string>>}
|
|
*/
|
|
export async function loadDefaultSkills(ctx = {}) {
|
|
return seedVfsFiles(ctx, getDefaultSkills(), { overwrite: false, label: 'skill' });
|
|
}
|
|
|
|
/**
|
|
* Restores all shipped system default SKILL files into the company's VFS.
|
|
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
|
|
* @returns {Promise<Array<string>>}
|
|
*/
|
|
export async function restoreDefaultSkills(ctx = {}) {
|
|
if (ctx.googleAuthId != null) {
|
|
await requireSelectedCompanyAdmin(ctx.googleAuthId);
|
|
}
|
|
return seedVfsFiles(ctx, getDefaultSkills(), { overwrite: true, label: 'skill' });
|
|
}
|
|
|
|
/**
|
|
* Loads the shipped example `.applet.js` files into the selected company's VFS.
|
|
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
|
|
* @returns {Promise<Array<string>>}
|
|
*/
|
|
export async function loadDefaultApplets(ctx = {}) {
|
|
return seedVfsFiles(ctx, getVersionedApplets(), { overwrite: false, label: 'applet' });
|
|
}
|
|
|
|
/**
|
|
* Restores all shipped system default example applets into the company's VFS.
|
|
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
|
|
* @returns {Promise<Array<string>>}
|
|
*/
|
|
export async function restoreDefaultApplets(ctx = {}) {
|
|
if (ctx.googleAuthId != null) {
|
|
await requireSelectedCompanyAdmin(ctx.googleAuthId);
|
|
}
|
|
return seedVfsFiles(ctx, getVersionedApplets(), { overwrite: true, label: 'applet' });
|
|
}
|
|
|
|
// #endregion
|
|
|
|
// #region Lifecycle & Initialization
|
|
// MARK: - Lifecycle & Initialization
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// #endregion
|
|
|
|
// #region Sandboxed VFS Factory
|
|
// MARK: - Sandboxed VFS Factory
|
|
|
|
/**
|
|
* Wraps a synchronous function into a Node-style callback function (`process.nextTick`).
|
|
* Automatically handles optional middle arguments (e.g. `(p, opt, cb)` vs `(p, cb)`).
|
|
*/
|
|
function cbWrap(syncFn, arityWithOpt = 3) {
|
|
return (...args) => {
|
|
let cb;
|
|
let callArgs = args;
|
|
if (typeof args[args.length - 1] === 'function') {
|
|
cb = args[args.length - 1];
|
|
callArgs = args.slice(0, -1);
|
|
while (callArgs.length < arityWithOpt - 1) {
|
|
callArgs.push(undefined);
|
|
}
|
|
}
|
|
try {
|
|
const res = syncFn(...callArgs);
|
|
if (cb) process.nextTick(cb, null, res);
|
|
} catch (err) {
|
|
if (cb) process.nextTick(cb, err);
|
|
else throw err;
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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, isAdmin = true } = options;
|
|
|
|
const assertWritablePath = (filePath) => {
|
|
assertNotReadOnlyDatabasePath(filePath);
|
|
if (!isAdmin && isProtectedAdminVfsPath(filePath)) {
|
|
throw new Error('Not authorized');
|
|
}
|
|
};
|
|
|
|
// #region Internal Sandbox Helpers
|
|
// MARK: - Internal Sandbox Helpers
|
|
|
|
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 resolved = path.resolve(vfsRoot, cleanVirtualPath(p));
|
|
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 {
|
|
const cachePath = path.join(vfsRoot, 'images', `${hash}.avif`);
|
|
return fs.existsSync(cachePath) ? fs.readFileSync(cachePath) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const formatBuffer = (buf, opt) => {
|
|
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
|
|
return encoding ? buf.toString(encoding) : buf;
|
|
};
|
|
|
|
const writeCopyBuffer = (buf, src, dest, mode) => {
|
|
const destDisk = safeResolve(dest);
|
|
fs.mkdirSync(path.dirname(destDisk), { recursive: true });
|
|
if (typeof mode === 'number' && (mode & fs.constants.COPYFILE_EXCL) && fs.existsSync(destDisk)) {
|
|
const err = new Error(`EEXIST: file already exists, copyfile '${src}' -> '${dest}'`);
|
|
err.code = 'EEXIST';
|
|
err.path = dest;
|
|
err.syscall = 'copyfile';
|
|
throw err;
|
|
}
|
|
try {
|
|
return fs.writeFileSync(destDisk, buf);
|
|
} catch (err) {
|
|
throw sanitizeFsError(err, dest);
|
|
}
|
|
};
|
|
|
|
// #endregion
|
|
|
|
// #region VFS Core Operations
|
|
// MARK: - VFS Core Operations
|
|
|
|
const vfs = {
|
|
// --- Read Operations ---
|
|
readFileSync: (p, opt) => {
|
|
const hash = extractFullHash(p);
|
|
if (hash) {
|
|
const buf = syncFetchImage(hash);
|
|
if (buf) return formatBuffer(buf, opt);
|
|
}
|
|
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 opts = typeof opt === 'function' ? undefined : opt;
|
|
vfs.promises.readFile(p, opts)
|
|
.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);
|
|
},
|
|
|
|
// --- Write & Append Operations ---
|
|
writeFileSync: (p, data, opt) => {
|
|
assertWritablePath(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);
|
|
}
|
|
},
|
|
|
|
createWriteStream: (p, opt) => {
|
|
assertWritablePath(p);
|
|
const diskPath = safeResolve(p);
|
|
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
|
return fs.createWriteStream(diskPath, opt);
|
|
},
|
|
|
|
appendFileSync: (p, data, opt) => {
|
|
assertWritablePath(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);
|
|
}
|
|
},
|
|
|
|
// --- Copy, Rename & Truncate Operations ---
|
|
copyFileSync: (src, dest, mode) => {
|
|
assertWritablePath(dest);
|
|
const hash = extractFullHash(src);
|
|
if (hash) {
|
|
const buf = syncFetchImage(hash);
|
|
if (buf) return writeCopyBuffer(buf, src, dest, mode);
|
|
}
|
|
const srcDisk = safeResolve(src);
|
|
const destDisk = safeResolve(dest);
|
|
fs.mkdirSync(path.dirname(destDisk), { recursive: true });
|
|
try {
|
|
return fs.copyFileSync(srcDisk, destDisk, mode);
|
|
} catch (err) {
|
|
throw sanitizeFsError(err, `${src} -> ${dest}`);
|
|
}
|
|
},
|
|
|
|
cpSync: (src, dest, opt) => {
|
|
assertWritablePath(dest);
|
|
const srcDisk = safeResolve(src);
|
|
const destDisk = safeResolve(dest);
|
|
fs.mkdirSync(path.dirname(destDisk), { recursive: true });
|
|
try {
|
|
return fs.cpSync(srcDisk, destDisk, opt);
|
|
} catch (err) {
|
|
throw sanitizeFsError(err, `${src} -> ${dest}`);
|
|
}
|
|
},
|
|
|
|
renameSync: (oldPath, newPath) => {
|
|
assertWritablePath(oldPath);
|
|
assertWritablePath(newPath);
|
|
const oldDisk = safeResolve(oldPath);
|
|
const newDisk = safeResolve(newPath);
|
|
if (!isAdmin && (directoryContainsProtectedAdminVfsFile(oldDisk) || directoryContainsProtectedAdminVfsFile(newDisk))) {
|
|
throw new Error('Not authorized');
|
|
}
|
|
fs.mkdirSync(path.dirname(newDisk), { recursive: true });
|
|
try {
|
|
return fs.renameSync(oldDisk, newDisk);
|
|
} catch (err) {
|
|
throw sanitizeFsError(err, `${oldPath} -> ${newPath}`);
|
|
}
|
|
},
|
|
|
|
truncateSync: (p, len) => {
|
|
assertWritablePath(p);
|
|
const diskPath = safeResolve(p);
|
|
try {
|
|
return fs.truncateSync(diskPath, len);
|
|
} catch (err) {
|
|
throw sanitizeFsError(err, p);
|
|
}
|
|
},
|
|
|
|
// --- Metadata & Access Operations ---
|
|
accessSync: (p, mode) => {
|
|
const hash = extractFullHash(p);
|
|
if (hash && syncFetchImage(hash)) {
|
|
if (typeof mode === 'number' && (mode & fs.constants.W_OK)) {
|
|
const err = new Error(`EACCES: permission denied, access '${p}'`);
|
|
err.code = 'EACCES';
|
|
err.path = p;
|
|
err.syscall = 'access';
|
|
throw err;
|
|
}
|
|
return;
|
|
}
|
|
const diskPath = safeResolve(p);
|
|
try {
|
|
return fs.accessSync(diskPath, mode);
|
|
} catch (err) {
|
|
throw sanitizeFsError(err, p);
|
|
}
|
|
},
|
|
|
|
realpathSync: (p, opt) => {
|
|
const diskPath = safeResolve(p);
|
|
try {
|
|
const resolved = fs.realpathSync(diskPath, opt);
|
|
const rel = path.relative(vfsRoot, resolved);
|
|
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
throw new Error(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
|
|
}
|
|
return rel ? rel.split(path.sep).join('/') : '.';
|
|
} catch (err) {
|
|
throw sanitizeFsError(err, p);
|
|
}
|
|
},
|
|
|
|
existsSync: (p) => {
|
|
const hash = extractFullHash(p);
|
|
if (hash && syncFetchImage(hash)) return true;
|
|
try {
|
|
return fs.existsSync(safeResolve(p));
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
exists: (p, cb) => {
|
|
const res = vfs.existsSync(p);
|
|
if (typeof cb === 'function') process.nextTick(cb, res);
|
|
},
|
|
|
|
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);
|
|
try {
|
|
return fs.statSync(diskPath, opt);
|
|
} catch (err) {
|
|
throw sanitizeFsError(err, p);
|
|
}
|
|
},
|
|
|
|
lstatSync: (p, opt) => vfs.statSync(p, opt),
|
|
|
|
// --- Directory & Removal Operations ---
|
|
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);
|
|
try {
|
|
return fs.readdirSync(diskPath, opt);
|
|
} catch (err) {
|
|
throw sanitizeFsError(err, p);
|
|
}
|
|
},
|
|
|
|
readdir: (p = '.', opt, cb) => {
|
|
const callback = typeof opt === 'function' ? opt : (typeof p === 'function' ? p : cb);
|
|
const opts = typeof opt === 'function' ? undefined : opt;
|
|
const pathArg = typeof p === 'function' ? '.' : p;
|
|
try {
|
|
const res = vfs.readdirSync(pathArg, opts);
|
|
if (typeof callback === 'function') process.nextTick(callback, null, res);
|
|
} catch (err) {
|
|
if (typeof callback === 'function') process.nextTick(callback, err);
|
|
else throw err;
|
|
}
|
|
},
|
|
|
|
mkdirSync: (p, opt) => {
|
|
assertNotReadOnlyDatabasePath(p);
|
|
const diskPath = safeResolve(p);
|
|
try {
|
|
return fs.mkdirSync(diskPath, opt);
|
|
} catch (err) {
|
|
throw sanitizeFsError(err, p);
|
|
}
|
|
},
|
|
|
|
unlinkSync: (p) => {
|
|
assertWritablePath(p);
|
|
const diskPath = safeResolve(p);
|
|
try {
|
|
return fs.unlinkSync(diskPath);
|
|
} catch (err) {
|
|
throw sanitizeFsError(err, p);
|
|
}
|
|
},
|
|
|
|
rmdirSync: (p, opt) => {
|
|
assertWritablePath(p);
|
|
const diskPath = safeResolve(p);
|
|
if (!isAdmin && directoryContainsProtectedAdminVfsFile(diskPath)) {
|
|
throw new Error('Not authorized');
|
|
}
|
|
try {
|
|
return fs.rmdirSync(diskPath, opt);
|
|
} catch (err) {
|
|
throw sanitizeFsError(err, p);
|
|
}
|
|
},
|
|
|
|
rmSync: (p, opt) => {
|
|
assertWritablePath(p);
|
|
const diskPath = safeResolve(p);
|
|
if (!isAdmin && directoryContainsProtectedAdminVfsFile(diskPath)) {
|
|
throw new Error('Not authorized');
|
|
}
|
|
try {
|
|
return fs.rmSync(diskPath, opt);
|
|
} catch (err) {
|
|
throw sanitizeFsError(err, p);
|
|
}
|
|
},
|
|
|
|
// #endregion
|
|
|
|
// #region VFS Promises API
|
|
// MARK: - VFS Promises API
|
|
|
|
promises: {
|
|
readFile: async (p, opt) => {
|
|
const hash = extractFullHash(p);
|
|
if (hash) {
|
|
const buf = syncFetchImage(hash);
|
|
if (buf) return formatBuffer(buf, opt);
|
|
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 */ }
|
|
return formatBuffer(img.buffer, opt);
|
|
}
|
|
} 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),
|
|
copyFile: async (src, dest, mode) => {
|
|
assertWritablePath(dest);
|
|
const hash = extractFullHash(src);
|
|
if (hash) {
|
|
const buf = await vfs.promises.readFile(src);
|
|
return writeCopyBuffer(buf, src, dest, mode);
|
|
}
|
|
return vfs.copyFileSync(src, dest, mode);
|
|
},
|
|
cp: async (src, dest, opt) => vfs.cpSync(src, dest, opt),
|
|
rename: async (oldPath, newPath) => vfs.renameSync(oldPath, newPath),
|
|
truncate: async (p, len) => vfs.truncateSync(p, len),
|
|
access: async (p, mode) => vfs.accessSync(p, mode),
|
|
realpath: async (p, opt) => vfs.realpathSync(p, opt),
|
|
stat: async (p, opt) => vfs.statSync(p, opt),
|
|
lstat: async (p, opt) => vfs.lstatSync(p, opt),
|
|
readdir: async (p, opt) => vfs.readdirSync(p, opt),
|
|
mkdir: async (p, opt) => vfs.mkdirSync(p, opt),
|
|
rmdir: async (p, opt) => vfs.rmdirSync(p, opt),
|
|
unlink: async (p) => vfs.unlinkSync(p),
|
|
rm: async (p, opt) => vfs.rmSync(p, opt),
|
|
constants: fs.constants,
|
|
},
|
|
|
|
// #endregion
|
|
|
|
// #region External Sandbox Utilities
|
|
// MARK: - External Sandbox Utilities
|
|
|
|
/**
|
|
* Resolve a virtual path to an absolute on-disk path for writing, applying the SAME
|
|
* containment + read-only guards as `writeFileSync`.
|
|
*/
|
|
resolveWritePath: (p) => {
|
|
assertWritablePath(p);
|
|
return safeResolve(p);
|
|
},
|
|
|
|
/**
|
|
* Resolve a virtual path to an absolute on-disk path for reading, applying containment checks.
|
|
*/
|
|
resolveReadPath: (p) => safeResolve(p),
|
|
|
|
/**
|
|
* Sanitize on-disk error paths back to virtual paths for display in sandbox transcripts.
|
|
*/
|
|
sanitizeError: (err, virtualPath) => sanitizeFsError(err, virtualPath),
|
|
|
|
constants: fs.constants,
|
|
|
|
// #endregion
|
|
};
|
|
|
|
// #region Callback Wrapper Bindings
|
|
// MARK: - Callback Wrapper Bindings
|
|
|
|
vfs.writeFile = cbWrap(vfs.writeFileSync, 4);
|
|
vfs.appendFile = cbWrap(vfs.appendFileSync, 4);
|
|
vfs.copyFile = cbWrap(vfs.copyFileSync, 4);
|
|
vfs.cp = cbWrap(vfs.cpSync, 4);
|
|
vfs.rename = cbWrap(vfs.renameSync, 3);
|
|
vfs.truncate = cbWrap(vfs.truncateSync, 3);
|
|
vfs.access = cbWrap(vfs.accessSync, 3);
|
|
vfs.realpath = cbWrap(vfs.realpathSync, 3);
|
|
vfs.stat = cbWrap(vfs.statSync, 3);
|
|
vfs.lstat = cbWrap(vfs.lstatSync, 3);
|
|
vfs.mkdir = cbWrap(vfs.mkdirSync, 3);
|
|
vfs.rmdir = cbWrap(vfs.rmdirSync, 3);
|
|
vfs.unlink = cbWrap(vfs.unlinkSync, 2);
|
|
vfs.rm = cbWrap(vfs.rmSync, 3);
|
|
|
|
vfs.realpathSync.native = (p, opt) => vfs.realpathSync(p, opt);
|
|
vfs.realpath.native = (p, opt, cb) => vfs.realpath(p, opt, cb);
|
|
vfs.promises.realpath.native = async (p, opt) => vfs.realpathSync(p, opt);
|
|
|
|
// #endregion
|
|
|
|
return vfs;
|
|
}
|
|
|
|
// #endregion
|