"""platform/objectstore — the media-blob port (SD-0002 §6.2, §6.3.1). A tiny put/get/delete port with two adapters, mirroring the SD-0001 mailer pattern: local-disk (dev/tests) and GCS (deployed). Owns no semantics — keys and content types come from the caller (the image-fetch phase). Objects are written once, never mutated (immutable, image-id-addressed). """ from __future__ import annotations import os from pathlib import Path from typing import Protocol from app.platform import config class ObjectNotFound(Exception): """Raised by get() when the key has no object.""" class ObjectStore(Protocol): def put(self, key: str, data: bytes, content_type: str) -> None: ... def get(self, key: str) -> bytes: ... def delete(self, key: str) -> None: ... def build_objectstore(kind: str) -> ObjectStore: """Select the objectstore adapter by configured kind (config.objectstore_kind, INV-8).""" if kind == "local": return LocalObjectStore(config.objectstore_local_dir()) if kind == "gcs": return GcsObjectStore(config.objectstore_bucket()) raise ValueError(f"unknown objectstore kind: {kind!r}") def _safe_segments(key: str) -> tuple[str, ...]: parts = tuple(p for p in key.split("/") if p) if not parts or any(p in {".", ".."} for p in parts): raise ValueError(f"unsafe objectstore key: {key!r}") return parts class LocalObjectStore: """Disk-backed adapter under a base dir; key path-segments become subdirs.""" def __init__(self, base_dir: str) -> None: self._base = Path(base_dir) def _path(self, key: str) -> Path: return self._base.joinpath(*_safe_segments(key)) def put(self, key: str, data: bytes, content_type: str) -> None: path = self._path(key) path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) def get(self, key: str) -> bytes: path = self._path(key) try: return path.read_bytes() except FileNotFoundError as exc: raise ObjectNotFound(key) from exc def delete(self, key: str) -> None: try: os.remove(self._path(key)) except FileNotFoundError: pass class GcsObjectStore: """GCS adapter; auth via the VM service account ADC (no secret bytes). google-cloud-storage is imported lazily so dev/test runs (local adapter) don't require the package at import time. """ def __init__(self, bucket_name: str) -> None: if not bucket_name: raise ValueError("gcs objectstore requires ECOMM_OBJECTSTORE_BUCKET") from google.cloud import storage # lazy self._bucket = storage.Client().bucket(bucket_name) def put(self, key: str, data: bytes, content_type: str) -> None: _safe_segments(key) self._bucket.blob(key).upload_from_string(data, content_type=content_type) def get(self, key: str) -> bytes: from google.cloud.exceptions import NotFound # lazy try: return self._bucket.blob(key).download_as_bytes() except NotFound as exc: raise ObjectNotFound(key) from exc def delete(self, key: str) -> None: from google.cloud.exceptions import NotFound # lazy try: self._bucket.blob(key).delete() except NotFound: pass