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:
co-authored by
Claude Opus 4.8
parent
e3313fb4ac
commit
25bba1cf2c
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.12.0",
|
||||
"version": "0.13.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user