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:
1 parent
718a4c7615
commit
6caf86ba96
4 files changed
+260
No files matched your search
+140
@@ -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]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;';
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user