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 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-08 10:53:57 +00:00
co-authored by Claude Opus 4.8
parent e3313fb4ac
commit 25bba1cf2c
8 changed files with 290 additions and 16 deletions
+7 -3
View File
@@ -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 highlighting picked from the extension). Binary and oversized files are
detected and offered as a download instead. Admins can edit and **Save**. detected and offered as a download instead. Admins can edit and **Save**.
- **Manage** (admin): create folders/files, rename, delete (recursive for - **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 - **Sandboxed**: all access is confined to `ALLOWED_BROWSE_ROOTS`; path
traversal and deleting a browse root are refused. To reach the real host 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 filesystem, mount it into the backend and set `HOST_ROOT_PREFIX` (see the
commented `/:/host_root` volume in `docker-compose.yml`). Endpoints live under commented `/:/host_root` volume in `docker-compose.yml`). Endpoints live under
`/api/files/*` (`list`, `read`, `write`, `mkdir`, `touch`, `rename`, `upload`, `/api/files/*` (`list`, `read`, `write`, `mkdir`, `touch`, `rename`, `copy`,
`download`, `DELETE`). `move`, `upload` — with optional `rel_path` for folder uploads —, `download`,
`DELETE`).
## Deploying an agent on another host ## Deploying an agent on another host
+1 -1
View File
@@ -40,7 +40,7 @@ from services import backup_service, compose_service
logger = logging.getLogger("stackpilot.agent") logger = logging.getLogger("stackpilot.agent")
AGENT_VERSION = "0.12.0" AGENT_VERSION = "0.13.0"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+1 -1
View File
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel() 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
+42 -3
View File
@@ -93,6 +93,12 @@ class RenameBody(BaseModel):
new_name: str new_name: str
class TransferBody(BaseModel):
src: str
dest_dir: str
overwrite: bool = False
@router.put("/write") @router.put("/write")
def write_file( def write_file(
body: WriteBody, body: WriteBody,
@@ -150,6 +156,36 @@ def rename(
return result 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("") @router.delete("")
def delete( def delete(
request: Request, request: Request,
@@ -171,11 +207,14 @@ async def upload(
request: Request, request: Request,
path: str = Form(...), path: str = Form(...),
overwrite: bool = Form(False), overwrite: bool = Form(False),
rel_path: str = Form(""),
file: UploadFile = File(...), file: UploadFile = File(...),
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(require_admin), user: User = Depends(require_admin),
) -> dict: ) -> 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. # Stream to a temp file first, then move into place atomically.
tmp = tempfile.NamedTemporaryFile(delete=False, dir=os.path.dirname(real)) tmp = tempfile.NamedTemporaryFile(delete=False, dir=os.path.dirname(real))
try: try:
@@ -189,6 +228,6 @@ async def upload(
raise HTTPException(status_code=400, detail=f"Upload failed: {exc}") from exc raise HTTPException(status_code=400, detail=f"Upload failed: {exc}") from exc
audit_service.record( audit_service.record(
session, user=user.username, action="file.upload", 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}
+94 -6
View File
@@ -175,14 +175,102 @@ def resolve_download(path: str) -> tuple[str, str]:
return real, os.path.basename(path) return real, os.path.basename(path)
def upload_target(dir_path: str, filename: str, overwrite: bool = False) -> str: def upload_target(
"""Validate an upload destination and return the real path to write to.""" 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) real_dir = _safe_real(dir_path)
if not os.path.isdir(real_dir): if not os.path.isdir(real_dir):
raise BrowseError(f"Not a directory: {dir_path}") raise BrowseError(f"Not a directory: {dir_path}")
name = os.path.basename(filename or "")
child = _child(dir_path, name) components: list[str]
real = _safe_real(child) 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: 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 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}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "stackpilot-frontend", "name": "stackpilot-frontend",
"private": true, "private": true,
"version": "0.12.0", "version": "0.13.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+12 -1
View File
@@ -44,15 +44,26 @@ export const filesApi = {
remove: (path: string, recursive = false) => remove: (path: string, recursive = false) =>
api.delete("/api/files", { params: { path, recursive } }).then((r) => r.data), 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) => { download: async (path: string, filename: string) => {
const res = await api.get("/api/files/download", { params: { path }, responseType: "blob" }); const res = await api.get("/api/files/download", { params: { path }, responseType: "blob" });
triggerDownload(res.data as Blob, filename); 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(); const form = new FormData();
form.append("path", path); form.append("path", path);
form.append("overwrite", String(overwrite)); form.append("overwrite", String(overwrite));
if (relPath) form.append("rel_path", relPath);
form.append("file", file); form.append("file", file);
const res = await api.post<{ ok: boolean; name: string }>("/api/files/upload", form); const res = await api.post<{ ok: boolean; name: string }>("/api/files/upload", form);
return res.data; return res.data;
+132
View File
@@ -8,6 +8,7 @@ import {
FolderPlus, FolderPlus,
FilePlus, FilePlus,
Upload, Upload,
FolderUp,
Download, Download,
Pencil, Pencil,
Trash2, Trash2,
@@ -17,6 +18,9 @@ import {
X, X,
HardDrive, HardDrive,
Link2, Link2,
Copy,
Scissors,
ClipboardPaste,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import Editor from "@monaco-editor/react"; import Editor from "@monaco-editor/react";
@@ -43,6 +47,13 @@ function langForName(name: string): string {
return LANG_BY_EXT[ext] ?? "plaintext"; return LANG_BY_EXT[ext] ?? "plaintext";
} }
interface Clipboard {
src: string;
name: string;
type: "dir" | "file";
mode: "copy" | "cut";
}
function join(path: string, name: string) { function join(path: string, name: string) {
return `${path === "/" ? "" : path}/${name}`; return `${path === "/" ? "" : path}/${name}`;
} }
@@ -67,7 +78,10 @@ export function Files() {
const [renaming, setRenaming] = useState<HostPathEntry | null>(null); const [renaming, setRenaming] = useState<HostPathEntry | null>(null);
const [deleting, setDeleting] = useState<HostPathEntry | null>(null); const [deleting, setDeleting] = useState<HostPathEntry | null>(null);
const [newKind, setNewKind] = useState<"dir" | "file" | null>(null); const [newKind, setNewKind] = useState<"dir" | "file" | null>(null);
const [clip, setClip] = useState<Clipboard | null>(null);
const [pasteConflict, setPasteConflict] = useState(false);
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
const { data, isLoading, isFetching, error } = useQuery({ const { data, isLoading, isFetching, error } = useQuery({
queryKey: ["files", path, showHidden], 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({ const remove = useMutation({
mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir"), mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir"),
onSuccess: () => { onSuccess: () => {
@@ -141,6 +203,13 @@ export function Files() {
<Button onClick={() => fileInput.current?.click()} loading={upload.isPending}> <Button onClick={() => fileInput.current?.click()} loading={upload.isPending}>
<Upload className="h-4 w-4" /> Upload <Upload className="h-4 w-4" /> Upload
</Button> </Button>
<Button
variant="outline"
onClick={() => folderInput.current?.click()}
loading={uploadFolder.isPending}
>
<FolderUp className="h-4 w-4" /> Upload folder
</Button>
<input <input
ref={fileInput} ref={fileInput}
type="file" type="file"
@@ -150,6 +219,18 @@ export function Files() {
if (f) upload.mutate(f); if (f) upload.mutate(f);
}} }}
/> />
<input
ref={folderInput}
type="file"
className="hidden"
multiple
// webkitdirectory/directory are non-standard but widely supported.
{...({ webkitdirectory: "", directory: "" } as Record<string, string>)}
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
if (files.length) uploadFolder.mutate(files);
}}
/>
</> </>
)} )}
</div> </div>
@@ -183,6 +264,28 @@ export function Files() {
))} ))}
</div> </div>
{clip && (
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-sky-50 px-3 py-2 text-sm dark:border-slate-700 dark:bg-sky-950/30">
{clip.mode === "copy" ? (
<Copy className="h-4 w-4 text-sky-500" />
) : (
<Scissors className="h-4 w-4 text-sky-500" />
)}
<span className="text-slate-600 dark:text-slate-300">
{clip.mode === "copy" ? "Copy" : "Move"} <span className="font-medium">{clip.name}</span> to{" "}
<span className="font-mono text-xs">{path}</span>
</span>
<div className="ml-auto flex gap-2">
<Button onClick={() => paste.mutate(false)} loading={paste.isPending}>
<ClipboardPaste className="h-4 w-4" /> Paste here
</Button>
<Button variant="outline" onClick={() => setClip(null)} disabled={paste.isPending}>
Cancel
</Button>
</div>
</div>
)}
{error ? ( {error ? (
<p className="p-4 text-sm text-red-500">{apiErrorMessage(error)}</p> <p className="p-4 text-sm text-red-500">{apiErrorMessage(error)}</p>
) : isLoading ? ( ) : isLoading ? (
@@ -238,6 +341,24 @@ export function Files() {
)} )}
{isAdmin && ( {isAdmin && (
<> <>
<button
title="Copy"
onClick={() =>
setClip({ src: join(path, e.name), name: e.name, type: e.type, mode: "copy" })
}
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Copy className="h-4 w-4 text-slate-500" />
</button>
<button
title="Cut (move)"
onClick={() =>
setClip({ src: join(path, e.name), name: e.name, type: e.type, mode: "cut" })
}
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Scissors className="h-4 w-4 text-slate-500" />
</button>
<button <button
title="Rename" title="Rename"
onClick={() => setRenaming(e)} onClick={() => setRenaming(e)}
@@ -317,6 +438,17 @@ export function Files() {
onCancel={() => setDeleting(null)} onCancel={() => setDeleting(null)}
/> />
)} )}
{pasteConflict && clip && (
<ConfirmDialog
title={`${clip.name}” already exists here`}
message={`Overwrite the existing ${clip.type === "dir" ? "folder" : "file"} at ${path}?`}
confirmLabel="Overwrite"
danger
busy={paste.isPending}
onConfirm={() => paste.mutate(true)}
onCancel={() => setPasteConflict(false)}
/>
)}
</div> </div>
); );
} }