mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
feat(files): raise the download cap to 2GB and stream /api/download
The 50MB cap on file-raw, the attachment /raw route and /api/download was memory protection for a `readFile()` that no longer exists: file-raw and /raw were rewritten to stream through `sendFileBody()` and answer Range requests, so size costs a read stream rather than RSS (measured: a 600MB download moved peak RSS by ~37MB). All the cap still did was refuse legitimate downloads of build artifacts, videos and archives. It is now MAX_FILE_DOWNLOAD_BYTES in config/buffer-limits.ts, default 2GB, env CODEMAN_MAX_DOWNLOAD_BYTES, 0 = unlimited. `parseByteLimitEnv()` is separate from the `parseInt(...) || default` idiom used elsewhere in that file precisely because that idiom reads 0 as falsy and would silently restore the default for the one value that means "no limit". /api/download was the last route that really did buffer the whole file. It now shares sendFileBody() with the other two, so it streams, advertises Accept-Ranges, and is resumable. Its Content-Disposition also goes through buildContentDisposition() rather than raw interpolation. Refusals move from 400 to 413 across all three, which is the correct status for the case; with the cap at 2GB it is a path almost nothing reaches now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -191,7 +191,9 @@ describe('file-raw range requests', () => {
|
||||
});
|
||||
|
||||
it('still refuses files past the raw size cap before looking at Range', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never);
|
||||
// 3GB, past the 2GB CODEMAN_MAX_DOWNLOAD_BYTES default. The cap is checked
|
||||
// before the range, so a small slice of an oversized file is refused too.
|
||||
mockedStat.mockResolvedValue({ size: 3 * 1024 * 1024 * 1024, isFile: () => true } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
@@ -199,6 +201,18 @@ describe('file-raw range requests', () => {
|
||||
headers: { range: 'bytes=0-99' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.statusCode).toBe(413);
|
||||
});
|
||||
|
||||
it('serves a 100MB file that the historical 50MB cap would have refused', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: rawUrl('big.mp4'),
|
||||
headers: { range: 'bytes=0-99' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(206);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -802,14 +802,27 @@ describe('file-routes', () => {
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects overly large raw files', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024 } as never); // 100MB
|
||||
it('serves a file past the historical 50MB cap', async () => {
|
||||
// The body is streamed and Range-aware, so size costs a read stream, not
|
||||
// RSS. The old 50MB refusal only blocked legitimate artifact downloads.
|
||||
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never); // 100MB
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=huge.bin`,
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('still refuses a file past the configured download cap', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 3 * 1024 * 1024 * 1024, isFile: () => true } as never); // 3GB > 2GB default
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=enormous.bin`,
|
||||
});
|
||||
expect(res.statusCode).toBe(413);
|
||||
expect(JSON.parse(res.body).error).toContain('CODEMAN_MAX_DOWNLOAD_BYTES');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -863,8 +876,9 @@ describe('file-routes', () => {
|
||||
});
|
||||
|
||||
it('downloads files scoped to the session working directory', async () => {
|
||||
const content = Buffer.from('download content');
|
||||
mockedReadFile.mockResolvedValue(content as never);
|
||||
// The body is streamed (shared sendFileBody path), so the bytes come from
|
||||
// the createReadStream mock rather than from readFile.
|
||||
const content = Buffer.from('fake file bytes');
|
||||
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
@@ -874,7 +888,19 @@ describe('file-routes', () => {
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-disposition']).toContain('filename="report.txt"');
|
||||
expect(res.body).toBe('download content');
|
||||
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||
expect(res.body).toBe('fake file bytes');
|
||||
});
|
||||
|
||||
it('refuses a download past the configured cap', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 3 * 1024 * 1024 * 1024, isFile: () => true } as never); // 3GB > 2GB default
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=enormous.bin`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(413);
|
||||
});
|
||||
|
||||
it('rejects absolute paths outside the session working directory', async () => {
|
||||
|
||||
Reference in New Issue
Block a user