| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879 |
- """Where an archive's files live on disk (#1820).
- An archive normally owns a directory, derived from its ``file_path``:
- ``<base_dir>/<dirname of 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 ``<archive_dir>/<id>/``
- 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/<id>/``, 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 ``<archive_dir>/<id>`` 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.
- ``<base_dir>/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
|