This commit is contained in:
seb
2026-10-07 13:06:00 +02:00
parent cbd7920eb6
commit 1743cef11c
4 changed files with 709 additions and 3 deletions

View File

@@ -1,3 +1,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 sharp from 'sharp';
import { getMimeType } from '../../tools/vfs.js';
@@ -355,7 +360,23 @@ async function renderThumbnail(buffer, { mime, filename }) {
* @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}:
@@ -436,6 +457,199 @@ export function register(app, basePath = '') {
* 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) {