notification_photos.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. """Where ad-hoc notification snapshots live on disk.
  2. Most events (first layer complete, plate not empty, printer errors, ...) hand
  3. their captured camera frame straight to providers as raw bytes. Home Assistant
  4. and Bark need an HTTP URL instead, since they fetch it themselves, so those
  5. bytes have to land somewhere servable first.
  6. These aren't tied to a PrintArchive (plate-not-empty runs before one exists)
  7. and shouldn't show up in an archive's photo gallery, so they get their own
  8. flat directory instead of reusing archive_paths.py. Nothing links to them
  9. beyond the notification that triggered the capture, so they're just pruned by
  10. age on write rather than tracked in the database.
  11. The filename is the credential. HA, Bark and Slack fetch the URL with no
  12. session, and it ends up in chat channels and on Bark's relay, so it must not
  13. carry anything that opens more than this one photo -- a camera stream token
  14. would open every printer's live stream for an hour. Instead each name embeds
  15. ``secrets.token_urlsafe(24)`` (192 bits), the route serves only names of that
  16. exact shape, and a photo older than ``_MAX_AGE_SECONDS`` is refused even if a
  17. prune hasn't removed it yet, so the age limit is the URL's expiry.
  18. """
  19. from __future__ import annotations
  20. import logging
  21. import re
  22. import secrets
  23. import time
  24. from datetime import datetime
  25. from pathlib import Path
  26. from backend.app.core.config import settings
  27. from backend.app.utils.safe_path import PathTraversalError, safe_join_under
  28. logger = logging.getLogger(__name__)
  29. # These only need to survive long enough for a provider to fetch them once
  30. # after the notification goes out, so a few days of slack is plenty.
  31. _MAX_AGE_SECONDS = 3 * 24 * 60 * 60 # 3 days
  32. # {event}_{YYYYmmdd}_{HHMMSS}_{token_urlsafe(24)}.jpg -- 24 random bytes encode
  33. # to exactly 32 URL-safe base64 characters.
  34. _TOKEN_BYTES = 24
  35. _FILENAME_RE = re.compile(r"[a-z0-9_]+_\d{8}_\d{6}_[A-Za-z0-9_-]{32}\.jpg")
  36. def notification_photos_dir() -> Path:
  37. return settings.base_dir / "notification_photos" # SEC-PATH-OK: constant subdirectory
  38. def _prune_old_photos(directory: Path) -> None:
  39. """Best-effort deletion of files older than ``_MAX_AGE_SECONDS``.
  40. Failures here must never block a notification from sending, so every
  41. error is swallowed after a debug log.
  42. """
  43. try:
  44. cutoff = time.time() - _MAX_AGE_SECONDS
  45. for entry in directory.iterdir():
  46. try:
  47. if entry.is_file() and entry.stat().st_mtime < cutoff:
  48. entry.unlink()
  49. except OSError:
  50. continue
  51. except OSError as e:
  52. logger.debug("Failed to prune notification photos: %s", e)
  53. def save_notification_photo(image_data: bytes, event_type: str) -> str:
  54. """Write *image_data* to the notification photos dir and return its filename.
  55. Runs synchronously — callers on the async path should wrap this in
  56. ``asyncio.to_thread``.
  57. """
  58. directory = notification_photos_dir()
  59. directory.mkdir(parents=True, exist_ok=True)
  60. _prune_old_photos(directory)
  61. safe_event = re.sub(r"[^a-z0-9_]", "", event_type.lower()) or "event"
  62. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  63. filename = f"{safe_event}_{timestamp}_{secrets.token_urlsafe(_TOKEN_BYTES)}.jpg"
  64. path = directory / filename # SEC-PATH-OK: filename generated above, not user input
  65. path.write_bytes(image_data)
  66. return filename
  67. def find_notification_photo(filename: str) -> Path | None:
  68. """Resolve *filename* under the notification photos dir.
  69. None unless it has the exact shape ``save_notification_photo`` produces,
  70. exists, and is younger than ``_MAX_AGE_SECONDS``.
  71. """
  72. if not _FILENAME_RE.fullmatch(filename):
  73. return None
  74. try:
  75. candidate = safe_join_under(notification_photos_dir(), filename, http=False)
  76. except PathTraversalError:
  77. return None
  78. try:
  79. if not candidate.is_file() or candidate.stat().st_mtime < time.time() - _MAX_AGE_SECONDS:
  80. return None
  81. except OSError:
  82. return None
  83. return candidate