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