From 1e491d73b30ad3f2f577b158adc4777e3f54b764 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sat, 18 Jul 2026 11:25:52 +0200 Subject: [PATCH] =?UTF-8?q?MinIO-runden=20er=20ferdig,=20verifisert=20og?= =?UTF-8?q?=20live=20=E2=80=94=20ADR-018=20er=20n=C3=A5=20helt=20komplett,?= =?UTF-8?q?=20ingenting=20utsatt=20igjen=20bortsett=20fra=20selve=20opplas?= =?UTF-8?q?tings-skjermen=20i=20frontend.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)? --- .claude/settings.local.json | 17 ++- CLAUDE.md | 59 +++++++++- FEATURE_BACKLOG.md | 7 +- app/config.py | 11 ++ app/main.py | 2 + app/requirements.txt | 4 + app/routers/registration.py | 18 ++- app/routers/tournaments.py | 137 +++++++++++++++++++++- app/storage.py | 101 ++++++++++++++++ docker-compose.yml | 24 ++++ frontend/app/t/[id]/page.tsx | 6 +- frontend/components/public-tournament.tsx | 78 ++++++++---- 12 files changed, 423 insertions(+), 41 deletions(-) create mode 100644 app/storage.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4e1db1c..c42ca86 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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", diff --git a/CLAUDE.md b/CLAUDE.md index 88e13c3..b55c388 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/...` på + 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 diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 9f737fc..bcefc8f 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -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()` på `/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}`. | diff --git a/app/config.py b/app/config.py index 4739b8d..73666f0 100644 --- a/app/config.py +++ b/app/config.py @@ -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() diff --git a/app/main.py b/app/main.py index b53ec7d..7b1c11a 100644 --- a/app/main.py +++ b/app/main.py @@ -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: diff --git a/app/requirements.txt b/app/requirements.txt index 93cc3f1..0ff94b8 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -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 diff --git a/app/routers/registration.py b/app/routers/registration.py index cfa7bb1..63ca8c0 100644 --- a/app/routers/registration.py +++ b/app/routers/registration.py @@ -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 + ], ) diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index b6ff7b6..db843a8 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -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, diff --git a/app/storage.py b/app/storage.py new file mode 100644 index 0000000..65777be --- /dev/null +++ b/app/storage.py @@ -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}" diff --git a/docker-compose.yml b/docker-compose.yml index 1e596c1..9c7f0d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/app/t/[id]/page.tsx b/frontend/app/t/[id]/page.tsx index 46a0b94..b592178 100644 --- a/frontend/app/t/[id]/page.tsx +++ b/frontend/app/t/[id]/page.tsx @@ -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 diff --git a/frontend/components/public-tournament.tsx b/frontend/components/public-tournament.tsx index fcf5d31..d7b7e43 100644 --- a/frontend/components/public-tournament.tsx +++ b/frontend/components/public-tournament.tsx @@ -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 }) {
- + {t.intro && (
@@ -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 (
- {/* Subtle decorative rings — intentional, part of the brand banner look. */} -
)