""" MinIO-objektlagring for landingsside-media (ADR-018, MinIO-runden). Opplasting går GJENNOM API-et, ikke direkte fra nettleser til MinIO -- bevisst, ikke bare enklere: alle bilder konverteres til AVIF server-side før lagring (plassbesparelse), noe en direkte presignert nettleser- opplasting ikke ville latt oss gjøre uten et eget etterbehandlingssteg. Dette betyr KUN ÉTT MinIO-klientoppsett trengs -- backend-en snakker alltid direkte med MinIO container-internt (teecup-minio:9000 -- bindestrek, IKKE understrek: MinIO validerer Host-headeren strengt og avviser understrek som ugyldig vertsnavn, funnet under scratch-verifisering), aldri via Caddy. Caddy sin /teecup-media/*-rute trengs KUN for offentlig LESING (anonym GET mot en public-read bucket-policy, ingen signatur involvert, derav ingen presignert-URL-Host-header-følsomhet å bekymre seg for). """ import asyncio import io import time import pillow_avif # noqa: F401 -- registrerer AVIF-plugin i Pillow ved import from minio import Minio from PIL import Image, UnidentifiedImageError from .config import settings ALLOWED_INPUT_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # 8 MB rå input, før konvertering _MAX_DIMENSION = 2400 # nedskaler urimelig store bilder før konvertering _AVIF_QUALITY = 65 BUCKET = settings.MINIO_BUCKET _client = Minio( settings.MINIO_INTERNAL_ENDPOINT, access_key=settings.MINIO_ROOT_USER, secret_key=settings.MINIO_ROOT_PASSWORD, secure=False, ) _PUBLIC_READ_POLICY = f"""{{ "Version": "2012-10-17", "Statement": [ {{ "Effect": "Allow", "Principal": {{"AWS": ["*"]}}, "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::{BUCKET}/*"] }} ] }}""" class InvalidImageError(Exception): pass def _ensure_bucket_sync() -> None: if not _client.bucket_exists(BUCKET): _client.make_bucket(BUCKET) # Idempotent: samme policy satt på nytt hver oppstart er en no-op. _client.set_bucket_policy(BUCKET, _PUBLIC_READ_POLICY) async def ensure_bucket() -> None: """Kalt fra app/main.py sin lifespan ved oppstart -- trygt å kjøre hver gang (oppretter kun bucket/policy hvis de ikke allerede er der).""" await asyncio.to_thread(_ensure_bucket_sync) def _convert_to_avif_sync(raw: bytes) -> bytes: try: with Image.open(io.BytesIO(raw)) as img: img.load() if img.mode not in ("RGB", "RGBA"): img = img.convert("RGB") if max(img.size) > _MAX_DIMENSION: img.thumbnail((_MAX_DIMENSION, _MAX_DIMENSION)) out = io.BytesIO() img.save(out, format="AVIF", quality=_AVIF_QUALITY) return out.getvalue() except UnidentifiedImageError as e: raise InvalidImageError("ikke en gjenkjennbar bildefil") from e def _put_sync(key: str, data: bytes) -> None: _client.put_object(BUCKET, key, io.BytesIO(data), length=len(data), content_type="image/avif") async def upload_image(prefix: str, resource_id: str, raw: bytes) -> str: """Konverterer rå bildedata (jpeg/png/webp/gif) til AVIF og laster opp. Returnerer objektnøkkelen -- IKKE en URL, se public_url().""" avif_bytes = await asyncio.to_thread(_convert_to_avif_sync, raw) key = f"{prefix}/{resource_id}/{int(time.time())}.avif" await asyncio.to_thread(_put_sync, key, avif_bytes) return key def public_url(key: str) -> str: return f"{settings.PUBLIC_BASE_URL}/{BUCKET}/{key}"