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:
co-authored by
Claude Opus 4.8
parent
5bcec06bbd
commit
f1782eca0e
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user