This commit is contained in:
seb
2026-10-03 23:13:36 +02:00
parent 1786879c4e
commit 9a7f345d8b
39 changed files with 4611 additions and 50 deletions

272
src/tools/vfs.js Normal file
View File

@@ -0,0 +1,272 @@
import fs from 'fs';
import path from 'path';
import { getStore } from '../db/store.js';
import env from '../config/env.js';
const FULL_HASH_REGEX = /^[a-fA-F0-9]{64}$/;
const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
function getMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.json': return 'application/json';
case '.avif': return 'image/avif';
case '.webp': return 'image/webp';
case '.png': return 'image/png';
case '.jpg':
case '.jpeg': return 'image/jpeg';
case '.txt': return 'text/plain';
case '.csv': return 'text/csv';
default: return 'application/octet-stream';
}
}
export function extractFullHash(p) {
if (typeof p !== 'string' || !p.trim()) return null;
const clean = p.trim().replace(/^([a-zA-Z]:|\/)+/, '');
const parts = clean.split('/');
const last = parts[parts.length - 1];
const withoutExt = last.replace(/\.[a-zA-Z0-9]+$/, '');
if (FULL_HASH_REGEX.test(withoutExt)) {
return withoutExt.toLowerCase();
}
for (const part of parts) {
const seg = part.replace(/\.[a-zA-Z0-9]+$/, '');
if (FULL_HASH_REGEX.test(seg)) {
return seg.toLowerCase();
}
}
return null;
}
export function isReadOnlyDatabasePath(filePath) {
if (typeof filePath !== 'string' || !filePath.trim()) return false;
const clean = filePath.trim().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").`
);
}
}
/**
* Ensure the local directory for this company's VFS exists and is hydrated from modelvfs.
* @param {{ companyId: number, googleAuthId: number }} ctx
* @returns {Promise<string>} vfsRoot directory path
*/
export async function prepareVfs({ companyId, googleAuthId }) {
const vfsRoot = path.resolve(process.cwd(), 'data/vfs', String(companyId));
fs.mkdirSync(vfsRoot, { recursive: true });
try {
const store = await getStore();
const files = await store.modelvfs.list(googleAuthId);
for (const file of files) {
const diskPath = path.resolve(vfsRoot, file.name);
if (!diskPath.startsWith(vfsRoot)) continue;
if (!fs.existsSync(diskPath)) {
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
const fullRecord = await store.modelvfs.read(googleAuthId, file.name);
if (fullRecord?.content) {
fs.writeFileSync(diskPath, fullRecord.content);
}
}
}
} catch (err) {
console.warn('[vfs] hydration warning:', err.message);
}
return vfsRoot;
}
/**
* Syncs any modified or newly written files from the disk vfsRoot back to the modelvfs table.
* @param {{ companyId: number, googleAuthId: number, vfsRoot: string }} ctx
*/
export async function syncVfsToDb({ googleAuthId, vfsRoot }) {
if (!fs.existsSync(vfsRoot)) return;
const store = await getStore();
function walk(dir, rel = '') {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const list = [];
for (const e of entries) {
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()) {
list.push({ relPath, fullPath });
}
}
return list;
}
const currentFiles = walk(vfsRoot);
for (const f of currentFiles) {
if (isReadOnlyDatabasePath(f.relPath)) continue;
try {
const content = fs.readFileSync(f.fullPath);
const mime = getMimeType(f.relPath);
await store.modelvfs.write(googleAuthId, {
name: f.relPath,
mime,
content,
});
} catch (err) {
console.error(`[vfs] failed to sync file ${f.relPath} to db:`, err.message);
}
}
}
/**
* Creates a sandboxed VFS module object mirroring Node's `fs` and `fs/promises`.
* @param {string} vfsRoot
* @param {{ googleAuthId: number, companyId: number }} [options]
*/
export function createVfs(vfsRoot, options = {}) {
const { googleAuthId } = options;
const safeResolve = (p) => {
if (typeof p !== 'string' || !p.trim()) {
throw new Error('Path must be a non-empty string');
}
const cleanVirtual = p.trim().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);
return fs.readFileSync(diskPath, opt);
},
writeFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
return fs.writeFileSync(diskPath, data, opt);
},
appendFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
return fs.appendFileSync(diskPath, data, opt);
},
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);
return fs.mkdirSync(diskPath, opt);
},
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) => 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),
},
};
return vfs;
}