Files
memind/mindspace-oa-drive/drive-handlers.mjs
T
john 97a3e89ff7 fix(mindspace): repair OA drive JSON body parsing and trash paths
Express already parses JSON request bodies, so drive handlers must read req.body directly. Also fix nested trash item original-path parsing to match file-drop-sync behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 19:04:27 +08:00

448 lines
16 KiB
JavaScript

import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import {
enrichFiles,
isPreviewable,
mimeFor,
removeFileMeta,
renameFileMeta,
setFileMeta,
statEntry,
} from './lib/fs-tree.mjs';
import { folderNameOk, joinRel, normalizeRelPath, safeJoin } from './lib/paths.mjs';
import { renameEntry, resolveConflictPath, transferEntry } from './lib/transfer.mjs';
import {
emptyTrash,
listTrash,
moveToTrash,
purgeTrashItem,
restoreFromTrash,
} from './lib/trash.mjs';
import { createZipStream } from './lib/zip.mjs';
import {
appendUploadChunk,
completeUploadSession,
createUploadSession,
getSession,
} from './lib/upload-resume.mjs';
const ROUTE_PREFIX = '/mindspace/v1/oa/drive';
function pathBasename(p) {
return p.split(/[/\\]/).pop();
}
function sendJson(res, status, data) {
res.status(status).json(data);
}
function sendDriveFile(res, filePath, { download = false, downloadName, contentType } = {}) {
const stream = fs.createReadStream(filePath);
const inline = !download && isPreviewable(filePath);
res.set({
'Content-Type': contentType || mimeFor(filePath),
'Content-Disposition': download
? `attachment; filename*=UTF-8''${encodeURIComponent(downloadName || pathBasename(filePath))}`
: inline
? 'inline'
: `attachment; filename*=UTF-8''${encodeURIComponent(downloadName || pathBasename(filePath))}`,
'Cache-Control': 'no-store',
});
stream.pipe(res);
}
async function readBodyBuffer(req, limit) {
if (Buffer.isBuffer(req.body) && req.body.length > 0) {
if (req.body.length > limit) throw Object.assign(new Error('payload too large'), { code: 'payload_too_large' });
return req.body;
}
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > limit) throw Object.assign(new Error('payload too large'), { code: 'payload_too_large' });
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
async function readJsonBody(req, limit = 1024 * 1024) {
if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {
return req.body;
}
const raw = (await readBodyBuffer(req, limit)).toString('utf8').trim();
if (!raw) return {};
return JSON.parse(raw);
}
function parseMultipart(buffer, boundary) {
const parts = [];
const delim = Buffer.from(`--${boundary}`);
let start = buffer.indexOf(delim) + delim.length + 2;
while (start < buffer.length) {
const next = buffer.indexOf(delim, start);
const chunk = buffer.subarray(start, next === -1 ? buffer.length : next - 2);
if (!chunk.length) break;
const headerEnd = chunk.indexOf('\r\n\r\n');
if (headerEnd === -1) break;
const headers = chunk.subarray(0, headerEnd).toString('utf8');
const body = chunk.subarray(headerEnd + 4);
const nameMatch = headers.match(/name="([^"]+)"/);
const fileMatch = headers.match(/filename="([^"]*)"/);
parts.push({
name: nameMatch?.[1] || '',
filename: fileMatch?.[1] || '',
body,
});
if (next === -1) break;
start = next + delim.length + 2;
}
return parts;
}
function clientMetaFromReq(req) {
const rawLabel = req.headers['x-fds-client-label'] || 'web';
let clientLabel = rawLabel;
try {
clientLabel = decodeURIComponent(rawLabel);
} catch {}
return {
clientId: req.headers['x-fds-client-id'] || 'web',
clientLabel,
userAgent: req.headers['user-agent'] || '',
};
}
function resolveSubPath(req) {
const raw = req.path.startsWith(ROUTE_PREFIX)
? req.path.slice(ROUTE_PREFIX.length)
: req.path;
return raw.startsWith('/') ? raw : `/${raw}`;
}
export function createDriveHandlers({
getRuntime,
getQuotaInfo = async () => null,
assertUploadAllowed = async () => {},
afterMutate = async () => {},
} = {}) {
async function handleUpload(req, res, runtime, url) {
const { ctx } = runtime;
const onConflict = url.searchParams.get('onConflict') || 'rename';
const ctype = req.headers['content-type'] || '';
const boundaryMatch = ctype.match(/boundary=(.+)$/);
if (!boundaryMatch) {
return sendJson(res, 400, { error: 'expected multipart' });
}
const buffer = await readBodyBuffer(req, ctx.maxBodyBytes);
const parts = parseMultipart(buffer, boundaryMatch[1]);
const saved = [];
const skipped = [];
const prefix = parts.find((p) => p.name === 'prefix')?.body.toString('utf8').trim() || '';
const clientMeta = clientMetaFromReq(req);
let totalAdded = 0;
for (const part of parts) {
if (part.name !== 'file' || !part.filename) continue;
try {
let relPath = part.filename.replace(/\\/g, '/').replace(/^\/+/, '');
if (prefix) relPath = joinRel(prefix, relPath);
await assertUploadAllowed(ctx.userId, part.body.length);
let dest = safeJoin(ctx.uploadDir, relPath);
if (fs.existsSync(dest)) {
if (onConflict === 'skip') {
skipped.push(relPath);
continue;
}
if (onConflict === 'overwrite') await fsp.rm(dest, { recursive: true, force: true });
else {
dest = resolveConflictPath(dest, 'rename');
relPath = dest.slice(ctx.uploadDir.length + 1).replace(/\\/g, '/');
}
}
await fsp.mkdir(path.dirname(dest), { recursive: true });
await fsp.writeFile(dest, part.body);
await setFileMeta(ctx, relPath, clientMeta);
saved.push(relPath);
totalAdded += part.body.length;
} catch {
skipped.push(part.filename);
}
}
await runtime.notifyChange();
await afterMutate(ctx.userId);
return sendJson(res, 200, { ok: true, saved, skipped, bytesAdded: totalAdded });
}
return {
async handle(req, res) {
const runtime = getRuntime(req.currentUser.id);
const { ctx } = runtime;
const subPath = resolveSubPath(req);
const url = new URL(subPath + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : ''), 'http://local');
const method = req.method;
try {
if (method === 'GET' && subPath === '/info') {
const quota = await getQuotaInfo(req.currentUser.id);
return sendJson(res, 200, {
pageSize: ctx.pageSize,
chunkSize: ctx.chunkSize,
quota,
});
}
if (method === 'GET' && subPath === '/events') {
res.set({
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write(': connected\n\n');
const remove = runtime.addSseClient(res);
req.on('close', remove);
const payload = runtime.getListCache().files.length
? runtime.getListCache()
: await runtime.refreshFileList();
res.write(`data: ${JSON.stringify({ ...payload, delta: false })}\n\n`);
return;
}
if (method === 'GET' && subPath === '/files') {
const payload = await runtime.refreshFileList();
return sendJson(res, 200, { ...payload, delta: false });
}
if (method === 'GET' && subPath === '/trash') {
return sendJson(res, 200, { items: await listTrash(ctx) });
}
if (method === 'GET' && subPath === '/download-zip') {
const items = (url.searchParams.get('items') || '')
.split(',')
.map(decodeURIComponent)
.filter(Boolean);
const stream = await createZipStream(ctx, items);
res.set({
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename="files.zip"',
'Cache-Control': 'no-store',
});
stream.pipe(res);
return;
}
if (
method === 'GET'
&& (subPath.startsWith('/view/') || subPath.startsWith('/download/'))
) {
const rel = subPath.startsWith('/view/')
? decodeURIComponent(subPath.slice('/view/'.length))
: decodeURIComponent(subPath.slice('/download/'.length));
const entry = statEntry(ctx, rel);
if (!entry || entry.isDirectory) {
return res.status(404).send('not found');
}
const download = subPath.startsWith('/download/') || url.searchParams.get('download') === '1';
return sendDriveFile(res, entry.full, { download, downloadName: pathBasename(rel) });
}
if (method === 'POST' && subPath === '/upload') {
return handleUpload(req, res, runtime, url);
}
if (method === 'POST' && subPath === '/folders') {
const body = await readJsonBody(req);
const name = normalizeRelPath(body.name);
if (!folderNameOk(name)) return sendJson(res, 400, { error: 'invalid name' });
const parent = normalizeRelPath(body.parent);
const rel = joinRel(parent, name);
const target = safeJoin(ctx.uploadDir, rel);
if (fs.existsSync(target)) return sendJson(res, 409, { error: 'exists' });
await fsp.mkdir(target, { recursive: false });
await runtime.notifyChange();
await afterMutate(ctx.userId);
return sendJson(res, 200, { ok: true, path: rel });
}
if (method === 'POST' && subPath === '/rename') {
const body = await readJsonBody(req);
const onConflict = body.onConflict || 'rename';
const result = await renameEntry({
uploadDir: ctx.uploadDir,
fromRel: normalizeRelPath(body.from),
newName: normalizeRelPath(body.name),
onConflict,
});
if (!result) return sendJson(res, 409, { error: 'skipped' });
await renameFileMeta(ctx, result.from, result.to);
await runtime.notifyChange();
await afterMutate(ctx.userId);
return sendJson(res, 200, { ok: true, ...result });
}
if (method === 'POST' && subPath === '/copy') {
const body = await readJsonBody(req);
const dest = normalizeRelPath(body.dest);
const onConflict = body.onConflict || 'rename';
const items = Array.isArray(body.items) ? body.items.map(normalizeRelPath).filter(Boolean) : [];
const copied = [];
const errors = [];
const clientMeta = clientMetaFromReq(req);
for (const fromRel of items) {
try {
const result = await transferEntry({
uploadDir: ctx.uploadDir,
fromRel,
destFolder: dest,
mode: 'copy',
onConflict,
});
if (!result) continue;
await setFileMeta(ctx, result.to, clientMeta);
copied.push(result);
} catch (err) {
errors.push({ from: fromRel, error: err.message });
}
}
await runtime.notifyChange();
await afterMutate(ctx.userId);
return sendJson(res, 200, { ok: true, copied, errors });
}
if (method === 'POST' && subPath === '/move') {
const body = await readJsonBody(req);
const dest = normalizeRelPath(body.dest);
const onConflict = body.onConflict || 'rename';
const items = Array.isArray(body.items) ? body.items.map(normalizeRelPath).filter(Boolean) : [];
const moved = [];
const errors = [];
for (const fromRel of items) {
try {
const result = await transferEntry({
uploadDir: ctx.uploadDir,
fromRel,
destFolder: dest,
mode: 'move',
onConflict,
});
if (!result) continue;
await renameFileMeta(ctx, result.from, result.to);
moved.push(result);
} catch (err) {
errors.push({ from: fromRel, error: err.message });
}
}
await runtime.notifyChange();
await afterMutate(ctx.userId);
return sendJson(res, 200, { ok: true, moved, errors });
}
if (method === 'POST' && subPath === '/trash') {
const body = await readJsonBody(req);
const items = Array.isArray(body.items) ? body.items.map(normalizeRelPath).filter(Boolean) : [];
const trashed = [];
for (const rel of items) {
try {
trashed.push(await moveToTrash(ctx, rel));
} catch {}
}
await runtime.notifyChange();
await afterMutate(ctx.userId);
return sendJson(res, 200, { ok: true, trashed });
}
if (method === 'POST' && subPath === '/trash/restore') {
const body = await readJsonBody(req);
const restored = await restoreFromTrash(ctx, body.id, body.onConflict || 'rename');
if (!restored) return sendJson(res, 409, { error: 'skipped' });
await runtime.notifyChange();
await afterMutate(ctx.userId);
return sendJson(res, 200, { ok: true, restored });
}
if (method === 'DELETE' && subPath === '/trash') {
await emptyTrash(ctx);
await runtime.notifyChange();
await afterMutate(ctx.userId);
return sendJson(res, 200, { ok: true });
}
if (method === 'DELETE' && subPath.startsWith('/trash/')) {
const id = decodeURIComponent(subPath.slice('/trash/'.length));
await purgeTrashItem(ctx, id);
await runtime.notifyChange();
await afterMutate(ctx.userId);
return sendJson(res, 200, { ok: true });
}
if (method === 'POST' && subPath === '/upload/session') {
const body = await readJsonBody(req);
await assertUploadAllowed(ctx.userId, Number(body.size));
const session = await createUploadSession(ctx, {
relPath: body.name,
totalSize: body.size,
prefix: normalizeRelPath(body.prefix),
clientMeta: { ...clientMetaFromReq(req), uploadedAt: Date.now() },
});
return sendJson(res, 200, session);
}
if (method === 'PATCH' && subPath.startsWith('/upload/session/')) {
const id = subPath.slice('/upload/session/'.length);
const range = req.headers['content-range'] || '';
const match = range.match(/bytes (\d+)-(\d+)\/(\d+)/);
const offset = match ? Number(match[1]) : 0;
const buffer = await readBodyBuffer(req, ctx.maxBodyBytes);
const meta = await appendUploadChunk(ctx, id, buffer, offset);
return sendJson(res, 200, meta);
}
if (method === 'POST' && subPath.startsWith('/upload/session/') && subPath.endsWith('/complete')) {
const id = subPath.slice('/upload/session/'.length, -'/complete'.length);
const result = await completeUploadSession(ctx, id);
await runtime.notifyChange();
await afterMutate(ctx.userId);
return sendJson(res, 200, { ok: true, ...result });
}
if (method === 'GET' && subPath.startsWith('/upload/session/')) {
const id = subPath.slice('/upload/session/'.length);
return sendJson(res, 200, await getSession(ctx, id));
}
if (method === 'DELETE' && subPath.startsWith('/files/')) {
const rel = decodeURIComponent(subPath.slice('/files/'.length));
const permanent = url.searchParams.get('permanent') === '1';
if (permanent) {
const target = safeJoin(ctx.uploadDir, rel);
if (!fs.existsSync(target)) {
return res.status(404).send('not found');
}
await fsp.rm(target, { recursive: true, force: true });
await removeFileMeta(ctx, rel);
} else {
await moveToTrash(ctx, rel);
}
await runtime.notifyChange();
await afterMutate(ctx.userId);
return sendJson(res, 200, { ok: true });
}
return sendJson(res, 404, { error: 'not found' });
} catch (error) {
if (error?.code === 'payload_too_large') {
return sendJson(res, 413, { error: error.message });
}
if (error?.code === 'quota_exceeded') {
return sendJson(res, 413, { error: error.message, code: 'quota_exceeded', details: error.details });
}
console.error('[oa-drive]', error);
return sendJson(res, 500, { error: error?.message || 'error' });
}
},
};
}