97a3e89ff7
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>
448 lines
16 KiB
JavaScript
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' });
|
|
}
|
|
},
|
|
};
|
|
}
|