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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user