feat(uploads): prompt uploads take documents

The Attach button, the Compose and paste-dialog pickers, terminal drop and the
clipboard now take documents beside images and videos: the office formats
pdf/docx/pptx/xlsx/zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml.

A document is admitted by its name's extension (the MIME a phone attaches to a
.md is anyone's guess), buffered under the same 50MB image cap, and judged on
its bytes by the same fail-closed sniffer: a magic-number signature for the
binary formats, UTF-8 with no NUL byte for text. It is saved under a sanitized
stem of its name, so Q3 report.pdf is findable as paste-<ts>-<hex>-Q3-report.pdf.

sh/py/js/html are refused by name with a clear 400. That is a scope choice, not
a security property: file-raw already serves non-media text inert, so this keeps
the server from storing what it would serve or an agent could run, and html and
svg are the ones deliberately kept out. One client classifier (_promptAttachKind)
now returns image, video or document, and the server's dispatch mirrors it: an
image MIME wins whatever the name says, a document extension takes the document
branch, and a foreign extension with no image MIME is refused only after the
image sniff fails, so nothing the image path accepted by its bytes is lost.
This commit is contained in:
JD committed 2026-10-13 20:31:39 -04:00
1 parent a8a8bb71a3
commit 244d9a49f1
18 files changed
+382 -96

No files matched your search

+20 -5
View File
@@ -19,7 +19,7 @@ function loadToolbar(sessionId: string | null = 'session-1', localEcho = true, k
const dom = new JSDOM(
'<!DOCTYPE html><html><body><span class="btn-attach-mobile-wrap">' +
'<button type="button" id="attachBtnMobile"></button>' +
'<input type="file" id="attachFileInput" accept="image/*,video/*" multiple>' +
'<input type="file" id="attachFileInput" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple>' +
'</span></body></html>'
);
const { window } = dom;
@@ -333,13 +333,13 @@ describe('phone toolbar Attach button', () => {
expect(composerBar._insertComposerText).not.toHaveBeenCalled();
});
it('rejects a selection with neither image nor video with a toast and no upload', async () => {
it('rejects a selection the server would refuse with a toast and no upload', async () => {
const { app, window, select } = loadToolbar();
select([new window.File(['%PDF-'], 'doc.pdf', { type: 'application/pdf' })]);
select([new window.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' })]);
await Promise.resolve();
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app._sendInputAsync).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith('Only image or video files are supported', 'error');
expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error');
});
it('classifies a pick by its name when the file manager supplied no MIME type', () => {
@@ -348,8 +348,23 @@ describe('phone toolbar Attach button', () => {
expect(kind('clip.ogv', '')).toBe('video');
expect(kind('IMG_0001.MOV', '')).toBe('video');
expect(kind('shot.heic', '')).toBe('image');
expect(kind('notes.pdf', '')).toBeNull();
expect(kind('clip.ogv', 'image/jpeg')).toBe('image'); // the MIME type wins when present
// Documents are known by the name alone: the MIME a file manager gives them varies.
expect(kind('notes.pdf', 'application/pdf')).toBe('document');
expect(kind('Q3 report.XLSX', '')).toBe('document');
expect(kind('todo.md', 'text/plain')).toBe('document');
expect(kind('data.csv', 'application/octet-stream')).toBe('document');
expect(kind('run.sh', 'text/x-shellscript')).toBeNull();
expect(kind('page.html', 'text/html')).toBeNull();
expect(kind('noext', '')).toBeNull();
});
it('uploads a document as picked, through the image path (no re-encode, no video flag)', async () => {
const { app, window, fetchMock } = loadToolbar();
const pdf = new window.File(['%PDF-'], 'notes.pdf', { type: 'application/pdf' });
expect(await app._normalizeImageForUpload(pdf)).toBe(pdf);
await app._uploadPasteImage('session-1', pdf);
expect(fetchMock.mock.calls[0][0]).toBe('/api/sessions/session-1/paste-image');
});
it('flags the upload as a video by name as well as by MIME type', async () => {