feat(preview): render XLSX spreadsheets in the file-preview overlay

xlsx files were download-only. Add a read-only, virtualized preview (sheet
tabs, number formats, merges, theme colours) parsed entirely in a browser
Web Worker with exceljs and fflate, loaded only when a spreadsheet is
opened. The workbook is checked against ZIP-bomb, entry and cell limits
before exceljs loads; cell text is written with textContent, formulas are
never evaluated and nothing referenced by the workbook is fetched. On the
server xlsx only joins the existing allowlist and classification, with a
10 MB cap on ?preview=true. xls and ods stay download-only.
This commit is contained in:
Aamer Akhter
2026-09-26 23:13:21 -04:00
parent 45ea2e1d32
commit 0b122e2c76
27 changed files with 3765 additions and 13 deletions
@@ -556,6 +556,52 @@ describe('file-routes attachment path guard (COD-53)', () => {
});
});
// ===== XLSX: client-side preview, same guard and routes =====
// xlsx joins the extension allowlist so the overlay can preview a workbook
// outside the workspace by id. Nothing else about the pipeline changes; only
// `?preview=true` adds a tighter size cap because the browser parses it.
describe('xlsx attachments', () => {
async function registerXlsx(size: number) {
mockedStat.mockResolvedValue({ size, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/tmp/report.xlsx', notify: false },
});
expect(res.statusCode).toBe(200);
return JSON.parse(res.body).data as { attachmentId: string; attachmentType: string };
}
it('registers an xlsx as a spreadsheet attachment', async () => {
const data = await registerXlsx(2048);
expect(data.attachmentType).toBe('spreadsheet');
});
it('still refuses xls and ods (download-only, no preview)', async () => {
for (const path of ['/tmp/legacy.xls', '/tmp/open.ods']) {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path, notify: false },
});
expect(res.statusCode, path).toBe(400);
}
});
it('caps a ?preview=true fetch at 10 MB but still serves a plain download', async () => {
const { attachmentId } = await registerXlsx(11 * 1024 * 1024);
const base = `/api/sessions/${harness.ctx._sessionId}/attachments/${attachmentId}/raw`;
const preview = await harness.app.inject({ method: 'GET', url: `${base}?preview=true` });
expect(preview.statusCode).toBe(413);
expect(JSON.parse(preview.body).error).toMatch(/too large to preview/i);
mockedCreateReadStream.mockReturnValue(Readable.from([Buffer.from('PK')]) as never);
const download = await harness.app.inject({ method: 'GET', url: `${base}?preview=true&download=true` });
expect(download.statusCode).toBe(200);
});
});
// ===== Quiet registration (click-to-preview) =====
// The file-preview overlay registers a clicked out-of-workspace path to mint
// an id it can render by. It is already putting the file on screen, so the
+42 -2
View File
@@ -695,12 +695,12 @@ describe('file-routes', () => {
expect(body.data.url).toContain('file-raw');
});
it('flags known-binary extensions (e.g. xlsx) instead of dumping mojibake', async () => {
it('flags known-binary extensions (e.g. xls) instead of dumping mojibake', async () => {
mockedStat.mockResolvedValue({ size: 4096 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xlsx`,
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xls`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
@@ -709,6 +709,30 @@ describe('file-routes', () => {
expect(body.data.content).toBeUndefined();
});
it('classifies xlsx as a client-side spreadsheet preview, never a text body', async () => {
mockedStat.mockResolvedValue({ size: 4096 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xlsx`,
});
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.type).toBe('spreadsheet');
expect(body.data.url).toBe(`/api/sessions/${harness.ctx._sessionId}/file-raw?path=sheet.xlsx`);
expect(body.data.content).toBeUndefined();
});
it('keeps ods (and xls) download-only binaries', async () => {
mockedStat.mockResolvedValue({ size: 4096 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.ods`,
});
expect(JSON.parse(res.body).data.type).toBe('binary');
});
it('sniffs NUL bytes and flags binary content for unknown extensions', async () => {
const binary = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01, 0x02]);
mockedReadFile.mockResolvedValue(binary as never);
@@ -868,6 +892,22 @@ describe('file-routes', () => {
expect(res.statusCode).toBe(413);
expect(JSON.parse(res.body).error).toContain('CODEMAN_MAX_DOWNLOAD_BYTES');
});
it('caps an xlsx ?preview=true fetch at 10 MB, leaving downloads and small previews alone', async () => {
mockedStat.mockResolvedValue({ size: 11 * 1024 * 1024 } as never);
const url = `/api/sessions/${harness.ctx._sessionId}/file-raw?path=book.xlsx`;
const preview = await harness.app.inject({ method: 'GET', url: `${url}&preview=true` });
expect(preview.statusCode).toBe(413);
expect(JSON.parse(preview.body).error).toMatch(/too large to preview/i);
const download = await harness.app.inject({ method: 'GET', url: `${url}&preview=true&download=true` });
expect(download.statusCode).toBe(200);
mockedStat.mockResolvedValue({ size: 2048 } as never);
const small = await harness.app.inject({ method: 'GET', url: `${url}&preview=true` });
expect(small.statusCode).toBe(200);
});
});
// ========== DELETE /api/sessions/:id/tail-file/:streamId ==========