"""Pure storage/validation helpers for chore evidence photos. This module deliberately has NO Home Assistant HTTP / aiohttp imports so it can be imported from the coordinator modules (for cleanup) and unit-tested without a real HA install. The aiohttp views live in ``http_photos.py``. Photos are stored as ``<32 hex>.`` under ``/taskmate_photos`` and served (auth-gated) at ``/api/taskmate/photo/``. """ from __future__ import annotations import logging import re import time from pathlib import Path _LOGGER = logging.getLogger(__name__) # Directory under the HA config dir (survives integration upgrades). PHOTOS_DIR = "taskmate_photos" # Public URL prefix for upload (POST) and serve (GET /). URL_PREFIX = "/api/taskmate/photo" # Defensive cap. The child card downscales + JPEG-compresses before upload, so a # legitimate evidence photo is well under this; the cap only catches abuse/bugs. MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # 8 MB # Total disk budget for all stored evidence photos. Each photo is small (the # card downscales + JPEG-compresses before upload), so this is generous — it # only caps runaway/abusive uploads (SEC-2). MAX_TOTAL_BYTES = 512 * 1024 * 1024 # 512 MB # Stored filenames are always a generated uuid4().hex + a known image extension. # Anything else is rejected before any filesystem access (path-traversal safety). FILENAME_RE = re.compile(r"^[0-9a-f]{32}\.(jpg|png|webp|heic)$") CONTENT_TYPES = { "jpg": "image/jpeg", "png": "image/png", "webp": "image/webp", "heic": "image/heic", } def detect_image_ext(data: bytes) -> str | None: """Return a file extension for known image magic bytes, else None. Sniffs the actual bytes rather than trusting any client-declared type. The card always sends JPEG; PNG/WebP/HEIC are accepted defensively for direct API callers. """ if data[:3] == b"\xff\xd8\xff": return "jpg" if data[:8] == b"\x89PNG\r\n\x1a\n": return "png" if data[:4] == b"RIFF" and data[8:12] == b"WEBP": return "webp" # ISO-BMFF: "....ftyp"; HEIC brands start the major-brand field. if data[4:8] == b"ftyp" and data[8:12] in (b"heic", b"heix", b"mif1", b"heif"): return "heic" return None def content_type_for(filename: str) -> str: """Map a stored filename to its Content-Type (defaults to image/jpeg).""" ext = filename.rsplit(".", 1)[-1].lower() return CONTENT_TYPES.get(ext, "image/jpeg") def photos_path(hass) -> Path: """Absolute path to the evidence-photos directory.""" return Path(hass.config.path(PHOTOS_DIR)) def is_taskmate_photo_url(photo_url: str) -> bool: """True only for a well-formed ``/api/taskmate/photo/`` URL of ours. Pure (no hass / no filesystem) so it can guard untrusted ``photo_url`` input at the service/coordinator boundary. Rejects blanks, foreign URLs and any dangerous scheme (``javascript:``, external ``http(s):``) and anything whose name fails the strict filename pattern. """ if not photo_url: return False prefix = URL_PREFIX + "/" if not photo_url.startswith(prefix): return False return bool(FILENAME_RE.match(photo_url[len(prefix):])) def photo_file_for_url(hass, photo_url: str) -> Path | None: """Map a ``/api/taskmate/photo/`` URL to its file path. Returns None for anything that isn't one of our photo URLs or whose name fails the strict filename pattern (rejects traversal, sub-paths, foreign URLs). Never touches the filesystem. """ if not photo_url: return None prefix = URL_PREFIX + "/" if not photo_url.startswith(prefix): return None name = photo_url[len(prefix):] if not FILENAME_RE.match(name): return None return photos_path(hass) / name def sign_photo_url(hass, photo_url: str, expiration_hours: int = 24) -> str: """Return a self-authenticating signed URL for one of our photo URLs. Browsers don't send the HA bearer token on plain ```` / top-level navigation requests, so the auth-gated serve view returns 401 on a bare URL. ``async_sign_path`` appends an ``?authSig=`` token the HTTP auth middleware accepts, so the admin panel can render thumbnails and open/save the full photo. Foreign/blank URLs are returned unchanged. The HA imports are local so this module stays importable without a real HA install (the pure helpers above are unit-tested). """ if not photo_url or not photo_url.startswith(URL_PREFIX + "/"): return photo_url from datetime import timedelta from homeassistant.components.http.auth import async_sign_path try: return async_sign_path(hass, photo_url, timedelta(hours=expiration_hours)) except Exception: # noqa: BLE001 - never break state delivery over a signing hiccup _LOGGER.debug("Could not sign photo URL %s", photo_url, exc_info=True) return photo_url async def async_delete_photo(hass, photo_url: str) -> None: """Best-effort delete of the file backing a photo URL. No-op for foreign URLs or a missing file; logs other OS errors at debug. """ path = photo_file_for_url(hass, photo_url) if path is None: return def _unlink() -> None: try: path.unlink() except FileNotFoundError: pass except OSError as err: # pragma: no cover - defensive _LOGGER.debug("Could not delete evidence photo %s: %s", path, err) await hass.async_add_executor_job(_unlink) def total_photos_bytes(hass) -> int: """Sum of all stored evidence-photo file sizes (0 if the dir is absent).""" directory = photos_path(hass) if not directory.is_dir(): return 0 total = 0 for p in directory.iterdir(): if p.is_file() and FILENAME_RE.match(p.name): try: total += p.stat().st_size except OSError: # pragma: no cover - defensive pass return total async def async_sweep_orphan_photos(hass, referenced_urls, max_age_hours: int = 24) -> int: """Delete stored photos not referenced by any completion (SEC-2). Covers photos orphaned by history pruning and uploads that were never attached to a completion. Files younger than ``max_age_hours`` are kept so an in-flight upload (uploaded but not yet submitted) is not removed. Returns the number of files deleted. """ prefix = URL_PREFIX + "/" referenced = { url[len(prefix):] for url in referenced_urls if url and url.startswith(prefix) } directory = photos_path(hass) def _sweep() -> int: if not directory.is_dir(): return 0 cutoff = time.time() - max_age_hours * 3600 removed = 0 for p in directory.iterdir(): if not (p.is_file() and FILENAME_RE.match(p.name)): continue if p.name in referenced: continue try: if p.stat().st_mtime < cutoff: p.unlink() removed += 1 except OSError: # pragma: no cover - defensive pass return removed return await hass.async_add_executor_job(_sweep)