Stage 319: PR #1868 — oversized upload preflight by @franksong2702

This commit is contained in:
nesquena-hermes
2026-05-08 15:16:19 +00:00
3 changed files with 99 additions and 3 deletions
+10
View File
@@ -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',
+22 -3
View File
@@ -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;i<total;i++){
const f=S.pendingFiles[i];const fd=new FormData();
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
const f=S.pendingFiles[i];
try{
if(f&&f.size>MAX_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});
@@ -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