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
@@ -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