library_paths.py 3.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
  1. """Where a library file's user photos live on disk (#3077).
  2. Photos are Bambuddy-side metadata, so they sit inside the library data dir
  3. regardless of whether the file itself is managed or external:
  4. ``<archive_dir>/library/photos/<file_id>/``. The routes, the trash sweeper,
  5. the external-folder scan and the dispatch cleanup all derive the directory
  6. from here — see ``archive_paths`` for why one path derived in several places
  7. is a bug waiting to happen.
  8. """
  9. from __future__ import annotations
  10. import logging
  11. import shutil
  12. import uuid
  13. from collections.abc import Sequence
  14. from pathlib import Path
  15. from backend.app.core.config import settings
  16. from backend.app.utils.safe_path import PathTraversalError, safe_join_under
  17. logger = logging.getLogger(__name__)
  18. def library_photos_dir(file_id: int) -> Path:
  19. """The photo directory for library file *file_id* (not created)."""
  20. library_dir = Path(settings.archive_dir) / "library"
  21. return library_dir / "photos" / str(file_id) # SEC-PATH-OK: file_id is an int primary key
  22. def remove_library_photos_dir(file_id: int) -> None:
  23. """Best-effort removal of a file's photo directory and everything in it."""
  24. photos_dir = library_photos_dir(file_id)
  25. if not photos_dir.is_dir():
  26. return
  27. try:
  28. shutil.rmtree(photos_dir)
  29. except OSError as e:
  30. logger.warning("Failed to remove library photos dir %s: %s", photos_dir, e)
  31. def move_library_photos(file_id: int, photos: Sequence[str], destination: Path) -> list[str]:
  32. """Move a library file's photos into *destination*, emptying its directory.
  33. Used where a library row is consumed by the archive that replaces it
  34. (``cleanup_library_after_dispatch``): the photos follow the file instead
  35. of being orphaned under an id nothing points at any more. Returns the
  36. names the photos ended up under, in order — a name already taken in
  37. *destination* gets a fresh one, because both sides draw photo names from
  38. the same 8-hex-digit alphabet.
  39. Best-effort: a photo that cannot be moved is left out of the returned
  40. list, so it is never named by an archive that does not have it. The
  41. caller is mid-dispatch and has nowhere to report to. The source
  42. directory is only removed once everything in the list did move, so a
  43. failure orphans the pictures rather than destroying them.
  44. """
  45. source_dir = library_photos_dir(file_id)
  46. if not source_dir.is_dir():
  47. return []
  48. moved: list[str] = []
  49. failed = False
  50. for filename in photos:
  51. try:
  52. source = safe_join_under(source_dir, filename, http=False)
  53. except PathTraversalError:
  54. failed = True
  55. continue
  56. if not source.is_file():
  57. continue
  58. target_name = filename
  59. try:
  60. destination.mkdir(parents=True, exist_ok=True)
  61. target = safe_join_under(destination, target_name, http=False)
  62. if target.exists():
  63. target_name = f"{uuid.uuid4().hex[:8]}{source.suffix.lower()}"
  64. target = destination / target_name # SEC-PATH-OK: uuid.uuid4().hex[:8] + suffix
  65. shutil.move(str(source), str(target))
  66. except (OSError, PathTraversalError) as e:
  67. logger.warning("Failed to move library photo %s to %s: %s", source, destination, e)
  68. failed = True
  69. continue
  70. moved.append(target_name)
  71. if failed:
  72. logger.warning("Kept library photos dir %s: not every photo reached %s", source_dir, destination)
  73. else:
  74. remove_library_photos_dir(file_id)
  75. return moved