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