"""Where an archive's files live on disk (#1820). An archive normally owns a directory, derived from its ``file_path``: ``//``. An archive created without a 3MF has ``file_path == ""``, and ``Path("").parent`` is ``Path(".")`` -- so every site that derived the directory that way silently resolved to ``base_dir`` itself, and all such archives shared one pile. The finish-photo capture path spotted that and used ``//`` instead. Nothing else did, so a captured photo was written to one directory and then looked for in another: the read 404'd, the delete removed the name and left the file, and the notification attachment never found the image. Four sites deriving the same directory four times is what let them drift, so they now all ask here. Photos written before this are still where they were put, which is why lookups check both locations rather than only the current one. Scope note: a *source 3MF* uploaded onto a no-3MF archive has its own layout, ``archive/no_source//``, chosen separately and stored in its own column. This module does not model that -- do not reach for ``archive_dir`` to find one. """ from __future__ import annotations from pathlib import Path from backend.app.core.config import settings from backend.app.utils.safe_path import PathTraversalError, safe_join_under def archive_dir(archive: object) -> Path: """The directory belonging to *archive*. Falls back to ``/`` for an archive with no 3MF, matching what the finish-photo capture has always written. """ file_path = getattr(archive, "file_path", "") or "" if file_path: return settings.base_dir / Path(file_path).parent return settings.archive_dir / str(archive.id) # SEC-PATH-OK: archive.id is an int primary key def archive_photos_dir(archive: object) -> Path: """Where photos for *archive* are written.""" return archive_dir(archive) / "photos" # SEC-PATH-OK: constant subdirectory def _legacy_shared_photos_dir(archive: object) -> Path | None: """Where a no-3MF archive's photos used to be read from, and uploaded to. ``/photos``, shared by every no-3MF archive at once. Only ever consulted for an archive that has no ``file_path``; one with a real path always resolved correctly and has no second location to check. """ if getattr(archive, "file_path", "") or "": return None return settings.base_dir / "photos" # SEC-PATH-OK: constant subdirectory def find_archive_photo(archive: object, filename: str) -> Path | None: """Locate an existing photo, or None if it is in neither location. *filename* must already have been checked for membership in ``archive.photos``; it is joined containment-checked regardless. A name that fails that check is treated as not found rather than raised on -- one caller is a background notification task, where an HTTP error would have nowhere to go. """ for directory in (archive_photos_dir(archive), _legacy_shared_photos_dir(archive)): if directory is None: continue try: candidate = safe_join_under(directory, filename, http=False) except PathTraversalError: return None if candidate.exists(): return candidate return None