u
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user