Merge branch 'dev' into feature/slicer-api

This commit is contained in:
MartinNYHC
2026-04-27 17:09:42 +02:00
committed by GitHub
13 changed files with 1294 additions and 31 deletions
+174 -13
View File
@@ -2,6 +2,7 @@
import base64
import binascii
import contextlib
import hashlib
import logging
import os
@@ -186,6 +187,106 @@ def _stored_file_path(abs_path: Path, is_external: bool) -> str:
return str(abs_path) if is_external else to_relative_path(abs_path)
class _MoveSkip(Exception):
"""Signalled by ``_move_file_bytes`` to skip a file with a user-visible reason.
Carries an optional `code` for machine-friendly grouping (the
front-end can localise it) and a fallback English `reason` for logs.
"""
def __init__(self, code: str, reason: str):
super().__init__(reason)
self.code = code
self.reason = reason
def _resolve_source_disk_path(file: LibraryFile) -> Path | None:
"""Return the absolute on-disk path for an existing LibraryFile, or None
if it can't be located (legacy DB row, deleted file, etc.)."""
if file.is_external:
return Path(file.file_path) if file.file_path else None
return to_absolute_path(file.file_path)
def _move_file_bytes(file: LibraryFile, target_folder: LibraryFolder | None) -> str:
"""Physically relocate `file`'s bytes to match `target_folder`.
Used by the move endpoint when source/target straddle the
managed↔external boundary (#1112 follow-up — the prior implementation
updated the DB row's ``folder_id`` but never moved the bytes, so a
file moved to an external SMB folder showed up in Bambuddy's UI but
not on the NAS).
Returns the new ``file_path`` value to persist (relative for managed
targets, absolute for external targets — matches the upload + scan
paths). Raises ``_MoveSkip`` for any condition that would make the
move unsafe (target unwritable, filename collision, source missing).
The copy-then-unlink ordering means a partial copy followed by a
failed unlink leaves both the source and the dest on disk — better
than the symmetric "rename or move" which would lose the source if
the target write didn't complete on a flaky mount. The DB row stays
pointed at the source until the caller commits the new ``file_path``.
"""
src = _resolve_source_disk_path(file)
if not src or not src.exists():
raise _MoveSkip("source_missing", "source file missing on disk")
target_is_external = target_folder is not None and target_folder.is_external
if target_is_external:
if target_folder.external_readonly:
# Already blocked at top level, but defence-in-depth.
raise _MoveSkip("target_readonly", "target external folder is read-only")
if not target_folder.external_path:
raise _MoveSkip("target_misconfigured", "target external folder has no path")
ext_dir = Path(target_folder.external_path)
if not ext_dir.exists() or not ext_dir.is_dir():
raise _MoveSkip("target_inaccessible", f"target path not accessible: {ext_dir}")
if not os.access(ext_dir, os.W_OK):
raise _MoveSkip("target_unwritable", f"target path not writable: {ext_dir}")
dest = (ext_dir / file.filename).resolve()
try:
dest.relative_to(ext_dir.resolve())
except ValueError:
raise _MoveSkip("invalid_filename", f"unsafe filename: {file.filename!r}") from None
if dest.exists():
raise _MoveSkip("name_collision", f"a file named {file.filename!r} already exists in target")
try:
shutil.copy2(src, dest)
except OSError as e:
# Clean up partial dest so a retry can succeed.
with contextlib.suppress(OSError):
dest.unlink(missing_ok=True)
raise _MoveSkip("copy_failed", f"copy failed: {e}") from e
else:
# → managed (root or non-external folder): generate a fresh UUID
# filename in the internal store so we don't collide with another
# file that happens to share `filename`.
ext = src.suffix.lower()
dest = get_library_files_dir() / f"{uuid.uuid4().hex}{ext}"
try:
shutil.copy2(src, dest)
except OSError as e:
with contextlib.suppress(OSError):
dest.unlink(missing_ok=True)
raise _MoveSkip("copy_failed", f"copy failed: {e}") from e
# Copy succeeded — unlink the original. A failure here leaves an
# orphan on disk but the DB row is consistent against the new dest.
try:
src.unlink(missing_ok=True)
except OSError as e:
logger.warning(
"Move: copied %s → %s but couldn't remove source: %s",
src,
dest,
e,
)
return _stored_file_path(dest, is_external=target_is_external)
def _clean_3mf_metadata(obj):
"""Strip bytes and thumbnail-carrier keys so the payload is JSON-storable.
@@ -3265,11 +3366,20 @@ async def move_files(
):
"""Move multiple files to a folder.
Files not owned by the user are skipped (unless user has *_all permission).
Cross-boundary moves (managed ↔ external, or external ↔ external)
physically relocate the bytes — see ``_move_file_bytes``. Same-boundary
moves stay DB-only because the file's on-disk location doesn't depend
on which managed folder owns it.
Files not owned by the user are skipped (unless user has ``*_all``
permission). Each skip carries a structured reason so the UI can
surface "5 of 10 files were skipped: 3 had filename collisions on
the NAS, 2 are no longer on disk" rather than a blank "skipped: 5".
"""
user, can_modify_all = auth_result
# Verify folder exists if specified
target_folder: LibraryFolder | None = None
if data.folder_id is not None:
folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))
target_folder = folder_result.scalar_one_or_none()
@@ -3278,25 +3388,76 @@ async def move_files(
if target_folder.is_external and target_folder.external_readonly:
raise HTTPException(status_code=403, detail="Cannot move files to a read-only external folder")
# Update files
target_is_external = target_folder is not None and target_folder.is_external
moved = 0
skipped = 0
skipped_reasons: list[dict] = []
for file_id in data.file_ids:
result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
result = await db.execute(
LibraryFile.active().options(selectinload(LibraryFile.folder)).where(LibraryFile.id == file_id)
)
file = result.scalar_one_or_none()
if file:
# Ownership check
if not can_modify_all and file.created_by_id != user.id:
skipped += 1
continue
# Cannot move external files out of their folder
if file.is_external:
skipped += 1
continue
if not file:
continue
# Ownership check
if not can_modify_all and file.created_by_id != user.id:
skipped += 1
skipped_reasons.append({"file_id": file_id, "code": "not_owner", "reason": "not the file owner"})
continue
# No bytes need to move when both ends are managed (same-boundary).
if not file.is_external and not target_is_external:
file.folder_id = data.folder_id
moved += 1
continue
return {"status": "success", "moved": moved, "skipped": skipped}
# Block moves out of a read-only external mount. The user only has
# read access to the source, and a move is semantically a delete on
# the source — which a read-only mount can't fulfil. Without this
# guard we'd succeed at copying to the target, fail to unlink the
# source, and the same file would now exist in two places (with
# the DB pointing at only one).
if file.is_external and file.folder is not None and file.folder.external_readonly:
skipped += 1
skipped_reasons.append(
{"file_id": file_id, "code": "source_readonly", "reason": "source is on a read-only external folder"}
)
continue
# Otherwise relocate the bytes, then update the DB row to match.
try:
new_file_path = _move_file_bytes(file, target_folder)
except _MoveSkip as e:
skipped += 1
skipped_reasons.append({"file_id": file_id, "code": e.code, "reason": e.reason})
continue
file.is_external = target_is_external
file.folder_id = data.folder_id
file.file_path = new_file_path
# External rows historically carry `file_hash=None` (scan skips
# hashing). When pulling an external file into managed storage,
# compute the hash so dedup detection works for future uploads
# of the same content.
if not target_is_external and file.file_hash is None:
try:
abs_path = to_absolute_path(new_file_path)
if abs_path:
file.file_hash = calculate_file_hash(abs_path)
except OSError:
pass # leave hash null; dedup just won't match this row
moved += 1
await db.commit()
return {
"status": "success",
"moved": moved,
"skipped": skipped,
"skipped_reasons": skipped_reasons,
}
@router.post("/bulk-delete", response_model=BulkDeleteResponse)