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)?
This commit is contained in:
Erol Haagenrud 2026-07-18 11:25:52 +02:00
parent 6d84aafef8
commit 1e491d73b3
12 changed files with 423 additions and 41 deletions

View file

@ -205,7 +205,22 @@
"Bash(rm -f /opt/teecup/.env.scratch /tmp/cowner.txt)",
"Bash(curl -s -o /dev/null -w '/clubs/ukjent: %{http_code}\\\\n' https://teecup.teeoff.no/clubs/ukjent-slug-garantert)",
"Bash(rm -f /opt/teecup/.env.scratch /tmp/ogowner.txt)",
"Bash(curl -s https://teecup.teeoff.no/t/00000000-0000-0000-0000-000000000000)"
"Bash(curl -s https://teecup.teeoff.no/t/00000000-0000-0000-0000-000000000000)",
"Bash(docker volume *)",
"Bash(getent hosts *)",
"Bash(ip addr *)",
"Bash(curl -s -4 https://api.ipify.org)",
"Bash(curl -s -6 https://api64.ipify.org)",
"Bash(python3 -m py_compile /opt/teecup/app/config.py /opt/teecup/app/storage.py /opt/teecup/app/main.py /opt/teecup/app/routers/tournaments.py /opt/teecup/app/routers/registration.py)",
"Bash(docker run --rm python:3.12-slim bash -c ' *)",
"Bash(sed -i -e 's#^TEECUP_DB_NAME=.*#TEECUP_DB_NAME=teecup_scratch#' -e 's#^TEECUP_MINIO_ROOT_USER=.*#TEECUP_MINIO_ROOT_USER=scratchadmin#' -e 's#^TEECUP_MINIO_ROOT_PASSWORD=.*#TEECUP_MINIO_ROOT_PASSWORD=scratchpassword123#' .env.scratch)",
"Bash(sed -i \"s#^TEECUP_MINIO_INTERNAL_ENDPOINT=.*##\" .env.scratch)",
"Bash(docker exec teecup_scratch_api python3 -c ' *)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('hero_image_url:', d['hero_image_url']\\)\")",
"Bash(echo \"dette er ikke et bilde\")",
"Bash(rm -f /opt/teecup/.env.scratch /tmp/minio_owner.txt)",
"Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teecup.teeoff.no/teecup-media/tournaments/4a1e6338-c1c0-4a05-be53-278a0cdff859/1784366368.avif)",
"Bash(curl -s -w '\\\\nHTTP %{http_code}\\\\n' https://teecup.teeoff.no/teecup-media/tournaments/__TRACKED_VAR__/1784366368.avif)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",

View file

