feat(files): upload files with drag and drop

This commit is contained in:
Bohdan Triapitsyn
2026-08-18 21:24:53 +03:00
parent 215749a65f
commit 423f5b9652
20 changed files with 657 additions and 4 deletions
@@ -16,6 +16,7 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
- `GET /api/fs/raw`
- `GET /api/fs/serve/:path(*)`
- `POST /api/fs/write`
- `POST /api/fs/upload`
- `POST /api/fs/delete`
- `POST /api/fs/rename`
- `POST /api/fs/reveal`
@@ -38,3 +39,4 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
- Read-only routes authorize the requested path against the workspace before resolving symlinks. A symlink reached through the workspace may therefore target a file outside it, while a directly requested outside path still requires an exact-path grant. Write routes keep canonical-target boundary checks.
- If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document.
- `GET /api/fs/list` may resolve symlinks with `realpath` to read directory contents, but the response `path` and each entry `path` must stay in the caller's requested path space (`path.join(requestedPath, name)`). Returning real paths breaks file-tree expansion for directories reached through workspace symlinks.
- `POST /api/fs/upload` accepts one `application/octet-stream` body (up to 100 MB) with `path` and optional `overwrite=true` query parameters. It rejects existing files with `409` unless overwrite is explicit, and resolves the destination parent before writing so uploads cannot escape through workspace symlinks.
+100
View File
@@ -139,6 +139,29 @@ const FILE_MIME_MAP = Object.freeze({
});
const MAX_SERVE_BYTES = 100 * 1024 * 1024;
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
const readUploadBody = async (req) => {
const declaredSize = Number.parseInt(req.headers?.['content-length'] || '0', 10);
if (Number.isFinite(declaredSize) && declaredSize > MAX_UPLOAD_BYTES) {
req.resume?.();
return null;
}
const chunks = [];
let size = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > MAX_UPLOAD_BYTES) {
req.resume?.();
return null;
}
chunks.push(buffer);
}
return Buffer.concat(chunks, size);
};
// Only deterministic, side-effect-free git plumbing path queries are cacheable.
// Anything outside this allowlist (including any non-git command) runs normally
@@ -1041,6 +1064,83 @@ export const registerFsRoutes = (app, dependencies) => {
}
});
app.post('/api/fs/upload', async (req, res) => {
const filePath = typeof req.query?.path === 'string' ? req.query.path.trim() : '';
const overwrite = req.query?.overwrite === 'true';
if (!filePath) {
return res.status(400).json({ error: 'Path is required' });
}
if (!String(req.headers?.['content-type'] || '').toLowerCase().startsWith('application/octet-stream')) {
return res.status(415).json({ error: 'Content-Type must be application/octet-stream' });
}
try {
const resolved = await resolveWorkspacePathFromContext({
req,
targetPath: filePath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
const canonicalBase = await fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base));
const requestedParent = path.dirname(resolved.resolved);
const canonicalParent = await fsPromises.realpath(requestedParent);
if (!isPathWithinRoot(canonicalParent, canonicalBase, path, os)) {
return res.status(403).json({ error: 'Access denied' });
}
const existingPath = await fsPromises.realpath(resolved.resolved).catch((error) => {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return null;
}
throw error;
});
const writePath = existingPath || path.join(canonicalParent, path.basename(resolved.resolved));
if (!isPathWithinRoot(writePath, canonicalBase, path, os)) {
return res.status(403).json({ error: 'Access denied' });
}
const body = await readUploadBody(req);
if (!body) {
return res.status(413).json({ error: `File exceeds maximum size of ${MAX_UPLOAD_BYTES} bytes` });
}
if (!overwrite) {
await fsPromises.writeFile(writePath, body, { flag: 'wx' });
} else {
const tmp = `${writePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
try {
await fsPromises.writeFile(tmp, body, { flag: 'wx' });
await fsPromises.rename(tmp, writePath);
} catch (error) {
await fsPromises.unlink(tmp).catch(() => {});
throw error;
}
}
return res.json({ success: true, path: resolved.resolved });
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'EEXIST') {
return res.status(409).json({ error: 'File already exists', reason: 'already-exists' });
}
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'Destination directory not found', reason: 'not-found' });
}
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access denied');
}
console.error('Failed to upload file:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to upload file' });
}
});
app.post('/api/fs/delete', async (req, res) => {
const { path: targetPath } = req.body || {};
if (!targetPath || typeof targetPath !== 'string') {
+125
View File
@@ -140,6 +140,26 @@ const registerWrite = (fsPromises) => {
return getRoute('POST', '/api/fs/write');
};
const registerUpload = (fsPromises) => {
const { app, getRoute } = createRouteRegistry();
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
...fsPromises,
},
spawn: vi.fn(),
crypto: { randomUUID: () => 'job-0' },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: async () => ({ directory: '/repo' }),
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
});
return getRoute('POST', '/api/fs/upload');
};
const registerRead = (fsPromises) => {
const { app, getRoute } = createRouteRegistry();
registerFsRoutes(app, {
@@ -233,6 +253,22 @@ const callWrite = async (handler, body) => {
return res;
};
const callUpload = async (handler, { body = Buffer.from('upload'), path: filePath = '/repo/file.bin', overwrite = false } = {}) => {
const res = createMockResponse();
const req = {
headers: {
'content-type': 'application/octet-stream',
'content-length': String(body.length),
},
query: { path: filePath, overwrite: overwrite ? 'true' : undefined },
async *[Symbol.asyncIterator]() {
yield body;
},
};
await handler(req, res);
return res;
};
const callRead = async (handler, query) => {
const res = createMockResponse();
await handler({ query }, res);
@@ -340,6 +376,95 @@ describe('fs write', () => {
});
});
describe('fs upload', () => {
it('creates a binary file without overwriting existing content', async () => {
const fsPromises = {
writeFile: vi.fn(async () => undefined),
rename: vi.fn(async () => undefined),
unlink: vi.fn(async () => undefined),
};
const handler = registerUpload(fsPromises);
const res = await callUpload(handler, { body: Buffer.from([0, 1, 2, 255]) });
expect(res.body).toEqual({ success: true, path: '/repo/file.bin' });
expect(fsPromises.writeFile).toHaveBeenCalledWith(
'/repo/file.bin',
Buffer.from([0, 1, 2, 255]),
{ flag: 'wx' },
);
expect(fsPromises.rename).not.toHaveBeenCalled();
});
it('returns a conflict instead of silently replacing an existing file', async () => {
const error = Object.assign(new Error('exists'), { code: 'EEXIST' });
const fsPromises = {
writeFile: vi.fn(async () => { throw error; }),
};
const handler = registerUpload(fsPromises);
const res = await callUpload(handler);
expect(res.statusCode).toBe(409);
expect(res.body).toEqual({ error: 'File already exists', reason: 'already-exists' });
});
it('atomically replaces a file only when overwrite is explicit', async () => {
const fsPromises = {
writeFile: vi.fn(async () => undefined),
rename: vi.fn(async () => undefined),
unlink: vi.fn(async () => undefined),
};
const handler = registerUpload(fsPromises);
const res = await callUpload(handler, { overwrite: true });
expect(res.body).toEqual({ success: true, path: '/repo/file.bin' });
const tmp = fsPromises.writeFile.mock.calls[0][0];
expect(tmp).toMatch(/^\/repo\/file\.bin\.tmp-/);
expect(fsPromises.writeFile).toHaveBeenCalledWith(tmp, Buffer.from('upload'), { flag: 'wx' });
expect(fsPromises.rename).toHaveBeenCalledWith(tmp, '/repo/file.bin');
});
it('rejects a destination parent that resolves outside the workspace', async () => {
const fsPromises = {
realpath: vi.fn(async (targetPath) => targetPath === '/repo/link' ? '/outside' : targetPath),
writeFile: vi.fn(async () => undefined),
};
const handler = registerUpload(fsPromises);
const res = await callUpload(handler, { path: '/repo/link/file.bin' });
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: 'Access denied' });
expect(fsPromises.writeFile).not.toHaveBeenCalled();
});
it('rejects streamed bodies larger than 100 MB', async () => {
const fsPromises = {
writeFile: vi.fn(async () => undefined),
};
const handler = registerUpload(fsPromises);
const chunk = Buffer.alloc(1024 * 1024);
const req = {
headers: { 'content-type': 'application/octet-stream' },
query: { path: '/repo/file.bin' },
async *[Symbol.asyncIterator]() {
for (let index = 0; index < 101; index += 1) {
yield chunk;
}
},
};
const res = createMockResponse();
await handler(req, res);
expect(res.statusCode).toBe(413);
expect(res.body).toEqual({ error: `File exceeds maximum size of ${100 * 1024 * 1024} bytes` });
expect(fsPromises.writeFile).not.toHaveBeenCalled();
});
});
describe('fs read', () => {
it('reads workspace files through symlinks that resolve outside the workspace', async () => {
const fsPromises = {