0.37.0: download whole folders (recursive) as a .zip from the file browser

The file browser/editor could only download individual files. Add a
recursive directory download that streams the folder as a zip archive,
on the local host and on every remote agent.

- file_service.archive_dir(): zip a directory recursively into a temp
  file, preserving the folder name as the archive root and empty
  subdirectories; symlinks are skipped (no sandbox escape / loops).
- /api/files/download and /agent/files/download branch on directories
  and return application/zip, cleaning up the temp file afterwards.
- Files page: show the download button for folders too (as <name>.zip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-21 19:54:50 +00:00
co-authored by Claude Opus 4.8
parent 5bcec06bbd
commit f1782eca0e
6 changed files with 67 additions and 12 deletions
+43
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import os
import shutil
import tempfile
import zipfile
from services.device_service import BrowseError, _is_allowed, _real_root
@@ -175,6 +177,47 @@ def resolve_download(path: str) -> tuple[str, str]:
return real, os.path.basename(path)
def is_dir(path: str) -> bool:
"""Whether ``path`` points at a directory inside the sandbox."""
return os.path.isdir(_safe_real(path))
def archive_dir(path: str) -> tuple[str, str]:
"""Zip a directory (recursively) into a temp file.
Returns ``(tmp_zip_path, download_filename)``. The caller is responsible
for deleting the temp file once it has been streamed to the client.
Symlinks are skipped so the archive cannot escape the sandbox or loop.
"""
real = _safe_real(path)
if not os.path.isdir(real):
raise BrowseError(f"Not a directory: {path}")
name = os.path.basename(path.rstrip("/")) or "root"
fd, tmp = tempfile.mkstemp(suffix=".zip")
os.close(fd)
try:
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(real):
# Don't follow symlinked directories (avoids loops / escapes).
dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d))]
rel_root = os.path.relpath(root, real)
if not files and not dirs and rel_root != ".":
# Preserve otherwise-empty directories.
zf.writestr(os.path.join(name, rel_root) + "/", "")
for f in files:
full = os.path.join(root, f)
if os.path.islink(full):
continue
zf.write(full, os.path.join(name, rel_root, f) if rel_root != "."
else os.path.join(name, f))
except OSError:
if os.path.exists(tmp):
os.unlink(tmp)
raise
return tmp, f"{name}.zip"
def upload_target(
dir_path: str,
filename: str,