mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
A failed TLS handshake pauses a printer's file service for five minutes, and the archive flow checks that pause at the top of its path loop and gives up before opening a connection. A print that starts inside one gets an empty fallback archive 13 milliseconds later, having never touched the network. Four minutes on, the pause clears and the cover endpoint downloads the same file, parses it, takes a thumbnail out of it, and publishes it to the shared 3MF cache under the exact key the archive flow looks up. Nothing ever looks: all three readers of that cache run before or during the print-start handler that already gave up, and print completion drops the cache as its first act, deleting the file. No path existed by which a fallback archive could become a real one. Offer a later 3MF to the running print's archive, filling the existing row rather than adding a second -- the row id carries the energy reading, the timelapse session and the start notification. That covers the reported case at no network cost, since opening the printer card already downloads the file. Schedule a bounded retry when the pause is what caused the fallback, spending the cache first and the printer only if that misses. Not scheduled for the other cause: a print kept on internal eMMC has no FTPS copy to come back for, and retrying it is the sweep removed in #2780. The two reasons are now recorded separately instead of both landing as "no 3MF". Recovery refuses anything that is not a readable 3MF -- a truncated download would replace an honest empty archive with wrong metadata -- and leaves an archive alone once it has a real file. Three things the recovery path has to get right, each with a test: Reduce every retry candidate to a bare name. MQTT hands `filename` over as "/data/Metadata/plate_1.gcode" on some firmware, and joining that onto the temp directory yields the absolute path itself, so the retry is fed the flow's own sanitised candidate list and strips the path again on its own account. Serialise recovery per printer. The cover endpoint's single-flight coalesces by view, so two views race each other, and the retry task and print completion can land on top of either -- each reads file_path == "" and runs a full copy, leaving the row on one timestamped directory and the rest orphaned. Keep looking for photos in the pre-recovery directory. `archive_dir` derives from `file_path`, so filling the row moves the archive's directory, and a photo uploaded to the empty card while the print ran stays where it was put.
102 lines
4.2 KiB
Python
102 lines
4.2 KiB
Python
"""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 _pre_recovery_photos_dir(archive: object) -> Path | None:
|
|
"""Where this archive's photos were written while it had no 3MF.
|
|
|
|
An archive that started as a no-3MF fallback and was later filled in from a
|
|
3MF that turned up (#2957) changes directory: ``archive_dir`` derives from
|
|
``file_path``, which goes from empty to a real path. Anything written to
|
|
``<archive_dir>/<id>/photos`` before that moment is still there, so it stays
|
|
a lookup candidate afterwards -- the same reason
|
|
:func:`_legacy_shared_photos_dir` exists.
|
|
|
|
None while ``file_path`` is empty, where this *is* the current directory and
|
|
the caller already checks it.
|
|
"""
|
|
if not (getattr(archive, "file_path", "") or ""):
|
|
return None
|
|
return settings.archive_dir / str(archive.id) / "photos" # SEC-PATH-OK: archive.id is an int primary key
|
|
|
|
|
|
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),
|
|
_pre_recovery_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
|