@ -604,11 +604,64 @@ Ferdig og verifisert:
forarbeid slik at et fremtidig relativt bilde-URL løses riktig uten en
egen fiks da.
**Rullet ut live**, kun `teecup_frontend`, `teeoff.no` upåvirket.
- **MinIO-runden LIVE (2026-07-18), samme dag — ADR-018 dermed HELT
ferdig, ingenting utsatt igjen:** ny `teecup-minio`-tjeneste (persistent
volum, genererte credentials i `.env`), backend-endepunkter for ekte
bildeopplasting til `tournament.hero_image_key`/`tournament_sponsor.
logo_key` (som lå inerte siden migrasjon 009), offentlige API-svar bygger
nå fulle URL-er, `og:image` koblet på i `/t/[id]` sin `generateMetadata`.
**Sent, men viktig presisert krav underveis:** brukeren avbrøt en
verktøyskall midtveis for å presisere at ALLE bilder skal konverteres
til AVIF for plassbesparelse. Snudde HELE opplastingsarkitekturen på
dette: droppet den opprinnelige planen om presignerte URL-er (nettleser
laster opp DIREKTE til MinIO) til fordel for ekte multipart-opplasting
GJENNOM API-et, som konverterer til AVIF (Pillow + pillow-avif-plugin)
FØR lagring. Dette forenklet arkitekturen betydelig: kun ÉN MinIO-klient
trengs nå (før: to separate klient-oppsett for henholdsvis internt
admin-arbeid og offentlig presignering), og Caddy sin nye rute trenger
ikke lenger bevare Host-headeren presist (relevant kun for SigV4-
signaturverifisering av presignerte URL-er, ikke for anonym public-read).
**To reelle feil funnet UNDER scratch-verifisering, aldri i produksjon:**
(1) `pillow-avif-plugin` krever ingen ekstra systempakker i
`python:3.12-slim` -- verifisert med et frittstående encode/decode-
rundtrip FØR det ble tatt i bruk i selve API-et. (2) MinIO validerer
`Host`-headeren STRENGT og avviser understrek som ugyldig vertsnavn --
et tjenestenavn med understrek (`teecup_minio`, konsistent med
`teecup_api`/`teecup_frontend`) feilet umiddelbart ved oppstart
("Invalid Request (invalid hostname)"). Bekreftet presist ved å teste
samme oppsett med bindestrek i stedet (`teecup-minio`) -- fungerte
umiddelbart. Alle referanser rettet til bindestrek FØR noe ble forsøkt
mot ekte infrastruktur.
**Caddy:** ny `/teecup-media/*`-rute på det EKSISTERENDE
`teecup.teeoff.no`-blokket (IKKE et nytt subdomene -- en tidlig sjekk
avdekket at `media.teecup.teeoff.no` fantes som en wildcard DNS-post,
men pekte til en IPv6-adresse denne serveren ikke har i det hele tatt;
path-prefiks på et allerede fungerende domene unngikk hele den DNS-
avhengigheten). Samme stale-inode-oppførsel som alle tidligere Caddy-
runder -- full `docker restart teeoff_caddy`, brukeren bekreftet
eksplisitt. `teeoff.no` upåvirket.
**Verifisert grundig, i flere lag:** frittstående AVIF-encode/decode-
test, full scratch-kjede (fersk Postgres + en ISOLERT scratch-MinIO-
container) med et ekte opplastet bilde -- bekreftet konvertert til
gyldig AVIF (800×400, 406 bytes for et helfarget testbilde), bekreftet
lagret med riktig nøkkel, bekreftet lesbart ANONYMT direkte mot MinIO
(uten Caddy, isolerer bucket-policyen), bekreftet `hero_image_url`/
`logo_url` bygget riktig i det offentlige API-svaret. Alle tre
valideringsveier testet (ugyldig content-type, korrupt bildeinnhold,
for stor fil >8MB) -- alle ga korrekt `VALIDATION_FAILED`. Etter
Caddy-omstart: et ekte anonymt kall mot `/teecup-media/...`
produksjonsdomenet ga en ekte MinIO S3-XML-feilrespons (`NoSuchKey` for
en scratch-nøkkel som naturligvis ikke finnes i prod) -- beviser ruten
treffer MinIO selv, ikke frontend sin egen 404-side.
`test_isolation.sql` fortsatt 12/12 gjennom hele runden.
**Bevisst utenfor omfang:** ingen faktisk opplasting av et EKTE bilde
til en EKTE, live turnering i denne runden (ville krevd å skrive
test-data i brukerens ekte konto uten å bli spurt) -- tilbudt, ikke
utført. Selve dra-og-slipp-opplastingsskjermen i frontend (V0) er
fortsatt ikke bygget, som avtalt fra starten av runden.
Neste steg:
1. MinIO/bildeopplasting (hero-bilder, sponsorlogoer, og:image) — siste
gjenstående punkt fra ADR-018, egen runde.
2. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) —
1. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) —
samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full
re-eksport hver gang — diff mot live-treet i et scratch-område før noe
pakkes ut over eksisterende filer, og sjekk om V0-skjermen bygger inn

