teecup/app/storage.py

125 lines
4.7 KiB
Python
Raw Permalink Normal View History

MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
"""
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
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
from minio import Minio
from PIL import Image, UnidentifiedImageError
from .config import settings
from .errors import app_error
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
ALLOWED_INPUT_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
2026-08-07 22:02:56 +02:00
# 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
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
_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:
2026-08-07 22:02:56 +02:00
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 20 MB).")
return raw
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
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}"