From 29829c3edf87a52eb9d4673610aa737a35131b46 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Fri, 8 May 2026 12:36:45 +0800 Subject: [PATCH] fix: preflight oversized browser uploads --- static/i18n.js | 10 +++ static/ui.js | 25 ++++++- tests/test_issue1867_upload_size_preflight.py | 67 +++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 tests/test_issue1867_upload_size_preflight.py diff --git a/static/i18n.js b/static/i18n.js index 6101c1f6..af1669a6 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -365,6 +365,7 @@ const LOCALES = { remove_title: 'Remove', empty_dir: '(empty)', upload_failed: 'Upload failed: ', + upload_too_large: (maxMb, fileMb) => `File is too large (${fileMb} MB). Maximum upload size is ${maxMb} MB.`, all_uploads_failed: (n) => `All ${n} upload(s) failed`, archive_extracted: (n, c) => `Extracted ${n} file(s) from ${c} archive(s)`, session_pin: 'Pin conversation', @@ -1383,6 +1384,7 @@ const LOCALES = { remove_title: '削除', empty_dir: '(空)', upload_failed: 'アップロード失敗: ', + upload_too_large: (maxMb, fileMb) => `ファイルが大きすぎます (${fileMb} MB)。最大アップロードサイズは ${maxMb} MB です。`, all_uploads_failed: (n) => `${n} 件のアップロードがすべて失敗しました`, archive_extracted: (n, c) => `${c} 個のアーカイブから ${n} 件のファイルを展開しました`, session_pin: '会話をピン留め', @@ -2321,6 +2323,7 @@ const LOCALES = { remove_title: 'Удаление', empty_dir: '(пусто)', upload_failed: 'Не удалось загрузить: ', + upload_too_large: (maxMb, fileMb) => `Файл слишком большой (${fileMb} МБ). Максимальный размер загрузки: ${maxMb} МБ.`, all_uploads_failed: (n) => `Не удалось загрузить все ${n} файлов`, archive_extracted: (n, c) => `Извлечено ${n} файл(ов) из ${c} архив(ов)`, settings_title: 'Настройки', @@ -3253,6 +3256,7 @@ const LOCALES = { remove_title: 'Quitar', empty_dir: '(vacío)', upload_failed: 'Error al subir: ', + upload_too_large: (maxMb, fileMb) => `El archivo es demasiado grande (${fileMb} MB). El tamaño máximo de subida es ${maxMb} MB.`, all_uploads_failed: (n) => `Fallaron las ${n} subida(s)`, archive_extracted: (n, c) => `${n} archivo(s) extraído(s) de ${c} archivo(s) comprimido(s)`, // settings panel @@ -4194,6 +4198,7 @@ const LOCALES = { remove_title: 'Entfernen', empty_dir: '(leer)', upload_failed: 'Upload fehlgeschlagen: ', + upload_too_large: (maxMb, fileMb) => `Datei ist zu groß (${fileMb} MB). Die maximale Uploadgröße beträgt ${maxMb} MB.`, all_uploads_failed: (n) => `Alle ${n} Upload(s) fehlgeschlagen`, // settings panel settings_title: 'Einstellungen', @@ -5165,6 +5170,7 @@ const LOCALES = { remove_title: '\u79fb\u9664', empty_dir: '(\u7a7a)', upload_failed: '\u4e0a\u4f20\u5931\u8d25\uff1a', + upload_too_large: (maxMb, fileMb) => `\u6587\u4ef6\u8fc7\u5927\uff08${fileMb} MB\uff09\u3002\u6700\u5927\u4e0a\u4f20\u5927\u5c0f\u4e3a ${maxMb} MB\u3002`, all_uploads_failed: (n) => `${n} \u4e2a\u6587\u4ef6\u5168\u90e8\u4e0a\u4f20\u5931\u8d25`, // settings panel settings_title: '\u8bbe\u7f6e', @@ -6064,6 +6070,7 @@ const LOCALES = { remove_title: '\u79fb\u9664', empty_dir: '(空)', upload_failed: '上傳失敗:', + upload_too_large: (maxMb, fileMb) => `檔案過大(${fileMb} MB)。最大上傳大小為 ${maxMb} MB。`, all_uploads_failed: (n) => `${n} 個檔案全部上傳失敗`, session_pin: '釘選對話', session_unpin: '取消釘選', @@ -6389,6 +6396,7 @@ const LOCALES = { title_set: '\u6a19\u984c\u5df2\u8a2d\u70ba', todos_no_active: '\u6b64\u6703\u8a71\u4e2d\u7121\u6d3b\u8e8d\u4efb\u52d9\u6e05\u55ae\u3002', upload_failed: '\u4e0a\u50b3\u5931\u6557\uff1a', + upload_too_large: (maxMb, fileMb) => `\u6a94\u6848\u904e\u5927\uff08${fileMb} MB\uff09\u3002\u6700\u5927\u4e0a\u50b3\u5927\u5c0f\u70ba ${maxMb} MB\u3002`, active_conversation_none: '\u672a\u9078\u53d6\u6d3b\u8e8d\u6703\u8a71\u3002', add: '\u65b0\u589e', add_failed: '\u65b0\u589e\u5931\u6557\uff1a', @@ -7103,6 +7111,7 @@ const LOCALES = { remove_title: 'Remover', empty_dir: '(vazio)', upload_failed: 'Falha ao upload: ', + upload_too_large: (maxMb, fileMb) => `O arquivo é grande demais (${fileMb} MB). O tamanho máximo de upload é ${maxMb} MB.`, all_uploads_failed: (n) => `Todos ${n} upload(s) falharam`, session_pin: 'Fixar conversa', session_unpin: 'Desfixar conversa', @@ -8008,6 +8017,7 @@ const LOCALES = { remove_title: 'Remove', empty_dir: '(비어 있음)', upload_failed: 'Upload failed: ', + upload_too_large: (maxMb, fileMb) => `File is too large (${fileMb} MB). Maximum upload size is ${maxMb} MB.`, all_uploads_failed: (n) => `All ${n} upload(s) failed`, session_pin: 'Pin conversation', session_unpin: 'Unpin conversation', diff --git a/static/ui.js b/static/ui.js index b7b8867b..1ceed738 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1,6 +1,8 @@ const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default',showHiddenWorkspaceFiles:false}; const INFLIGHT={}; // keyed by session_id while request in-flight const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns +const MAX_UPLOAD_BYTES=20*1024*1024; +const MAX_UPLOAD_MB=Math.round(MAX_UPLOAD_BYTES/1024/1024); // Tracks which session's queue to drain in setBusy(false). // Set to activeSid just before setBusy(false) in done/error handlers so the // queue drains the session that *finished*, not the one currently viewed. @@ -6758,7 +6760,22 @@ function renderTray(){ // non-media files use paperclip chip tray.appendChild(chip); }); } -function addFiles(files){for(const f of files){if(!S.pendingFiles.find(p=>p.name===f.name))S.pendingFiles.push(f);}renderTray();} +function _uploadTooLargeMessage(file){ + const fileSizeMb=Math.ceil(((file&&file.size)||0)/1024/1024); + return t('upload_too_large',MAX_UPLOAD_MB,fileSizeMb); +} +function _showUploadTooLarge(file){ + const message=`${t('upload_failed')}${file&&file.name?file.name:'file'} \u2014 ${_uploadTooLargeMessage(file)}`; + if(typeof setStatus==='function')setStatus(`\u274c ${message}`); + else if(typeof showToast==='function')showToast(message,5000,'error'); +} +function addFiles(files){ + for(const f of files){ + if(f&&f.size>MAX_UPLOAD_BYTES){_showUploadTooLarge(f);continue;} + if(!S.pendingFiles.find(p=>p.name===f.name))S.pendingFiles.push(f); + } + renderTray(); +} async function uploadPendingFiles(){ if(!S.pendingFiles.length||!S.session)return[]; const names=[];let failures=0; @@ -6766,9 +6783,11 @@ async function uploadPendingFiles(){ barWrap.classList.add('active');bar.style.width='0%'; const total=S.pendingFiles.length; for(let i=0;iMAX_UPLOAD_BYTES)throw new Error(_uploadTooLargeMessage(f)); + const fd=new FormData(); + fd.append('session_id',S.session.session_id);fd.append('file',f,f.name); const isArchive=_ARCHIVE_EXTS.test(f.name); const url=new URL(isArchive?'api/upload/extract':'api/upload',document.baseURI||location.href).href; const res=await fetch(url,{method:'POST',credentials:'include',body:fd}); diff --git a/tests/test_issue1867_upload_size_preflight.py b/tests/test_issue1867_upload_size_preflight.py new file mode 100644 index 00000000..ae187672 --- /dev/null +++ b/tests/test_issue1867_upload_size_preflight.py @@ -0,0 +1,67 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = ROOT / "static" / "ui.js" +I18N_JS = ROOT / "static" / "i18n.js" +CONFIG_PY = ROOT / "api" / "config.py" + + +def _function_body(src: str, name: str) -> str: + marker = f"function {name}" + start = src.index(marker) + brace = src.index("{", start) + depth = 0 + for idx in range(brace, len(src)): + if src[idx] == "{": + depth += 1 + elif src[idx] == "}": + depth -= 1 + if depth == 0: + return src[brace : idx + 1] + raise AssertionError(f"{name} function body not found") + + +def test_upload_limit_constant_matches_server_limit(): + """The browser preflight limit must match api.config.MAX_UPLOAD_BYTES.""" + ui = UI_JS.read_text(encoding="utf-8") + config = CONFIG_PY.read_text(encoding="utf-8") + + assert "const MAX_UPLOAD_BYTES=20*1024*1024;" in ui + assert "MAX_UPLOAD_BYTES = 20 * 1024 * 1024" in config + + +def test_file_picker_rejects_oversize_files_before_queueing(): + """Selecting an oversized file should never add it to pending uploads.""" + src = UI_JS.read_text(encoding="utf-8") + body = _function_body(src, "addFiles") + + size_gate = body.index("f&&f.size>MAX_UPLOAD_BYTES") + status_notice = body.index("_showUploadTooLarge(f)") + push_pending = body.index("S.pendingFiles.push(f)") + + assert size_gate < status_notice < push_pending + assert "continue;" in body[size_gate:push_pending] + + +def test_pending_uploads_skip_fetch_for_oversize_files(): + """Restored or queued oversized files should fail locally before fetch().""" + src = UI_JS.read_text(encoding="utf-8") + body = _function_body(src, "uploadPendingFiles") + + size_gate = body.index("f&&f.size>MAX_UPLOAD_BYTES") + form_data = body.index("const fd=new FormData()") + upload_fetch = body.index("fetch(url") + + assert size_gate < form_data < upload_fetch + assert "throw new Error(_uploadTooLargeMessage(f))" in body[size_gate:form_data] + + +def test_upload_too_large_has_user_facing_message(): + """The status toast should explain the 20 MB limit instead of a network reset.""" + i18n = I18N_JS.read_text(encoding="utf-8") + ui = UI_JS.read_text(encoding="utf-8") + + assert "upload_too_large" in i18n + assert "Maximum upload size is" in i18n + assert "_uploadTooLargeMessage(file)" in ui