View file

@ -408,8 +408,9 @@
## Landingssider (turnering + organisasjon) — ADR-018 ✅ HELT FERDIG 2026-07-18
Reist av brukeren 2026-07-18, rett etter registrerings-ADR-en (ADR-017).
Backend, begge frontend-skjermer OG Open Graph-metadata bygget og live
samme dag. Kun MinIO/bilder (bevisst egen runde) gjenstår.
Backend, begge frontend-skjermer, Open Graph-metadata OG MinIO-bilde-
opplasting (med AVIF-konvertering) bygget og live samme dag. Kun selve
dra-og-slipp-opplastingsskjermen i frontend (V0) gjenstår.
| Del | Status | Notat |
|---|---|---|
@ -420,7 +421,7 @@ samme dag. Kun MinIO/bilder (bevisst egen runde) gjenstår.
| Org-landingsside: klubbprofil, liste over turneringer (API) | ✅ | `GET /public/orgs/{slug}` — kun `public`-synlige turneringer. Bekreftet: klubb-profil KAN være offentlig (brukerens valg). |
| Lesbar URL (slug) for organisasjon | ✅ | Fantes faktisk allerede i skjemaet siden migrasjon 001 (oversett, funnet da migrasjon 009 feilet mot scratch — se CLAUDE.md-status). Kun `CHECK`-constraints lagt til i 009. |
| Organisator kan faktisk SETTE disse feltene | ✅ | Implisitt hull fylt under bygging: `PATCH /orgs/{id}/tournaments/{id}` (visibility/description/registrering), `PATCH /orgs/{id}` (slug/public_profile), full sponsor-CRUD. |
| Bilder (hero, sponsorlogoer) | 📋 | Bevisst utsatt (brukerens valg). `hero_image_key`/`logo_key`-kolonner finnes allerede (inerte), men krever MinIO — ikke satt opp. Egen, senere runde. |
| Bilder (hero, sponsorlogoer), backend | ✅ | Ny `teecup-minio`-tjeneste, ekte multipart-opplasting → AVIF-konvertering (Pillow) → lagring, live på `teecup.teeoff.no/teecup-media/*`. Selve opplastingsskjermen i frontend (V0) gjenstår. |
| Del-metadata (Open Graph: og:title/og:description) | ✅ | `generateMetadata()``/t/[id]`+`/clubs/[slug]`, ekte data fra API-et, verifisert mot produksjonsimaget. `og:image` gjenstår (MinIO). |
| Blind draw-skjuling på offentlig side | ✅ | Arves automatisk via delt `_fetch_sessions()`-hjelpefunksjon (ADR-018 Beslutning E) — ikke reimplementert. |
| Antall påmeldte / ledige plasser vist åpent | ✅ | `confirmed_count` i `GET /public/tournaments/{id}`. |

View file

@ -72,5 +72,16 @@ class Settings:
# scratch-/dev-testing kan overstyre uten kodeendring.
PUBLIC_BASE_URL: str = os.getenv("TEECUP_PUBLIC_BASE_URL", "https://teecup.teeoff.no").rstrip("/")
# MinIO (ADR-018 MinIO-runde). Root-credentials -- ekte hemmeligheter,
# _required. INTERNAL_ENDPOINT (container-til-container, ingen Caddy)
# brukes KUN til engangs bucket-/policy-oppsett ved oppstart.
# PUBLIC_BASE_URL gjenbrukes til å bygge presignerte URL-er nettleseren
# faktisk skal treffe -- se app/storage.py for hvorfor de to endepunktene
# bevisst er atskilt.
MINIO_ROOT_USER: str = _required("TEECUP_MINIO_ROOT_USER")
MINIO_ROOT_PASSWORD: str = _required("TEECUP_MINIO_ROOT_PASSWORD")
MINIO_BUCKET: str = os.getenv("TEECUP_MINIO_BUCKET", "teecup-media")
MINIO_INTERNAL_ENDPOINT: str = os.getenv("TEECUP_MINIO_INTERNAL_ENDPOINT", "teecup-minio:9000")
settings = Settings()

