From 25bba1cf2ccce76afda380bef42e17a76ca1a4f1 Mon Sep 17 00:00:00 2001 From: menzelj Date: Mon, 8 Jun 2026 10:53:57 +0000 Subject: [PATCH] File browser: folder upload + copy/move (0.13.0) Folder upload: the Files page gained an "Upload folder" picker (webkitdirectory); each file is sent with its webkitRelativePath and the backend recreates the directory tree. upload_target now accepts an optional rel_path, creating intermediate dirs (mkdir -p) inside the sandbox with each component validated against traversal. Copy/move: new file_service.copy/move + POST /api/files/{copy,move} (admin, audit-logged). The UI adds per-row copy/cut actions, a clipboard bar to paste into the current directory, and an overwrite prompt on conflict. Both refuse to move/copy a folder into itself or its own subtree and are sandbox-checked on source and destination. Co-Authored-By: Claude Opus 4.8 --- README.md | 10 ++- backend/agent_app.py | 2 +- backend/main.py | 2 +- backend/routers/files.py | 45 ++++++++++- backend/services/file_service.py | 100 +++++++++++++++++++++-- frontend/package.json | 2 +- frontend/src/api/files.ts | 13 ++- frontend/src/pages/Files.tsx | 132 +++++++++++++++++++++++++++++++ 8 files changed, 290 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5908d05..5a7efc3 100644 --- a/README.md +++ b/README.md @@ -154,13 +154,17 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. highlighting picked from the extension). Binary and oversized files are detected and offered as a download instead. Admins can edit and **Save**. - **Manage** (admin): create folders/files, rename, delete (recursive for - folders), upload files, and download any file. Every mutation is audit-logged. + folders), upload files **or whole folders** (the directory tree is recreated + server-side), and download any file. **Copy/cut & paste** moves files and + folders between directories (clipboard bar + per-row copy/cut, with an + overwrite prompt on conflict). Every mutation is audit-logged. - **Sandboxed**: all access is confined to `ALLOWED_BROWSE_ROOTS`; path traversal and deleting a browse root are refused. To reach the real host filesystem, mount it into the backend and set `HOST_ROOT_PREFIX` (see the commented `/:/host_root` volume in `docker-compose.yml`). Endpoints live under - `/api/files/*` (`list`, `read`, `write`, `mkdir`, `touch`, `rename`, `upload`, - `download`, `DELETE`). + `/api/files/*` (`list`, `read`, `write`, `mkdir`, `touch`, `rename`, `copy`, + `move`, `upload` — with optional `rel_path` for folder uploads —, `download`, + `DELETE`). ## Deploying an agent on another host diff --git a/backend/agent_app.py b/backend/agent_app.py index b54291d..c721278 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -40,7 +40,7 @@ from services import backup_service, compose_service logger = logging.getLogger("stackpilot.agent") -AGENT_VERSION = "0.12.0" +AGENT_VERSION = "0.13.0" # --------------------------------------------------------------------------- # diff --git a/backend/main.py b/backend/main.py index 8ac3e3b..173ea89 100644 --- a/backend/main.py +++ b/backend/main.py @@ -55,7 +55,7 @@ async def lifespan(app: FastAPI): schedule_task.cancel() -app = FastAPI(title="StackPilot", version="0.12.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.13.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/backend/routers/files.py b/backend/routers/files.py index fe0fde3..e703924 100644 --- a/backend/routers/files.py +++ b/backend/routers/files.py @@ -93,6 +93,12 @@ class RenameBody(BaseModel): new_name: str +class TransferBody(BaseModel): + src: str + dest_dir: str + overwrite: bool = False + + @router.put("/write") def write_file( body: WriteBody, @@ -150,6 +156,36 @@ def rename( return result +@router.post("/copy") +def copy( + body: TransferBody, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + result = _guard(file_service.copy, body.src, body.dest_dir, body.overwrite) + audit_service.record( + session, user=user.username, action="file.copy", + target=body.src, detail=f"-> {result['path']}", ip=_ip(request), + ) + return result + + +@router.post("/move") +def move( + body: TransferBody, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + result = _guard(file_service.move, body.src, body.dest_dir, body.overwrite) + audit_service.record( + session, user=user.username, action="file.move", + target=body.src, detail=f"-> {result['path']}", ip=_ip(request), + ) + return result + + @router.delete("") def delete( request: Request, @@ -171,11 +207,14 @@ async def upload( request: Request, path: str = Form(...), overwrite: bool = Form(False), + rel_path: str = Form(""), file: UploadFile = File(...), session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: - real = _guard(file_service.upload_target, path, file.filename or "", overwrite) + real = _guard( + file_service.upload_target, path, file.filename or "", overwrite, rel_path or None + ) # Stream to a temp file first, then move into place atomically. tmp = tempfile.NamedTemporaryFile(delete=False, dir=os.path.dirname(real)) try: @@ -189,6 +228,6 @@ async def upload( raise HTTPException(status_code=400, detail=f"Upload failed: {exc}") from exc audit_service.record( session, user=user.username, action="file.upload", - target=path, detail=file.filename, ip=_ip(request), + target=path, detail=rel_path or file.filename, ip=_ip(request), ) - return {"ok": True, "name": file.filename} + return {"ok": True, "name": rel_path or file.filename} diff --git a/backend/services/file_service.py b/backend/services/file_service.py index a22a666..46c774d 100644 --- a/backend/services/file_service.py +++ b/backend/services/file_service.py @@ -175,14 +175,102 @@ def resolve_download(path: str) -> tuple[str, str]: return real, os.path.basename(path) -def upload_target(dir_path: str, filename: str, overwrite: bool = False) -> str: - """Validate an upload destination and return the real path to write to.""" +def upload_target( + dir_path: str, + filename: str, + overwrite: bool = False, + rel_path: str | None = None, +) -> str: + """Validate an upload destination and return the real path to write to. + + When ``rel_path`` is given (a folder-upload's relative path such as + ``photos/2024/img.jpg``) the intermediate directories are created under + ``dir_path`` and the file lands at their leaf. Each path component is + validated to block traversal. Otherwise the file lands directly in + ``dir_path`` under ``filename``. + """ real_dir = _safe_real(dir_path) if not os.path.isdir(real_dir): raise BrowseError(f"Not a directory: {dir_path}") - name = os.path.basename(filename or "") - child = _child(dir_path, name) - real = _safe_real(child) + + components: list[str] + if rel_path: + # Normalise separators, drop empty segments, validate each component. + components = [p for p in rel_path.replace("\\", "/").split("/") if p not in ("", ".")] + if not components: + raise BrowseError("Invalid upload path") + else: + components = [os.path.basename(filename or "")] + + # Build the logical path one component at a time; _child rejects "..". + logical = dir_path + for comp in components: + logical = _child(logical, comp) + real = _safe_real(logical) + + # Create intermediate directories (mkdir -p), staying inside the sandbox. + parent = os.path.dirname(real) + try: + os.makedirs(parent, exist_ok=True) + except PermissionError as exc: + raise BrowseError(f"Permission denied: {dir_path}") from exc + if os.path.exists(real) and not overwrite: - raise BrowseError(f"Already exists: {name}") + raise BrowseError(f"Already exists: {os.path.basename(logical)}") return real + + +# --------------------------------------------------------------------------- # +# Copy / move +# --------------------------------------------------------------------------- # + + +def _transfer_dest(src: str, dest_dir: str, overwrite: bool) -> tuple[str, str, str]: + """Validate a copy/move and return (src_real, dest_real, dest_logical).""" + src_real = _safe_real(src) + if not os.path.lexists(src_real): + raise BrowseError(f"No such path: {src}") + real_dest_dir = _safe_real(dest_dir) + if not os.path.isdir(real_dest_dir): + raise BrowseError(f"Not a directory: {dest_dir}") + + name = os.path.basename(src.rstrip("/")) + dest_logical = _child(dest_dir, name) + dest_real = _safe_real(dest_logical) + + # Refuse to copy/move a directory into itself or its own subtree. + src_norm = os.path.normpath(src_real) + dest_norm = os.path.normpath(dest_real) + if dest_norm == src_norm or dest_norm.startswith(src_norm + os.sep): + raise BrowseError("Cannot move or copy a folder into itself") + if os.path.exists(dest_real) and not overwrite: + raise BrowseError(f"Already exists: {name}") + return src_real, dest_real, dest_logical + + +def copy(src: str, dest_dir: str, overwrite: bool = False) -> dict: + src_real, dest_real, dest_logical = _transfer_dest(src, dest_dir, overwrite) + try: + if os.path.isdir(src_real) and not os.path.islink(src_real): + if os.path.exists(dest_real): + shutil.rmtree(dest_real) + shutil.copytree(src_real, dest_real, symlinks=True) + else: + shutil.copy2(src_real, dest_real, follow_symlinks=False) + except OSError as exc: + raise BrowseError(f"Could not copy {src}: {exc.strerror or exc}") from exc + return {"path": dest_logical} + + +def move(src: str, dest_dir: str, overwrite: bool = False) -> dict: + src_real, dest_real, dest_logical = _transfer_dest(src, dest_dir, overwrite) + try: + if os.path.exists(dest_real) and overwrite: + if os.path.isdir(dest_real) and not os.path.islink(dest_real): + shutil.rmtree(dest_real) + else: + os.remove(dest_real) + shutil.move(src_real, dest_real) + except OSError as exc: + raise BrowseError(f"Could not move {src}: {exc.strerror or exc}") from exc + return {"path": dest_logical} diff --git a/frontend/package.json b/frontend/package.json index 3b1fd49..2966efb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.12.0", + "version": "0.13.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index 72c9200..48e1957 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -44,15 +44,26 @@ export const filesApi = { remove: (path: string, recursive = false) => api.delete("/api/files", { params: { path, recursive } }).then((r) => r.data), + copy: (src: string, destDir: string, overwrite = false) => + api + .post<{ path: string }>("/api/files/copy", { src, dest_dir: destDir, overwrite }) + .then((r) => r.data), + + move: (src: string, destDir: string, overwrite = false) => + api + .post<{ path: string }>("/api/files/move", { src, dest_dir: destDir, overwrite }) + .then((r) => r.data), + download: async (path: string, filename: string) => { const res = await api.get("/api/files/download", { params: { path }, responseType: "blob" }); triggerDownload(res.data as Blob, filename); }, - upload: async (path: string, file: File, overwrite = false) => { + upload: async (path: string, file: File, overwrite = false, relPath = "") => { const form = new FormData(); form.append("path", path); form.append("overwrite", String(overwrite)); + if (relPath) form.append("rel_path", relPath); form.append("file", file); const res = await api.post<{ ok: boolean; name: string }>("/api/files/upload", form); return res.data; diff --git a/frontend/src/pages/Files.tsx b/frontend/src/pages/Files.tsx index e0e3626..5e68663 100644 --- a/frontend/src/pages/Files.tsx +++ b/frontend/src/pages/Files.tsx @@ -8,6 +8,7 @@ import { FolderPlus, FilePlus, Upload, + FolderUp, Download, Pencil, Trash2, @@ -17,6 +18,9 @@ import { X, HardDrive, Link2, + Copy, + Scissors, + ClipboardPaste, } from "lucide-react"; import { toast } from "sonner"; import Editor from "@monaco-editor/react"; @@ -43,6 +47,13 @@ function langForName(name: string): string { return LANG_BY_EXT[ext] ?? "plaintext"; } +interface Clipboard { + src: string; + name: string; + type: "dir" | "file"; + mode: "copy" | "cut"; +} + function join(path: string, name: string) { return `${path === "/" ? "" : path}/${name}`; } @@ -67,7 +78,10 @@ export function Files() { const [renaming, setRenaming] = useState(null); const [deleting, setDeleting] = useState(null); const [newKind, setNewKind] = useState<"dir" | "file" | null>(null); + const [clip, setClip] = useState(null); + const [pasteConflict, setPasteConflict] = useState(false); const fileInput = useRef(null); + const folderInput = useRef(null); const { data, isLoading, isFetching, error } = useQuery({ queryKey: ["files", path, showHidden], @@ -95,6 +109,54 @@ export function Files() { }, }); + // Folder upload: send each file with its relative path so the backend + // recreates the directory structure. Continues past individual failures. + const uploadFolder = useMutation({ + mutationFn: async (files: File[]) => { + let ok = 0; + let failed = 0; + for (const f of files) { + const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name; + try { + await filesApi.upload(path, f, true, rel); + ok += 1; + } catch { + failed += 1; + } + } + return { ok, failed }; + }, + onSuccess: ({ ok, failed }) => { + if (failed) toast.warning(`Uploaded ${ok} file(s), ${failed} failed`); + else toast.success(`Uploaded ${ok} file(s)`); + refresh(); + }, + onError: (e) => toast.error(apiErrorMessage(e)), + onSettled: () => { + if (folderInput.current) folderInput.current.value = ""; + }, + }); + + const paste = useMutation({ + mutationFn: (overwrite: boolean) => { + const op = clip!.mode === "copy" ? filesApi.copy : filesApi.move; + return op(clip!.src, path, overwrite); + }, + onSuccess: () => { + toast.success(clip!.mode === "copy" ? "Copied" : "Moved"); + setClip(null); + setPasteConflict(false); + refresh(); + }, + onError: (e) => { + if (apiErrorMessage(e).startsWith("Already exists")) { + setPasteConflict(true); + } else { + toast.error(apiErrorMessage(e)); + } + }, + }); + const remove = useMutation({ mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir"), onSuccess: () => { @@ -141,6 +203,13 @@ export function Files() { + + )} + onChange={(e) => { + const files = Array.from(e.target.files ?? []); + if (files.length) uploadFolder.mutate(files); + }} + /> )} @@ -183,6 +264,28 @@ export function Files() { ))} + {clip && ( +
+ {clip.mode === "copy" ? ( + + ) : ( + + )} + + {clip.mode === "copy" ? "Copy" : "Move"} {clip.name} to{" "} + {path} + +
+ + +
+
+ )} + {error ? (

{apiErrorMessage(error)}

) : isLoading ? ( @@ -238,6 +341,24 @@ export function Files() { )} {isAdmin && ( <> + +