fix(upload): surface payload-too-large errors, reject empty files, use toast not chat-log

Root cause of "uploads silently land as 0-byte __file": aiohttp default body
limit is 1 MiB; PDFs almost always exceed it, request.json() raises
HTTPRequestEntityTooLarge, the generic read_json() swallows it as bare
Exception, upload_handler then sees an empty dict and happily writes an
empty file with name='file', returning ok=true. Front-end thinks the
upload worked.

Bridge:
- Set web.Application(client_max_size=1MB) explicitly so the limit is
  visible at the call site (kept at 1MB intentionally for now; bump later
  if larger uploads are needed)
- upload_handler reads body itself instead of read_json so it can catch
  HTTPRequestEntityTooLarge and HTTPBadRequest and return a real error
  to the client instead of silently writing 0 bytes
- Reject blob == b'' as a second line of defence

Frontend:
- addFiles skips files with size === 0 (handles drag-folder ghost File
  and edge-case clipboard items) with a dedicated 'upload.empty' toast
- 4x showSystem(...) in addFiles -> showChanToast(title, detail, 'err').
  Upload failures should be transient toasts, not permanent system
  messages glued into the chat transcript.
This commit is contained in:
dd3xp committed 2026-05-21 23:39:39 +08:00
1 parent 22d4654e85
commit e2a67f0803
2 files changed
+19 -7

No files matched your search

+8 -5
View File
@@ -269,7 +269,7 @@ const I18N = {
'slash.unknown': '未知命令', 'slash.unknown': '未知命令',
'upload.hint': '上传文件:选择 / 拖拽 / 粘贴', 'upload.hint': '上传文件:选择 / 拖拽 / 粘贴',
'upload.button': '上传文件', 'upload.button': '上传文件',
'upload.tooLarge': '文件过大或数量超限', 'upload.tooLarge': '文件过大或数量超限', 'upload.empty': '跳过空文件',
'upload.failed': '上传失败', 'upload.failed': '上传失败',
'upload.removeTitle': '移除', 'upload.removeTitle': '移除',
'upload.dropHint': '松开以上传文件', 'upload.dropHint': '松开以上传文件',
@@ -362,7 +362,7 @@ const I18N = {
'slash.unknown': 'Unknown command', 'slash.unknown': 'Unknown command',
'upload.hint': 'Upload file: pick / drag / paste', 'upload.hint': 'Upload file: pick / drag / paste',
'upload.button': 'Upload file', 'upload.button': 'Upload file',
'upload.tooLarge': 'File too large or limit reached', 'upload.tooLarge': 'File too large or limit reached', 'upload.empty': 'Skipped empty file',
'upload.failed': 'Upload failed', 'upload.failed': 'Upload failed',
'upload.removeTitle': 'Remove', 'upload.removeTitle': 'Remove',
'upload.dropHint': 'Drop to upload files', 'upload.dropHint': 'Drop to upload files',
@@ -1572,14 +1572,17 @@ async function addFiles(fileList) {
const files = Array.from(fileList || []); const files = Array.from(fileList || []);
if (files.length === 0) return; if (files.length === 0) return;
let skipped = false; let skipped = false;
let emptyHit = false;
const accepted = []; const accepted = [];
for (const f of files) { for (const f of files) {
if (!f || f.size === 0) { emptyHit = true; continue; }
if (f.size > MAX_UPLOAD_BYTES) { skipped = true; continue; } if (f.size > MAX_UPLOAD_BYTES) { skipped = true; continue; }
if (state.pendingFiles.length + accepted.length >= MAX_UPLOAD_FILES) { skipped = true; break; } if (state.pendingFiles.length + accepted.length >= MAX_UPLOAD_FILES) { skipped = true; break; }
accepted.push(f); accepted.push(f);
} }
if (emptyHit) showChanToast(t('upload.empty'), '', 'err');
if (accepted.length === 0) { if (accepted.length === 0) {
if (skipped) showSystem(t('upload.tooLarge')); if (skipped) showChanToast(t('upload.tooLarge'), '', 'err');
return; return;
} }
for (const f of accepted) { for (const f of accepted) {
@@ -1602,10 +1605,10 @@ async function addFiles(fileList) {
insertPlaceholderInComposer(placeholderFor(entry)); insertPlaceholderInComposer(placeholderFor(entry));
renderThumbStrip(); renderThumbStrip();
} catch (e) { } catch (e) {
showSystem(t('upload.failed') + ': ' + (e.message || e)); showChanToast(t('upload.failed'), e.message || String(e), 'err');
} }
} }
if (skipped) showSystem(t('upload.tooLarge')); if (skipped) showChanToast(t('upload.tooLarge'), '', 'err');
} }
if (uploadBtn && imgInput) uploadBtn.addEventListener('click', (e) => { if (uploadBtn && imgInput) uploadBtn.addEventListener('click', (e) => {
+11 -2
View File
@@ -1081,7 +1081,14 @@ async def upload_handler(request):
Body: {name: "<original filename>", dataUrl: "data:<mime>;base64,<...>"} Body: {name: "<original filename>", dataUrl: "data:<mime>;base64,<...>"}
Returns: {ok: true, path: "<abs path>"} Returns: {ok: true, path: "<abs path>"}
""" """
data = await read_json(request) try:
data = await request.json()
if not isinstance(data, dict):
data = {}
except web.HTTPRequestEntityTooLarge:
return json_ok({"ok": False, "error": "file too large for bridge body limit"})
except Exception as e:
return json_ok({"ok": False, "error": f"invalid request: {e}"})
name = (data.get("name") or "file").strip().replace("/", "_").replace("\\", "_") name = (data.get("name") or "file").strip().replace("/", "_").replace("\\", "_")
data_url = data.get("dataUrl") or "" data_url = data.get("dataUrl") or ""
if "," in data_url: if "," in data_url:
@@ -1092,6 +1099,8 @@ async def upload_handler(request):
blob = base64.b64decode(b64) blob = base64.b64decode(b64)
except Exception as e: except Exception as e:
return json_ok({"ok": False, "error": f"decode failed: {e}"}) return json_ok({"ok": False, "error": f"decode failed: {e}"})
if not blob:
return json_ok({"ok": False, "error": "empty file"})
safe_name = name or "file" safe_name = name or "file"
fpath = _WEB_UPLOAD_DIR / f"{uuid.uuid4().hex[:12]}__{safe_name}" fpath = _WEB_UPLOAD_DIR / f"{uuid.uuid4().hex[:12]}__{safe_name}"
fpath.write_bytes(blob) fpath.write_bytes(blob)
@@ -1216,7 +1225,7 @@ async def token_stats_handler(request):
def create_app(): def create_app():
app = web.Application(middlewares=[cors_middleware]) app = web.Application(middlewares=[cors_middleware], client_max_size=1 * 1024 * 1024)
app.router.add_get("/ws", ws_handler) app.router.add_get("/ws", ws_handler)
app.router.add_get("/status", status_handler) app.router.add_get("/status", status_handler)
app.router.add_get("/config", get_config_handler) app.router.add_get("/config", get_config_handler)