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
+7
View File
@@ -33,6 +33,7 @@ from fastapi import (
WebSocketDisconnect,
)
from fastapi.responses import FileResponse, JSONResponse
from starlette.background import BackgroundTask
from pydantic import BaseModel
from config import settings
@@ -666,6 +667,12 @@ def files_read(path: str = Query(...)) -> dict:
@app.get("/agent/files/download", dependencies=[Depends(verify_token)])
def files_download(path: str = Query(...)):
if _file_guard(file_service.is_dir, path):
tmp, filename = _file_guard(file_service.archive_dir, path)
return FileResponse(
tmp, filename=filename, media_type="application/zip",
background=BackgroundTask(os.unlink, tmp),
)
real, filename = _file_guard(file_service.resolve_download, path)
return FileResponse(real, filename=filename, media_type="application/octet-stream")
+7
View File
@@ -20,6 +20,7 @@ from fastapi import (
UploadFile,
)
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from pydantic import BaseModel
from sqlmodel import Session
@@ -69,6 +70,12 @@ def download(
path: str = Query(...),
_user: User = Depends(get_current_user),
):
if _guard(file_service.is_dir, path):
tmp, filename = _guard(file_service.archive_dir, path)
return FileResponse(
tmp, filename=filename, media_type="application/zip",
background=BackgroundTask(os.unlink, tmp),
)
real, filename = _guard(file_service.resolve_download, path)
return FileResponse(real, filename=filename, media_type="application/octet-stream")
+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,
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.36.1"
APP_VERSION = "0.37.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.36.1",
"version": "0.37.0",
"type": "module",
"scripts": {
"dev": "vite",
+8 -10
View File
@@ -202,7 +202,7 @@ export function Files() {
const download = (e: HostPathEntry) =>
filesApi
.download(join(path, e.name), e.name, host)
.download(join(path, e.name), e.type === "dir" ? `${e.name}.zip` : e.name, host)
.catch((err) => toast.error(apiErrorMessage(err)));
return (
@@ -407,15 +407,13 @@ export function Files() {
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-1">
{e.type === "file" && (
<button
title="Download"
onClick={() => download(e)}
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Download className="h-4 w-4 text-slate-500" />
</button>
)}
<button
title={e.type === "dir" ? "Download as .zip" : "Download"}
onClick={() => download(e)}
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Download className="h-4 w-4 text-slate-500" />
</button>
{isAdmin && (
<>
<button