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
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
+1 -1
View File
@@ -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"
# --------------------------------------------------------------------------- #
+1 -1
View File
@@ -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,
+42 -3
View File
@@ -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}
+94 -6
View File
@@ -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}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.12.0",
"version": "0.13.0",
"type": "module",
"scripts": {
"dev": "vite",
+12 -1
View File
@@ -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;
+132
View File
@@ -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<HostPathEntry | null>(null);
const [deleting, setDeleting] = useState<HostPathEntry | 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 folderInput = useRef<HTMLInputElement>(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() {
<Button onClick={() => fileInput.current?.click()} loading={upload.isPending}>
<Upload className="h-4 w-4" /> Upload
</Button>
<Button
variant="outline"
onClick={() => folderInput.current?.click()}
loading={uploadFolder.isPending}
>
<FolderUp className="h-4 w-4" /> Upload folder
</Button>
<input
ref={fileInput}
type="file"
@@ -150,6 +219,18 @@ export function Files() {
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>
@@ -183,6 +264,28 @@ export function Files() {
))}
</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 ? (
<p className="p-4 text-sm text-red-500">{apiErrorMessage(error)}</p>
) : isLoading ? (
@@ -238,6 +341,24 @@ export function Files() {
)}
{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
title="Rename"
onClick={() => setRenaming(e)}
@@ -317,6 +438,17 @@ export function Files() {
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>
);
}