View file

@ -16,11 +16,13 @@ from fastapi import FastAPI
from .db import init_pool, close_pool
from .routers import auth, matches, organizations, players, registration, scoring, tournaments
from . import storage
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_pool()
await storage.ensure_bucket()
try:
yield
finally:

View file

@ -3,3 +3,7 @@ uvicorn[standard]>=0.30
asyncpg>=0.29
pydantic[email]>=2.7
pyjwt>=2.8
minio>=7.2
pillow>=10.0
pillow-avif-plugin>=1.4
python-multipart>=0.0.9

View file

@ -25,6 +25,7 @@ from pydantic import BaseModel, EmailStr, Field
from ..auth import CurrentUser, get_current_user_optional
from ..db import org_connection, plain_connection
from ..errors import app_error, translate_db_errors
from .. import storage
from .tournaments import SessionOut, _fetch_sessions
router = APIRouter(prefix="/public/tournaments", tags=["public-registration"])
@ -94,6 +95,7 @@ class SponsorOut(BaseModel):
id: str
name: str
url: str | None
logo_url: str | None
class PublicTournamentInfo(BaseModel):
@ -109,6 +111,7 @@ class PublicTournamentInfo(BaseModel):
registration_deadline: datetime | None
registration_capacity: int | None
confirmed_count: int
hero_image_url: str | None
sponsors: list[SponsorOut]
@ -122,7 +125,7 @@ async def get_public_tournament(
row = await conn.fetchrow(
"""
SELECT t.id::text, t.name, o.name AS organization_name, t.status::text,
t.visibility, t.description,
t.visibility, t.description, t.hero_image_key,
t.start_date, t.end_date, t.registration_deadline,
t.registration_capacity,
(SELECT count(*)::int FROM tournament_registration tr
@ -140,7 +143,7 @@ async def get_public_tournament(
await _check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
sponsor_rows = await conn.fetch(
"SELECT id::text, name, url FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
"SELECT id::text, name, url, logo_key FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
tournament_id,
)
@ -159,7 +162,16 @@ async def get_public_tournament(
registration_deadline=deadline,
registration_capacity=row["registration_capacity"],
confirmed_count=row["confirmed_count"],
sponsors=[SponsorOut(**dict(r)) for r in sponsor_rows],
hero_image_url=storage.public_url(row["hero_image_key"]) if row["hero_image_key"] else None,
sponsors=[
SponsorOut(
id=r["id"],
name=r["name"],
url=r["url"],
logo_url=storage.public_url(r["logo_key"]) if r["logo_key"] else None,
)
for r in sponsor_rows
],
)

View file

@ -10,13 +10,14 @@ rostret på et lag som ikke finnes ennå).
import json
from datetime import date, datetime
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, UploadFile
from pydantic import BaseModel, Field
from ..auth import get_authorized_org
from ..blind_draw import locked_team_ids
from ..db import org_connection
from ..errors import app_error, translate_db_errors
from .. import storage
router = APIRouter()
@ -34,7 +35,7 @@ class TournamentCreate(BaseModel):
_TOURNAMENT_COLUMNS = """
id::text, name, status::text, start_date, end_date,
visibility, description, registration_deadline, registration_capacity,
registration_overflow_policy, registration_requires_approval
registration_overflow_policy, registration_requires_approval, hero_image_key
"""
@ -50,6 +51,7 @@ class Tournament(BaseModel):
registration_capacity: int | None
registration_overflow_policy: str
registration_requires_approval: bool
hero_image_key: str | None
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
@ -93,6 +95,9 @@ class TournamentUpdate(BaseModel):
registration_capacity: int | None = Field(default=None, gt=0)
registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
registration_requires_approval: bool | None = None
# hero_image_key er BEVISST IKKE med her -- den settes kun via
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
@router.patch("/orgs/{organization_id}/tournaments/{tournament_id}", response_model=Tournament)
@ -128,6 +133,43 @@ async def update_tournament(
return Tournament(**dict(row))
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
response_model=Tournament,
)
async def upload_hero_image(
tournament_id: str,
file: UploadFile,
organization_id: str = Depends(get_authorized_org),
) -> Tournament:
"""Ekte multipart-opplasting (IKKE en presignert nettleser-URL): filen
går gjennom API-et, som konverterer den til AVIF før den lagres i MinIO
-- se app/storage.py sin moduldocstring for hvorfor."""
if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
raw = await file.read(storage.MAX_UPLOAD_BYTES + 1)
if len(raw) > storage.MAX_UPLOAD_BYTES:
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
async with org_connection(organization_id) as conn:
exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
if exists is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
try:
key = await storage.upload_image("tournaments", tournament_id, raw)
except storage.InvalidImageError:
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
row = await conn.fetchrow(
f"UPDATE tournament SET hero_image_key = $1 WHERE id = $2 RETURNING {_TOURNAMENT_COLUMNS}",
key,
tournament_id,
)
return Tournament(**dict(row))
# ---------------------------------------------------------------------------
# Lag (ADR-011: maks to lag per turnering, håndhevet her — ikke i skjemaet —
# slik at match-modellen kan holdes generell for fremtidige turneringstyper)
@ -501,7 +543,7 @@ async def create_session(
# ---------------------------------------------------------------------------
# Sponsorer (ADR-018) -- navn+lenke aktivt, logo inert til MinIO-runden.
# Sponsorer (ADR-018) -- navn+lenke+logo, alle aktive fra MinIO-runden.
# ---------------------------------------------------------------------------
class SponsorCreate(BaseModel):
@ -509,10 +551,14 @@ class SponsorCreate(BaseModel):
url: str | None = None
_SPONSOR_COLUMNS = "id::text, name, url, logo_key"
class Sponsor(BaseModel):
id: str
name: str
url: str | None
logo_key: str | None
@router.get(
@ -525,7 +571,7 @@ async def list_sponsors(
) -> list[Sponsor]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"SELECT id::text, name, url FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
f"SELECT {_SPONSOR_COLUMNS} FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
tournament_id,
)
return [Sponsor(**dict(r)) for r in rows]
@ -546,10 +592,10 @@ async def add_sponsor(
if tournament is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
row = await conn.fetchrow(
"""
f"""
INSERT INTO tournament_sponsor (organization_id, tournament_id, name, url)
VALUES ($1, $2, $3, $4)
RETURNING id::text, name, url
RETURNING {_SPONSOR_COLUMNS}
""",
organization_id,
tournament_id,
@ -559,6 +605,85 @@ async def add_sponsor(
return Sponsor(**dict(row))
class SponsorUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=200)
url: str | None = None
# logo_key er BEVISST IKKE med -- settes kun via upload_sponsor_logo
# under, samme begrunnelse som tournament.hero_image_key over.
@router.patch(
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}",
response_model=Sponsor,
)
async def update_sponsor(
tournament_id: str,
sponsor_id: str,
body: SponsorUpdate,
organization_id: str = Depends(get_authorized_org),
) -> Sponsor:
updates = body.model_dump(exclude_unset=True)
if not updates:
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
values = list(updates.values())
values.append(sponsor_id)
values.append(tournament_id)
async with org_connection(organization_id) as conn, translate_db_errors():
row = await conn.fetchrow(
f"""
UPDATE tournament_sponsor SET {', '.join(set_clauses)}
WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)}
RETURNING {_SPONSOR_COLUMNS}
""",
*values,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")
return Sponsor(**dict(row))
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}/logo",
response_model=Sponsor,
)
async def upload_sponsor_logo(
tournament_id: str,
sponsor_id: str,
file: UploadFile,
organization_id: str = Depends(get_authorized_org),
) -> Sponsor:
if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
raw = await file.read(storage.MAX_UPLOAD_BYTES + 1)
if len(raw) > storage.MAX_UPLOAD_BYTES:
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
async with org_connection(organization_id) as conn:
exists = await conn.fetchval(
"SELECT id FROM tournament_sponsor WHERE id = $1 AND tournament_id = $2",
sponsor_id,
tournament_id,
)
if exists is None:
raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")
try:
key = await storage.upload_image("sponsors", sponsor_id, raw)
except storage.InvalidImageError:
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
row = await conn.fetchrow(
f"UPDATE tournament_sponsor SET logo_key = $1 WHERE id = $2 RETURNING {_SPONSOR_COLUMNS}",
key,
sponsor_id,
)
return Sponsor(**dict(row))
@router.delete(
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}",
status_code=204,

