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