"""Batch presigning for awareness-material images. Materials store S3 **object keys**; every read has to exchange those for short-lived presigned GET URLs. Two things make that worth its own module rather than a loop over ``app.core.storage.presign_get``: 1. **Cost.** ``presign_get`` re-resolves the storage config (3 ``platform_settings`` rows) on every call. A comic with 20 pages, in a list of 12 materials, would fire hundreds of extra DB round-trips per page load. The feed hit exactly this and fixed it the same way (``FeedService`` hydration, #2124-era) — config resolved once, the botocore client reused, signing itself being CPU-only HMAC. 2. **Authorisation.** Minting a presigned URL *is* the authorisation decision: once minted it bypasses Helix auth entirely. So every key is re-parsed and re-checked against the awareness purpose immediately before signing, even though the write path already validated it. A key that we would refuse to accept today must not be signed today either — rows written before a gate existed are precisely the ones a gate has to catch. """ from __future__ import annotations import logging from collections.abc import Iterable from sqlalchemy.ext.asyncio import AsyncSession from app.core.storage import ( StorageNotConfiguredError, _get_s3_client, resolve_storage_config, ) logger = logging.getLogger(__name__) #: The single upload purpose this domain owns. Registered in #: ``app.core.storage_uploads.PURPOSE_MAX_BYTES`` and given a read policy #: in ``app.storage_router.presign_get_endpoint``. AWARENESS_PURPOSE = "awareness_material" #: Presigned GET lifetime. Matches the rest of the platform (feed #: attachments, inventory evidence) — long enough for a slow mobile #: connection to finish loading a long comic, short enough that a URL #: pasted into a chat is dead by the time anyone clicks it. PRESIGN_GET_TTL_SECONDS = 600 def is_awareness_key(object_key: str) -> bool: """True when ``object_key`` has the shape the upload router produces AND belongs to this domain's purpose. Uses the router's own parser so the shape check can never drift from the one the ACL enforces — a second implementation of "does this key look right" is a second thing to get wrong. """ # Local import: ``app.storage_router`` imports domain services, so a # module-level import here would close an import cycle. from app.storage_router import parse_object_key parsed = parse_object_key(object_key or "") return parsed is not None and parsed[0] == AWARENESS_PURPOSE async def presign_material_keys( db: AsyncSession, keys: Iterable[str], ) -> dict[str, str]: """Map each valid awareness object key to a presigned GET URL. Keys that fail validation, or that fail to sign, are simply **absent from the result** — callers render a placeholder rather than a broken image. Returning a partial map beats raising: one bad key in a forty-page comic must not 500 the whole library. Returns an empty map when storage is not configured (local dev without S3), which the frontend renders as "cover unavailable" instead of erroring. """ # De-dupe while preserving order: the same cover key legitimately # appears many times in one list read, and signing it once is both # cheaper and produces a stable URL the browser can cache. wanted = list(dict.fromkeys(k for k in keys if k)) if not wanted: return {} try: cfg = await resolve_storage_config(db) client = _get_s3_client(cfg["region"]) bucket = cfg["bucket"] except StorageNotConfiguredError: logger.warning("secaw.presign.storage_not_configured keys=%d", len(wanted)) return {} out: dict[str, str] = {} for key in wanted: if not is_awareness_key(key): # Not an error the reader can act on, but very much one the # operator needs to see: it means a material row references # an object outside this domain's purpose. logger.warning("secaw.presign.refused key=%s", key) continue try: out[key] = client.generate_presigned_url( "get_object", Params={"Bucket": bucket, "Key": key}, ExpiresIn=PRESIGN_GET_TTL_SECONDS, ) except Exception: logger.exception("secaw.presign.failed key=%s", key) return out