archive_paths.py 3.3 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
  1. """Where an archive's files live on disk (#1820).
  2. An archive normally owns a directory, derived from its ``file_path``:
  3. ``<base_dir>/<dirname of file_path>/``. An archive created without a 3MF has
  4. ``file_path == ""``, and ``Path("").parent`` is ``Path(".")`` -- so every site
  5. that derived the directory that way silently resolved to ``base_dir`` itself,
  6. and all such archives shared one pile.
  7. The finish-photo capture path spotted that and used ``<archive_dir>/<id>/``
  8. instead. Nothing else did, so a captured photo was written to one directory and
  9. then looked for in another: the read 404'd, the delete removed the name and
  10. left the file, and the notification attachment never found the image. Four
  11. sites deriving the same directory four times is what let them drift, so they
  12. now all ask here.
  13. Photos written before this are still where they were put, which is why lookups
  14. check both locations rather than only the current one.
  15. Scope note: a *source 3MF* uploaded onto a no-3MF archive has its own layout,
  16. ``archive/no_source/<id>/``, chosen separately and stored in its own column.
  17. This module does not model that -- do not reach for ``archive_dir`` to find one.
  18. """
  19. from __future__ import annotations
  20. from pathlib import Path
  21. from backend.app.core.config import settings
  22. from backend.app.utils.safe_path import PathTraversalError, safe_join_under
  23. def archive_dir(archive: object) -> Path:
  24. """The directory belonging to *archive*.
  25. Falls back to ``<archive_dir>/<id>`` for an archive with no 3MF, matching
  26. what the finish-photo capture has always written.
  27. """
  28. file_path = getattr(archive, "file_path", "") or ""
  29. if file_path:
  30. return settings.base_dir / Path(file_path).parent
  31. return settings.archive_dir / str(archive.id) # SEC-PATH-OK: archive.id is an int primary key
  32. def archive_photos_dir(archive: object) -> Path:
  33. """Where photos for *archive* are written."""
  34. return archive_dir(archive) / "photos" # SEC-PATH-OK: constant subdirectory
  35. def _legacy_shared_photos_dir(archive: object) -> Path | None:
  36. """Where a no-3MF archive's photos used to be read from, and uploaded to.
  37. ``<base_dir>/photos``, shared by every no-3MF archive at once. Only ever
  38. consulted for an archive that has no ``file_path``; one with a real path
  39. always resolved correctly and has no second location to check.
  40. """
  41. if getattr(archive, "file_path", "") or "":
  42. return None
  43. return settings.base_dir / "photos" # SEC-PATH-OK: constant subdirectory
  44. def find_archive_photo(archive: object, filename: str) -> Path | None:
  45. """Locate an existing photo, or None if it is in neither location.
  46. *filename* must already have been checked for membership in
  47. ``archive.photos``; it is joined containment-checked regardless. A name
  48. that fails that check is treated as not found rather than raised on --
  49. one caller is a background notification task, where an HTTP error would
  50. have nowhere to go.
  51. """
  52. for directory in (archive_photos_dir(archive), _legacy_shared_photos_dir(archive)):
  53. if directory is None:
  54. continue
  55. try:
  56. candidate = safe_join_under(directory, filename, http=False)
  57. except PathTraversalError:
  58. return None
  59. if candidate.exists():
  60. return candidate
  61. return None