diff --git a/api/routes.py b/api/routes.py index fb2caeab..18354166 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4150,6 +4150,9 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/file/raw": return _handle_file_raw(handler, parsed) + if parsed.path == "/api/folder/download": + return _handle_folder_download(handler, parsed) + if parsed.path == "/api/file": return _handle_file_read(handler, parsed) @@ -6630,6 +6633,143 @@ def _file_raw_target(session, sid: str, rel: str) -> Path | None: return None +# ─── /api/folder/download ─────────────────────────────────────────────────── +# Configurable caps. Match the HERMES_WEBUI_MAX_UPLOAD_MB style used elsewhere +# (api/config.py) so operators have one consistent env-var convention. +def _folder_zip_max_bytes() -> int: + try: + mb = int(os.getenv("HERMES_WEBUI_FOLDER_ZIP_MAX_MB", "1024")) + except ValueError: + mb = 1024 + return max(1, mb) * 1024 * 1024 + + +def _folder_zip_max_files() -> int: + try: + return max(1, int(os.getenv("HERMES_WEBUI_FOLDER_ZIP_MAX_FILES", "50000"))) + except ValueError: + return 50000 + + +def _folder_download_collect(target: Path, workspace_root: Path, + max_bytes: int, max_files: int): + """Walk target dir; return (files, total_bytes, hit_limit_reason_or_None). + + files is a list of (filesystem_path, archive_name) tuples ready for + ZipFile.write. Symlinks escaping the workspace are skipped. + """ + import os as _os + files = [] + total_bytes = 0 + for root, dirs, names in _os.walk(target, followlinks=False): + root_path = Path(root) + try: + if not root_path.resolve().is_relative_to(workspace_root): + dirs[:] = [] + continue + except (ValueError, OSError): + dirs[:] = [] + continue + for name in names: + fp = root_path / name + if fp.is_symlink(): + try: + if not fp.resolve().is_relative_to(workspace_root): + continue + except (ValueError, OSError): + continue + try: + size = fp.stat().st_size + except OSError: + continue + if len(files) >= max_files: + return files, total_bytes, "max_files" + if total_bytes + size > max_bytes: + return files, total_bytes, "max_bytes" + try: + arcname = fp.relative_to(target) + except ValueError: + continue + files.append((fp, str(arcname))) + total_bytes += size + return files, total_bytes, None + + +def _handle_folder_download(handler, parsed): + """GET /api/folder/download?session_id=...&path=... + + Streams a zip of /. Symlinks escaping the + workspace are skipped. Empty folders return an empty (valid) zip. + Respects HERMES_WEBUI_FOLDER_ZIP_MAX_MB and HERMES_WEBUI_FOLDER_ZIP_MAX_FILES. + Pre-flights the walk so size/count failures return a clean 413 with JSON + body BEFORE any zip bytes are sent. + """ + import zipfile + from urllib.parse import parse_qs + + qs = parse_qs(parsed.query) + sid = qs.get("session_id", [""])[0] + if not sid: + return bad(handler, "session_id is required") + try: + s = get_session(sid) + except KeyError: + return bad(handler, "Session not found", 404) + + rel = qs.get("path", [""])[0] + try: + target = safe_resolve(Path(s.workspace), rel) + except ValueError: + return bad(handler, "invalid path", 400) + if not target.exists(): + return j(handler, {"error": "not found"}, status=404) + if not target.is_dir(): + return bad(handler, "path must be a directory; use /api/file/raw for single files", 400) + + workspace_root = Path(s.workspace).resolve() + max_bytes = _folder_zip_max_bytes() + max_files = _folder_zip_max_files() + + files, total_bytes, limit_hit = _folder_download_collect( + target, workspace_root, max_bytes, max_files + ) + if limit_hit == "max_files": + return j(handler, { + "error": "too many files", + "limit": max_files, + "configure": "HERMES_WEBUI_FOLDER_ZIP_MAX_FILES", + }, status=413) + if limit_hit == "max_bytes": + return j(handler, { + "error": "folder too large", + "limit_bytes": max_bytes, + "configure": "HERMES_WEBUI_FOLDER_ZIP_MAX_MB", + }, status=413) + + zip_name = (target.name or "workspace") + ".zip" + handler.send_response(200) + handler.send_header("Content-Type", "application/zip") + handler.send_header( + "Content-Disposition", + _content_disposition_value("attachment", zip_name), + ) + handler.send_header("Cache-Control", "no-store") + handler.end_headers() + + written = 0 + with zipfile.ZipFile(handler.wfile, mode="w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as zf: + for fp, arcname in files: + try: + zf.write(fp, arcname=arcname) + written += 1 + except (OSError, PermissionError) as e: + logger.warning("folder-download: skipping %s: %s", fp, e) + logger.info( + "folder-download: streamed %d/%d files (~%d bytes) from %s", + written, len(files), total_bytes, target, + ) + + def _handle_file_raw(handler, parsed): qs = parse_qs(parsed.query) sid = qs.get("session_id", [""])[0] diff --git a/static/i18n.js b/static/i18n.js index 05800281..f02c9fb8 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -401,6 +401,7 @@ const LOCALES = { reveal_in_finder: 'Reveal in File Manager', reveal_failed: 'Failed to reveal: ', copy_file_path: 'Copy file path', + download_folder: 'Download Folder', path_copied: 'File path copied to clipboard', path_copy_failed: 'Failed to copy path: ', session_rename: 'Rename conversation', diff --git a/static/ui.js b/static/ui.js index cb78ba09..195d0b2f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7788,6 +7788,23 @@ function _showFileContextMenu(e, item){ }; menu.appendChild(copyPathItem); + // Download as zip — only for directories. Streams the folder contents + // through /api/folder/download which builds the zip on the fly. + if(item.type==='dir'){ + const dlItem=document.createElement('div'); + dlItem.textContent=t('download_folder'); + dlItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; + dlItem.onmouseenter=()=>dlItem.style.background='var(--hover-bg)'; + dlItem.onmouseleave=()=>dlItem.style.background=''; + dlItem.onclick=()=>{ + menu.remove(); + const url='/api/folder/download?session_id='+encodeURIComponent(S.session.session_id) + + '&path='+encodeURIComponent(item.path||''); + window.location.href=url; + }; + menu.appendChild(dlItem); + } + // Divider + Delete const sep=document.createElement('hr'); sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;'; diff --git a/tests/test_folder_download.py b/tests/test_folder_download.py new file mode 100644 index 00000000..2f046355 --- /dev/null +++ b/tests/test_folder_download.py @@ -0,0 +1,102 @@ +"""Tests for /api/folder/download — matches the static-inspection style used +elsewhere in the hermes-webui test suite (see tests/test_issue1867_upload_size_preflight.py). +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ROUTES_PY = ROOT / "api" / "routes.py" +UI_JS = ROOT / "static" / "ui.js" + + +def test_folder_download_handler_defined(): + src = ROUTES_PY.read_text(encoding="utf-8") + assert "def _handle_folder_download(handler, parsed):" in src + assert "/api/folder/download?session_id=" in src # in handler docstring + assert 'Content-Type", "application/zip"' in src + assert "zipfile.ZipFile(handler.wfile" in src + + +def test_folder_download_dispatch_registered(): + src = ROUTES_PY.read_text(encoding="utf-8") + assert 'parsed.path == "/api/folder/download"' in src + assert "_handle_folder_download(handler, parsed)" in src + + +def test_folder_download_uses_safe_resolve(): + src = ROUTES_PY.read_text(encoding="utf-8") + handler_idx = src.index("def _handle_folder_download") + end_idx = src.index("\n\ndef ", handler_idx + 1) + body = src[handler_idx:end_idx] + assert "safe_resolve(Path(s.workspace), rel)" in body + assert "ValueError" in body + + +def test_folder_download_skips_escaping_symlinks(): + src = ROUTES_PY.read_text(encoding="utf-8") + collect_idx = src.index("def _folder_download_collect") + end_idx = src.index("\n\ndef ", collect_idx + 1) + body = src[collect_idx:end_idx] + assert "followlinks=False" in body + assert "is_symlink()" in body + assert "is_relative_to(workspace_root)" in body + + +def test_folder_download_respects_max_files_env(): + src = ROUTES_PY.read_text(encoding="utf-8") + assert 'HERMES_WEBUI_FOLDER_ZIP_MAX_FILES' in src + assert '"too many files"' in src + assert 'status=413' in src + + +def test_folder_download_respects_max_bytes_env(): + src = ROUTES_PY.read_text(encoding="utf-8") + assert 'HERMES_WEBUI_FOLDER_ZIP_MAX_MB' in src + assert '"folder too large"' in src + assert 'limit_bytes' in src + + +def test_folder_download_preflights_before_streaming(): + """Pre-flight collect must run BEFORE send_response so 413 can return JSON.""" + src = ROUTES_PY.read_text(encoding="utf-8") + handler_idx = src.index("def _handle_folder_download") + end_idx = src.index("\n\n# ", handler_idx) if "\n\n# " in src[handler_idx:] else len(src) + body = src[handler_idx:end_idx] + collect_call = body.index("_folder_download_collect") + send_response = body.index("handler.send_response(200)") + limit_check = body.index('"too many files"') + assert collect_call < limit_check < send_response + + +def test_folder_download_rejects_files(): + src = ROUTES_PY.read_text(encoding="utf-8") + assert "path must be a directory" in src + assert "/api/file/raw" in src # error message guides user + + +def test_folder_download_streams_not_buffers(): + src = ROUTES_PY.read_text(encoding="utf-8") + assert "zipfile.ZipFile(handler.wfile" in src + assert "allowZip64=True" in src + handler_idx = src.index("def _handle_folder_download") + end_idx = src.index("\n\ndef ", handler_idx + 1) + body = src[handler_idx:end_idx] + assert "io.BytesIO" not in body, "must stream, not buffer in memory" + + +def test_ui_context_menu_has_download_folder(): + src = UI_JS.read_text(encoding="utf-8") + assert "download_folder" in src + download_idx = src.index("download_folder") + snippet = src[max(0, download_idx - 200):download_idx] + assert "item.type==='dir'" in snippet or "item.type === 'dir'" in snippet + + +def test_ui_download_folder_uses_endpoint(): + src = UI_JS.read_text(encoding="utf-8") + download_idx = src.index("download_folder") + snippet = src[download_idx:download_idx + 600] + assert "/api/folder/download" in snippet + assert "session_id=" in snippet + assert "path=" in snippet + assert "encodeURIComponent" in snippet