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:
parent
6d84aafef8
commit
1e491d73b3
12 changed files with 423 additions and 41 deletions
|
|
@ -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",
|
||||
|
|
|
|||
59
CLAUDE.md
59
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
|
||||
|
|
|
|||
|
|
@ -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}`. |
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
101
app/storage.py
Normal 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}"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue