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
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.36.1"
|
||||
APP_VERSION = "0.37.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.36.1",
|
||||
"version": "0.37.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user