teecup/app/storage.py
2026-08-07 22:02:56 +02:00

124 lines
4.7 KiB
Python

"""
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 fastapi import UploadFile
from minio import Minio
from PIL import Image, UnidentifiedImageError
from .config import settings
from .errors import app_error
ALLOWED_INPUT_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
# 20 MB rå input, FØR konvertering -- denne grensen beskytter ikke selve
# lagringsstørrelsen (det gjør AVIF-konverteringen under, som uansett gir en
# liten fil uavhengig av inputstørrelse), den er kun en øvre sanity-grense.
# Hevet fra 8 MB 2026-08-06 -- moderne telefonkameraer (bl.a. Pixel 8 Pro i
# høy kvalitet/HDR+) produserer rutinemessig JPEG-er i 8-15 MB-klassen, som
# den gamle grensen avviste FØR bildet fikk sjansen til å konverteres ned.
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
_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
async def read_optional_image(image: UploadFile | None) -> bytes | None:
"""Delt valideringssteg for et valgfritt multipart-bildefelt (brukt av
både org-feeden/lag-chatten, messaging.py, og round_messages.py) --
content-type-allowlist + størrelsesgrense, ingen faktisk dekoding her
(det gjør _convert_to_avif_sync, kalt separat av upload_image)."""
if image is None:
return None
if image.content_type not in ALLOWED_INPUT_CONTENT_TYPES:
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
raw = await image.read(MAX_UPLOAD_BYTES + 1)
if len(raw) > MAX_UPLOAD_BYTES:
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 20 MB).")
return raw
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}"