feat(workspace): download folder as zip via /api/folder/download

Adds a "Download Folder" item to the workspace file-tree right-click
menu and a GET /api/folder/download endpoint that streams the
directory as a zip with Content-Disposition: attachment.

Configurable caps:
  HERMES_WEBUI_FOLDER_ZIP_MAX_MB    (default 1024)
  HERMES_WEBUI_FOLDER_ZIP_MAX_FILES (default 50000)

Pre-flights the walk so cap-exceeded returns 413 + JSON BEFORE any
zip bytes are sent. Symlinks resolving outside the workspace are
skipped. Mirrors the existing _handle_file_raw shape (session_id
resolution, safe_resolve, RFC 5987 filename via
_content_disposition_value). Stdlib zipfile only; no new dependencies.

Tests: 11 static-inspection tests matching the style of
tests/test_issue1867_upload_size_preflight.py. All passing on
Python 3.11/3.12/3.13.
This commit is contained in:
Bryan Bartley committed 2026-05-18 21:40:02 -05:00
1 parent 718a4c7615
commit 6caf86ba96
4 files changed
+260

No files matched your search

+140
View File
@@ -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 <session.workspace>/<path>. 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]
+1
View File
@@ -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',
+17
View File
@@ -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;';
+102
View File
@@ -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