| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788 |
- """Where a library file's user photos live on disk (#3077).
- Photos are Bambuddy-side metadata, so they sit inside the library data dir
- regardless of whether the file itself is managed or external:
- ``<archive_dir>/library/photos/<file_id>/``. The routes, the trash sweeper,
- the external-folder scan and the dispatch cleanup all derive the directory
- from here — see ``archive_paths`` for why one path derived in several places
- is a bug waiting to happen.
- """
- from __future__ import annotations
- import logging
- import shutil
- import uuid
- from collections.abc import Sequence
- from pathlib import Path
- from backend.app.core.config import settings
- from backend.app.utils.safe_path import PathTraversalError, safe_join_under
- logger = logging.getLogger(__name__)
- def library_photos_dir(file_id: int) -> Path:
- """The photo directory for library file *file_id* (not created)."""
- library_dir = Path(settings.archive_dir) / "library"
- return library_dir / "photos" / str(file_id) # SEC-PATH-OK: file_id is an int primary key
- def remove_library_photos_dir(file_id: int) -> None:
- """Best-effort removal of a file's photo directory and everything in it."""
- photos_dir = library_photos_dir(file_id)
- if not photos_dir.is_dir():
- return
- try:
- shutil.rmtree(photos_dir)
- except OSError as e:
- logger.warning("Failed to remove library photos dir %s: %s", photos_dir, e)
- def move_library_photos(file_id: int, photos: Sequence[str], destination: Path) -> list[str]:
- """Move a library file's photos into *destination*, emptying its directory.
- Used where a library row is consumed by the archive that replaces it
- (``cleanup_library_after_dispatch``): the photos follow the file instead
- of being orphaned under an id nothing points at any more. Returns the
- names the photos ended up under, in order — a name already taken in
- *destination* gets a fresh one, because both sides draw photo names from
- the same 8-hex-digit alphabet.
- Best-effort: a photo that cannot be moved is left out of the returned
- list, so it is never named by an archive that does not have it. The
- caller is mid-dispatch and has nowhere to report to. The source
- directory is only removed once everything in the list did move, so a
- failure orphans the pictures rather than destroying them.
- """
- source_dir = library_photos_dir(file_id)
- if not source_dir.is_dir():
- return []
- moved: list[str] = []
- failed = False
- for filename in photos:
- try:
- source = safe_join_under(source_dir, filename, http=False)
- except PathTraversalError:
- failed = True
- continue
- if not source.is_file():
- continue
- target_name = filename
- try:
- destination.mkdir(parents=True, exist_ok=True)
- target = safe_join_under(destination, target_name, http=False)
- if target.exists():
- target_name = f"{uuid.uuid4().hex[:8]}{source.suffix.lower()}"
- target = destination / target_name # SEC-PATH-OK: uuid.uuid4().hex[:8] + suffix
- shutil.move(str(source), str(target))
- except (OSError, PathTraversalError) as e:
- logger.warning("Failed to move library photo %s to %s: %s", source, destination, e)
- failed = True
- continue
- moved.append(target_name)
- if failed:
- logger.warning("Kept library photos dir %s: not every photo reached %s", source_dir, destination)
- else:
- remove_library_photos_dir(file_id)
- return moved
|