101
app/storage.py Normal file
View file

@ -0,0 +1,101 @@
"""
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}"

View file

@ -1,4 +1,20 @@
services:
# Bindestrek, IKKE understrek: MinIO validerer Host-headeren strengt og
# avviser understrek som ugyldig vertsnavn (funnet under scratch-
# verifisering -- se CLAUDE.md-status).
teecup-minio:
image: minio/minio:latest
container_name: teecup-minio
environment:
MINIO_ROOT_USER: ${TEECUP_MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${TEECUP_MINIO_ROOT_PASSWORD}
command: server /data --console-address ":9001"
volumes:
- teecup_minio_data:/data
networks:
- teeoff_default
restart: unless-stopped
teecup_api:
build: .
container_name: teecup_api
@ -14,9 +30,14 @@ services:
TEECUP_SMTP_USER: ${TEECUP_SMTP_USER}
TEECUP_SMTP_PASS: ${TEECUP_SMTP_PASS}
TEECUP_FROM_EMAIL: ${TEECUP_FROM_EMAIL}
TEECUP_MINIO_ROOT_USER: ${TEECUP_MINIO_ROOT_USER}
TEECUP_MINIO_ROOT_PASSWORD: ${TEECUP_MINIO_ROOT_PASSWORD}
TEECUP_MINIO_BUCKET: ${TEECUP_MINIO_BUCKET}
networks:
- teeoff_default
restart: unless-stopped
depends_on:
- teecup-minio
teecup_frontend:
build:
@ -30,6 +51,9 @@ services:
depends_on:
- teecup_api
volumes:
teecup_minio_data:
networks:
teeoff_default:
external: true

View file

@ -10,6 +10,7 @@ type PublicTournamentInfo = {
name: string
organization_name: string
description: string | null
hero_image_url: string | null
}
export async function generateMetadata({
@ -27,11 +28,12 @@ export async function generateMetadata({
const title = `${data.name} | ${data.organization_name}`
const description =
data.description?.trim() || `${data.name} arrangeres av ${data.organization_name} på TeeCup.`
const images = data.hero_image_url ? [{ url: data.hero_image_url }] : undefined
return {
title,
description,
openGraph: { title, description, siteName: "TeeCup", type: "website" },
twitter: { card: "summary", title, description },
openGraph: { title, description, siteName: "TeeCup", type: "website", images },
twitter: { card: images ? "summary_large_image" : "summary", title, description, images },
}
} catch {
// Feil her skal ALDRI hindre selve siden i å laste -- kun forringe

View file

@ -34,6 +34,7 @@ type Sponsor = {
id: string
name: string
url: string
logoUrl?: string
}
type TournamentInfo = {
@ -44,6 +45,7 @@ type TournamentInfo = {
endDate?: Date
registered: number
capacity?: number // undefined = no cap
heroImageUrl?: string
sessions: Session[]
sponsors: Sponsor[]
}
@ -53,7 +55,7 @@ type Outcome = "confirmed" | "waitlist" | "pending"
// --- API-typer (matcher app/routers/registration.py) -----------------------
type ApiSponsor = { id: string; name: string; url: string | null }
type ApiSponsor = { id: string; name: string; url: string | null; logo_url: string | null }
type ApiTournamentInfo = {
id: string
@ -68,6 +70,7 @@ type ApiTournamentInfo = {
registration_deadline: string | null
registration_capacity: number | null
confirmed_count: number
hero_image_url: string | null
sponsors: ApiSponsor[]
}
@ -190,8 +193,14 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) {
endDate: info.end_date ? new Date(info.end_date) : undefined,
registered: info.confirmed_count,
capacity: info.registration_capacity ?? undefined,
heroImageUrl: info.hero_image_url ?? undefined,
sessions,
sponsors: info.sponsors.map((s) => ({ id: s.id, name: s.name, url: s.url ?? "#" })),
sponsors: info.sponsors.map((s) => ({
id: s.id,
name: s.name,
url: s.url ?? "#",
logoUrl: s.logo_url ?? undefined,
})),
}
return (
@ -204,7 +213,7 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) {
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
<div className="flex flex-col gap-6">
<Banner name={t.name} club={t.club} />
<Banner name={t.name} club={t.club} imageUrl={t.heroImageUrl} />
{t.intro && (
<section aria-label="Om turneringen">
@ -237,18 +246,34 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) {
// --- Banner ----------------------------------------------------------------
function Banner({ name, club }: { name: string; club: string }) {
function Banner({ name, club, imageUrl }: { name: string; club: string; imageUrl?: string }) {
return (
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-primary to-primary/80 px-6 py-10 shadow-lg shadow-primary/20 sm:px-8 sm:py-12">
{/* Subtle decorative rings — intentional, part of the brand banner look. */}
<div
aria-hidden="true"
className="pointer-events-none absolute -right-16 -top-16 size-56 rounded-full border-[12px] border-primary-foreground/10"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-20 -left-10 size-48 rounded-full border-[12px] border-primary-foreground/10"
/>
{imageUrl ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element -- images.unoptimized er alt satt, ingen next/image-fordel her */}
<img
src={imageUrl}
alt=""
aria-hidden="true"
className="absolute inset-0 size-full object-cover"
/>
{/* Skrim for lesbar tekst over et vilkårlig opplastet bilde. */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-black/10" />
</>
) : (
<>
{/* Ingen bilde lastet opp ennå -- bevisst permanent fargebanner, ikke en "kommer snart"-plassholder. */}
<div
aria-hidden="true"
className="pointer-events-none absolute -right-16 -top-16 size-56 rounded-full border-[12px] border-primary-foreground/10"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-20 -left-10 size-48 rounded-full border-[12px] border-primary-foreground/10"
/>
</>
)}
<div className="relative flex flex-col gap-2">
<span className="text-sm font-semibold uppercase tracking-wide text-primary-foreground/80">
{club}
@ -357,16 +382,23 @@ function Sponsors({ sponsors }: { sponsors: Sponsor[] }) {
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Sponsorer
</h2>
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
{sponsors.map((sp) => (
<a
key={sp.id}
href={sp.url}
className="text-sm font-semibold text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
>
{sp.name}
</a>
))}
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
{sponsors.map((sp) =>
sp.logoUrl ? (
<a key={sp.id} href={sp.url} aria-label={sp.name} className="opacity-80 transition-opacity hover:opacity-100">
{/* eslint-disable-next-line @next/next/no-img-element -- images.unoptimized er alt satt */}
<img src={sp.logoUrl} alt={sp.name} className="h-8 w-auto object-contain" />
</a>
) : (
<a
key={sp.id}
href={sp.url}
className="text-sm font-semibold text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
>
{sp.name}
</a>
),
)}
</div>
</section>
)