mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
feat: make upload size limit runtime-configurable
Signed-off-by: Yao Ning <zay11022@gmail.com>
This commit is contained in:
+1
-1
@@ -322,7 +322,7 @@ POST /api/approval/respond:
|
||||
### 4.6 File Upload Parser
|
||||
|
||||
parse_multipart(rfile, content_type, content_length):
|
||||
- Reads all content_length bytes from rfile into memory (up to MAX_UPLOAD_BYTES = 20MB)
|
||||
- Reads all content_length bytes from rfile into memory (up to MAX_UPLOAD_BYTES, default 20MB, env-overridable via HERMES_WEBUI_MAX_UPLOAD_MB)
|
||||
- Extracts boundary from Content-Type header
|
||||
- Splits raw bytes on b'--' + boundary
|
||||
- For each part: parses MIME headers via email.parser.HeaderParser
|
||||
|
||||
+1
-1
@@ -558,7 +558,7 @@ FAIL: Multiple messages sent while one is in flight.
|
||||
### T12.2: Upload Failure Shows Status
|
||||
SETUP: Active session.
|
||||
STEPS:
|
||||
1. Try to attach a file larger than 20MB (if available)
|
||||
1. Try to attach a file larger than the configured upload limit (20MB by default; overridden by `HERMES_WEBUI_MAX_UPLOAD_MB` if set)
|
||||
EXPECT:
|
||||
- Status bar shows an error message about file size or the upload is rejected
|
||||
- The chat is not broken (can still send messages)
|
||||
|
||||
+31
-1
@@ -55,6 +55,36 @@ PROJECTS_FILE = STATE_DIR / "projects.json"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _env_mb_bytes(name: str, default_mb: int) -> int:
|
||||
"""Parse an optional megabyte environment variable into bytes.
|
||||
|
||||
Accepts values like ``200``, ``200MB``, or ``200MiB``. Invalid or
|
||||
non-positive values fall back to the provided default.
|
||||
"""
|
||||
raw = os.getenv(name, "").strip()
|
||||
if not raw:
|
||||
return default_mb * 1024 * 1024
|
||||
m = re.match(r"^(\d+)\s*(?:m|mb|mib)?$", raw, re.IGNORECASE)
|
||||
if not m:
|
||||
logger.warning(
|
||||
"Invalid %s=%r; expected a positive integer in MB. Falling back to %sMB.",
|
||||
name,
|
||||
raw,
|
||||
default_mb,
|
||||
)
|
||||
return default_mb * 1024 * 1024
|
||||
value_mb = int(m.group(1))
|
||||
if value_mb <= 0:
|
||||
logger.warning(
|
||||
"Invalid %s=%r; expected a value greater than zero. Falling back to %sMB.",
|
||||
name,
|
||||
raw,
|
||||
default_mb,
|
||||
)
|
||||
return default_mb * 1024 * 1024
|
||||
return value_mb * 1024 * 1024
|
||||
|
||||
|
||||
# ── Hermes agent directory discovery ─────────────────────────────────────────
|
||||
def _discover_agent_dir() -> Path:
|
||||
"""
|
||||
@@ -485,7 +515,7 @@ def verify_hermes_imports() -> tuple:
|
||||
|
||||
# ── Limits ───────────────────────────────────────────────────────────────────
|
||||
MAX_FILE_BYTES = 200_000
|
||||
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||
MAX_UPLOAD_BYTES = _env_mb_bytes("HERMES_WEBUI_MAX_UPLOAD_MB", 20)
|
||||
|
||||
# ── File type maps ───────────────────────────────────────────────────────────
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".bmp"}
|
||||
|
||||
+5
-1
@@ -3143,7 +3143,11 @@ def handle_get(handler, parsed) -> bool:
|
||||
version_token = quote(WEBUI_VERSION, safe="")
|
||||
from api.extensions import inject_extension_tags
|
||||
|
||||
html = _INDEX_HTML_PATH.read_text(encoding="utf-8").replace("__WEBUI_VERSION__", version_token)
|
||||
html = (
|
||||
_INDEX_HTML_PATH.read_text(encoding="utf-8")
|
||||
.replace("__WEBUI_VERSION__", version_token)
|
||||
.replace("__MAX_UPLOAD_BYTES__", str(MAX_UPLOAD_BYTES))
|
||||
)
|
||||
return t(
|
||||
handler,
|
||||
inject_extension_tags(html),
|
||||
|
||||
+2
-5
@@ -12,6 +12,8 @@ from api.helpers import j, bad
|
||||
from api.models import get_session
|
||||
from api.workspace import safe_resolve_ws
|
||||
|
||||
_MAX_EXTRACTED_BYTES = 10 * MAX_UPLOAD_BYTES
|
||||
|
||||
|
||||
def parse_multipart(rfile, content_type, content_length) -> tuple:
|
||||
import re as _re, email.parser as _ep
|
||||
@@ -96,11 +98,6 @@ def handle_upload(handler):
|
||||
return j(handler, {'error': 'Upload failed'}, status=500)
|
||||
|
||||
|
||||
# Maximum total extracted bytes — guards against zip/tar bombs.
|
||||
# Set to 10x the upload limit; a legitimate archive rarely exceeds 3-4x.
|
||||
_MAX_EXTRACTED_BYTES = 10 * 20 * 1024 * 1024 # 200 MB
|
||||
|
||||
|
||||
def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
|
||||
"""Extract a zip or tar archive into the workspace.
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#141425':'#FAF7F0';document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.setAttribute('content',c);m.removeAttribute('media');});}catch(e){}})()</script>
|
||||
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
|
||||
<script>(function(){try{if(localStorage.getItem('hermes-webui-sidebar-collapsed')==='1')document.documentElement.dataset.sidebarCollapsed='1';}catch(e){}})()</script>
|
||||
<script>window.__HERMES_CONFIG__={maxUploadBytes:__MAX_UPLOAD_BYTES__};</script>
|
||||
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" integrity="sha384-LJcOxlx9IMbNXDqJ2axpfEQKkAYbFjJfhXexLfiRJhjDU81mzgkiQq8rkV0j6dVh" crossorigin="anonymous">
|
||||
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
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_BYTES=(window.__HERMES_CONFIG__&&window.__HERMES_CONFIG__.maxUploadBytes)||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
|
||||
|
||||
@@ -5,6 +5,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS = ROOT / "static" / "ui.js"
|
||||
I18N_JS = ROOT / "static" / "i18n.js"
|
||||
CONFIG_PY = ROOT / "api" / "config.py"
|
||||
UPLOAD_PY = ROOT / "api" / "upload.py"
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
@@ -23,12 +24,12 @@ def _function_body(src: str, name: str) -> str:
|
||||
|
||||
|
||||
def test_upload_limit_constant_matches_server_limit():
|
||||
"""The browser preflight limit must match api.config.MAX_UPLOAD_BYTES."""
|
||||
"""The browser preflight should read the runtime upload limit."""
|
||||
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
|
||||
assert "window.__HERMES_CONFIG__.maxUploadBytes" in ui
|
||||
assert 'MAX_UPLOAD_BYTES = _env_mb_bytes("HERMES_WEBUI_MAX_UPLOAD_MB", 20)' in config
|
||||
|
||||
|
||||
def test_file_picker_rejects_oversize_files_before_queueing():
|
||||
@@ -58,10 +59,17 @@ def test_pending_uploads_skip_fetch_for_oversize_files():
|
||||
|
||||
|
||||
def test_upload_too_large_has_user_facing_message():
|
||||
"""The status toast should explain the 20 MB limit instead of a network reset."""
|
||||
"""The status toast should explain the upload 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
|
||||
|
||||
|
||||
def test_archive_extraction_limit_tracks_upload_limit():
|
||||
"""Archive extraction guard should scale with the configured upload limit."""
|
||||
upload = UPLOAD_PY.read_text(encoding="utf-8")
|
||||
|
||||
assert "_MAX_EXTRACTED_BYTES = 10 * MAX_UPLOAD_BYTES" in upload
|
||||
|
||||
Reference in New Issue
Block a user