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
|
2026-08-06 07:01:22 +02:00
|
|
|
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
|
2026-08-06 07:01:22 +02:00
|
|
|
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"}
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 07:01:22 +02:00
|
|
|
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 8 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}"
|