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
+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}