diff --git a/.claude/DONE b/.claude/DONE deleted file mode 100644 index c1f56f9..0000000 --- a/.claude/DONE +++ /dev/null @@ -1 +0,0 @@ -MVP ferdig og verifisert 2026-09-30. Se docs/status.md. diff --git a/.env.example b/.env.example index 88bb12d..2e4ddae 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ # Kopier til .env og tilpass. .env skal aldri committes. +# `npm run setup` gjør kopieringen for deg (og overskriver aldri en eksisterende .env). +# +# Bruker eller passord annerledes enn under? Sett dem i DATABASE_URL og TEST_DATABASE_URL, f.eks. +# postgres://it2810:hemmelig@localhost:5432/project2 # Backend PORT=3001 @@ -16,13 +20,20 @@ IMDB_MIN_VOTES=100 # Frontend (valgfri – standard er /graphql) VITE_GRAPHQL_URL= -# Bilder og handling fra TMDB (https://www.themoviedb.org/settings/api). Uten nøkkel vises -# plassholdere i stedet for plakater. +# Bilder og handling fra TMDB (https://www.themoviedb.org/settings/api). +# La stå tom i utvikling: `npm run dev` starter da en falsk TMDB-server (port 3999) og en falsk +# Internet Archive (port 3998) og setter URL-ene under selv, så plakater og gratisfilmer vises uten +# nøkler. Med ekte nøkkel brukes ekte TMDB. (Backend alene, uten `npm run dev`, viser plassholdere.) TMDB_API_KEY= -# Overstyres i utvikling/E2E for å bruke den lokale falske TMDB-serveren (npm run tmdb:mock -w backend): +# Settes automatisk av `npm run dev` når nøkkelen er tom. Manuelt (npm run tmdb:mock -w backend): # TMDB_API_URL=http://localhost:3999/3 # TMDB_IMAGE_URL=http://localhost:3999/t/p -# Lovlige gratisfilmer fra Internet Archive (npm run db:archive). Standard er den ekte tjenesten; -# pek mot den falske serveren (npm run archive:mock -w backend) i utvikling/E2E: +# Lovlige gratisfilmer fra Internet Archive (npm run db:archive). Standard er den ekte tjenesten. +# `npm run dev` bruker den falske serveren (port 3998) når TMDB_API_KEY er tom og denne er utkommentert. +# Manuelt (npm run archive:mock -w backend): # ARCHIVE_URL=http://localhost:3998 + +# Antall syntetiske titler som `npm run setup` genererer hvis data/ er tom (standard 20000). +# 120000 gir det store settet, men tar lengre tid. +# FIXTURE_SIZE=20000 diff --git a/.gitignore b/.gitignore index f4a5630..a58a0bf 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,6 @@ blob-report/ .vite/ *.tsbuildinfo .DS_Store +.claude/DONE .claude/DONE.local data/e2e/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3d15688..df22dda 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -13,6 +13,7 @@ default: stages: - check - test + - e2e variables: HUSKY: '0' @@ -39,3 +40,42 @@ unit-tests: TEST_DATABASE_URL: postgres://postgres@postgres:5432/project2_test script: - npm test + +# Bygger begge pakkene slik at en ødelagt produksjonsbygging oppdages før deploy. +build: + stage: test + script: + - npm run build + artifacts: + paths: + - frontend/dist/ + expire_in: 1 week + +# E2E mot ekte backend og database. Playwright-bildet har nettleserne ferdig installert, så +# `playwright install` trengs ikke. Versjonen må følge @playwright/test i package-lock.json. +# Playwright starter selv falsk TMDB, falsk Internet Archive, backend og frontend (se +# playwright.config.ts), og global-setup.ts migrerer og fyller databasen med syntetiske data. +e2e: + stage: e2e + # Bygger selv via webServer i playwright.config.ts; dist fra build-jobben trengs ikke. + dependencies: [] + image: mcr.microsoft.com/playwright:v1.63.0-noble + services: + - name: postgres:16 + alias: postgres + variables: + POSTGRES_DB: project2_e2e + POSTGRES_USER: postgres + POSTGRES_HOST_AUTH_METHOD: trust + E2E_DATABASE_URL: postgres://postgres@postgres:5432/project2_e2e + script: + - npm run test:e2e + artifacts: + # always: en flaky test (feiler først, består på retry) gir også trace i test-results/. + when: always + reports: + junit: test-results/junit.xml + paths: + - playwright-report/ + - test-results/ + expire_in: 1 week diff --git a/.gitlab/issue_templates/Oppgave.md b/.gitlab/issue_templates/Oppgave.md new file mode 100644 index 0000000..3893c49 --- /dev/null +++ b/.gitlab/issue_templates/Oppgave.md @@ -0,0 +1,22 @@ +## Mål + + + +## Ferdig når + + + +- [ ] +- [ ] Tester er lagt til eller oppdatert, og lint, typecheck, `npm test` og E2E er grønne +- [ ] Merge request er godkjent av en annen i gruppa + +## Ansvarlig + + + +- Ansvarlig: @ +- Reviewer: @ + +## Notater + + diff --git a/.gitlab/merge_request_templates/Default.md b/.gitlab/merge_request_templates/Default.md new file mode 100644 index 0000000..511e04d --- /dev/null +++ b/.gitlab/merge_request_templates/Default.md @@ -0,0 +1,33 @@ +## Oppsummering + + + +## Issue + +Closes # + + + +## Slik er det testet + + + +- + +## Sjekkliste + +- [ ] `npm run lint` er grønn +- [ ] `npm run typecheck` er grønn +- [ ] `npm test` er grønn (backend mot PostgreSQL og komponenttester) +- [ ] `npm run test:e2e` er grønn, eller endringen berører ikke brukerflyter +- [ ] Tilgjengelighet: tastatur, synlig fokus og skjermlesertekst er sjekket for nye eller endrede elementer +- [ ] Utypisk bruk er testet der det er relevant: tomt søk, ingen treff, spesialtegn, lange strenger, nettverksfeil +- [ ] Dokumentasjon i `docs/` er oppdatert hvis oppførsel eller oppsett er endret +- [ ] Ingen hemmeligheter (`.env`, nøkler) i diffen + +## Review + +Reviewer: @ + + diff --git a/.prettierignore b/.prettierignore index f4e4841..8e933e6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ playwright-report test-results package-lock.json data +.claude diff --git a/CLAUDE.md b/CLAUDE.md index 5919673..371f6dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,16 +4,16 @@ Du jobber autonomt. Nicolay ser ikke på skjermen før prosjektet er ferdig. Ikk ## Roller -- **Hovedsesjonen (Opus) er daglig leder.** Den planlegger, deler opp arbeid, delegerer til subagenter, verifiserer og krysser av i `PLAN.md`. Lederen skriver helst ikke produksjonskode selv. +- **Hovedsesjonen (Opus) er daglig leder.** Den planlegger, deler opp arbeid, delegerer til subagenter, verifiserer og krysser av i `docs/prosess/plan.md`. Lederen skriver helst ikke produksjonskode selv. - **Subagenter (Sonnet):** `backend-utvikler`, `frontend-utvikler`, `tester`, `reviewer`. Gi dem små, avgrensede oppgaver med tydelig ferdigkriterium. Kjør uavhengige oppgaver parallelt. ## Arbeidsløkke (hver runde) -1. Les `PLAN.md` og `BLOCKERS.md`. +1. Les `docs/prosess/plan.md` og `docs/prosess/blokkeringer.md`. 2. Velg neste uavkryssede punkt (eller flere uavhengige). 3. Deleger → la `reviewer` gå gjennom endringen → la `tester` kjøre relevante tester. 4. Kjør selv `npm run lint && npm run typecheck && npm test` i berørte pakker. Rødt = ikke ferdig. -5. Commit, kryss av i `PLAN.md`, skriv én linje i `docs/ki-logg.md`. +5. Commit, kryss av i `docs/prosess/plan.md`, skriv én linje i `docs/ki/ki-logg.md`. 6. Er alle MVP-kriterier oppfylt og verifisert: opprett filen `.claude/DONE`. ## Git @@ -21,7 +21,7 @@ Du jobber autonomt. Nicolay ser ikke på skjermen før prosjektet er ferdig. Ikk - Jobb alltid på grener fra `mvp`. Aldri commit eller push til `main`, aldri force-push. - Én gren per oppgave (`feat/…`, `fix/…`, `test/…`), merge til `mvp` når review og tester er grønne. - Små commits med Conventional Commits-meldinger på engelsk. -- Hvis `glab` er innlogget: opprett issue per PLAN-punkt og MR mot `mvp`, reviewer kommenterer i MR-en. Ellers: gjør det samme lokalt og loggfør i `docs/ki-logg.md`. +- Hvis `glab` er innlogget: opprett issue per PLAN-punkt og MR mot `mvp`, reviewer kommenterer i MR-en. Ellers: gjør det samme lokalt og loggfør i `docs/ki/ki-logg.md`. ## Tekniske rammer (ikke avvik uten å skrive begrunnelse i beslutninger.md) @@ -44,4 +44,4 @@ Du jobber autonomt. Nicolay ser ikke på skjermen før prosjektet er ferdig. Ikk - Ingen `sudo`, ingen endringer utenfor repoet, ingen hemmeligheter i git (`.env` i `.gitignore`, `.env.example` i repo). - README.md skrives av gruppa. Agentene legger bare fakta og installasjonssteg i `docs/`, som gruppa kan bygge README på. -- Står du fast på noe som krever Nicolay (passord, VM, VPN, tilganger): skriv det i `BLOCKERS.md` og gå videre. +- Står du fast på noe som krever Nicolay (passord, VM, VPN, tilganger): skriv det i `docs/prosess/blokkeringer.md` og gå videre. diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 3fef19a..0000000 --- a/PLAN.md +++ /dev/null @@ -1,72 +0,0 @@ -# PLAN – MVP - -Målet er nådd når alle bokser under er krysset av og verifisert. Lederen krysser av, ikke utviklerne. - -## Datasett (standardvalg) - -IMDb non-commercial datasets (`title.basics.tsv.gz`, `title.ratings.tsv.gz`) fra https://datasets.imdbws.com – ingen API-nøkkel. Filtrer til filmer og serier med minst 100 stemmer (~100k+ rader). Bilder hoppes over i MVP (bærekraft), evt. placeholders. -Brukergenererte data: anmeldelser (1–5 stjerner + tekst) og "min liste". - -## M0 – Oppsett - -- [x] Monorepo med npm workspaces, TypeScript strict, ESLint, Prettier, Husky + lint-staged -- [x] Scripts i rot: `dev`, `build`, `lint`, `typecheck`, `test`, `test:e2e`, `db:migrate`, `db:seed` -- [x] `.env.example`, `.gitignore`, `docs/beslutninger.md`, `docs/ki-logg.md`, `BLOCKERS.md` -- [x] GitLab CI (`.gitlab-ci.yml`): lint, typecheck, unit-tester - -## M1 – Database og import - -- [x] Skjema + migrasjoner (titles, genres, reviews, lists), indekser inkl. `pg_trgm` GIN på tittel -- [x] Importskript som strømmer TSV (ikke les alt i minnet) og kan kjøres på nytt (idempotent) -- [x] `EXPLAIN ANALYZE` for søk før/etter indeks lagret i `docs/ytelse.md` - -## M2 – GraphQL-API - -- [x] `search(query, filters, sort, first, after)` med connection-type (edges, pageInfo, totalCount) -- [x] `title(id)` med detaljer og anmeldelser -- [x] `facets(query, filters)` → antall treff per sjanger / tiår / type -- [x] Mutations: `addReview`, `toggleList` (anonym bruker-id via cookie/localStorage-uuid) -- [x] Input-validering, feilhåndtering, maks `first`, query-dybde begrenset -- [x] API-tester for alle queries/mutations inkl. kanttilfeller - -## M3 – Frontend - -- [x] Søkefelt med debounce, case-insensitivt, tøm-knapp -- [x] Resultatliste med uendelig scroll + "Last flere"-knapp, skeleton-lasting -- [x] Filterpanel (sjanger, tiår, type, min. rating) med antall treff, aktive filtre som chips, nullstill -- [x] Sortering (relevans, rating, år, tittel) – alltid på server -- [x] Detaljside med anmeldelser og skjema for ny anmeldelse -- [x] "Min liste"-side -- [x] State i URL, tilbake-knapp bevarer scroll -- [x] Tomt/ingen treff/feil-tilstander med "prøv igjen" -- [x] Mørk modus (følger system), responsivt ned til 320 px - -## M4 – Kvalitet - -- [x] Komponenttester for søk, liste, filter, detaljside, anmeldelsesskjema -- [x] Playwright E2E: søk → filtrer → sorter → scroll → detalj → skriv anmeldelse → se den i lista -- [x] axe-sjekk i Playwright uten feil på alle sider -- [x] Lighthouse ≥ 90 på alle kategorier (resultat i `docs/ytelse.md`) -- [x] Alle tester, lint og typecheck grønne fra ren klon (`npm ci`) - -## M5 – Deploy-klargjøring (kjøres ikke mot VM uten Nicolay) - -- [x] `docs/deploy.md`: Postgres-oppsett, systemd-fil for backend, Apache-konfig for `/project2`, gzip/cache-headere -- [x] `deploy/`-mappe med systemd-unit og Apache-konfig ferdig utfylt for `it2810-17.idi.ntnu.no` - -## M6 – Se lovlige filmer fra Internet Archive - -Kun filmer som Internet Archive markerer som fri bruk (public domain eller Creative Commons). Vi -lenker til og strømmer fra archive.org og lagrer ingen video selv. - -- [x] Backend: tabell `title_streams` (title_id, archive_id, fil, lisens, varighet) + migrering -- [x] Importskript `db:archive`: hent filmer fra Internet Archive-samlinger med fri lisens, koble til IMDb-titler via IMDb-ID i metadata (ellers tittel + år), velg en MP4-fil som nettlesere kan spille. Idempotent, testet mot falsk Archive-server -- [x] API: `Title.stream { url, license, archiveUrl }` og filter `availableOnly` (+ fasettantall) -- [x] Frontend: filter «Kun filmer du kan se», rad «Se gratis nå» på forsiden, merke på plakater -- [x] Videospiller på `/watch/:id`: spill/pause, spoling (tidslinje og ±10 s), volum og demping, fullskjerm, tastatur (mellomrom, piler, M, F), undertekster når de finnes, lisens og kilde under spilleren -- [x] Tester: import og kobling, API, spillerkontroller (komponent), E2E med liten testvideo, axe -- [x] Deploy: `setup-vm.sh` kjører `db:archive` etter importen - -## Ferdig - -- [x] Oppsummering i `docs/status.md`: hva er gjort, hva gjenstår, hva Nicolay må gjøre diff --git a/README.md b/README.md index e7e6cc2..62afa8b 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,141 @@ # Filmsøk – IT2810 prosjekt 2, gruppe 17 -Søk, filtrer og anmeld over 190 000 filmer og serier fra IMDb, med plakater fra TMDB. +Søk, filtrer, sorter og anmeld over 190 000 filmer og serier fra IMDb, med plakater fra TMDB og +lovlige gratisfilmer fra Internet Archive som kan spilles direkte i appen. -**🔗 Kjørende versjon:** (NTNU-nett eller VPN) +**Kjørende versjon:** (krever NTNU-nett eller VPN). -## Arbeidsfordeling +![Forsiden i mørk modus med rader av filmplakater](docs/img/forside-mork.jpg) -| Navn | Rolle | -| ------- | -------------------------------------------------------------------- | -| Nicolay | Prosjektleder. Arkitektur, GraphQL-API og React, frontend og backend | -| Strula | Backendansvarlig: database, import og resolvere | -| Brage | Frontendansvarlig: komponenter, design og tilgjengelighet | -| Daniel | Testansvarlig: enhets-, komponent- og E2E-tester | +## Kom i gang -## Funksjonalitet +Lokalt, uten VPN. Krever Node.js ≥ 20 og PostgreSQL med contrib-pakken (`pg_trgm`, `unaccent`). -- Søk på tittel med 300 ms debounce, uten skille på store og små bokstaver -- Filter på sjanger, tiår, type og rating, med antall treff per valg -- Sortering på relevans, rating, år og tittel, alltid på serveren -- Uendelig scroll med «Last flere»-knapp -- Detaljside, anmeldelser (1–5 stjerner) og «Min liste» -- Søk og filtre i URL-en, mørk og lys modus, responsivt ned til 320 px +```bash +npm ci +npm run setup # lager databaser og fyller dem med syntetiske data +npm run dev # http://localhost:5173/project2/ +``` -## Teknologi +Detaljer, ekte IMDb-data og feilsøking: [`docs/oppsett.md`](docs/oppsett.md#rask-start-2-minutter). +Tester: `npm test` (backend og frontend) og `npm run test:e2e` (Playwright). -**Frontend:** React, TypeScript, Vite, Apollo Client · **Backend:** Node.js, GraphQL (Yoga) · -**Database:** PostgreSQL med `pg_trgm` · **Test:** Vitest, Testing Library, Playwright, axe +## Funksjonalitet -All søk, filtrering og sortering skjer i SQL med cursor-paginering. API-et er beskrevet i -[`docs/api.md`](docs/api.md). +| Krav i oppgaven | Slik er det løst | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Søk | 300 ms debounce, ingen request for tomt eller uendret søk, uavhengig av store/små bokstaver og aksenter. Korte søk matcher ordstart | +| Store resultatsett | Uendelig scroll med «Last flere» som alternativ. Cursor-paginering (keyset) i SQL, 20 per side | +| Detaljer | Egen side per tittel med handling, sjangre, rating, anmeldelser og eventuell gratisfilm | +| Sortering og filtrering | Sjanger, tiår, type, minste rating og «gratis». Antall treff per valg. Sortering på relevans, rating, år og tittel. Alt skjer i SQL | +| Brukergenererte data | Anmeldelser (1–5 stjerner og tekst) og «Min liste», lagret i PostgreSQL. Egne anmeldelser kan slettes | +| Tilgjengelighet | WCAG 2.1 AA, tastatur overalt, `aria-live` for antall treff | +| Bærekraft | Lazy-loading, små bilder, cache og komprimering, mørk modus, få avhengigheter | +| Design | Kinoinspirert, mørkt grensesnitt med lys variant. Bla-modus med rader når søket er tomt, søkemodus med filtre og liste når man søker | + +Søketilstanden ligger i URL-en (kan deles og bokmerkes), gratisfilmer spilles i egen spiller med +tastaturstyring og undertekster, og feiltilstander (tomt søk, ingen treff, nettverksfeil, API nede) +har egne meldinger. + +| | | +| ------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| ![Søkeresultater med filterpanel og antall treff](docs/img/sok.jpg) | ![Detaljside med handling, rating og anmeldelser](docs/img/detalj.jpg) | +| ![Videospiller for en gratisfilm](docs/img/spiller.jpg) | ![Appen på mobil](docs/img/mobil.jpg) | +| ![Forsiden i lys modus](docs/img/forside-lys.jpg) | | + +## Arkitektur -## Pipeline +``` +Nettleser (React + Apollo Client) + │ GET /project2/… statiske filer (Vite-bygg), Apache med gzip og cache-headere + │ POST /project2/graphql ──▶ Apache (reverse proxy) ──▶ Node + GraphQL Yoga :3001 (127.0.0.1) + │ ├─▶ PostgreSQL (pg_trgm, unaccent) + │ └─▶ TMDB API (plakater, bufret i DB) + └─ video ──────────────────────────────────────────────────────▶ archive.org (direkte) +``` ``` -feature-gren ─▶ pre-commit ─▶ pull request ─▶ CI ─▶ main ─▶ deploy til VM +backend/ GraphQL-skjema, resolvere, søk (src/), migreringer og importskript, API-tester mot PostgreSQL +frontend/ React-app: apollo/ (klient, cache), components/, pages/ (lazy-lastet), hooks/ og lib/ +e2e/ Playwright-tester med axe +deploy/ systemd-enhet, Apache-konfig, oppsett- og oppdateringsskript for VM-en +docs/ all dokumentasjon, se docs/README.md ``` -1. **Pre-commit (Husky + lint-staged):** ESLint og Prettier på endrede filer. -2. **CI (`.gitlab-ci.yml`):** lint, typecheck og enhetstester mot PostgreSQL. -3. **Review og merge** til `main`. -4. **Deploy med én kommando:** - ```bash - ssh -t @it2810-17.idi.ntnu.no bash T17-Project2/deploy/oppdater.sh - ``` - Kommandoen henter siste kode, bygger, migrerer databasen, importerer data, henter plakater og - starter backend (systemd) og Apache på nytt. Den stopper med feilmelding hvis API-et ikke svarer. +## Viktigste valg + +Alle valg med begrunnelse: [`docs/beslutninger.md`](docs/beslutninger.md). Hvordan det henger +sammen, steg for steg: [`docs/forklaring.md`](docs/forklaring.md). + +- **PostgreSQL med `pg_trgm`, GIN og `unaccent`:** delstrengsøk og aksentfolding er indeksert, og + målt 3 ms mot 77 ms uten indeks. Tall i [`docs/ytelse.md`](docs/ytelse.md). +- **Cursor (keyset) i stedet for `OFFSET`:** side 1000 er like rask som side 1, og ingen hull eller + duplikater når data endres. +- **GraphQL (Yoga) med grenser:** klienten henter bare feltene den trenger. Dybde-, kostnads-, + `first`- og rate-limit samt maskerte feil beskytter API-et ([`docs/forklaring.md`](docs/forklaring.md#5-vern-av-api-et)). +- **Apollo Client og URL som kilde til søketilstand:** deling, bokmerker og tilbake-knappen virker + uten egen synkronisering. +- **Tilgjengelighet:** fokusstyring ved rutebytte og sletting, ett `role="alert"`-banner ved nedetid, + axe i E2E på alle sider i lys og mørk modus. +- **Bærekraft:** lazy-lastede ruter og rader, `srcset`, `immutable`-cache, gzip, ingen UI-, spiller- + eller ORM-bibliotek. +- **Ingen SSR:** en statisk SPA bak Apache er enklere å drifte. Prisen er mobil-Lighthouse på 95–97 + ([begrunnelse](docs/beslutninger.md#hvorfor-ikke-ssr)). -## Kjøre lokalt +## Testing -```bash -npm ci -cp .env.example .env -npm run db:migrate && npm run db:seed # krever IMDb-filene i data/ (se docs/oppsett.md) -npm run dev # http://localhost:5173/project2/ -``` +| Type | Antall | Kommando | +| ----------------------------------------------- | -----: | ------------------ | +| API og database (Vitest mot ekte PostgreSQL) | 490 | `npm test` | +| Komponenter og hooks (Vitest + Testing Library) | 288 | `npm test` | +| E2E med axe (Playwright, desktop og mobil) | 101 | `npm run test:e2e` | -## Testing +Typisk bruk testes som hele flyter (søk, filtrer, scroll, anmeld, spill film med tastatur). Utypisk +bruk testes eksplisitt: tomt søk, ingen treff, spesialtegn og SQL-injeksjon, veldig lange strenger, +ugyldige cursorer, samtidige kall, nettverksfeil og database nede. Oppsettet er beskrevet i +[`docs/forklaring.md`](docs/forklaring.md#9-testoppsett). + +## Deploy på VM + +Backend og database kjører direkte på `it2810-17.idi.ntnu.no` uten Docker: PostgreSQL, Node som +systemd-tjeneste på port 3001 og Apache som serverer frontend under `/project2`. Oppdatering: +`ssh -t @it2810-17.idi.ntnu.no bash T17-Project2/deploy/oppdater.sh`. Se +[`docs/deploy.md`](docs/deploy.md). + +## Prosess og KI + +Prosjektet er utviklet med Claude Code som KI-assistent, styrt av reglene i [`CLAUDE.md`](CLAUDE.md) +og planen i [`docs/prosess/plan.md`](docs/prosess/plan.md). Hva KI-en har laget, hva vi har +bestemt og kontrollert selv, og hvilke feil KI-en gjorde, står i +[`docs/ki/ki-deklarasjon.md`](docs/ki/ki-deklarasjon.md). Arbeidet er gjort på egne grener med +issues og PR-er med review, se [`docs/prosess/grener-og-pr.md`](docs/prosess/grener-og-pr.md). + +## Arbeidsfordeling + +| Navn | Ansvarsområde | +| ------- | -------------------------------------------------------------------- | +| Nicolay | Prosjektleder. Arkitektur, GraphQL-API og React, frontend og backend | +| Sturla | Backendansvarlig: database, import og resolvere | +| Brage | Frontendansvarlig: komponenter, design og tilgjengelighet | +| Daniel | Testansvarlig: enhets-, komponent- og E2E-tester | -| Type | Antall | Kommando | -| ------------------- | -----: | ------------------ | -| Backend (API og DB) | 209 | `npm test` | -| Komponenter | 111 | `npm test` | -| E2E + axe | 48 | `npm run test:e2e` | +Mye av koden er skrevet av KI-agenter styrt av gruppa (se [Prosess og KI](#prosess-og-ki)), +så git-historikken viser ikke hvem som har vurdert, testet og godkjent hva. Den enkeltes bidrag er +beskrevet i egen fil i Canvas. -E2E-testene dekker hele flyten fra søk til anmeldelse, på desktop og mobil, i lys og mørk -modus. +## Kjente begrensninger -## Dokumentasjon +- **Ingen innlogging:** anmeldelser og liste følger nettleseren via en anonym ID. +- **Korte søk (1–2 tegn)** matcher bare starten av ord, av hensyn til ytelsen. +- **Vanlige ord** som «the» med relevanssortering tar ca. 140 ms. +- **Ytelsestallene** er målt på syntetiske data (120 000 titler), ikke det ekte datasettet. +- **Rate limiting ligger i minnet** og nullstilles ved omstart. +- **VM-en kjører http, ikke https:** Lighthouse Best Practices blir lavere, og brotli og HTTP/2 mangler. +- **Klient-rendret (ingen SSR):** mobil Performance stopper på 95–97. -[Oppsett](docs/oppsett.md) · [Deploy](docs/deploy.md) · [API](docs/api.md) · -[Beslutninger](docs/beslutninger.md) · [Ytelse](docs/ytelse.md) · [KI-logg](docs/ki-logg.md) +## Mer dokumentasjon -Prosjektet er utviklet med Claude Code som KI-assistent. Bruken er logget i -[`docs/ki-logg.md`](docs/ki-logg.md). +Indeks over all dokumentasjon: [`docs/README.md`](docs/README.md). _Bilder og beskrivelser fra TMDB. Produktet bruker TMDB-API-et, men er ikke godkjent eller -sertifisert av TMDB._ +sertifisert av TMDB. Filmer fra Internet Archive vises med lisens og kilde under spilleren._ diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql index d5110fe..6a6591d 100644 --- a/backend/migrations/001_init.sql +++ b/backend/migrations/001_init.sql @@ -51,7 +51,7 @@ CREATE TABLE list_items ( ); -- Tekstsøk: uttrykkene må være identiske med de i søkespørringen (lower(...)) for at --- planleggeren skal kunne bruke indeksene. +-- planleggeren skal kunne bruke indeksene (erstattet av 004_unaccent_search.sql). CREATE INDEX titles_primary_title_trgm_idx ON titles USING gin (lower(primary_title) gin_trgm_ops); CREATE INDEX titles_original_title_trgm_idx ON titles USING gin (lower(original_title) gin_trgm_ops); diff --git a/backend/migrations/004_unaccent_search.sql b/backend/migrations/004_unaccent_search.sql new file mode 100644 index 0000000..2428707 --- /dev/null +++ b/backend/migrations/004_unaccent_search.sql @@ -0,0 +1,43 @@ +-- Aksentuavhengig søk («amelie» finner «Amélie») og ordprefiks for korte søk (1–2 tegn). + +CREATE EXTENSION IF NOT EXISTS unaccent; + +-- unaccent() er bare STABLE (ordlisten kan byttes), og Postgres krever IMMUTABLE i indeksuttrykk og +-- genererte kolonner. Standardgrepet er en IMMUTABLE innpakning som peker på ordlisten med fullt +-- kvalifisert navn, slik at den ikke avhenger av search_path. Ordlisten endres i praksis aldri; +-- skulle den gjøre det, må kolonnene og indeksene bygges på nytt. Det er IMMUTABLE som lar +-- planleggeren folde kall med konstant argument (søketeksten) til en konstant, slik at indeksene kan +-- brukes; STRICT og PARALLEL SAFE er bare ekstra optimalisering. +CREATE FUNCTION f_unaccent(text) RETURNS text + LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT + AS $$ SELECT public.unaccent('public.unaccent', $1) $$; + +-- Normalisert tittel (små bokstaver, uten aksenter) som lagrede, genererte kolonner. Et rent +-- indeksuttrykk `lower(f_unaccent(primary_title))` ville også virket, men da regnes unaccent ut +-- på nytt for hver rad som leses i recheck, filter og relevansberegning (målt: ca. 2x tregere på +-- brede søk som «the»; se docs/ytelse.md). Prisen er ca. 2 x tittellengde ekstra lagring per rad. +-- +-- Alle tre kolonnene legges til i ÉN ALTER TABLE, fordi hver slik kolonne skriver om hele tabellen +-- mens den holder ACCESS EXCLUSIVE-lås (ingen lesing eller skriving på titles under migreringen). +-- Det tar ca. 4 s lokalt på 120 000 titler (regn med 10–20 s på VM); stopp gjerne backend under migreringen (se docs/deploy.md). +-- title_words er en tsvector av begge titlene til ordprefikssøk (se lenger ned). +ALTER TABLE titles + ADD COLUMN primary_title_norm text GENERATED ALWAYS AS (lower(f_unaccent(primary_title))) STORED, + ADD COLUMN original_title_norm text GENERATED ALWAYS AS (lower(f_unaccent(original_title))) STORED, + ADD COLUMN title_words tsvector GENERATED ALWAYS AS ( + to_tsvector('simple', lower(f_unaccent(primary_title)) || ' ' || lower(f_unaccent(original_title))) + ) STORED; + +-- Trigramindeksene byttes til de normaliserte kolonnene (se src/search.ts). +DROP INDEX titles_primary_title_trgm_idx; +DROP INDEX titles_original_title_trgm_idx; +CREATE INDEX titles_primary_title_trgm_idx + ON titles USING gin (primary_title_norm gin_trgm_ops); +CREATE INDEX titles_original_title_trgm_idx + ON titles USING gin (original_title_norm gin_trgm_ops); + +-- Søk på 1–2 tegn har ingen trigrammer. De gjøres om til ordprefiks («ma» finner «The Matrix») +-- mot en lagret tsvector av begge titlene, med GIN. 'simple' (ingen stemming eller stoppord) fordi +-- titler ikke er prosa og «the» skal kunne søkes på. Dette erstatter prefiksindekser på hele +-- tittelen, som bare fant titler som BEGYNTE med søketeksten. +CREATE INDEX titles_title_words_idx ON titles USING gin (title_words); diff --git a/backend/package.json b/backend/package.json index d913daa..def09f1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "db:seed": "tsx scripts/import-imdb.ts", "db:fixture": "tsx scripts/generate-fixture.ts", "db:artwork": "tsx scripts/prefetch-artwork.ts", + "db:measure": "tsx scripts/measure-performance.ts", "db:archive": "tsx scripts/import-archive.ts", "tmdb:mock": "tsx scripts/tmdb-mock.ts", "archive:mock": "tsx scripts/archive-mock.ts" diff --git a/backend/scripts/dev.test.ts b/backend/scripts/dev.test.ts new file mode 100644 index 0000000..a305c5d --- /dev/null +++ b/backend/scripts/dev.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { planDev } from './dev.js'; + +const names = (env: NodeJS.ProcessEnv) => planDev(env).commands.map((c) => c.name); +const backendEnv = (env: NodeJS.ProcessEnv) => + planDev(env).commands.find((c) => c.name === 'backend')?.env; + +describe('planDev', () => { + it('starts both mocks and points the backend at them when TMDB_API_KEY is empty', () => { + const plan = planDev({ TMDB_API_KEY: '' }); + expect(plan.mocks).toBe(true); + expect(names({ TMDB_API_KEY: '' })).toEqual(['backend', 'tmdb', 'archive', 'frontend']); + expect(backendEnv({})).toEqual({ + TMDB_API_KEY: 'test', + TMDB_API_URL: 'http://localhost:3999/3', + TMDB_IMAGE_URL: 'http://localhost:3999/t/p', + ARCHIVE_URL: 'http://localhost:3998', + }); + expect(plan.ports.map((p) => p.port)).toEqual([3001, 3999, 3998]); + }); + + it('treats a whitespace-only key as empty', () => { + expect(planDev({ TMDB_API_KEY: ' ' }).mocks).toBe(true); + }); + + it('runs as before with a real key: no mocks and no overridden URLs', () => { + const env = { TMDB_API_KEY: 'abc123' }; + const plan = planDev(env); + expect(plan.mocks).toBe(false); + expect(names(env)).toEqual(['backend', 'frontend']); + expect(backendEnv(env)).toBeUndefined(); + expect(plan.ports).toEqual([{ name: 'backend', port: 3001 }]); + }); + + it('honours custom ports', () => { + const env = { TMDB_MOCK_PORT: '4999', ARCHIVE_MOCK_PORT: '4998', PORT: '4001' }; + const plan = planDev(env); + expect(backendEnv(env)).toMatchObject({ + TMDB_API_URL: 'http://localhost:4999/3', + ARCHIVE_URL: 'http://localhost:4998', + }); + expect(plan.ports.map((p) => p.port)).toEqual([4001, 4999, 4998]); + }); + + it('falls back to default ports for garbage values', () => { + const ports = planDev({ TMDB_MOCK_PORT: 'abc', ARCHIVE_MOCK_PORT: '-5' }).ports; + expect(ports.map((p) => p.port)).toEqual([3001, 3999, 3998]); + }); + + it('keeps an explicit ARCHIVE_URL and skips the archive mock', () => { + const env = { ARCHIVE_URL: 'https://archive.org' }; + expect(names(env)).toEqual(['backend', 'tmdb', 'frontend']); + expect(backendEnv(env)).not.toHaveProperty('ARCHIVE_URL'); + }); + + it('uses only portable command strings (no shell-specific env syntax)', () => { + for (const c of planDev({}).commands) { + expect(c.command).toMatch(/^npm run [\w:]+ -w (backend|frontend)$/); + } + }); +}); diff --git a/backend/scripts/dev.ts b/backend/scripts/dev.ts new file mode 100644 index 0000000..a4f2d34 --- /dev/null +++ b/backend/scripts/dev.ts @@ -0,0 +1,127 @@ +import { createServer } from 'node:net'; +import { pathToFileURL } from 'node:url'; +import concurrently from 'concurrently'; + +/** + * `npm run dev`: backend og frontend sammen. Uten TMDB_API_KEY startes i tillegg de falske TMDB- og + * Archive-serverne, slik at plakater og gratisfilmer vises uten nøkler og uten internett. Valget + * gjøres her og ikke i package.json, for `VAR=verdi kommando` og `${VAR:-...}` fungerer ikke i cmd.exe. + */ + +const DEFAULT_TMDB_MOCK_PORT = 3999; +const DEFAULT_ARCHIVE_MOCK_PORT = 3998; + +export interface PlannedCommand { + name: string; + command: string; + prefixColor: string; + env?: Record; +} + +export interface DevPlan { + /** True når de falske tjenestene brukes (ingen TMDB-nøkkel). */ + mocks: boolean; + commands: PlannedCommand[]; + /** Porter som må være ledige, for en tydelig feilmelding før noe starter. */ + ports: { name: string; port: number }[]; +} + +function port(value: string | undefined, fallback: number): number { + const n = Number.parseInt(value ?? '', 10); + return Number.isInteger(n) && n > 0 ? n : fallback; +} + +/** + * Bestemmer hvilke prosesser som startes og hvilke miljøvariabler backend får. `env` er miljøet etter at + * .env er lest. Ren funksjon, slik at valget kan testes uten å starte noe. + */ +export function planDev(env: NodeJS.ProcessEnv): DevPlan { + const backendPort = port(env.PORT, 3001); + const ports = [{ name: 'backend', port: backendPort }]; + const commands: PlannedCommand[] = []; + const hasKey = !!env.TMDB_API_KEY?.trim(); + + if (hasKey) { + commands.push({ name: 'backend', command: 'npm run dev -w backend', prefixColor: 'blue' }); + } else { + const tmdbPort = port(env.TMDB_MOCK_PORT, DEFAULT_TMDB_MOCK_PORT); + const backendEnv: Record = { + // Backend gjør bare nettverkskall når nøkkelen er satt; verdien er ellers uten betydning for mocken. + TMDB_API_KEY: 'test', + TMDB_API_URL: `http://localhost:${tmdbPort}/3`, + TMDB_IMAGE_URL: `http://localhost:${tmdbPort}/t/p`, + }; + ports.push({ name: 'tmdb-mock', port: tmdbPort }); + commands.push({ + name: 'tmdb', + command: 'npm run tmdb:mock -w backend', + prefixColor: 'yellow', + env: { TMDB_MOCK_PORT: String(tmdbPort) }, + }); + + // Satt ARCHIVE_URL er et bevisst valg (for eksempel ekte archive.org) og respekteres. + if (!env.ARCHIVE_URL?.trim()) { + const archivePort = port(env.ARCHIVE_MOCK_PORT, DEFAULT_ARCHIVE_MOCK_PORT); + backendEnv.ARCHIVE_URL = `http://localhost:${archivePort}`; + ports.push({ name: 'archive-mock', port: archivePort }); + commands.push({ + name: 'archive', + command: 'npm run archive:mock -w backend', + prefixColor: 'cyan', + env: { ARCHIVE_MOCK_PORT: String(archivePort) }, + }); + } + commands.unshift({ + name: 'backend', + command: 'npm run dev -w backend', + prefixColor: 'blue', + env: backendEnv, + }); + } + + commands.push({ name: 'frontend', command: 'npm run dev -w frontend', prefixColor: 'magenta' }); + return { mocks: !hasKey, commands, ports }; +} + +function isPortFree(p: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.once('error', () => resolve(false)); + server.listen(p, () => server.close(() => resolve(true))); + }); +} + +async function main() { + // Importen leser .env som en bivirkning; må skje før planDev ser på process.env. + await import('../src/config.js'); + const plan = planDev(process.env); + + const busy: string[] = []; + for (const { name, port: p } of plan.ports) { + if (!(await isPortFree(p))) busy.push(`${name} (port ${p})`); + } + if (busy.length) { + console.error( + `Port(er) i bruk: ${busy.join(', ')}.\n` + + 'Stopp prosessen som bruker dem (kanskje en annen `npm run dev` eller E2E-kjøring), og prøv igjen.', + ); + process.exit(1); + } + + console.log( + plan.mocks + ? 'TMDB_API_KEY er tom: starter falsk TMDB og falsk Internet Archive sammen med appen.' + : 'TMDB_API_KEY er satt: bruker ekte TMDB.', + ); + // Når én prosess krasjer (f.eks. databasen er nede) skal resten stoppes, ellers ser det ut som + // appen kjører mens den ikke virker. + const { result } = concurrently(plan.commands, { killOthersOn: ['failure'] }); + await result.catch(() => process.exit(1)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/backend/scripts/import-archive.test.ts b/backend/scripts/import-archive.test.ts index f73c85b..3820e67 100644 --- a/backend/scripts/import-archive.test.ts +++ b/backend/scripts/import-archive.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { buildTitleIndex, + CANDIDATE_QUERY, + CURATED_COLLECTIONS, chooseFiles, extractImdbId, extractYear, @@ -254,3 +256,15 @@ describe('chooseFiles', () => { expect(chooseFiles([f('film.mp4', 'h.264'), f('a.srt', 'SubRip')])?.subtitlesFile).toBeNull(); }); }); + +describe('CANDIDATE_QUERY', () => { + it('tar med alle kuraterte samlinger uansett lisens', () => { + for (const c of CURATED_COLLECTIONS) expect(CANDIDATE_QUERY).toContain(`collection:${c} OR`); + }); + + it('krever IMDb-referanse for lisensbaserte treff, så hjemmevideoer ikke skannes', () => { + expect(CANDIDATE_QUERY).toMatch( + /\(\(licenseurl:\*creativecommons\* OR licenseurl:\*publicdomain\*\) AND external-identifier:\*imdb\*\)/, + ); + }); +}); diff --git a/backend/scripts/import-archive.ts b/backend/scripts/import-archive.ts index 1ef1b58..f2b9b25 100644 --- a/backend/scripts/import-archive.ts +++ b/backend/scripts/import-archive.ts @@ -278,8 +278,15 @@ export function chooseFiles(files: unknown): ChosenFile | null { // --- HTTP --------------------------------------------------------------------------------- +/** + * Kuraterte samlinger tas med uansett. Lisensbaserte treff krever i tillegg en IMDb-referanse: + * av ca. 259 000 Creative Commons-elementer ble under 1 % koblet til en IMDb-film (resten er + * hjemmevideoer o.l.), og å skanne alle tok 10-30 minutter. `classic_tv` er utelatt fordi + * samlingen bare er lovlig med lisens-URL, og de treffene dekkes av lisensgrenen. + */ export const CANDIDATE_QUERY = - 'mediatype:movies AND (collection:feature_films OR collection:film_noir OR collection:silent_films OR collection:classic_tv OR licenseurl:*creativecommons* OR licenseurl:*publicdomain*)'; + 'mediatype:movies AND (collection:feature_films OR collection:film_noir OR collection:silent_films OR ' + + '((licenseurl:*creativecommons* OR licenseurl:*publicdomain*) AND external-identifier:*imdb*))'; const SCRAPE_FIELDS = 'identifier,title,year,date,licenseurl,external-identifier,collection'; @@ -370,6 +377,12 @@ export interface ImportOptions { retryDelayMs?: number; pageSize?: number; fetch?: typeof fetch; + /** + * Første Ctrl+C: slutt å lete etter nye kandidater, men lagre dem som allerede er koblet. + * Leses mellom elementene, så et pågående Scrape-kall får fullføre. + */ + stopCollecting?: { aborted: boolean }; + /** Andre Ctrl+C: avbryt alt, også lagringen. Leses mellom kandidatene. */ signal?: { aborted: boolean }; log?: (msg: string) => void; } @@ -408,10 +421,52 @@ export async function importArchive(opts: ImportOptions): Promise }>("SELECT id, primary_title, original_title, start_year FROM titles WHERE title_type = 'movie'"); const titles = buildTitleIndex(movieRows); - // 1) Samle kandidater fra Scrape API (billig: 1000 per kall) og koble dem. + // Utfall per IMDb-tittel. Tellerne i summary utledes herfra til slutt, slik at en tittel som + // lagres to ganger (se «IMDb-ID slår tittelkobling» under) bare telles én gang. + const outcome = new Map(); + let processed = 0; + + // Henter filliste per koblet kandidat og lagrer. Kjøres per Scrape-side, så en lang skanning + // (10-30 min mot hele Archive) ikke mister alt ved avbrudd eller feil underveis. + const store = async (list: Candidate[]) => { + let next = 0; + const worker = async () => { + while (!opts.signal?.aborted) { + const cand = list[next++]; + if (!cand) return; + const id = cand.item.identifier; + const titleId = cand.link.titleId; + try { + const meta = (await getJson(`${base}/metadata/${encodeURIComponent(id)}`)) as { + files?: unknown; + } | null; + const chosen = chooseFiles(meta?.files); + if (!chosen) { + outcome.set(titleId, 'noFile'); + log(`Hopper over ${id}: ingen spillbar fil.`); + } else { + await upsert(pool, cand, chosen); + outcome.set(titleId, 'imported'); + } + } catch (err) { + outcome.set(titleId, 'failed'); + log(`Feilet for ${id}: ${(err as Error).message}`); + } + if (++processed % 25 === 0) log(`${processed} kandidater behandlet.`); + } + }; + await Promise.all(Array.from({ length: opts.concurrency ?? 4 }, worker)); + }; + + // Samle kandidater fra Scrape API (billig: 1000 per kall), koble dem og lagre side for side. + // Flere Archive-elementer for samme film: IMDb-ID slår tittelkobling, ellers vinner det første, + // så en ny kjøring gir samme utfall. Kommer IMDb-treffet først på en senere side, upsertes det + // over tittelkoblingen som allerede er lagret. const claimed = new Map(); let cursor: string | undefined; - collect: do { + let stop = false; + do { + if (opts.signal?.aborted || opts.stopCollecting?.aborted) break; const url = new URL(`${base}/services/search/v1/scrape`); url.searchParams.set('q', CANDIDATE_QUERY); url.searchParams.set('fields', SCRAPE_FIELDS); @@ -433,8 +488,21 @@ export async function importArchive(opts: ImportOptions): Promise for (const r of rows) existing.add(r.id); } + // Nye eller erstattede kandidater fra denne siden; lagres når siden er ferdig. + const fresh = new Map(); for (const item of items) { - if (opts.signal?.aborted) break collect; + if (opts.signal?.aborted) { + stop = true; + break; + } + if (opts.stopCollecting?.aborted) { + stop = true; + log( + `Avbrutt: lagrer ${fresh.size} filmer som allerede er funnet. ` + + 'Trykk Ctrl+C igjen for å avbryte helt.', + ); + break; + } summary.scanned++; if (typeof item.identifier !== 'string' || !isSafeArchiveId(item.identifier)) continue; if (!isLawful(item)) { @@ -446,49 +514,29 @@ export async function importArchive(opts: ImportOptions): Promise summary.unlinked++; continue; } - // Flere Archive-elementer for samme film: IMDb-ID slår tittelkobling, ellers vinner det - // første, så en ny kjøring gir samme utfall. const prev = claimed.get(link.titleId); if (!prev || (prev.link.via === 'title' && link.via === 'imdb')) { - claimed.set(link.titleId, { item, link }); + const cand = { item, link }; + claimed.set(link.titleId, cand); + fresh.set(link.titleId, cand); } if (prev) summary.duplicates++; - if (opts.limit !== undefined && claimed.size >= opts.limit) break collect; + if (opts.limit !== undefined && claimed.size >= opts.limit) { + stop = true; + break; + } } + await store([...fresh.values()]); cursor = typeof page.cursor === 'string' && page.cursor && items.length > 0 ? page.cursor : undefined; log(`Skannet ${summary.scanned} elementer, ${claimed.size} koblet til IMDb-titler.`); - } while (cursor); - - // 2) Hent filliste per koblet kandidat og lagre. - const todo = [...claimed.values()]; - let next = 0; - let done = 0; - const worker = async () => { - while (!opts.signal?.aborted) { - const cand = todo[next++]; - if (!cand) return; - const id = cand.item.identifier; - try { - const meta = (await getJson(`${base}/metadata/${encodeURIComponent(id)}`)) as { - files?: unknown; - } | null; - const chosen = chooseFiles(meta?.files); - if (!chosen) { - summary.noFile++; - log(`Hopper over ${id}: ingen spillbar fil.`); - } else { - await upsert(pool, cand, chosen); - summary.imported++; - } - } catch (err) { - summary.failed++; - log(`Feilet for ${id}: ${(err as Error).message}`); - } - if (++done % 25 === 0) log(`${done}/${todo.length} behandlet.`); - } - }; - await Promise.all(Array.from({ length: opts.concurrency ?? 4 }, worker)); + } while (cursor && !stop); + + for (const o of outcome.values()) { + if (o === 'imported') summary.imported++; + else if (o === 'noFile') summary.noFile++; + else summary.failed++; + } return summary; } @@ -519,14 +567,27 @@ async function main() { const limitText = arg >= 0 ? process.argv[arg + 1] : process.env.ARCHIVE_LIMIT; const limit = Number.parseInt(limitText ?? '', 10) || undefined; const pool = createPool(config.databaseUrl); + // To trinn: første Ctrl+C stopper skanningen men lagrer det som er funnet, andre avbryter helt. + const stopCollecting = { aborted: false }; const signal = { aborted: false }; process.on('SIGINT', () => { - console.log('\nAvbryter etter pågående kall …'); - signal.aborted = true; + if (!stopCollecting.aborted) { + console.log('\nStopper skanningen etter pågående kall …'); + stopCollecting.aborted = true; + } else { + console.log('\nAvbryter etter pågående kall …'); + signal.aborted = true; + } }); console.log(`Henter fra ${config.archiveUrl}${limit ? ` (maks ${limit} titler)` : ''}`); try { - const s = await importArchive({ pool, ...(limit ? { limit } : {}), signal, log: console.log }); + const s = await importArchive({ + pool, + ...(limit ? { limit } : {}), + stopCollecting, + signal, + log: console.log, + }); console.log( `Ferdig: ${s.imported} lagret, ${s.unlawful} uten fri lisens, ${s.unlinked} uten IMDb-kobling, ` + `${s.duplicates} duplikater, ${s.noFile} uten spillbar fil, ${s.failed} feilet (av ${s.scanned} skannet).`, diff --git a/backend/scripts/import-imdb.ts b/backend/scripts/import-imdb.ts index 53f12ce..b620227 100644 --- a/backend/scripts/import-imdb.ts +++ b/backend/scripts/import-imdb.ts @@ -61,11 +61,15 @@ async function loadRatings(file: string, minVotes: number, log: (m: string) => v * Upsert av én batch. Arrays sendes som én parameter per kolonne og pakkes ut med unnest(): * få parametere uansett batch-størrelse, og data blir aldri limt inn i SQL-teksten. */ -async function upsertBatch(pool: Pool, rows: TitleRow[]): Promise { +async function upsertBatch(pool: Pool, allRows: TitleRow[]): Promise { const client = await pool.connect(); try { await client.query('BEGIN'); - await client.query( + // WHERE ... IS DISTINCT FROM: en omkjøring uten nye data skal ikke skrive en eneste rad. Uten + // vilkåret lager Postgres en ny radversjon for hver eksisterende tittel (bloat som må vakuumeres), + // regner ut de genererte kolonnene (unaccent, tsvector) på nytt og oppdaterer GIN-indeksene. + // Bare rader som faktisk ble satt inn eller endret kommer tilbake fra RETURNING. + const { rows: changed } = await client.query<{ id: string }>( `INSERT INTO titles (id, title_type, primary_title, original_title, start_year, end_year, runtime_minutes, average_rating, num_votes, genres) SELECT id, title_type, primary_title, original_title, start_year, end_year, @@ -84,24 +88,39 @@ async function upsertBatch(pool: Pool, rows: TitleRow[]): Promise { runtime_minutes = EXCLUDED.runtime_minutes, average_rating = EXCLUDED.average_rating, num_votes = EXCLUDED.num_votes, - genres = EXCLUDED.genres`, + genres = EXCLUDED.genres + WHERE (titles.title_type, titles.primary_title, titles.original_title, titles.start_year, + titles.end_year, titles.runtime_minutes, titles.average_rating, titles.num_votes, + titles.genres) + IS DISTINCT FROM + (EXCLUDED.title_type, EXCLUDED.primary_title, EXCLUDED.original_title, + EXCLUDED.start_year, EXCLUDED.end_year, EXCLUDED.runtime_minutes, + EXCLUDED.average_rating, EXCLUDED.num_votes, EXCLUDED.genres) + RETURNING id`, [ - rows.map((r) => r.id), - rows.map((r) => r.titleType), - rows.map((r) => r.primaryTitle), - rows.map((r) => r.originalTitle), - rows.map((r) => r.startYear), - rows.map((r) => r.endYear), - rows.map((r) => r.runtimeMinutes), - rows.map((r) => r.averageRating), - rows.map((r) => r.numVotes), + allRows.map((r) => r.id), + allRows.map((r) => r.titleType), + allRows.map((r) => r.primaryTitle), + allRows.map((r) => r.originalTitle), + allRows.map((r) => r.startYear), + allRows.map((r) => r.endYear), + allRows.map((r) => r.runtimeMinutes), + allRows.map((r) => r.averageRating), + allRows.map((r) => r.numVotes), // Sjangernavn inneholder aldri komma (IMDb skiller dem med komma). - rows.map((r) => r.genres.join(',')), + allRows.map((r) => r.genres.join(',')), ], ); - // Normalisert kopi: nye sjangre, og title_genres bygges på nytt for titlene i batchen - // slik at en endret sjangerliste ved omkjøring ikke etterlater gamle koblinger. + // Normalisert kopi: nye sjangre, og title_genres bygges på nytt for titlene som ble satt inn + // eller endret, slik at en endret sjangerliste ved omkjøring ikke etterlater gamle koblinger. + // Uendrede titler har allerede riktige koblinger (samme transaksjon som titles-raden). + const changedIds = new Set(changed.map((r) => r.id)); + const rows = allRows.filter((r) => changedIds.has(r.id)); + if (rows.length === 0) { + await client.query('COMMIT'); + return; + } const pairsTitle: string[] = []; const pairsGenre: string[] = []; for (const r of rows) { @@ -178,7 +197,9 @@ export async function runImport(pool: Pool, opts: ImportOptions): Promise<{ impo } async function main() { - const pool = createPool(config.databaseUrl); + // Én batch på 2 000 rader oppdaterer tre GIN-indekser og kan ta flere sekunder på trege disker; + // API-ets 15 s-grense er for stram for en førstegangsinnlasting. + const pool = createPool(config.databaseUrl, { statementTimeoutMs: 10 * 60_000 }); try { await migrate(pool, console.log); await runImport(pool, { diff --git a/backend/scripts/measure-performance.test.ts b/backend/scripts/measure-performance.test.ts new file mode 100644 index 0000000..b8cc4e5 --- /dev/null +++ b/backend/scripts/measure-performance.test.ts @@ -0,0 +1,199 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { config } from '../src/config.js'; +import { createPool, type Pool } from '../src/db.js'; +import { resetData, TITLE_COUNT } from '../test/fixtures.js'; +import { + capturePool, + describeNode, + explainStatement, + formatReport, + measure, + median, + parsePlan, + SCENARIOS, + type PlanNode, +} from './measure-performance.js'; + +const indexScan: PlanNode = { + 'Node Type': 'Index Scan', + 'Index Name': 'titles_rating_idx', + 'Relation Name': 'titles', + 'Actual Total Time': 0.9, + 'Actual Loops': 1, +}; + +describe('parsePlan', () => { + it('velger noden med mest egentid, ikke den øverste', () => { + const plan = parsePlan([ + { + Plan: { + 'Node Type': 'Limit', + 'Actual Total Time': 1.0, + 'Actual Loops': 1, + Plans: [indexScan], + }, + 'Execution Time': 1.2, + }, + ]); + expect(plan).toEqual({ executionMs: 1.2, mainNode: 'Index Scan using titles_rating_idx' }); + }); + + it('regner tid over alle sløyfer', () => { + const plan = parsePlan([ + { + Plan: { + 'Node Type': 'Nested Loop', + 'Actual Total Time': 10, + 'Actual Loops': 1, + Plans: [ + { + 'Node Type': 'Seq Scan', + 'Relation Name': 'a', + 'Actual Total Time': 1, + 'Actual Loops': 1, + }, + // 0,5 ms x 10 sløyfer = 5 ms, mer enn Seq Scan og Nested Loop selv (4 ms). + { + 'Node Type': 'Index Scan', + 'Index Name': 'b_pkey', + 'Actual Total Time': 0.5, + 'Actual Loops': 10, + }, + ], + }, + 'Execution Time': 10.5, + }, + ]); + expect(plan.mainNode).toBe('Index Scan using b_pkey'); + }); + + it('kaster tydelig feil på uventet format', () => { + expect(() => parsePlan(null)).toThrow(/EXPLAIN/); + expect(() => parsePlan([{ Plan: indexScan }])).toThrow(/Execution Time/); + expect(() => parsePlan([])).toThrow(/EXPLAIN/); + }); +}); + +describe('describeNode', () => { + it('lister indeksene en bitmapskanning kombinerer, uten duplikater', () => { + const node: PlanNode = { + 'Node Type': 'Bitmap Heap Scan', + 'Relation Name': 'titles', + Plans: [ + { + 'Node Type': 'BitmapOr', + Plans: [ + { 'Node Type': 'Bitmap Index Scan', 'Index Name': 'a_trgm' }, + { 'Node Type': 'Bitmap Index Scan', 'Index Name': 'b_trgm' }, + { 'Node Type': 'Bitmap Index Scan', 'Index Name': 'a_trgm' }, + ], + }, + ], + }; + expect(describeNode(node)).toBe('Bitmap Heap Scan on titles (a_trgm, b_trgm)'); + }); + + it('viser sorteringsmetode og tabell når det ikke er noen indeks', () => { + expect(describeNode({ 'Node Type': 'Sort', 'Sort Method': 'top-N heapsort' })).toBe( + 'Sort (top-N heapsort)', + ); + expect(describeNode({ 'Node Type': 'Seq Scan', 'Relation Name': 'titles' })).toBe( + 'Seq Scan on titles', + ); + expect(describeNode({ 'Node Type': 'Aggregate' })).toBe('Aggregate'); + }); +}); + +describe('median', () => { + it('tar midtverdien og ignorerer rekkefølgen', () => { + expect(median([5, 1, 3])).toBe(3); + expect(median([4, 1, 3, 2])).toBe(2.5); + expect(median([7])).toBe(7); + }); + + it('er upåvirket av én ekstrem kjøring', () => { + expect(median([1.1, 1.0, 1.2, 250, 1.1])).toBe(1.1); + }); + + it('kaster på tom liste', () => { + expect(() => median([])).toThrow(); + }); +}); + +describe('formatReport', () => { + it('lager en markdown-tabell og escaper rørtegn i cellene', () => { + const md = formatReport( + { titleCount: 190000, pgVersion: '16.4', date: '2026-10-08', runs: 5, jit: 'off' }, + [ + { name: 'Søk «a|b»', ms: 3.14159, mainNode: 'Index Scan using x', statementCount: 1 }, + { name: 'Fasetter', ms: 120.56, mainNode: 'Seq Scan on titles', statementCount: 4 }, + ], + ); + expect(md).toContain('2026-10-08'); + expect(md).toContain('PostgreSQL 16.4'); + expect(md).toContain('| Scenario | ms (median) | Planens hovednode |'); + expect(md).toContain('| Søk «a\\|b» | 3,14 | Index Scan using x |'); + expect(md).toContain('| Fasetter, sum av 4 spørringer | 120,6 | Seq Scan on titles |'); + }); +}); + +describe('capturePool', () => { + it('fanger SQL og parametere uten å røre en database', async () => { + const { pool, captured } = capturePool(); + await pool.query('SELECT $1', ['x']); + expect(captured).toEqual([{ text: 'SELECT $1', values: ['x'] }]); + }); +}); + +describe('mot testdatabasen', () => { + let pool: Pool; + beforeAll(async () => { + pool = createPool(config.testDatabaseUrl); + await resetData(pool); + }); + afterAll(async () => { + await pool.end(); + }); + + it('måler alle scenarioene og gir en rad per scenario', async () => { + const results = await measure(pool, SCENARIOS, { runs: 2, warmups: 0 }); + expect(results).toHaveLength(SCENARIOS.length); + for (const r of results) { + expect(Number.isFinite(r.ms)).toBe(true); + expect(r.ms).toBeGreaterThanOrEqual(0); + expect(r.mainNode.length).toBeGreaterThan(0); + } + // Fasettene er fire spørringer (sjangre, tiår, typer, tilgjengelige); resten er én. + expect(results.find((r) => r.name.startsWith('Fasetter'))?.statementCount).toBe(4); + expect(results.filter((r) => r.statementCount === 1)).toHaveLength(SCENARIOS.length - 1); + // Fixture-datasettet har langt færre enn 50 sider; tabellen skal si det i stedet for å late som. + expect(TITLE_COUNT).toBeLessThan(50 * 20); + expect(results.find((r) => r.name.startsWith('Side 50'))?.name).toMatch(/rakk bare til side/); + + const md = formatReport( + { titleCount: TITLE_COUNT, pgVersion: '16', date: '2026-10-08', runs: 2, jit: 'off' }, + results, + ); + const tableRows = md + .split('\n') + .filter((l) => l.startsWith('| ') && !l.startsWith('| Scenario')); + // Skilletegnraden («| --- |») kommer i tillegg til én rad per scenario. + expect(tableRows).toHaveLength(SCENARIOS.length + 1); + }); + + it('avviser setninger som ikke er SELECT', async () => { + await expect( + explainStatement(pool, { text: 'DELETE FROM titles', values: [] }), + ).rejects.toThrow(/SELECT/); + const { rows } = await pool.query<{ n: string }>('SELECT count(*) AS n FROM titles'); + expect(Number(rows[0]?.n)).toBe(TITLE_COUNT); + }); + + it('kan måle spørringer med parametere og spesialtegn', async () => { + const plan = await explainStatement(pool, { + text: 'SELECT id FROM titles WHERE primary_title_norm LIKE $1', + values: ["%'; DROP TABLE titles; --%"], + }); + expect(plan.executionMs).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/backend/scripts/measure-performance.ts b/backend/scripts/measure-performance.ts new file mode 100644 index 0000000..1fc83d9 --- /dev/null +++ b/backend/scripts/measure-performance.ts @@ -0,0 +1,353 @@ +import { pathToFileURL } from 'node:url'; +import { config } from '../src/config.js'; +import { createPool, type Pool } from '../src/db.js'; +import { getFacets, searchTitles, type SearchArgs, type SortField } from '../src/search.js'; +import type { Filters } from '../src/validation.js'; + +/** + * Måler søkespørringene mot en ekte database og skriver en markdown-tabell til docs/ytelse.md. + * Selve SQL-en kopieres ikke hit: den fanges opp fra search.ts ved å gi funksjonene en «pool» som + * bare skriver ned det den får, slik at tallene alltid gjelder nøyaktig de spørringene API-et + * bygger, også etter at search.ts endres. Kjøres på VM-en via deploy/mal-ytelse.sh. + */ + +export interface Statement { + text: string; + values: unknown[]; +} + +/** Gir search.ts noe som ser ut som en Pool, men som bare registrerer spørringene (og svarer tomt). */ +export function capturePool(): { pool: Pool; captured: Statement[] } { + const captured: Statement[] = []; + const pool = { + query: (text: string, values: unknown[] = []) => { + captured.push({ text, values }); + return Promise.resolve({ rows: [] }); + }, + } as unknown as Pool; + return { pool, captured }; +} + +export interface PlanNode { + 'Node Type': string; + 'Index Name'?: string; + 'Relation Name'?: string; + 'Sort Method'?: string; + 'Actual Total Time'?: number; + 'Actual Loops'?: number; + Plans?: PlanNode[]; +} + +/** Total tid en node brukte (alle sløyfer), inkludert barna. */ +const totalTime = (n: PlanNode) => (n['Actual Total Time'] ?? 0) * (n['Actual Loops'] ?? 1); + +function* walk(node: PlanNode): Generator { + yield node; + for (const child of node.Plans ?? []) yield* walk(child); +} + +/** + * Tid noden brukte selv (uten barna). Hovednoden er den med mest egentid: «Limit» og «Sort» over + * en indeksskanning bruker nesten ingenting selv, og skal ikke bli stående som planens + * «hovednode» bare fordi de ligger øverst. + */ +const selfTime = (n: PlanNode) => + Math.max(0, totalTime(n) - (n.Plans ?? []).reduce((sum, c) => sum + totalTime(c), 0)); + +/** Kort, lesbar beskrivelse som «Index Scan using titles_rating_idx». */ +export function describeNode(node: PlanNode): string { + const type = node['Node Type']; + if (type === 'Bitmap Heap Scan') { + // Selve nytten av en bitmapskanning er hvilke indekser den kombinerer. + const indexes = [...walk(node)] + .map((n) => n['Index Name']) + .filter((name): name is string => Boolean(name)); + const on = node['Relation Name'] ? ` on ${node['Relation Name']}` : ''; + return `${type}${on}${indexes.length ? ` (${[...new Set(indexes)].join(', ')})` : ''}`; + } + if (node['Index Name']) return `${type} using ${node['Index Name']}`; + if (type === 'Sort' && node['Sort Method']) return `${type} (${node['Sort Method']})`; + if (node['Relation Name']) return `${type} on ${node['Relation Name']}`; + return type; +} + +export interface ParsedPlan { + executionMs: number; + mainNode: string; +} + +/** Leser `EXPLAIN (ANALYZE, FORMAT JSON)`-svaret: [{ Plan, "Execution Time", ... }]. */ +export function parsePlan(explainJson: unknown): ParsedPlan { + const root = Array.isArray(explainJson) ? explainJson[0] : undefined; + const plan = root?.Plan as PlanNode | undefined; + const executionMs = root?.['Execution Time']; + if (!plan || typeof executionMs !== 'number') { + throw new Error('Uventet svar fra EXPLAIN: fant ikke Plan og Execution Time.'); + } + let main = plan; + for (const node of walk(plan)) if (selfTime(node) > selfTime(main)) main = node; + return { executionMs, mainNode: describeNode(main) }; +} + +export function median(values: number[]): number { + if (values.length === 0) throw new Error('Median av ingen verdier.'); + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2; +} + +const READ_ONLY_SQL = /^\s*(SELECT|WITH)\b/i; + +/** + * Kjører EXPLAIN ANALYZE på én setning. ANALYZE kjører spørringen på ekte, så alt som ikke er en ren + * SELECT avvises her i tillegg til at økten er satt til skrivebeskyttet. + */ +export async function explainStatement(db: Pool, stmt: Statement): Promise { + if (!READ_ONLY_SQL.test(stmt.text)) { + throw new Error('Bare SELECT kan måles (EXPLAIN ANALYZE kjører setningen).'); + } + const { rows } = await db.query<{ 'QUERY PLAN': unknown }>( + `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${stmt.text}`, + stmt.values, + ); + return parsePlan(rows[0]?.['QUERY PLAN']); +} + +export const NO_FILTERS: Filters = { + genres: [], + decades: [], + types: [], + minRating: null, + availableOnly: false, +}; + +const PAGE_SIZE = 20; +const DEEP_PAGE = 50; + +interface Captured { + statements: Statement[]; + /** Vises i parentes bak scenarionavnet, f.eks. hvis datasettet var for lite til å nå side 50. */ + note?: string; +} + +export interface Scenario { + name: string; + /** `db` kan brukes til å kjøre ekte spørringer (cursor-paginering); capture-poolen til å fange SQL. */ + capture: (db: Pool) => Promise; +} + +const search = ( + name: string, + query: string | null, + field: SortField, + filters: Filters = NO_FILTERS, +): Scenario => ({ + name, + capture: async () => { + const { pool, captured } = capturePool(); + const args: SearchArgs = { + query, + filters, + field, + direction: 'DESC', + first: PAGE_SIZE, + after: null, + }; + await searchTitles(pool, args); + return { statements: captured }; + }, +}); + +export const SCENARIOS: Scenario[] = [ + search('Søk «dark», relevans', 'dark', 'RELEVANCE'), + search('Søk «dark», rating', 'dark', 'RATING'), + search('Søk «a» (1 tegn, ordprefiks), relevans', 'a', 'RELEVANCE'), + search('Søk «ma» (2 tegn, ordprefiks), relevans', 'ma', 'RELEVANCE'), + search('Søk «the» (vanlig ord), relevans', 'the', 'RELEVANCE'), + search('Søk «amelie» (aksent), relevans', 'amelie', 'RELEVANCE'), + search('Ingen søk, rating, sjanger Drama', null, 'RATING', { ...NO_FILTERS, genres: ['Drama'] }), + { + name: `Side ${DEEP_PAGE} via cursor, rating`, + capture: async (db) => { + // Cursoren kan bare lages av en faktisk forrige side, så de første sidene kjøres på ekte. + let after: string | null = null; + let page = 1; + while (page < DEEP_PAGE) { + const result: Awaited> = await searchTitles(db, { + query: null, + filters: NO_FILTERS, + field: 'RATING', + direction: 'DESC', + first: PAGE_SIZE, + after, + }); + if (!result.pageInfo.hasNextPage || !result.pageInfo.endCursor) break; + after = result.pageInfo.endCursor; + page++; + } + const { pool, captured } = capturePool(); + await searchTitles(pool, { + query: null, + filters: NO_FILTERS, + field: 'RATING', + direction: 'DESC', + first: PAGE_SIZE, + after, + }); + return { + statements: captured, + note: page < DEEP_PAGE ? `datasettet rakk bare til side ${page}` : undefined, + }; + }, + }, + { + name: 'totalCount for «the»', + capture: async () => { + const { pool, captured } = capturePool(); + const connection = await searchTitles(pool, { + query: 'the', + filters: NO_FILTERS, + field: 'RELEVANCE', + direction: 'DESC', + first: PAGE_SIZE, + after: null, + }); + // totalCount er lazy: telle-spørringen bygges først nå, akkurat som når klienten ber om feltet. + captured.length = 0; + await connection.totalCount(); + return { statements: captured }; + }, + }, + { + // API-et holder disse i minnet i fem minutter (facetCache.ts). Her måles det en bom koster: + // det første kallet etter oppstart eller utløp, og det alle kall kostet før cachen. + name: 'Fasetter uten filter (uten cache)', + capture: async () => { + const { pool, captured } = capturePool(); + await getFacets(pool, null, NO_FILTERS); + return { statements: captured }; + }, + }, +]; + +export interface ScenarioResult { + name: string; + /** Median av summen av alle setningene i scenarioet, per kjøring. */ + ms: number; + mainNode: string; + statementCount: number; +} + +export const RUNS = 5; + +export interface MeasureOptions { + runs?: number; + warmups?: number; + onProgress?: (message: string) => void; +} + +export async function measure( + db: Pool, + scenarios: Scenario[] = SCENARIOS, + { runs = RUNS, warmups = 1, onProgress = () => {} }: MeasureOptions = {}, +): Promise { + const results: ScenarioResult[] = []; + for (const scenario of scenarios) { + onProgress(scenario.name); + const { statements, note } = await scenario.capture(db); + // Oppvarming slik at tallene gjelder varm cache (som i drift), ikke første lesing fra disk. + for (let i = 0; i < warmups; i++) { + for (const stmt of statements) await explainStatement(db, stmt); + } + const sums: number[] = []; + const perStatement: number[][] = statements.map(() => []); + let lastPlans: ParsedPlan[] = []; + for (let run = 0; run < runs; run++) { + lastPlans = []; + for (const [i, stmt] of statements.entries()) { + const plan = await explainStatement(db, stmt); + lastPlans.push(plan); + perStatement[i]!.push(plan.executionMs); + } + sums.push(lastPlans.reduce((sum, p) => sum + p.executionMs, 0)); + } + // Fasettene sender fire spørringer; planen som vises er den tregeste av dem. + const slowest = perStatement + .map((ms) => median(ms)) + .reduce((best, v, i, all) => (v > all[best]! ? i : best), 0); + results.push({ + name: note ? `${scenario.name} (${note})` : scenario.name, + ms: median(sums), + mainNode: lastPlans[slowest]?.mainNode ?? '(ingen spørring)', + statementCount: statements.length, + }); + } + return results; +} + +export interface ReportInfo { + titleCount: number; + pgVersion: string; + date: string; + runs: number; + jit: string; +} + +const cell = (text: string) => text.replaceAll('|', '\\|'); +const formatMs = (ms: number) => ms.toFixed(ms < 10 ? 2 : 1).replace('.', ','); + +export function formatReport(info: ReportInfo, results: ScenarioResult[]): string { + const rows = results.map((r) => { + const name = r.statementCount > 1 ? `${r.name}, sum av ${r.statementCount} spørringer` : r.name; + return `| ${cell(name)} | ${formatMs(r.ms)} | ${cell(r.mainNode)} |`; + }); + return [ + `**Målt ${info.date}**: ${info.titleCount.toLocaleString('nb-NO')} titler, PostgreSQL ${info.pgVersion}, jit = ${info.jit}.`, + `Median av ${info.runs} kjøringer med \`EXPLAIN (ANALYZE, BUFFERS)\` etter én oppvarming (varm cache). Søkene har \`LIMIT 21\` (= \`first: 20\` + 1).`, + '', + '| Scenario | ms (median) | Planens hovednode |', + '| --- | ---: | --- |', + ...rows, + '', + ].join('\n'); +} + +async function main() { + // Rolig timeout: en kald, uindeksert spørring mot hele datasettet kan ta sekunder, og en + // avbrutt måling sier ingenting. Poolens vanlige 15 s er til for å beskytte API-et. + const pool = createPool(config.databaseUrl, { statementTimeoutMs: 120_000 }); + const client = await pool.connect(); + try { + // Målingen skal aldri kunne endre data, selv om en spørring skulle være feil bygget. + await client.query('SET default_transaction_read_only = on'); + const db = { query: client.query.bind(client) } as unknown as Pool; + const count = await client.query<{ n: string }>('SELECT count(*) AS n FROM titles'); + const version = await client.query<{ server_version: string }>('SHOW server_version'); + const jit = await client.query<{ jit: string }>('SHOW jit'); + const results = await measure(db, SCENARIOS, { + onProgress: (name) => console.error(`Måler: ${name}`), + }); + process.stdout.write( + formatReport( + { + titleCount: Number(count.rows[0]?.n ?? 0), + pgVersion: version.rows[0]?.server_version.split(' ')[0] ?? 'ukjent', + date: new Date().toISOString().slice(0, 10), + runs: RUNS, + jit: jit.rows[0]?.jit ?? 'ukjent', + }, + results, + ), + ); + } finally { + client.release(); + await pool.end(); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/backend/scripts/migrate.test.ts b/backend/scripts/migrate.test.ts new file mode 100644 index 0000000..f17f0f6 --- /dev/null +++ b/backend/scripts/migrate.test.ts @@ -0,0 +1,61 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import pg from 'pg'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { config } from '../src/config.js'; +import { migrate } from './migrate.js'; + +// Eget skjema så schema_migrations og prøvetabellen ikke blander seg med testdatabasens egne. +const SCHEMA = 'migrate_test'; + +let admin: pg.Pool; +let pool: pg.Pool; +let dir: string; + +beforeAll(async () => { + admin = new pg.Pool({ connectionString: config.testDatabaseUrl, max: 1 }); + await admin.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`); + await admin.query(`CREATE SCHEMA ${SCHEMA}`); + // Samme type grense som createPool setter i API-et, men kort nok til at testen går raskt. + pool = new pg.Pool({ + connectionString: config.testDatabaseUrl, + max: 1, + statement_timeout: 100, + options: `-c search_path=${SCHEMA}`, + }); + dir = mkdtempSync(path.join(tmpdir(), 'migrate-test-')); + writeFileSync( + path.join(dir, '001_slow.sql'), + // Fire ganger poolens statement_timeout: ville blitt avbrutt uten SET i migrate(). + `SELECT pg_sleep(0.4); + CREATE TABLE probe AS + SELECT current_setting('statement_timeout') AS st, current_setting('lock_timeout') AS lt;`, + ); +}); + +afterAll(async () => { + await pool.end(); + await admin.query(`DROP SCHEMA ${SCHEMA} CASCADE`); + await admin.end(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('migrate', () => { + it('avbryter ikke en langsom setning og setter lock_timeout under migreringen', async () => { + await expect(migrate(pool, () => {}, dir)).resolves.toEqual(['001_slow.sql']); + const { rows } = await pool.query<{ st: string; lt: string }>('SELECT st, lt FROM probe'); + expect(rows[0]).toEqual({ st: '0', lt: '30s' }); + }); + + it('gjenoppretter poolens grenser etterpå (tilkoblingen er delt med resten av prosessen)', async () => { + const { rows } = await pool.query<{ st: string; lt: string }>( + "SELECT current_setting('statement_timeout') AS st, current_setting('lock_timeout') AS lt", + ); + expect(rows[0]).toEqual({ st: '100ms', lt: '0' }); + }); + + it('er idempotent: kjører ingenting når alt er kjørt', async () => { + await expect(migrate(pool, () => {}, dir)).resolves.toEqual([]); + }); +}); diff --git a/backend/scripts/migrate.ts b/backend/scripts/migrate.ts index 1299fc5..41c7e02 100644 --- a/backend/scripts/migrate.ts +++ b/backend/scripts/migrate.ts @@ -30,8 +30,8 @@ const LOCK_KEY = 281_001; export async function migrate( pool: Pool, log: (msg: string) => void = () => {}, + dir: string = findMigrationsDir(), ): Promise { - const dir = findMigrationsDir(); const files = readdirSync(dir) .filter((f) => /^\d+_.+\.sql$/.test(f)) .sort(); @@ -40,6 +40,12 @@ export async function migrate( const client = await pool.connect(); const applied: string[] = []; try { + // Poolens statement_timeout (15 s) er et vern mot løpske spørringer fra API-et, ikke mot + // migreringer: ALTER TABLE ... ADD COLUMN ... STORED skriver om hele titles og tar 10-20 s + // eller mer på VM-en med 190 000 titler. En avbrutt migrering ruller tilbake og kan aldri + // fullføres. lock_timeout i stedet for å vente evig på en lås som en hengende økt holder. + await client.query('SET statement_timeout = 0'); + await client.query("SET lock_timeout = '30s'"); await client.query('SELECT pg_advisory_lock($1)', [LOCK_KEY]); await client.query(`CREATE TABLE IF NOT EXISTS schema_migrations ( name text PRIMARY KEY, @@ -67,6 +73,9 @@ export async function migrate( } } finally { await client.query('SELECT pg_advisory_unlock($1)', [LOCK_KEY]).catch(() => {}); + // Tilkoblingen går tilbake til poolen; uten RESET ville resten av prosessen (f.eks. importen) + // fortsatt kjørt uten statement_timeout. + await client.query('RESET statement_timeout; RESET lock_timeout').catch(() => {}); client.release(); } return applied; diff --git a/backend/scripts/setup-core.ts b/backend/scripts/setup-core.ts new file mode 100644 index 0000000..c85ffd9 --- /dev/null +++ b/backend/scripts/setup-core.ts @@ -0,0 +1,152 @@ +import { copyFileSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import pg from 'pg'; + +/** + * Rene hjelpefunksjoner for `npm run setup`. De ligger adskilt fra setup.ts fordi setup.ts må opprette + * .env FØR `src/config.ts` leses (config laster .env ved import), og fordi disse kan testes uten + * en Postgres-server. + */ + +/** Antall syntetiske titler som genereres når ingenting annet er bedt om: raskt, men nok til å se søk og paginering. */ +export const DEFAULT_FIXTURE_SIZE = 20_000; + +/** + * Kopierer .env.example til .env hvis .env mangler. Overskriver aldri: en eksisterende .env kan + * inneholde passord og en ekte TMDB-nøkkel som ikke kommer tilbake. + */ +export function ensureEnvFile(root: string): 'created' | 'exists' | 'no-template' { + const target = path.join(root, '.env'); + if (existsSync(target)) return 'exists'; + const template = path.join(root, '.env.example'); + if (!existsSync(template)) return 'no-template'; + copyFileSync(template, target); + return 'created'; +} + +/** FIXTURE_SIZE fra miljøet, ellers standard. Samme regel som db:fixture, men med en tydelig feilmelding. */ +export function fixtureSize(env: NodeJS.ProcessEnv): number { + const raw = env.FIXTURE_SIZE?.trim(); + if (!raw) return DEFAULT_FIXTURE_SIZE; + const n = Number(raw); + if (!Number.isInteger(n) || n < 20) { + throw new Error(`FIXTURE_SIZE må være et heltall ≥ 20, fikk «${raw}».`); + } + return n; +} + +/** Passordet skal aldri havne i terminalen eller i en logg som limes inn i en chat. */ +export function redact(connectionString: string): string { + try { + const url = new URL(connectionString); + if (url.password) url.password = '***'; + return url.toString(); + } catch { + return '(ugyldig tilkoblingsstreng)'; + } +} + +/** Databasenavnet i en tilkoblingsstreng. */ +export function databaseName(connectionString: string): string { + const name = decodeURIComponent(new URL(connectionString).pathname.slice(1)); + if (!name) + throw new Error(`Tilkoblingsstrengen mangler databasenavn: ${redact(connectionString)}`); + return name; +} + +/** Samme server, men koblet til vedlikeholdsdatabasen `postgres` der CREATE DATABASE kan kjøres. */ +export function adminConnectionString(connectionString: string): string { + const url = new URL(connectionString); + url.pathname = '/postgres'; + return url.toString(); +} + +export function quoteIdent(name: string): string { + return `"${name.replace(/"/g, '""')}"`; +} + +/** + * Gjør en rå feil fra pg/Node om til noe en student kan handle på. Postgres-feilkoder (SQLSTATE) og + * Node-nettverkskoder er stabile, i motsetning til meldingsteksten. + */ +export function explainConnectionError(err: unknown, connectionString: string): string { + const e = err as { code?: string; message?: string }; + const shown = redact(connectionString); + // AggregateError (localhost som både IPv4 og IPv6) har tom melding. + const raw = e.message || 'ingen svar'; + // Når serveren krever passord og URL-en ikke har noe, kaster pg en vanlig Error uten SQLSTATE. + const code = e.code ?? (/password/i.test(raw) ? '28P01' : undefined); + switch (code) { + case 'ECONNREFUSED': + case 'ETIMEDOUT': + case 'ENOTFOUND': + return ( + `Fikk ikke kontakt med PostgreSQL (${shown}).\n` + + ' - Er PostgreSQL startet? Linux: `sudo systemctl start postgresql`, macOS (Homebrew): `brew services start postgresql`.\n' + + ' - Bruker serveren en annen vert eller port? Rediger DATABASE_URL og TEST_DATABASE_URL i .env.' + ); + case '28P01': + case '28000': + return ( + `PostgreSQL avviste innloggingen (${shown}): ${raw}\n` + + ' Sett riktig bruker og passord i .env, for eksempel:\n' + + ' DATABASE_URL=postgres://BRUKER:PASSORD@localhost:5432/project2\n' + + ' TEST_DATABASE_URL=postgres://BRUKER:PASSORD@localhost:5432/project2_test\n' + + ' Mangler du en bruker: sudo -u postgres psql -c "CREATE USER it2810 PASSWORD \'hemmelig\' SUPERUSER CREATEDB;"' + ); + case '42501': + return ( + `Brukeren i ${shown} har ikke lov til å opprette databaser (${raw}).\n` + + ' Gi den CREATEDB (og SUPERUSER, siden migreringene lager utvidelsene pg_trgm og unaccent):\n' + + ' sudo -u postgres psql -c "ALTER USER BRUKER WITH CREATEDB SUPERUSER;"\n' + + ' eller opprett databasene selv: `sudo -u postgres createdb project2` og `sudo -u postgres createdb project2_test`.' + ); + case '3D000': + return ( + `Databasen «postgres» finnes ikke på serveren (${shown}), så setup får ikke opprettet de andre.\n` + + ' Opprett databasene selv (`createdb project2`, `createdb project2_test`) og kjør `npm run setup` på nytt.' + ); + default: + return `Kunne ikke koble til PostgreSQL (${shown}): ${raw}`; + } +} + +/** Hint som skrives når migreringen feiler; den vanligste årsaken er manglende utvidelser eller rettigheter. */ +export const MIGRATION_HINT = + 'Migreringen feilet. Vanlige årsaker:\n' + + ' - `permission denied to create extension "pg_trgm"` eller "unaccent": brukeren må være superuser, eller en superuser\n' + + ' må kjøre `CREATE EXTENSION pg_trgm; CREATE EXTENSION unaccent;` i databasen først.\n' + + ' - `extension "pg_trgm" is not available`: installer contrib-pakken (Debian/Ubuntu: `sudo apt install postgresql-contrib`).'; + +/** + * Oppretter databasen hvis den mangler. Kobler til `postgres` på samme server (som e2e/global-setup.ts), + * fordi man ikke kan kjøre CREATE DATABASE fra en database som ikke finnes ennå. + */ +export async function ensureDatabase(connectionString: string): Promise<'created' | 'exists'> { + const name = databaseName(connectionString); + const admin = new pg.Client({ + connectionString: adminConnectionString(connectionString), + connectionTimeoutMillis: 5000, + }); + // pg emitterer 'error' på klienten ved frakoblet server midt i en spørring; uten lytter krasjer prosessen. + admin.on('error', () => {}); + try { + await admin.connect(); + } catch (err) { + throw new Error(explainConnectionError(err, connectionString), { cause: err }); + } + try { + const found = await admin.query('SELECT 1 FROM pg_database WHERE datname = $1', [name]); + if (found.rowCount) return 'exists'; + try { + await admin.query(`CREATE DATABASE ${quoteIdent(name)}`); + } catch (err) { + // 42P04 = duplicate_database: en annen prosess rakk det først, som er like bra. + if ((err as { code?: string }).code === '42P04') return 'exists'; + throw new Error(explainConnectionError(err, connectionString), { cause: err }); + } + return 'created'; + } finally { + await admin.end(); + } +} diff --git a/backend/scripts/setup.test.ts b/backend/scripts/setup.test.ts new file mode 100644 index 0000000..fc030c7 --- /dev/null +++ b/backend/scripts/setup.test.ts @@ -0,0 +1,141 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import pg from 'pg'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { config } from '../src/config.js'; +import { + adminConnectionString, + databaseName, + DEFAULT_FIXTURE_SIZE, + ensureDatabase, + ensureEnvFile, + explainConnectionError, + fixtureSize, + quoteIdent, + redact, +} from './setup-core.js'; + +describe('ensureEnvFile', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'setup-env-')); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it('copies .env.example to .env when .env is missing', () => { + writeFileSync(path.join(dir, '.env.example'), 'PORT=3001\n'); + expect(ensureEnvFile(dir)).toBe('created'); + expect(readFileSync(path.join(dir, '.env'), 'utf8')).toBe('PORT=3001\n'); + }); + + it('never overwrites an existing .env', () => { + writeFileSync(path.join(dir, '.env.example'), 'PORT=3001\n'); + writeFileSync(path.join(dir, '.env'), 'TMDB_API_KEY=secret\n'); + expect(ensureEnvFile(dir)).toBe('exists'); + expect(readFileSync(path.join(dir, '.env'), 'utf8')).toBe('TMDB_API_KEY=secret\n'); + }); + + it('does not crash when there is no template', () => { + expect(ensureEnvFile(dir)).toBe('no-template'); + }); + + it('keeps an empty existing .env as it is', () => { + writeFileSync(path.join(dir, '.env.example'), 'PORT=3001\n'); + writeFileSync(path.join(dir, '.env'), ''); + expect(ensureEnvFile(dir)).toBe('exists'); + expect(readFileSync(path.join(dir, '.env'), 'utf8')).toBe(''); + }); +}); + +describe('fixtureSize', () => { + it('defaults to a fast dataset', () => { + expect(fixtureSize({})).toBe(DEFAULT_FIXTURE_SIZE); + expect(fixtureSize({ FIXTURE_SIZE: '' })).toBe(DEFAULT_FIXTURE_SIZE); + }); + + it('accepts the large dataset', () => { + expect(fixtureSize({ FIXTURE_SIZE: '120000' })).toBe(120_000); + }); + + it.each(['abc', '12.5', '5', '-1'])('rejects %s', (v) => { + expect(() => fixtureSize({ FIXTURE_SIZE: v })).toThrow(/FIXTURE_SIZE/); + }); +}); + +describe('connection string helpers', () => { + it('extracts the database name and builds the admin URL on the same server', () => { + const url = 'postgres://u:pw@db.example:5433/project2?sslmode=disable'; + expect(databaseName(url)).toBe('project2'); + const admin = new URL(adminConnectionString(url)); + expect(admin.pathname).toBe('/postgres'); + expect(admin.host).toBe('db.example:5433'); + expect(admin.username).toBe('u'); + }); + + it('rejects a URL without a database name', () => { + expect(() => databaseName('postgres://u@localhost:5432')).toThrow(/databasenavn/); + }); + + it('hides passwords', () => { + expect(redact('postgres://u:hemmelig@localhost/x')).not.toContain('hemmelig'); + expect(redact('not a url')).toBe('(ugyldig tilkoblingsstreng)'); + }); + + it('quotes identifiers', () => { + expect(quoteIdent('a"b')).toBe('"a""b"'); + }); +}); + +describe('explainConnectionError', () => { + const url = 'postgres://postgres:pw@localhost:5432/project2'; + + it('tells the user to start PostgreSQL when nothing answers', () => { + const msg = explainConnectionError(Object.assign(new Error(''), { code: 'ECONNREFUSED' }), url); + expect(msg).toMatch(/Er PostgreSQL startet/); + expect(msg).not.toContain(':pw@'); + }); + + it('shows an example DATABASE_URL with password on authentication failure', () => { + const msg = explainConnectionError(Object.assign(new Error('nope'), { code: '28P01' }), url); + expect(msg).toContain('DATABASE_URL=postgres://BRUKER:PASSORD@localhost:5432/project2'); + }); + + it("recognises pg's missing-password error, which has no error code", () => { + const err = new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string'); + expect(explainConnectionError(err, url)).toContain('BRUKER:PASSORD'); + }); + + it('explains CREATEDB on insufficient privilege', () => { + const msg = explainConnectionError(Object.assign(new Error('x'), { code: '42501' }), url); + expect(msg).toMatch(/CREATEDB/); + }); + + it('falls back to the raw message', () => { + expect(explainConnectionError(new Error('boom'), url)).toContain('boom'); + }); +}); + +describe('ensureDatabase (against the test server)', () => { + const name = `project2_setup_unit_${process.pid}`; + const url = Object.assign(new URL(config.testDatabaseUrl), { pathname: `/${name}` }).toString(); + + afterEach(async () => { + const admin = new pg.Client({ connectionString: adminConnectionString(url) }); + await admin.connect(); + await admin.query(`DROP DATABASE IF EXISTS ${quoteIdent(name)}`); + await admin.end(); + }); + + it('creates a missing database and is idempotent', async () => { + expect(await ensureDatabase(url)).toBe('created'); + expect(await ensureDatabase(url)).toBe('exists'); + }); + + it('explains an unreachable server without leaking the password', async () => { + const dead = 'postgres://u:hemmelig@127.0.0.1:1/project2'; + const err = await ensureDatabase(dead).catch((e: Error) => e); + expect((err as Error).message).toMatch(/Fikk ikke kontakt med PostgreSQL/); + expect((err as Error).message).not.toContain('hemmelig'); + }); +}); diff --git a/backend/scripts/setup.ts b/backend/scripts/setup.ts new file mode 100644 index 0000000..388aef3 --- /dev/null +++ b/backend/scripts/setup.ts @@ -0,0 +1,121 @@ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + databaseName, + ensureDatabase, + ensureEnvFile, + fixtureSize, + MIGRATION_HINT, + redact, +} from './setup-core.js'; + +/** + * `npm run setup`: gjør en fersk klon om til noe som kan kjøres med `npm run dev`, uten nøkler, + * VPN eller nedlasting. Hvert steg kan kjøres på nytt uten skade (.env overskrives aldri, databaser + * opprettes bare hvis de mangler, migrering og import er upserts). + */ + +// Ikke import fra ../src/config.js på toppnivå: config leser .env ved import, og .env finnes kanskje +// ikke før steg 1 har kjørt. +const repoRoot = path.resolve(import.meta.dirname, '..', '..'); + +function step(n: number, total: number, msg: string) { + console.log(`\n[${n}/${total}] ${msg}`); +} + +/** + * Kjører en npm-kommando i terminalen. `shell: true` gjør at `npm` (npm.cmd på Windows) finnes uten + * plattformspesifikk kode. + */ +function run(command: string, env: NodeJS.ProcessEnv = {}): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, { + cwd: repoRoot, + env: { ...process.env, ...env }, + stdio: 'inherit', + shell: true, + }); + child.once('error', reject); + child.once('exit', (code, signal) => + code === 0 ? resolve() : reject(new Error(`«${command}» avsluttet med ${code ?? signal}.`)), + ); + }); +} + +const TOTAL = 6; + +async function main() { + step(1, TOTAL, 'Miljøfil'); + const envState = ensureEnvFile(repoRoot); + if (envState === 'created') console.log('Lagde .env fra .env.example.'); + else if (envState === 'exists') console.log('.env finnes allerede og beholdes som den er.'); + else console.log('Fant verken .env eller .env.example; bruker standardverdier.'); + + const { config } = await import('../src/config.js'); + if (config.databaseUrl === config.testDatabaseUrl) { + throw new Error( + 'DATABASE_URL og TEST_DATABASE_URL må være to ulike databaser (testene tømmer den ene).', + ); + } + + step(2, TOTAL, 'Databaser'); + for (const [label, url] of [ + ['app', config.databaseUrl], + ['test', config.testDatabaseUrl], + ] as const) { + const state = await ensureDatabase(url); + console.log( + `${state === 'created' ? 'Opprettet' : 'Fant'} ${label}databasen «${databaseName(url)}» (${redact(url)}).`, + ); + } + + step(3, TOTAL, 'Skjema (migrering)'); + try { + await run('npm run db:migrate -w backend'); + } catch (err) { + throw new Error(`${(err as Error).message}\n\n${MIGRATION_HINT}`, { cause: err }); + } + + step(4, TOTAL, 'Titler'); + const basics = path.join(config.imdbDataDir, 'title.basics.tsv.gz'); + const ratings = path.join(config.imdbDataDir, 'title.ratings.tsv.gz'); + if (existsSync(basics) && existsSync(ratings)) { + console.log(`Bruker datafilene som allerede ligger i ${config.imdbDataDir}.`); + } else { + const size = fixtureSize(process.env); + console.log( + `Ingen datafiler i ${config.imdbDataDir}; genererer ${size} syntetiske titler ` + + '(FIXTURE_SIZE=120000 gir det store settet).', + ); + await run('npm run db:fixture -w backend', { FIXTURE_SIZE: String(size) }); + } + await run('npm run db:seed -w backend'); + + step(5, TOTAL, 'Gratisfilmer (falsk Internet Archive)'); + // Tilfeldig ledig port: setup skal ikke feile om 3998 er opptatt av en kjørende dev- eller E2E-stack. + // Databasen lagrer bare pekere (element-id og filnavn), ikke vertsnavnet, så porten spiller ingen rolle senere. + const { startArchiveMock } = await import('./archive-mock.js'); + const mock = await startArchiveMock(0); + try { + await run('npm run db:archive -w backend', { ARCHIVE_URL: `http://localhost:${mock.port}` }); + } finally { + await mock.close(); + } + + step(6, TOTAL, 'Kontroll'); + console.log( + config.tmdbApiKey + ? 'TMDB_API_KEY er satt i .env; plakater hentes fra ekte TMDB.' + : 'TMDB_API_KEY er tom; `npm run dev` bruker en falsk TMDB-server med tegnede plakater.', + ); + console.log('\nFerdig! Kjør npm run dev og åpne http://localhost:5173/project2/'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error(`\nSetup feilet:\n${err instanceof Error ? err.message : err}`); + process.exit(1); + }); +} diff --git a/backend/src/app.ts b/backend/src/app.ts index c4facf2..a0899f8 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,13 +1,40 @@ -import { GraphQLError, type ValidationRule } from 'graphql'; +import { GraphQLError, parse, type ValidationRule } from 'graphql'; import { createYoga, type Plugin } from 'graphql-yoga'; import { ArtworkService } from './artwork.js'; import { config } from './config.js'; import { createContext, type Context } from './context.js'; import type { Pool } from './db.js'; +import { complexityLimit } from './complexityLimit.js'; +import { isDatabaseUnavailable, serviceUnavailableError } from './dbErrors.js'; import { depthLimit } from './depthLimit.js'; +import { FacetCache } from './facetCache.js'; +import { DEFAULT_RATE_LIMITS, MutationLimiter, type RateLimitOptions } from './rateLimit.js'; import { schema } from './schema.js'; +import type { FacetsResult } from './search.js'; + +/** Serverkonteksten Yoga får fra node:http; fravær av `req` betyr kall via yoga.fetch (tester). */ +interface NodeServerContext { + req?: { socket?: { remoteAddress?: string } }; +} export const MAX_QUERY_DEPTH = 6; +// Frontendens største spørring (Title-detaljene) har ca. 45 felt og ett rotfelt. Grensene gir rundt +// tre ganger slingringsmonn, men stopper hundrevis av aliasede søk eller TMDB-oppslag i ett dokument. +export const MAX_ROOT_FIELDS = 8; +export const MAX_FIELDS = 150; +// Vektet kostnad (felt under en liste teller `first` ganger, variabel `first` = 50). Frontendens +// største spørring (forsiden/Featured) ligger på ca. 1 100; 2 500 slipper den gjennom to ganger, +// men stopper f.eks. 8 x search(first: 50) med nøstede anmeldelser. +export const MAX_COST = 2500; +// Tak på antall tokens i ett dokument. graphql-js sin regel OverlappingFieldsCanBeMerged er +// kvadratisk i antall felt, og complexityLimit stopper ikke de andre reglene: `{ genres genres ... }` +// med 8 000 felt (55 kB) holdt event-loopen i ca. 5 s. Tokengrensen avviser dokumentet under +// parsing, før noen valideringsregel kjører. Frontendens største operasjon har 128 tokens og +// introspeksjonsspørringen til GraphiQL ca. 180, så 1 000 gir åtte ganger slingringsmonn. +export const MAX_TOKENS = 1000; +// Alle legitime forespørsler er små (spørring + variabler); 25 MB-standarden i Yoga lar en +// angriper tvinge oss til å lese og JSON-parse store kropper før noen grense slår inn. +export const MAX_REQUEST_BODY_BYTES = 64 * 1024; /** * Egen regel i stedet for graphqls NoSchemaIntrospectionCustomRule: den bruker instanceof-sjekker @@ -26,7 +53,29 @@ const noIntrospection: ValidationRule = (context) => ({ }, }); -export interface AppOptions { +const HEALTH_TIMEOUT_MS = 2000; + +/** + * Kort tidsgrense utenom poolens egen: en database bak en brannmur svarer ikke i det hele tatt, + * og helsesjekken skal da svare 503 raskt i stedet for å henge til Apache gir opp. + */ +async function databaseResponds(pool: Pool, timeoutMs = HEALTH_TIMEOUT_MS): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }); + const query = pool.query('SELECT 1').then( + () => true, + () => false, + ); + try { + return await Promise.race([query, timeout]); + } finally { + clearTimeout(timer); + } +} + +interface AppOptions { pool: Pool; /** Slår av introspeksjon og GraphiQL. Standard: NODE_ENV === 'production'. */ production?: boolean; @@ -36,6 +85,10 @@ export interface AppOptions { artwork?: ArtworkService; /** Base-URL for Internet Archive i stream-URL-ene. Standard: ARCHIVE_URL. */ archiveUrl?: string; + /** Overstyrer grensene for mutations (se rateLimit.ts). Standard: DEFAULT_RATE_LIMITS. */ + rateLimits?: Partial; + /** Cache for fasetter uten søketekst. Standard: FacetCache med fem minutters levetid. */ + facetCache?: FacetCache; } /** @@ -48,24 +101,64 @@ export function createApp({ corsOrigin = false, artwork, archiveUrl, + rateLimits, + facetCache = new FacetCache(), }: AppOptions) { + // Én begrenser per app: tilstanden må overleve mellom requests, i motsetning til konteksten. + const limiter = new MutationLimiter({ ...DEFAULT_RATE_LIMITS, ...rateLimits }); const artworkService = artwork ?? new ArtworkService({ pool, apiKey: config.tmdbApiKey }); const isProd = production ?? process.env.NODE_ENV === 'production'; // Egendefinert plugin i stedet for ekstra pakker: envelop lar oss legge til valideringsregler. const limits: Plugin = { + onParse({ setParseFn }) { + setParseFn((source, options) => parse(source, { ...options, maxTokens: MAX_TOKENS })); + }, onValidate({ addValidationRule }) { addValidationRule(depthLimit(MAX_QUERY_DEPTH)); + addValidationRule( + complexityLimit({ + maxRootFields: MAX_ROOT_FIELDS, + maxFields: MAX_FIELDS, + maxCost: MAX_COST, + }), + ); if (isProd) addValidationRule(noIntrospection); }, }; - return createYoga({ + // Yogas innebygde /health svarer «alive» uten å se på databasen. Vi trenger det motsatte: sjekk.sh, + // Apache og drift skal få vite om API-et faktisk kan svare, så Yogas flyttes og vår tar over stien. + const health: Plugin = { + async onRequest({ request, url, endResponse }) { + if (url.pathname !== '/health' || request.method !== 'GET') return; + const ok = await databaseResponds(pool); + endResponse( + Response.json( + { status: ok ? 'ok' : 'db-unavailable' }, + { status: ok ? 200 : 503, headers: { 'cache-control': 'no-store' } }, + ), + ); + }, + }; + + return createYoga({ schema, graphqlEndpoint: '/graphql', - plugins: [limits], - context: ({ request }) => - createContext(pool, request, artworkService, archiveUrl ?? config.archiveUrl), + healthCheckEndpoint: '/__yoga-health', + plugins: [health, limits], + maxRequestBodySize: MAX_REQUEST_BODY_BYTES, + // `req` finnes bare når Yoga kjører under node:http (ikke i yoga.fetch i tester). + context: ({ request, req }) => + createContext( + pool, + request, + artworkService, + archiveUrl ?? config.archiveUrl, + limiter, + req?.socket?.remoteAddress, + facetCache, + ), logging: process.env.NODE_ENV !== 'test', graphiql: !isProd, landingPage: !isProd, @@ -88,6 +181,7 @@ export function createApp({ if (http?.status && http.status < 500) { return error as GraphQLError; } + if (isDatabaseUnavailable(error)) return serviceUnavailableError(); if (process.env.NODE_ENV !== 'test') console.error('Uventet feil:', error); return new GraphQLError('Intern feil.', { extensions: { code: 'INTERNAL_SERVER_ERROR' } }); }, diff --git a/backend/src/artwork.ts b/backend/src/artwork.ts index d9bcb25..7fe9d2f 100644 --- a/backend/src/artwork.ts +++ b/backend/src/artwork.ts @@ -20,7 +20,8 @@ export const BACKDROP_WIDTHS = [300, 780, 1280] as const; const SAFE_PATH = /^\/[A-Za-z0-9._-]+\.(jpg|jpeg|png|svg)$/; /** - * Snapper til nærmeste tillatte bredde (likt avstand: den største, bedre kvalitet enn skarphet). + * Snapper til nærmeste tillatte bredde. Ved lik avstand velges den største bredden, siden bedre + * kvalitet veier tyngre enn en litt mindre fil. * Ugyldig bredde (≤ 0 eller ikke heltall) avvises heller enn å gjettes på: det er en klientfeil. */ export function snapWidth(width: number, allowed: readonly number[]): number { @@ -50,7 +51,10 @@ type Outcome = | { kind: 'found'; artwork: Artwork } | { kind: 'missing' } | { kind: 'error' } - | { kind: 'disabled' }; + | { kind: 'disabled' } + // Køen var full: tittelen ble ikke forsøkt. Verken lagret eller negativt cachet, slik at den + // slås opp som vanlig neste gang det er plass. + | { kind: 'busy' }; export interface ArtworkOptions { pool: Pool; @@ -59,6 +63,8 @@ export interface ArtworkOptions { imageUrl?: string; /** Maks samtidige TMDB-kall totalt i prosessen. */ concurrency?: number; + /** Maks ventende oppslag utover de samtidige; flere avvises uten å kalle TMDB. */ + maxQueue?: number; /** Tidsgrense per TMDB-kall. */ callTimeoutMs?: number; /** Hvor lenge én request maksimalt venter på bilder. */ @@ -89,6 +95,7 @@ export class ArtworkService { private readonly apiKey: string | undefined; private readonly apiUrl: string; private readonly concurrency: number; + private readonly maxQueue: number; private readonly callTimeoutMs: number; private readonly batchDeadlineMs: number; private readonly negativeTtlMs: number; @@ -109,6 +116,7 @@ export class ArtworkService { this.apiUrl = (opts.apiUrl ?? config.tmdbApiUrl).replace(/\/+$/, ''); this.imageUrl = (opts.imageUrl ?? config.tmdbImageUrl).replace(/\/+$/, ''); this.concurrency = opts.concurrency ?? 8; + this.maxQueue = opts.maxQueue ?? 200; this.callTimeoutMs = opts.callTimeoutMs ?? 4000; this.batchDeadlineMs = opts.batchDeadlineMs ?? 2500; this.negativeTtlMs = opts.negativeTtlMs ?? 5 * 60_000; @@ -178,6 +186,15 @@ export class ArtworkService { return new Map(result); } + /** + * Venter til alle påbegynte oppslag er ferdige og lagret. En GraphQL-forespørsel gir opp etter + * batchDeadlineMs, men oppslaget fortsetter og skriver til title_artwork etterpå; den som trenger + * en rolig database (tester, nedstengning) må derfor vente på dette. + */ + async idle(): Promise { + while (this.inflight.size > 0) await Promise.allSettled([...this.inflight.values()]); + } + /** Slår opp én tittel hos TMDB og lagrer resultatet. Kaster aldri. */ lookup(id: string, kind: TitleKind): Promise { const existing = this.inflight.get(id); @@ -194,6 +211,13 @@ export class ArtworkService { private async doLookup(id: string, kind: TitleKind): Promise { if (!this.enabled) return { kind: 'disabled' }; + // Uten tak kan scraping av søk og detaljsider fylle køen med titusenvis av ventende oppslag + // (hver holder en lukning og en forespørsel som allerede har gitt opp etter batchDeadlineMs). + // De ville sultet vanlige brukere, siden køen er delt og FIFO. Avvisning er trygt: bildet + // er valgfritt og hentes ved neste visning. + if (this.active >= this.concurrency && this.waiting.length >= this.maxQueue) { + return { kind: 'busy' }; + } await this.acquire(); let outcome: Outcome; try { diff --git a/backend/src/complexityLimit.test.ts b/backend/src/complexityLimit.test.ts new file mode 100644 index 0000000..ea6b6c6 --- /dev/null +++ b/backend/src/complexityLimit.test.ts @@ -0,0 +1,152 @@ +import { buildSchema, parse, validate } from 'graphql'; +import { describe, expect, it } from 'vitest'; +import { complexityLimit } from './complexityLimit.js'; + +const schema = buildSchema(` + type Node { id: ID, name: String, child: Node, reviews(first: Int): Node } + type Query { root: Node, facets: Node, search(first: Int): Node, myList(first: Int): Node } +`); +const check = (q: string, maxRootFields = 3, maxFields = 10, maxCost = 1e9) => + validate(schema, parse(q), [complexityLimit({ maxRootFields, maxFields, maxCost })]); + +const aliases = (n: number) => Array.from({ length: n }, (_, i) => `a${i}: root { id }`).join(' '); + +describe('complexityLimit', () => { + it('godtar rotfelt akkurat på grensen og avviser ett over', () => { + expect(check(`{ ${aliases(3)} }`, 3, 100)).toHaveLength(0); + const errors = check(`{ ${aliases(4)} }`, 3, 100); + expect(errors).toHaveLength(1); + expect(errors[0]?.extensions.code).toBe('BAD_USER_INPUT'); + expect(errors[0]?.message).toContain('rotfelt'); + }); + + it('teller aliaser av samme felt hver for seg', () => { + expect(check(`{ ${aliases(500)} }`, 8, 1000)).toHaveLength(1); + }); + + it('teller felt totalt: grensen er inklusiv', () => { + // root + id + name + child + id + name = 6 felt. + const q = '{ root { id name child { id name } } }'; + expect(check(q, 3, 6)).toHaveLength(0); + const errors = check(q, 3, 5); + expect(errors).toHaveLength(1); + expect(errors[0]?.extensions.code).toBe('BAD_USER_INPUT'); + expect(errors[0]?.message).toContain('6 felt'); + }); + + it('teller felt i fragmenter og inline-fragmenter, og hvert bruk av fragmentet', () => { + const q = ` + query { root { ...F } b: root { ...F } c: root { ... on Node { id name } } } + fragment F on Node { id name }`; + // 3 rotfelt + 2 + 2 + 2 = 9 + expect(check(q, 3, 9)).toHaveLength(0); + expect(check(q, 3, 8)).toHaveLength(1); + }); + + it('hopper over introspeksjon', () => { + expect(check('{ __schema { types { fields { type { ofType { name } } } } } }')).toHaveLength(0); + }); + + it('tåler eksponentielt voksende og sirkulære fragmenter uten å henge', () => { + // Hvert nivå bruker det neste ti ganger: 10^8 noder uten memoisering. + const levels = 8; + let doc = '{ root { ...F0 } }'; + for (let i = 0; i < levels; i++) { + const uses = Array.from({ length: 10 }, () => `...F${i + 1}`).join(' '); + doc += ` fragment F${i} on Node { ${uses} }`; + } + doc += ` fragment F${levels} on Node { id }`; + const started = Date.now(); + expect(check(doc, 3, 1000)).toHaveLength(1); + expect(Date.now() - started).toBeLessThan(500); + // Sirkel A -> B -> A: selve avvisningen gjøres av NoFragmentCycles; vi må bare ikke løpe i ring. + expect(() => + check('{ root { ...A } } fragment A on Node { child { ...B } } fragment B on Node { ...A }'), + ).not.toThrow(); + }); + + it('tåler eksponentielt voksende fragmentkjeder spredt fra rotnivå (CPU-DoS)', () => { + // 636 byte i originalrapporten: 8 nivåer med ti spredninger hver tok ca. 60 s uten memoisering. + const levels = 8; + let doc = '{ ...F0 }'; + for (let i = 0; i < levels; i++) { + const uses = Array.from({ length: 10 }, () => `...F${i + 1}`).join(' '); + doc += ` fragment F${i} on Query { ${uses} }`; + } + doc += ` fragment F${levels} on Query { root { id } }`; + const started = Date.now(); + const errors = check(doc, 8, 1000); + expect(Date.now() - started).toBeLessThan(200); + // 10^8 rotfelt: må avvises, og med rotfelt-meldingen. + expect(errors).toHaveLength(1); + expect(errors[0]?.message).toContain('rotfelt'); + }); + + describe('vektet kostnad (first)', () => { + const cost = (q: string, max: number) => check(q, 100, 1000, max); + + it('vekter feltene under en liste med literal first', () => { + // search(1) + 50 x (id + name = 2) = 101 + const q = '{ search(first: 50) { id name } }'; + expect(cost(q, 101)).toHaveLength(0); + const errors = cost(q, 100); + expect(errors).toHaveLength(1); + expect(errors[0]?.extensions.code).toBe('BAD_USER_INPUT'); + expect(errors[0]?.message).toContain('first'); + }); + + it('bruker standard sidestørrelse når first utelates (search: 20, reviews: 10)', () => { + expect(cost('{ search { id } }', 21)).toHaveLength(0); + expect(cost('{ search { id } }', 20)).toHaveLength(1); + // search(1) + 20 x (reviews(1) + 10 x id) = 1 + 20 x 11 = 221 + expect(cost('{ search { reviews { id } } }', 221)).toHaveLength(0); + expect(cost('{ search { reviews { id } } }', 220)).toHaveLength(1); + }); + + it('regner en variabel first som 50, også når den har en lav standardverdi', () => { + expect(cost('query($n: Int) { search(first: $n) { id } }', 51)).toHaveLength(0); + expect(cost('query($n: Int) { search(first: $n) { id } }', 50)).toHaveLength(1); + // Standardverdien kan overstyres av klienten, så den skal ikke gi rabatt. + expect(cost('query($n: Int = 5) { search(first: $n) { id } }', 51)).toHaveLength(0); + expect(cost('query($n: Int = 5) { search(first: $n) { id } }', 50)).toHaveLength(1); + }); + + it('stopper omgåelsen via standardverdi på variabel (default 1, sendt som 50)', () => { + const q = + 'query($n: Int = 1) { a: search(first: $n) { id name } b: search(first: $n) { id name } }'; + // Regnet som first = 1 ville dette kostet 2 x (1 + 2) = 6; riktig er 2 x (1 + 50 x 2) = 202. + const errors = cost(q, 100); + expect(errors).toHaveLength(1); + expect(errors[0]?.message).toContain('kostbar'); + }); + + it('multipliserer nøstede lister og avviser 8 x search(first: 50) med reviews(first: 50)', () => { + const one = 'search(first: 50) { id reviews(first: 50) { id name } }'; + const q = `{ ${Array.from({ length: 8 }, (_, i) => `s${i}: ${one}`).join(' ')} }`; + // Under rotfelt- og totalgrensene, men 8 x 50 x 50 x ... er enormt. + const errors = check(q, 8, 1000, 2500); + expect(errors).toHaveLength(1); + expect(errors[0]?.message).toContain('kostbar'); + }); + + it('gir facets en høy fast vekt slik at aliasede facets stoppes', () => { + const facets = (n: number) => + `{ ${Array.from({ length: n }, (_, i) => `f${i}: facets { id }`).join(' ')} }`; + // Hver forekomst: 1 + 500 + 1 (id) = 502; grensen 2 500 rommer fire, ikke fem. + expect(check(facets(1), 8, 100, 2500)).toHaveLength(0); + expect(check(facets(4), 8, 100, 2500)).toHaveLength(0); + const errors = check(facets(5), 8, 100, 2500); + expect(errors).toHaveLength(1); + expect(errors[0]?.message).toContain('kostbar'); + // 8 aliaser (rotfeltgrensen) ga før 32 parallelle aggregeringer. + expect(check(facets(8), 8, 100, 2500)).toHaveLength(1); + }); + + it('teller listevekt gjennom fragmenter', () => { + const q = '{ search(first: 10) { ...F } } fragment F on Node { id name }'; + // 1 + 10 x 2 = 21 + expect(cost(q, 21)).toHaveLength(0); + expect(cost(q, 20)).toHaveLength(1); + }); + }); +}); diff --git a/backend/src/complexityLimit.ts b/backend/src/complexityLimit.ts new file mode 100644 index 0000000..bb362ca --- /dev/null +++ b/backend/src/complexityLimit.ts @@ -0,0 +1,148 @@ +import { + GraphQLError, + Kind, + type ArgumentNode, + type FieldNode, + type FragmentDefinitionNode, + type SelectionSetNode, + type ValidationRule, +} from 'graphql'; + +export interface ComplexityLimits { + /** Maks antall rotfelt (aliaser teller hver for seg: `a: search`, `b: search` er to). */ + maxRootFields: number; + /** Maks antall felt totalt i én operasjon, fragmenter ekspandert og aliaser talt hver for seg. */ + maxFields: number; + /** Maks vektet kostnad: feltene under en liste teller `first` ganger (se `LIST_FIELDS`). */ + maxCost: number; +} + +/** + * Listefeltene (Relay-forbindelser) og standard sidestørrelse når `first` utelates. Navnene er + * hardkodet i stedet for å slås opp i skjemaet for å holde regelen enkel; nye forbindelser må + * legges til her. + */ +const LIST_FIELDS: Record = { search: 20, myList: 20, reviews: 10 }; +/** + * Tilleggskostnad per forekomst for felt som er dyre uavhengig av hvor mange felt som velges under + * dem. Hver `facets` kjører fire aggregeringer over hele titles-tabellen (poolen har 10 + * tilkoblinger), så åtte aliasede `facets` ga 32 parallelle helskanninger. Med 500 og en + * kostnadsgrense på 2 500 rommer en operasjon fire; frontend sender én. + */ +const FIELD_WEIGHTS: Record = { facets: 500 }; +/** Tak for `first` (samme som MAX_FIRST i validation.ts), brukt når `first` er en variabel. */ +const MAX_FIRST_ASSUMED = 50; + +/** + * Dybdegrensen stopper bare nøsting. Én flat spørring med hundrevis av aliasede `search(...)` + * (hver gir en egen SQL-spørring) eller `posterUrl`/`overview` (TMDB-oppslag) er grunn, men dyr, + * og `first: 50` på nøstede lister multipliserer arbeidet. Regelen teller derfor tre ting: + * - rotfelt (det er dem som starter databasekall), + * - alle felt totalt (størrelsen på svaret og antall resolver-kall), + * - vektet kostnad: dyre felt som `facets` har en fast tilleggskostnad (se `FIELD_WEIGHTS`), og et listefelt koster `first` ganger feltene under seg. En variabel `first` + * regnes som 50 (verste tilfelle) siden verdien ikke er kjent ved validering. Det overvurderer + * litt (totalCount og pageInfo telles også ganger `first`), som er trygt. + * + * Grensene er satt godt over de største spørringene frontend sender (se operations.ts og + * vaktbikkjetestene i test/server.test.ts), slik at vanlig bruk aldri kommer i nærheten. + */ +export function complexityLimit({ + maxRootFields, + maxFields, + maxCost, +}: ComplexityLimits): ValidationRule { + return (context) => ({ + OperationDefinition(operation) { + const fragments = new Map(); + for (const def of context.getDocument().definitions) { + if (def.kind === Kind.FRAGMENT_DEFINITION) fragments.set(def.name.value, def); + } + + // Alt memoiseres per fragment. Uten det kan en kjede der hvert nivå spres ti ganger i det + // neste (F0 -> 10 x F1 -> 10 x F2 ...) gi eksponentielt mange besøk, og dermed CPU-DoS under + // selve valideringen (et dokument på under 1 kB tok minutter). + interface Counts { + roots: number; + fields: number; + cost: number; + } + const memo = new Map(); + const visitFragment = (name: string, visiting: Set): Counts | null => { + const frag = fragments.get(name); + // Sirkulære fragmenter fanges av NoFragmentCycles; her må vi bare ikke løpe i ring. + if (!frag || visiting.has(name)) return null; + let c = memo.get(name); + if (!c) { + visiting.add(name); + c = count(frag.selectionSet, visiting); + visiting.delete(name); + memo.set(name, c); + } + return c; + }; + + const count = (set: SelectionSetNode, visiting: Set): Counts => { + const total: Counts = { roots: 0, fields: 0, cost: 0 }; + for (const sel of set.selections) { + if (sel.kind === Kind.FIELD) { + // Introspeksjon har egen regel (slått av i produksjon) og er stor av natur. + if (sel.name.value === '__schema' || sel.name.value === '__type') continue; + const sub = sel.selectionSet ? count(sel.selectionSet, visiting) : null; + const mult = sub ? multiplier(sel) : 1; + total.roots += 1; + total.fields += 1 + (sub?.fields ?? 0); + total.cost += 1 + (FIELD_WEIGHTS[sel.name.value] ?? 0) + mult * (sub?.cost ?? 0); + } else { + const c = + sel.kind === Kind.INLINE_FRAGMENT + ? count(sel.selectionSet, visiting) + : visitFragment(sel.name.value, visiting); + if (!c) continue; + total.roots += c.roots; + total.fields += c.fields; + total.cost += c.cost; + } + } + return total; + }; + + const { roots, fields, cost } = count(operation.selectionSet, new Set()); + const fail = (message: string) => + context.reportError( + new GraphQLError(message, { + nodes: [operation], + extensions: { code: 'BAD_USER_INPUT' }, + }), + ); + if (roots > maxRootFields) { + fail( + `Spørringen har for mange rotfelt (${roots}); maks tillatt er ${maxRootFields}. ` + + 'Del den opp i flere forespørsler.', + ); + } else if (fields > maxFields) { + fail( + `Spørringen er for omfattende (${fields} felt); maks tillatt er ${maxFields}. ` + + 'Be om færre felt eller del den opp.', + ); + } else if (cost > maxCost) { + fail( + `Spørringen er for kostbar (${Math.round(cost)}); maks tillatt er ${maxCost}. ` + + 'Be om færre elementer per side (first) eller færre felt.', + ); + } + }, + }); +} + +/** Hvor mange ganger feltene under et listefelt kan gjentas. 1 for alt som ikke er en liste. */ +function multiplier(field: FieldNode): number { + const fallback = LIST_FIELDS[field.name.value]; + if (fallback === undefined) return 1; + const first = field.arguments?.find((a: ArgumentNode) => a.name.value === 'first'); + if (!first) return fallback; + if (first.value.kind === Kind.INT) return Math.max(1, Number.parseInt(first.value.value, 10)); + // En variabel regnes alltid som verste tilfelle, også når den har standardverdi: klienten kan + // overstyre standardverdien i `variables`, så `query($n: Int = 1) { search(first: $n) }` sendt med + // n = 50 ville ellers blitt vektet som first = 1 og omgått kostnadsgrensen. + return MAX_FIRST_ASSUMED; +} diff --git a/backend/src/config.ts b/backend/src/config.ts index bb91b09..d81ea22 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -31,6 +31,10 @@ function int(value: string | undefined, fallback: number): number { export const config = { port: int(process.env.PORT, 3001), + // I produksjon står Apache foran og er eneste vei inn; å lytte på loopback hindrer at noen når + // port 3001 direkte og dermed omgår proxyen (og IP-baserte grenser som stoler på den). + // Utvikling og tester lytter på alle grensesnitt som før. HOST overstyrer begge deler. + host: process.env.HOST || (process.env.NODE_ENV === 'production' ? '127.0.0.1' : undefined), databaseUrl: process.env.DATABASE_URL || 'postgres://postgres@localhost:5432/project2', testDatabaseUrl: process.env.TEST_DATABASE_URL || 'postgres://postgres@localhost:5432/project2_test', diff --git a/backend/src/context.ts b/backend/src/context.ts index a585356..b28d640 100644 --- a/backend/src/context.ts +++ b/backend/src/context.ts @@ -1,11 +1,14 @@ import { createArtworkLoader, type ArtworkLoader, type ArtworkService } from './artwork.js'; import type { Pool } from './db.js'; import { config } from './config.js'; +import { FacetCache } from './facetCache.js'; import { batchLoader } from './loaders.js'; +import { clientIp, MutationLimiter } from './rateLimit.js'; +import type { FacetsResult } from './search.js'; import { createStreamLoader, type StreamLoader } from './stream.js'; import { parseUserId } from './validation.js'; -export interface ReviewStats { +interface ReviewStats { count: number; average: number | null; } @@ -14,6 +17,11 @@ export interface Context { pool: Pool; /** Gyldig, normalisert UUID fra x-user-id, ellers null. */ userId: string | null; + /** Klientens IP bak proxy, ellers null. Brukes bare til begrensning av mutations. */ + ip: string | null; + limiter: MutationLimiter; + /** Delt mellom requests (lages i createApp), i motsetning til loaderne under. */ + facetCache: FacetCache; artwork: ArtworkService; loaders: { artwork: ArtworkLoader; @@ -29,11 +37,17 @@ export function createContext( request: Request, artwork: ArtworkService, archiveUrl: string = config.archiveUrl, + limiter: MutationLimiter = new MutationLimiter(), + remoteAddress?: string, + facetCache: FacetCache = new FacetCache(), ): Context { const userId = parseUserId(request.headers.get('x-user-id')); return { pool, userId, + ip: clientIp(request, remoteAddress), + limiter, + facetCache, artwork, loaders: { artwork: createArtworkLoader(artwork), diff --git a/backend/src/cursor.test.ts b/backend/src/cursor.test.ts index 6de894a..647f6a2 100644 --- a/backend/src/cursor.test.ts +++ b/backend/src/cursor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { decodeCursor, encodeCursor, type KeyDef } from './keyset.js'; +import { decodeCursor, encodeCursor, TIMESTAMP_PATTERN, type KeyDef } from './keyset.js'; const keys: KeyDef[] = [ { expr: 'a', cast: 'int', kind: 'int' }, @@ -39,4 +39,34 @@ describe('cursor', () => { expect(() => decodeCursor(encodeCursor('S', ['abc']), 'S', k)).toThrow(); expect(decodeCursor(encodeCursor('S', ['123']), 'S', k)).toEqual(['123']); }); + + describe('tidsstempel', () => { + const ts: KeyDef[] = [ + { expr: 't', cast: 'timestamptz', kind: 'text', pattern: TIMESTAMP_PATTERN }, + ]; + const decode = (v: string) => decodeCursor(encodeCursor('S', [v]), 'S', ts); + + it.each([ + '2026-05-01 12:00:00+00', + '2026-05-01 12:00:00.123456+02:00', + '2024-02-29T23:59:59Z', + '2026-05-01 12:00:00', + ])('godtar %s', (v) => { + expect(decode(v)).toEqual([v]); + }); + + it.each([ + '2026-13-45 00:00:00', + '2026-02-30 00:00:00', + '2025-02-29 00:00:00', + '2026-00-10 00:00:00', + '0000-01-01 00:00:00', + '2026-05-01 24:00:00', + '2026-05-01 12:60:00', + '2026-05-01 12:00:60', + '2026-05-01 12:00:00+99', + ])('avviser ugyldig dato eller klokkeslett %s som ugyldig cursor', (v) => { + expect(() => decode(v)).toThrowError(/cursor/i); + }); + }); }); diff --git a/backend/src/db.ts b/backend/src/db.ts index ac8b063..703fed0 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -2,13 +2,20 @@ import pg from 'pg'; /** * Felles pool-oppsett. statement_timeout er et sikkerhetsnett: en uventet dyr spørring skal - * ikke kunne holde en tilkobling (og dermed hele API-et) opptatt i minutter. + * ikke kunne holde en tilkobling (og dermed hele API-et) opptatt i minutter. Skript som med vilje + * kjører lange setninger (import) kan oppgi en høyere grense. */ -export function createPool(connectionString: string): pg.Pool { +export function createPool( + connectionString: string, + { statementTimeoutMs = 15_000 }: { statementTimeoutMs?: number } = {}, +): pg.Pool { const pool = new pg.Pool({ connectionString, max: 10, - statement_timeout: 15_000, + // Uten tidsgrense henger en tilkobling mot en database bak brannmur til OS gir opp, og + // /health og spørringer fyller poolen mens de venter. + connectionTimeoutMillis: 5_000, + statement_timeout: statementTimeoutMs, }); // Uten en error-lytter krasjer Node hvis en ledig tilkobling brytes (f.eks. Postgres restarter). pool.on('error', (err) => { diff --git a/backend/src/dbErrors.test.ts b/backend/src/dbErrors.test.ts new file mode 100644 index 0000000..bd15593 --- /dev/null +++ b/backend/src/dbErrors.test.ts @@ -0,0 +1,35 @@ +import { GraphQLError } from 'graphql'; +import { describe, expect, it } from 'vitest'; +import { isDatabaseUnavailable } from './dbErrors.js'; + +const withCode = (code: string) => Object.assign(new Error('x'), { code }); + +describe('isDatabaseUnavailable', () => { + it.each(['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', '57P01', '57P03', '08006', '08001'])( + 'regner %s som nedetid', + (code) => { + expect(isDatabaseUnavailable(withCode(code))).toBe(true); + }, + ); + + it('kjenner igjen pg-pools tilkoblingstimeout og AggregateError', () => { + expect(isDatabaseUnavailable(new Error('timeout exceeded when trying to connect'))).toBe(true); + const agg = Object.assign(new Error(''), { + errors: [withCode('ECONNREFUSED'), withCode('ECONNREFUSED')], + }); + expect(isDatabaseUnavailable(agg)).toBe(true); + }); + + it('pakker ut originalError fra GraphQLError', () => { + const wrapped = new GraphQLError('x', { originalError: withCode('ECONNREFUSED') }); + expect(isDatabaseUnavailable(wrapped)).toBe(true); + }); + + it('regner ikke vanlige feil, SQL-feil eller ikke-feil som nedetid', () => { + expect(isDatabaseUnavailable(new Error('boom'))).toBe(false); + expect(isDatabaseUnavailable(withCode('42P01'))).toBe(false); + expect(isDatabaseUnavailable(withCode('57014'))).toBe(false); + expect(isDatabaseUnavailable(null)).toBe(false); + expect(isDatabaseUnavailable('ECONNREFUSED')).toBe(false); + }); +}); diff --git a/backend/src/dbErrors.ts b/backend/src/dbErrors.ts new file mode 100644 index 0000000..daab2cb --- /dev/null +++ b/backend/src/dbErrors.ts @@ -0,0 +1,46 @@ +import { GraphQLError } from 'graphql'; + +const CONNECTION_CODES = new Set([ + 'ECONNREFUSED', + 'ENOTFOUND', + 'ETIMEDOUT', + 'ECONNRESET', + 'EAI_AGAIN', +]); + +/** + * Er dette en feil fordi databasen ikke kan nås (i motsetning til en bug i en spørring)? + * Klient og helsesjekk skal skille de to: nedetid forklares med ett banner, bugs vises som feil. + */ +export function isDatabaseUnavailable(error: unknown): boolean { + // Resolverfeil kommer pakket i GraphQLError; pg-feilen ligger i originalError. + const err = ((error as GraphQLError)?.originalError ?? error) as { + code?: unknown; + message?: unknown; + errors?: unknown[]; + } | null; + if (!err || typeof err !== 'object') return false; + const code = typeof err.code === 'string' ? err.code : ''; + // 57P01/57P03: Postgres stenger ned eller starter opp. Klasse 08: tilkoblingsfeil. + if (CONNECTION_CODES.has(code) || code === '57P01' || code === '57P03' || code.startsWith('08')) { + return true; + } + // pg-pool gir en vanlig Error uten kode når connectionTimeoutMillis utløper. + if ( + typeof err.message === 'string' && + /timeout exceeded when trying to connect/i.test(err.message) + ) { + return true; + } + // Node 20+ pakker flere adresseforsøk (IPv4 og IPv6) i en AggregateError. + return ( + Array.isArray(err.errors) && err.errors.length > 0 && err.errors.every(isDatabaseUnavailable) + ); +} + +/** Generisk melding og kode uten SQL, vertsnavn eller stack. */ +export function serviceUnavailableError(): GraphQLError { + return new GraphQLError('Tjenesten er midlertidig utilgjengelig.', { + extensions: { code: 'SERVICE_UNAVAILABLE' }, + }); +} diff --git a/backend/src/depthLimit.test.ts b/backend/src/depthLimit.test.ts index 7d61258..1ec06dc 100644 --- a/backend/src/depthLimit.test.ts +++ b/backend/src/depthLimit.test.ts @@ -28,4 +28,17 @@ describe('depthLimit', () => { it('hopper over introspeksjon', () => { expect(check('{ __schema { types { fields { type { ofType { name } } } } } }')).toHaveLength(0); }); + it('tåler eksponentielt voksende fragmentkjeder uten å henge (CPU-DoS)', () => { + const levels = 8; + let doc = '{ root { ...F0 } }'; + for (let i = 0; i < levels; i++) { + const uses = Array.from({ length: 10 }, () => `...F${i + 1}`).join(' '); + doc += ` fragment F${i} on Node { ${uses} }`; + } + doc += ` fragment F${levels} on Node { child { child { child { id } } } }`; + const started = Date.now(); + expect(check(doc)).toHaveLength(1); // dybde 1 + 3 + 1 > 3 + expect(Date.now() - started).toBeLessThan(200); + expect(check(doc.replace('child { child { child { id } } }', 'id'))).toHaveLength(0); + }); }); diff --git a/backend/src/depthLimit.ts b/backend/src/depthLimit.ts index 218a914..b1bec03 100644 --- a/backend/src/depthLimit.ts +++ b/backend/src/depthLimit.ts @@ -20,29 +20,38 @@ export function depthLimit(max: number): ValidationRule { if (def.kind === Kind.FRAGMENT_DEFINITION) fragments.set(def.name.value, def); } - const depthOf = (set: SelectionSetNode, level: number, visiting: Set): number => { - let deepest = level; + // Dybden regnes relativt til utvalget og memoiseres per fragment. Uten memoisering kan en + // fragmentkjede der hvert nivå spres ti ganger i det neste (F0 -> 10 x F1 -> ...) gi + // eksponentielt mange besøk, og dermed CPU-DoS under selve valideringen. + const memo = new Map(); + const heightOf = (set: SelectionSetNode, visiting: Set): number => { + let deepest = 0; for (const sel of set.selections) { if (sel.kind === Kind.FIELD) { if (sel.name.value === '__schema' || sel.name.value === '__type') continue; - const d = sel.selectionSet ? depthOf(sel.selectionSet, level + 1, visiting) : level; + const d = sel.selectionSet ? 1 + heightOf(sel.selectionSet, visiting) : 0; deepest = Math.max(deepest, d); } else if (sel.kind === Kind.INLINE_FRAGMENT) { - deepest = Math.max(deepest, depthOf(sel.selectionSet, level, visiting)); + deepest = Math.max(deepest, heightOf(sel.selectionSet, visiting)); } else { const name = sel.name.value; const frag = fragments.get(name); // Sirkulære fragmenter fanges av NoFragmentCycles; her må vi bare ikke løpe i ring. if (!frag || visiting.has(name)) continue; - visiting.add(name); - deepest = Math.max(deepest, depthOf(frag.selectionSet, level, visiting)); - visiting.delete(name); + let h = memo.get(name); + if (h === undefined) { + visiting.add(name); + h = heightOf(frag.selectionSet, visiting); + visiting.delete(name); + memo.set(name, h); + } + deepest = Math.max(deepest, h); } } return deepest; }; - const depth = depthOf(operation.selectionSet, 0, new Set()); + const depth = heightOf(operation.selectionSet, new Set()); if (depth > max) { context.reportError( new GraphQLError(`Spørringen er for dyp (${depth}); maks tillatt dybde er ${max}.`, { diff --git a/backend/src/errors.ts b/backend/src/errors.ts index d7ecb9d..b7d5e0f 100644 --- a/backend/src/errors.ts +++ b/backend/src/errors.ts @@ -1,6 +1,6 @@ import { GraphQLError } from 'graphql'; -export type ErrorCode = 'BAD_USER_INPUT' | 'NOT_FOUND' | 'UNAUTHENTICATED'; +type ErrorCode = 'BAD_USER_INPUT' | 'NOT_FOUND' | 'UNAUTHENTICATED' | 'RATE_LIMITED'; function make(code: ErrorCode) { return (message: string) => new GraphQLError(message, { extensions: { code } }); @@ -9,3 +9,7 @@ function make(code: ErrorCode) { export const badInput = make('BAD_USER_INPUT'); export const notFound = make('NOT_FOUND'); export const unauthenticated = make('UNAUTHENTICATED'); + +/** `retryAfterSeconds` lar klienten vise hvor lenge brukeren må vente i stedet for å gjette. */ +export const rateLimited = (message: string, retryAfterSeconds: number) => + new GraphQLError(message, { extensions: { code: 'RATE_LIMITED', retryAfterSeconds } }); diff --git a/backend/src/facetCache.test.ts b/backend/src/facetCache.test.ts new file mode 100644 index 0000000..f84db8c --- /dev/null +++ b/backend/src/facetCache.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FacetCache, facetCacheKey } from './facetCache.js'; +import type { Filters } from './validation.js'; + +const NONE: Filters = { genres: [], decades: [], types: [], minRating: null, availableOnly: false }; + +describe('FacetCache', () => { + it('laster én gang og gir samme svar til levetiden er ute', async () => { + let now = 0; + const cache = new FacetCache({ ttlMs: 1000, now: () => now }); + const load = vi.fn().mockResolvedValueOnce(1).mockResolvedValueOnce(2); + + expect(await cache.get('k', load)).toBe(1); + now = 999; + expect(await cache.get('k', load)).toBe(1); + expect(load).toHaveBeenCalledTimes(1); + + now = 1000; + expect(await cache.get('k', load)).toBe(2); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('samtidige kall for samme nøkkel deler én lasting', async () => { + const cache = new FacetCache(); + let resolve!: (v: string) => void; + const load = vi.fn(() => new Promise((r) => (resolve = r))); + + const a = cache.get('k', load); + const b = cache.get('k', load); + resolve('svar'); + + expect(await Promise.all([a, b])).toEqual(['svar', 'svar']); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('husker ikke feil: neste kall prøver databasen igjen', async () => { + const cache = new FacetCache(); + const load = vi.fn().mockRejectedValueOnce(new Error('nede')).mockResolvedValueOnce(7); + + await expect(cache.get('k', load)).rejects.toThrow('nede'); + expect(cache.size).toBe(0); + expect(await cache.get('k', load)).toBe(7); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('en feilet gammel lasting fjerner ikke en nyere, vellykket oppføring', async () => { + let now = 0; + const cache = new FacetCache({ ttlMs: 10, now: () => now }); + let reject!: (e: Error) => void; + const slow = cache.get('k', () => new Promise((_, r) => (reject = r))); + now = 10; // den første er utløpt; neste kall laster på nytt + expect(await cache.get('k', () => Promise.resolve(5))).toBe(5); + + reject(new Error('sent')); + await expect(slow).rejects.toThrow('sent'); + expect(cache.size).toBe(1); + expect(await cache.get('k', () => Promise.resolve(6))).toBe(5); + }); + + it('kaster ut minst nylig brukte nøkkel over grensen', async () => { + const cache = new FacetCache({ maxEntries: 2 }); + await cache.get('a', () => Promise.resolve('a1')); + await cache.get('b', () => Promise.resolve('b1')); + await cache.get('a', () => Promise.resolve('a2')); // a blir nylig brukt + await cache.get('c', () => Promise.resolve('c1')); // b kastes ut + + expect(cache.size).toBe(2); + expect(await cache.get('a', () => Promise.resolve('a3'))).toBe('a1'); + expect(await cache.get('b', () => Promise.resolve('b2'))).toBe('b2'); + }); + + it('clear tømmer alt', async () => { + const cache = new FacetCache(); + await cache.get('k', () => Promise.resolve(1)); + cache.clear(); + expect(await cache.get('k', () => Promise.resolve(2))).toBe(2); + }); +}); + +describe('facetCacheKey', () => { + it('caches ikke når det er søketekst', () => { + expect(facetCacheKey('dark', NONE)).toBeNull(); + }); + + it('gir samme nøkkel uansett rekkefølge i filtrene', () => { + const a = facetCacheKey(null, { + ...NONE, + genres: ['Drama', 'Action'], + decades: [2000, 1990], + types: ['series', 'movie'], + }); + const b = facetCacheKey(null, { + ...NONE, + genres: ['Action', 'Drama'], + decades: [1990, 2000], + types: ['movie', 'series'], + }); + expect(a).toBe(b); + }); + + it('slår sammen duplikater i filtrene', () => { + const dup = facetCacheKey(null, { + ...NONE, + genres: ['Drama', 'Drama'], + decades: [1990, 1990], + types: ['movie', 'movie'], + }); + const single = facetCacheKey(null, { + ...NONE, + genres: ['Drama'], + decades: [1990], + types: ['movie'], + }); + expect(dup).toBe(single); + }); + + it('skiller på hvert filter', () => { + const keys = [ + NONE, + { ...NONE, genres: ['Drama'] }, + { ...NONE, decades: [1990] }, + { ...NONE, types: ['movie' as const] }, + { ...NONE, minRating: 8 }, + { ...NONE, availableOnly: true }, + ].map((f) => facetCacheKey(null, f)); + expect(new Set(keys).size).toBe(keys.length); + }); +}); diff --git a/backend/src/facetCache.ts b/backend/src/facetCache.ts new file mode 100644 index 0000000..52b2d5e --- /dev/null +++ b/backend/src/facetCache.ts @@ -0,0 +1,102 @@ +import type { Filters } from './validation.js'; + +/** + * Fasettene uten søketekst er de samme for alle besøkende, men koster fire aggregeringer over hele + * `titles` (ca. 85 ms på 120 000 syntetiske titler, mer på ekte data). Filterpanelet ber om dem ved + * hvert besøk på søkesiden uten tekst og hver «Se alle»-lenke fra forsiden, så de holdes i minnet. + * + * Hvorfor i minnet og ikke en forhåndsberegnet tabell: tallene endres bare når importskriptene + * kjører (anmeldelser og «min liste» påvirker ikke fasettene), backenden er én prosess, og en tabell + * måtte holdes i takt fra tre importskript og testene. Prisen er at tallene kan være opptil + * `ttlMs` gamle etter en import. Se docs/beslutninger.md. + */ + +/** Fem minutter: kort nok til at en import synes raskt, lang nok til at nesten alle treff er i minnet. */ +export const FACET_CACHE_TTL_MS = 5 * 60_000; +/** + * Øvre grense for antall filterkombinasjoner. Forsiden lenker til åtte, men validerte filtre gir + * svært mange mulige nøkler, og minnet skal ikke kunne vokse ved at noen prøver dem alle. + */ +export const FACET_CACHE_MAX_ENTRIES = 64; + +interface Entry { + value: Promise; + expiresAt: number; +} + +export interface FacetCacheOptions { + ttlMs?: number; + maxEntries?: number; + now?: () => number; +} + +/** + * Liten TTL-cache med LRU-utkasting. Lagrer løftet, ikke svaret, slik at samtidige requests for + * samme nøkkel deler én databasespørring i stedet for å sende hver sin mens den første pågår. + */ +export class FacetCache { + private readonly entries = new Map>(); + private readonly ttlMs: number; + private readonly maxEntries: number; + private readonly now: () => number; + + constructor({ + ttlMs = FACET_CACHE_TTL_MS, + maxEntries = FACET_CACHE_MAX_ENTRIES, + now = Date.now, + }: FacetCacheOptions = {}) { + this.ttlMs = ttlMs; + this.maxEntries = maxEntries; + this.now = now; + } + + get size(): number { + return this.entries.size; + } + + async get(key: string, load: () => Promise): Promise { + const now = this.now(); + const hit = this.entries.get(key); + if (hit && hit.expiresAt > now) { + // Flytt bakerst: Map beholder innsettingsrekkefølgen, så første nøkkel er minst nylig brukt. + this.entries.delete(key); + this.entries.set(key, hit); + return hit.value; + } + if (hit) this.entries.delete(key); + + const value = load(); + const entry: Entry = { value, expiresAt: now + this.ttlMs }; + this.entries.set(key, entry); + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value as string; + this.entries.delete(oldest); + } + // En feil (f.eks. databasen nede) skal ikke bli husket i fem minutter: neste request prøver igjen. + value.catch(() => { + if (this.entries.get(key) === entry) this.entries.delete(key); + }); + return value; + } + + clear(): void { + this.entries.clear(); + } +} + +/** + * Nøkkel for fasetter som kan deles mellom brukere, eller null når de ikke skal caches. `query` er + * allerede validert (tom eller bare mellomrom er null). Med søketekst caches ingenting: + * nøkkelrommet er ubegrenset, nesten hvert søk er unikt, og trigramindeksene gjør dem raske nok + * uansett. Filtrene dedupliseres og sorteres så rekkefølge og duplikater i URL-en ikke gir flere nøkler for samme svar. + */ +export function facetCacheKey(query: string | null, filters: Filters): string | null { + if (query !== null) return null; + return JSON.stringify([ + [...new Set(filters.genres)].sort(), + [...new Set(filters.decades)].sort((a, b) => a - b), + [...new Set(filters.types)].sort(), + filters.minRating, + filters.availableOnly, + ]); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index b010f55..a953083 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,8 +11,14 @@ const yoga = createApp({ }); const server = createServer(yoga); -server.listen(config.port, () => { - console.log(`GraphQL-API på http://localhost:${config.port}/graphql`); +// Node lukker ledige forbindelser etter 5 s, og Apache (mod_proxy) gjenbruker forbindelser lenger +// enn det. Treffer en forespørsel en forbindelse Node akkurat har lukket, gir Apache 502. Lengre +// tidsgrense enn Apaches gjør at det er Apache som lukker først. headersTimeout må ligge over +// keepAliveTimeout, ellers kan Node avbryte en gjenbrukt forbindelse midt i neste forespørsel. +server.keepAliveTimeout = 65_000; +server.headersTimeout = 66_000; +server.listen(config.port, config.host, () => { + console.log(`GraphQL-API på http://${config.host ?? 'localhost'}:${config.port}/graphql`); }); // Rydder opp så tilkoblinger lukkes pent (systemd sender SIGTERM, Ctrl+C sender SIGINT). diff --git a/backend/src/keyset.ts b/backend/src/keyset.ts index 339a894..679aafa 100644 --- a/backend/src/keyset.ts +++ b/backend/src/keyset.ts @@ -4,7 +4,7 @@ import { badInput } from './errors.js'; * Keyset-paginering (cursor) på en sammensatt sorteringsnøkkel. OFFSET ville blitt tregere jo * lenger ned brukeren scroller og gir duplikater/hull når data endres mellom sidene. * - * Alle nøkler sorteres i SAMMEN retning, slik at vi kan bruke radsammenligning + * Alle nøkler sorteres i SAMME retning, slik at vi kan bruke radsammenligning * `(a, b, id) < ($1, $2, $3)`. Det lar Postgres bruke en vanlig btree-indeks på (a, b, id) * i stedet for en OR-kjede, og siste nøkkel er alltid unik slik at rekkefølgen er total. */ @@ -53,13 +53,34 @@ export function encodeCursor(signature: string, values: unknown[]): string { const INT32 = 2_147_483_647; +/** + * Mønsteret slipper gjennom `2026-13-45 00:00:00`, som Postgres avviser med 22008 (og klienten + * ville fått en maskert 500). Komponentene sjekkes derfor mot en ekte kalender: Date.UTC ruller + * ugyldige datoer over (30. februar blir 2. mars), så en rundtur avslører dem. Date.parse på selve + * strengen ville vært enklere, men Postgres' format (`+00`, mikrosekunder) er ikke ISO 8601, og + * resultatet av ikke-ISO-parsing varierer mellom motorer. + */ +function validTimestamp(v: string): boolean { + const m = + /^(\d{4})-(\d\d)-(\d\d)[ T](\d\d):(\d\d):(\d\d)(?:\.\d+)?(?:[+-](\d\d)(?::?(\d\d))?)?/.exec(v); + if (!m) return false; + const [year, month, day, hour, minute, second, offH, offM] = m + .slice(1) + .map((x) => Number(x ?? 0)) as [number, number, number, number, number, number, number, number]; + if (year < 1 || hour > 23 || minute > 59 || second > 59 || offH > 15 || offM > 59) return false; + const d = new Date(Date.UTC(2000, month - 1, day)); + d.setUTCFullYear(year); + return d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === day; +} + function validValue(k: KeyDef, v: unknown): boolean { if (k.kind === 'text') { return ( typeof v === 'string' && v.length <= 1000 && !v.includes('\0') && - (k.pattern ? k.pattern.test(v) : true) + (k.pattern ? k.pattern.test(v) : true) && + (k.pattern === TIMESTAMP_PATTERN ? validTimestamp(v) : true) ); } if (typeof v !== 'number' || !Number.isFinite(v)) return false; diff --git a/backend/src/rateLimit.ts b/backend/src/rateLimit.ts new file mode 100644 index 0000000..5c8dc38 --- /dev/null +++ b/backend/src/rateLimit.ts @@ -0,0 +1,131 @@ +import { rateLimited } from './errors.js'; + +export interface RateLimitOptions { + /** addReview: anmeldelser per minutt per bruker (også burst-størrelsen). */ + reviewsPerMinute: number; + /** toggleList og deleteReview: kall per minutt per bruker. */ + mutationsPerMinute: number; + /** Per-IP-grensen er dette ganger brukergrensen (flere brukere kan dele nett, f.eks. en klasse). */ + ipFactor: number; +} + +export const DEFAULT_RATE_LIMITS: RateLimitOptions = { + // Romslig nok til at en person som rydder opp eller tester appen aldri merker det, men en + // skript-løkke stoppes etter ett minutts verdi. + reviewsPerMinute: 10, + mutationsPerMinute: 60, + ipFactor: 3, +}; + +type MutationKind = 'review' | 'mutation'; + +interface Bucket { + tokens: number; + updatedAt: number; +} + +/** Hindrer at minnet vokser uten grense hvis noen finner opp nye UUID-er i det uendelige. */ +const MAX_KEYS = 50_000; + +/** + * Token bucket i minnet: `capacity` tokens, fylt jevnt opp til full over ett minutt. Et kall koster + * ett token, så en rolig bruker aldri møter grensen mens en løkke stoppes etter `capacity` kall. + */ +export class TokenBuckets { + private readonly buckets = new Map(); + + constructor( + private readonly capacity: number, + private readonly now: () => number = Date.now, + ) {} + + /** Tar ett token. Returnerer 0 hvis det gikk bra, ellers sekunder til neste token er tilgjengelig. */ + take(key: string): number { + const now = this.now(); + const perMs = this.capacity / 60_000; + const b = this.buckets.get(key) ?? { tokens: this.capacity, updatedAt: now }; + b.tokens = Math.min(this.capacity, b.tokens + (now - b.updatedAt) * perMs); + b.updatedAt = now; + // Flytter nøkkelen sist i Map-rekkefølgen, slik at de eldste er først når vi må rydde. + this.buckets.delete(key); + this.buckets.set(key, b); + if (this.buckets.size > MAX_KEYS) this.prune(now); + if (b.tokens >= 1) { + b.tokens -= 1; + return 0; + } + return Math.max(1, Math.ceil((1 - b.tokens) / perMs / 1000)); + } + + private prune(now: number) { + // En full bøtte er identisk med en ny, så å glemme den endrer ingenting. + const perMs = this.capacity / 60_000; + for (const [key, b] of this.buckets) { + if (b.tokens + (now - b.updatedAt) * perMs >= this.capacity) this.buckets.delete(key); + } + // Fortsatt for mange (aktivt misbruk): kast de eldste uansett. Verste utfall er at en + // misbruker får en ny bøtte; minnet er viktigere enn presisjon her. + for (const key of this.buckets.keys()) { + if (this.buckets.size <= MAX_KEYS) break; + this.buckets.delete(key); + } + } +} + +/** + * Enkel begrensning på mutations. Tilstanden ligger i prosessminnet, noe som er greit så lenge + * backend kjører som én prosess (én systemd-tjeneste på VM-en). Ved flere instanser eller restart + * nullstilles grensene; en delt grense ville krevd Redis eller en tabell, og det er overkill her. + */ +export class MutationLimiter { + private readonly user: Record; + private readonly ip: Record; + + constructor(options: RateLimitOptions = DEFAULT_RATE_LIMITS, now: () => number = Date.now) { + const cap = { review: options.reviewsPerMinute, mutation: options.mutationsPerMinute }; + const ipCap = (n: number) => Math.max(1, Math.round(n * options.ipFactor)); + this.user = { + review: new TokenBuckets(cap.review, now), + mutation: new TokenBuckets(cap.mutation, now), + }; + this.ip = { + review: new TokenBuckets(ipCap(cap.review), now), + mutation: new TokenBuckets(ipCap(cap.mutation), now), + }; + } + + /** Kaster RATE_LIMITED når brukeren (eller IP-adressen) har brukt opp kvoten for denne typen. */ + check(kind: MutationKind, userId: string, ip: string | null): void { + // Rekkefølge: IP først, og `||` stopper ved første avvisning. Avvises IP-en, rører vi ikke + // brukerkvoten. Omvendt har et IP-token allerede blitt brukt når brukerkvoten avviser. Det er + // bevisst og greit: forespørselen kom fra den IP-en uansett, så en bruker som spammer skal + // også telle mot IP-ens kvote (og dermed til slutt stoppes på IP selv om hun bytter bruker-id). + const wait = (ip ? this.ip[kind].take(ip) : 0) || this.user[kind].take(userId); + if (wait > 0) { + throw rateLimited(`For mange forsøk. Vent ${wait} sekunder og prøv igjen.`, wait); + } + } +} + +/** 127.0.0.0/8, ::1 og IPv4-mappede varianter (::ffff:127.0.0.1). */ +export function isLoopback(address: string): boolean { + return /^(::ffff:)?127\./i.test(address) || address === '::1'; +} + +/** + * Klientens IP bak Apache: mod_proxy legger den til sist i X-Forwarded-For, så siste ledd er + * ikke noe klienten kan forfalske (tidligere ledd kan). Headeren stoles BARE på når selve + * TCP-forbindelsen kommer fra loopback, dvs. fra proxyen på samme maskin. Kommer forbindelsen fra + * en annen adresse (noen har nådd port 3001 direkte), brukes den adressen og headeren ignoreres, + * ellers kunne klienten satt en ny falsk IP i hver forespørsel og omgått IP-grensen. + * + * `remoteAddress` er undefined når appen kalles uten node:http (tester via yoga.fetch); da finnes + * ingen forbindelse å sjekke, og headeren brukes som den er. Uten header begrenses bare per bruker. + */ +export function clientIp(request: Request, remoteAddress?: string): string | null { + if (remoteAddress && !isLoopback(remoteAddress)) return remoteAddress.slice(0, 64); + const header = request.headers.get('x-forwarded-for'); + if (!header) return null; + const last = header.split(',').at(-1)?.trim(); + return last ? last.slice(0, 64) : null; +} diff --git a/backend/src/resolvers/index.ts b/backend/src/resolvers/index.ts index 0ffa673..7a91a83 100644 --- a/backend/src/resolvers/index.ts +++ b/backend/src/resolvers/index.ts @@ -1,6 +1,7 @@ import { BACKDROP_WIDTHS, POSTER_WIDTHS, buildImageUrl } from '../artwork.js'; -import { notFound, unauthenticated } from '../errors.js'; +import { badInput, notFound, unauthenticated } from '../errors.js'; import type { Context } from '../context.js'; +import { facetCacheKey } from '../facetCache.js'; import { DEFAULT_DIRECTION, getFacets, @@ -75,7 +76,13 @@ export const resolvers = { _: unknown, args: { query?: string | null; filters?: FiltersInput | null }, ctx: Context, - ) => getFacets(ctx.pool, validateQuery(args.query), validateFilters(args.filters)), + ) => { + const query = validateQuery(args.query); + const filters = validateFilters(args.filters); + const key = facetCacheKey(query, filters); + const load = () => getFacets(ctx.pool, query, filters); + return key === null ? load() : ctx.facetCache.get(key, load); + }, myList: async ( _: unknown, @@ -97,6 +104,8 @@ export const resolvers = { Mutation: { addReview: async (_: unknown, args: { input: ReviewInput }, ctx: Context) => { if (!ctx.userId) throw unauthenticated('Mangler gyldig x-user-id.'); + // Før validering: ugyldige forsøk skal også koste, ellers kan en løkke prøve fritt. + ctx.limiter.check('review', ctx.userId, ctx.ip); const input = validateReview(args.input); if (!isPlausibleTitleId(input.titleId) || !(await getTitle(ctx.pool, input.titleId))) { throw notFound('Fant ikke tittelen.'); @@ -112,6 +121,7 @@ export const resolvers = { toggleList: async (_: unknown, args: { titleId: string }, ctx: Context) => { if (!ctx.userId) throw unauthenticated('Mangler gyldig x-user-id.'); + ctx.limiter.check('mutation', ctx.userId, ctx.ip); const title = isPlausibleTitleId(args.titleId) ? await getTitle(ctx.pool, args.titleId) : null; @@ -132,6 +142,25 @@ export const resolvers = { ); return { ...mapTitle(title), inMyList: rows[0]?.in_list ?? false }; }, + + deleteReview: async (_: unknown, args: { id: string }, ctx: Context) => { + if (!ctx.userId) throw unauthenticated('Mangler gyldig x-user-id.'); + ctx.limiter.check('mutation', ctx.userId, ctx.ip); + // bigint tar maks 19 siffer; 18 er nok og gir aldri overflow i ::bigint-castet. + if (!/^[1-9]\d{0,17}$/.test(args.id)) throw badInput('Ugyldig id.'); + // Eierskapet sjekkes i selve DELETE-en. «Finnes ikke» og «tilhører andre» gir samme svar, + // så en fremmed ikke kan bruke mutationen til å lete etter anmeldelses-id-er. + const { rows } = await ctx.pool.query<{ title_id: string }>( + 'DELETE FROM reviews WHERE id = $1::bigint AND user_id = $2 RETURNING title_id', + [args.id, ctx.userId], + ); + const titleId = rows[0]?.title_id; + if (!titleId) throw notFound('Fant ikke anmeldelsen.'); + const title = await getTitle(ctx.pool, titleId); + // Tittelen kan ikke mangle (reviews.title_id har fremmednøkkel), men typene krever sjekken. + if (!title) throw notFound('Fant ikke tittelen.'); + return { deletedId: args.id, title: mapTitle(title) }; + }, }, Title: { diff --git a/backend/src/search.ts b/backend/src/search.ts index 3d1338d..963eaf1 100644 --- a/backend/src/search.ts +++ b/backend/src/search.ts @@ -25,7 +25,7 @@ export interface TitleRow { genres: string[]; } -export const TITLE_COLUMNS = `t.id, t.title_type, t.primary_title, t.original_title, t.start_year, +const TITLE_COLUMNS = `t.id, t.title_type, t.primary_title, t.original_title, t.start_year, t.end_year, t.runtime_minutes, t.average_rating, t.num_votes, t.genres`; export interface Connection { @@ -44,12 +44,46 @@ class Params { } } -/** Gjør om brukerens tekst til et LIKE-mønster der %, _ og \ betyr seg selv. */ -export function escapeLike(text: string): string { - return text.replace(/[\\%_]/g, (c) => `\\${c}`); -} +/** + * Normalisert tittel (små bokstaver, uten aksenter) ligger som genererte kolonner, med indekser på + * dem (004_unaccent_search.sql). Søketeksten må normaliseres med nøyaktig samme uttrykk, og det + * gjøres i Postgres (`norm`) for at TypeScript og databasen aldri skal uenes om hva «uten aksent» + * betyr. Kolonnene i stedet for et uttrykk i spørringen unngår at unaccent() kjøres på nytt for + * hver rad i filter, recheck og relevansberegning. + */ +const norm = (expr: string) => `lower(f_unaccent(${expr}))`; +const TITLE_NORM = 't.primary_title_norm'; +const ORIGINAL_NORM = 't.original_title_norm'; + +/** + * LIKE-mønster for delstrengsøk der %, _ og \ i søketeksten betyr seg selv. Rekkefølgen er viktig: + * normaliser FØRST (unaccent), escape ETTERPÅ. unaccent.rules mapper bl.a. fullbreddetegnene + * `%`, `_` og `\` til `%`, `_` og `\`; escapet vi før normaliseringen, ble de jokertegn og + * traff hele tabellen. Begge trinn gjøres i Postgres. Regex-strengene er vanlige SQL-strenger + * (standard_conforming_strings = on): `[\\%_]` er en tegnklasse med `\`, `%` og `_`, og `\\\1` i + * erstatningen er en backslash etterfulgt av treffet. + */ +const likePattern = (param: string) => + String.raw`'%' || regexp_replace(${norm(`${param}::text`)}, '([\\%_])', '\\\1', 'g') || '%'`; -export type Dimension = 'genres' | 'decades' | 'types' | 'available'; +/** + * Søk på 1–2 tegn har ingen trigrammer, så GIN-indeksene kan ikke brukes og delstrengsøk ville + * lest hele tabellen og regnet likhet på nesten alle rader (ca. 0,3 s på 120 000 titler). Slike + * søk er derfor ordprefiks («ma» finner «The Matrix») mot en generert tsvector med GIN-indeks, og + * rangeres etter popularitet (se docs/ytelse.md). + * + * Søketeksten strippes for tegnsetting, mellomrom og kontrolltegn FØR den settes inn i + * tsquery-syntaksen, fordi `& | ! ( ) : * < > ' \` ellers er operatorer eller gir syntaksfeil. Det + * som er igjen er bokstaver og sifre, så uttrykket kan ikke kaste feil. Blir ingenting igjen + * (f.eks. «%» eller «'») gir NULLIF NULL, og dermed ingen treff. + */ +const wordPrefixQuery = (param: string) => + `to_tsquery('simple', NULLIF(regexp_replace(${norm(`${param}::text`)}, '[[:punct:][:space:][:cntrl:]]', '', 'g'), '') || ':*')`; + +const SHORT_QUERY_MAX = 2; +const isShortQuery = (query: string): boolean => [...query].length <= SHORT_QUERY_MAX; + +type Dimension = 'genres' | 'decades' | 'types' | 'available'; // Ett PK-oppslag i title_streams per kandidatrad (semi-join), aldri en skanning av tabellen. const HAS_STREAM = 'EXISTS (SELECT 1 FROM title_streams s WHERE s.title_id = t.id)'; @@ -66,11 +100,12 @@ function filterConditions( ): string[] { const conds: string[] = []; if (query) { - // lower() gjøres i Postgres på begge sider, så samme funksjon brukes som i indeksen. - const like = p.add(`%${escapeLike(query)}%`); - conds.push( - `(lower(t.primary_title) LIKE lower(${like}) OR lower(t.original_title) LIKE lower(${like}))`, - ); + if (isShortQuery(query)) { + conds.push(`t.title_words @@ ${wordPrefixQuery(p.add(query))}`); + } else { + const like = likePattern(p.add(query)); + conds.push(`(${TITLE_NORM} LIKE ${like} OR ${ORIGINAL_NORM} LIKE ${like})`); + } } if (skip !== 'genres' && filters.genres.length > 0) { // @> = «inneholder alle»; bruker GIN-indeksen på titles.genres. @@ -106,20 +141,23 @@ const ID: KeyDef = { expr: 't.id', cast: 'text', kind: 'text' }; /** * Sorteringsnøkler per felt. Manglende verdier erstattes med en sentinel (COALESCE) slik at * radsammenligningen i cursoren aldri møter NULL (NULL < x er ukjent, og rader ville forsvunnet). - * Konsekvens: titler uten rating/år havner sist i DESC og først i ASC. Uttrykkene må være - * identiske med indeksene i 001_init.sql. + * Konsekvens: titler uten rating/år havner sist i DESC og først i ASC. For RATING, YEAR og TITLE + * må uttrykkene være identiske med indeksene (001_init.sql) for at planleggeren skal kunne bruke + * dem; RELEVANCE har ingen indeks og beregnes for radene filteret slipper gjennom. */ function sortKeys(field: SortField, query: string | null, p: Params): KeyDef[] { switch (field) { case 'RELEVANCE': { - if (!query) return [VOTES, ID]; - const q = `lower(${p.add(query)}::text)`; + // Korte søk rangeres bare etter popularitet: likhet mot 1–2 tegn er støy, og å regne den ut + // for alle prefikstreffene var det dyreste ved søket. + if (!query || isShortQuery(query)) return [VOTES, ID]; + const q = norm(`${p.add(query)}::text`); // Likhet alene rangerer obskure titler med identisk navn over klassikerne brukeren som regel // mener («Dark Knight», 1 500 stemmer, før «The Dark Knight»). word_similarity gir full score // når søkeordene står i tittelen, likhet belønner eksakte treff, og popularitet (log10 av // stemmer, mettet ved 10 mill.) skiller resten. Stemmer og id er tiebreak. - const wordSim = `GREATEST(word_similarity(${q}, lower(t.primary_title)), word_similarity(${q}, lower(t.original_title)))`; - const sim = `GREATEST(similarity(lower(t.primary_title), ${q}), similarity(lower(t.original_title), ${q}))`; + const wordSim = `GREATEST(word_similarity(${q}, ${TITLE_NORM}), word_similarity(${q}, ${ORIGINAL_NORM}))`; + const sim = `GREATEST(similarity(${TITLE_NORM}, ${q}), similarity(${ORIGINAL_NORM}, ${q}))`; const pop = `LEAST(log(GREATEST(t.num_votes, 1)) / 7.0, 1.0)`; const rel = `(0.6 * ${wordSim} + 0.2 * ${sim} + 0.2 * ${pop})`; return [{ expr: rel, select: `(${rel})::float8`, cast: 'float8', kind: 'float' }, VOTES, ID]; @@ -182,7 +220,10 @@ export async function searchTitles(pool: Pool, args: SearchArgs): Promise fra cachen + title: Title! # tittelen med oppdaterte userRating og reviewCount + } + type Query { - # query: tom/null = alle titler. Case-insensitivt delstrengsøk i primær- og originaltittel. + # query: tom/null = alle titler. Case- og aksentuavhengig delstrengsøk («amelie» finner «Amélie») + # i primær- og originaltittel. Søk på 1–2 tegn er ordprefiks («ma» finner «The Matrix») og rangeres etter popularitet. search( query: String filters: SearchFilters @@ -139,5 +145,7 @@ export const typeDefs = /* GraphQL */ ` type Mutation { addReview(input: AddReviewInput!): Review! toggleList(titleId: ID!): Title! # returnerer tittelen med oppdatert inMyList + # Sletter bare brukerens egne anmeldelser. Andres og ukjente id-er gir begge NOT_FOUND. + deleteReview(id: ID!): DeleteReviewPayload! } `; diff --git a/backend/src/validation.test.ts b/backend/src/validation.test.ts index fb15d02..8128e00 100644 --- a/backend/src/validation.test.ts +++ b/backend/src/validation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + isPlausibleTitleId, parseUserId, validateFilters, validateFirst, @@ -76,3 +77,24 @@ describe('validateReview', () => { expect(() => validateReview({ ...ok, text: 'x'.repeat(2001) })).toThrow(); }); }); + +describe('isPlausibleTitleId', () => { + it.each(['tt0111161', 'tt1234567', 'tt12345678', 'tt1234567890'])('godtar %s', (id) => + expect(isPlausibleTitleId(id)).toBe(true), + ); + it.each([ + '', + 'tt', + 'tt123456', + 'tt12345678901', + 'TT0111161', + 'nm0000001', + '0111161', + 'tt011116a', + 'tt0111161\n', + ' tt0111161', + 'tt0111161\0', + "tt0111161' OR 1=1", + 'tt' + '1'.repeat(1000), + ])('avviser %j', (id) => expect(isPlausibleTitleId(id)).toBe(false)); +}); diff --git a/backend/src/validation.ts b/backend/src/validation.ts index a7486cc..ea872c4 100644 --- a/backend/src/validation.ts +++ b/backend/src/validation.ts @@ -1,9 +1,9 @@ import { badInput } from './errors.js'; export const MAX_FIRST = 50; -export const MAX_QUERY_LENGTH = 200; -export const MAX_AUTHOR_LENGTH = 50; -export const MAX_REVIEW_TEXT_LENGTH = 2000; +const MAX_QUERY_LENGTH = 200; +const MAX_AUTHOR_LENGTH = 50; +const MAX_REVIEW_TEXT_LENGTH = 2000; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -123,7 +123,13 @@ export function validateReview(input: ReviewInput): ReviewInput { return { titleId: input.titleId, author, rating: input.rating, text }; } -/** ID-er er tconst («tt0111161»); alt annet kan vi avvise uten databasekall. */ +const TITLE_ID_RE = /^tt\d{7,10}$/; + +/** + * ID-er er IMDb tconst: «tt» + 7–10 siffer (dagens ID-er har 7–8; 10 gir slingringsmonn når IMDb + * går over til flere siffer). Alt annet avvises uten databasekall, og regelen holder også + * NUL-byte og vilkårlig lange strenger unna Postgres. + */ export function isPlausibleTitleId(id: string): boolean { - return id.length > 0 && id.length <= 30 && !id.includes('\0'); + return TITLE_ID_RE.test(id); } diff --git a/backend/test/archive.test.ts b/backend/test/archive.test.ts index 04421dc..cfa2e77 100644 --- a/backend/test/archive.test.ts +++ b/backend/test/archive.test.ts @@ -207,6 +207,122 @@ describe('importArchive mot falsk Archive', () => { }); }); +describe('importArchive: lagring underveis og avbrudd', () => { + const scrapeCalls = () => + mock.state.calls.filter((c) => c.startsWith('/services/search/v1/scrape')).length; + /** fetch som varsler etter hvert Scrape-svar (med løpenummer) og hvert metadata-svar. */ + const spy = + (onScrape: (n: number) => void, onMetadata?: () => void): typeof fetch => + async (input, init) => { + const res = await fetch(input, init); + const url = String(input); + if (url.includes('/services/search/v1/scrape')) onScrape(scrapeCalls()); + else if (url.includes('/metadata/')) onMetadata?.(); + return res; + }; + + it('lagrer hver side før neste hentes, så første sides filmer overlever et avbrudd', async () => { + const stopCollecting = { aborted: false }; + const s = await run({ + stopCollecting, + log: (m) => { + if (m.startsWith('Skannet 3 ')) stopCollecting.aborted = true; + }, + }); + expect(scrapeCalls()).toBe(1); + expect(s).toMatchObject({ scanned: 3, imported: 3 }); + expect((await streams()).map((r) => r.title_id)).toEqual([ + 'tt0068646', + 'tt0111161', + 'tt0133093', + ]); + }); + + it('første avbrudd midt i skanningen stopper innsamlingen og logger at funn lagres', async () => { + const stopCollecting = { aborted: false }; + const logs: string[] = []; + const s = await run({ + stopCollecting, + log: (m) => logs.push(m), + // Flagget settes mens side 2 er på vei inn, slik Ctrl+C under et pågående kall gjør. + fetch: spy((n) => { + if (n === 2) stopCollecting.aborted = true; + }), + }); + expect(scrapeCalls()).toBe(2); + expect(s).toMatchObject({ scanned: 3, imported: 3, failed: 0 }); + expect(logs.some((m) => m.includes('Avbrutt: lagrer 0 filmer') && m.includes('igjen'))).toBe( + true, + ); + expect(await streams()).toHaveLength(3); + }); + + it('første avbrudd før start henter ingenting og lagrer ingenting', async () => { + const s = await run({ stopCollecting: { aborted: true } }); + expect(scrapeCalls()).toBe(0); + expect(s.imported).toBe(0); + }); + + it('andre avbrudd stopper lagringen også: bare pågående kandidat fullføres', async () => { + const signal = { aborted: false }; + // Det andre Ctrl+C kommer mens første metadata-kall er i gang. + const s = await run({ + signal, + concurrency: 1, + fetch: spy( + () => {}, + () => { + signal.aborted = true; + }, + ), + }); + expect(s.imported).toBe(1); + expect(await streams()).toHaveLength(1); + expect(scrapeCalls()).toBe(1); + }); + + it('avbrutt før start via signal lagrer ingenting', async () => { + const s = await run({ signal: { aborted: true } }); + expect(scrapeCalls()).toBe(0); + expect(s.imported).toBe(0); + expect(await streams()).toHaveLength(0); + }); + + it('IMDb-treff på en senere side erstatter tittelkoblingen som allerede er lagret', async () => { + await pool.query( + `INSERT INTO titles (id, title_type, primary_title, original_title, start_year) + VALUES ('tt9990003', 'movie', 'Solo Film', 'Solo Film', 1950) ON CONFLICT DO NOTHING`, + ); + const item = (identifier: string, extra: Record): MockItem => ({ + scrape: { identifier, collection: 'feature_films', ...extra }, + files: [{ name: `${identifier}.webm`, format: 'WebM', length: '10' }], + }); + const custom = await startArchiveMock(0, { + videoPath: path.join(dir, 'v.webm'), + pageSize: 1, + items: [ + item('first-by-title', { title: 'Solo Film', year: '1950' }), + item('second-by-title', { title: 'Solo Film', year: '1950' }), + item('third-by-imdb', { + title: 'Other', + year: '2000', + 'external-identifier': 'urn:imdb:tt9990003', + }), + ], + }); + try { + const s = await run({ archiveUrl: `http://127.0.0.1:${custom.port}` }); + expect(s).toMatchObject({ duplicates: 2, imported: 1, failed: 0 }); + const rows = await streams(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ title_id: 'tt9990003', archive_id: 'third-by-imdb' }); + } finally { + await custom.close(); + await pool.query(`DELETE FROM titles WHERE id LIKE 'tt999000%'`); + } + }); +}); + describe('falsk Archive: nedlasting', () => { const url = () => `http://127.0.0.1:${mock.port}/download/mock-shawshank/mock-shawshank.webm`; diff --git a/backend/test/artwork.test.ts b/backend/test/artwork.test.ts index a730a79..2980bad 100644 --- a/backend/test/artwork.test.ts +++ b/backend/test/artwork.test.ts @@ -14,8 +14,11 @@ const API = () => `http://127.0.0.1:${mock.port}/3`; const IMG = () => `http://127.0.0.1:${mock.port}/t/p`; const quiet = () => {}; -const service = (over: Partial = {}) => - new ArtworkService({ +// Oppslag som en forespørsel har gitt opp på, fortsetter i bakgrunnen og lagrer etterpå. Uten å +// vente på dem kunne en rad fra forrige test dukke opp etter tømmingen i beforeEach. +const services: ArtworkService[] = []; +const service = (over: Partial = {}) => { + const svc = new ArtworkService({ pool, apiKey: 'test', apiUrl: API(), @@ -23,6 +26,9 @@ const service = (over: Partial = {}) => log: quiet, ...over, }); + services.push(svc); + return svc; +}; async function gql(svc: ArtworkService, query: string) { const yoga = createApp({ pool, production: false, artwork: svc }); @@ -48,10 +54,12 @@ beforeAll(async () => { mock = await startTmdbMock(0); }); afterAll(async () => { + await Promise.all(services.splice(0).map((s) => s.idle())); await mock.close(); await pool.end(); }); beforeEach(async () => { + await Promise.all(services.splice(0).map((s) => s.idle())); await pool.query('DELETE FROM title_artwork'); mock.state.calls.length = 0; mock.state.delayMs = 0; @@ -306,15 +314,17 @@ describe('bilde-URL-er', () => { describe('tidsgrense', () => { it('svarer innen ca. 2,5 s med null, og lagrer resultatet i bakgrunnen', async () => { mock.state.delayMs = 3000; + const svc = service(); const started = Date.now(); - const res = await gql(service(), poster(FOUND)); + const res = await gql(svc, poster(FOUND)); const elapsed = Date.now() - started; expect(res.data.title.posterUrl).toBeNull(); expect(elapsed).toBeGreaterThanOrEqual(2400); expect(elapsed).toBeLessThan(3000); expect(await rowFor(FOUND)).toBeUndefined(); - await new Promise((r) => setTimeout(r, 900)); + // Vent på selve oppslaget i stedet for en fast pause, som er for kort på en belastet maskin. + await svc.idle(); expect((await rowFor(FOUND)).status).toBe('found'); expect((await gql(service(), poster(FOUND))).data.title.posterUrl).not.toBeNull(); }, 15_000); @@ -367,6 +377,60 @@ describe('antall kall per side', () => { }); }); +describe('begrenset oppslagskø', () => { + it('avviser oppslag utover køgrensen uten TMDB-kall og uten negativ cache', async () => { + let release!: () => void; + const gate = new Promise((r) => (release = r)); + let calls = 0; + const svc = service({ + concurrency: 1, + maxQueue: 2, + fetch: async (...args) => { + calls++; + await gate; + return fetch(...args); + }, + }); + // 1 pågår + 2 i kø = 3 aksepterte; de to neste avvises. + const ids = ['tt0000001', 'tt0000002', 'tt0000003', 'tt0000004', 'tt0000005']; + const lookups = ids.map((id) => svc.lookup(id, 'MOVIE')); + const rejected = await Promise.all([lookups[3]!, lookups[4]!]); + expect(rejected.map((o) => o.kind)).toEqual(['busy', 'busy']); + expect(calls).toBe(1); + + release(); + const accepted = await Promise.all(lookups.slice(0, 3)); + expect(accepted.map((o) => o.kind)).toEqual(['found', 'found', 'found']); + expect(await rowFor('tt0000004')).toBeUndefined(); + + // Ingen negativ cache: når køen er tom, slås de avviste opp som vanlig. + expect((await svc.lookup('tt0000004', 'MOVIE')).kind).toBe('found'); + expect((await rowFor('tt0000004')).status).toBe('found'); + }); + + it('gir null for avviste titler i et GraphQL-svar, ikke feil', async () => { + let release!: () => void; + const gate = new Promise((r) => (release = r)); + const svc = service({ + concurrency: 1, + maxQueue: 0, + batchDeadlineMs: 100, + fetch: async (...args) => { + await gate; + return fetch(...args); + }, + }); + const res = await gql( + svc, + '{ a: title(id: "tt0000001") { posterUrl } b: title(id: "tt0000002") { posterUrl } }', + ); + release(); + expect(res.errors).toBeUndefined(); + expect(res.data.a.posterUrl).toBeNull(); + expect(res.data.b.posterUrl).toBeNull(); + }); +}); + describe('db:artwork (prefetch)', () => { it('henter de mest populære uten rad, hopper over ferdige og kan kjøres på nytt', async () => { const svc = service(); diff --git a/backend/test/delete-review.test.ts b/backend/test/delete-review.test.ts new file mode 100644 index 0000000..56bd5d1 --- /dev/null +++ b/backend/test/delete-review.test.ts @@ -0,0 +1,106 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- dynamiske GraphQL-svar */ +import { describe, expect, it } from 'vitest'; +import { setupApi, USER_A, USER_B } from './helpers.js'; + +const env = setupApi(); + +const ADD = /* GraphQL */ ` + mutation Add($input: AddReviewInput!) { + addReview(input: $input) { + id + } + } +`; +const DELETE = /* GraphQL */ ` + mutation Delete($id: ID!) { + deleteReview(id: $id) { + deletedId + title { + id + userRating + reviewCount + } + } + } +`; +const REVIEW_IDS = /* GraphQL */ ` + query ($id: ID!) { + title(id: $id) { + reviews { + edges { + node { + id + } + } + } + } + } +`; + +const titleId = 'tt0000101'; +const add = async (user: string, rating: number) => + (await env.gql(ADD, { input: { titleId, author: 'Kari', rating, text: 'ok' } }, user)).data! + .addReview.id as string; +const code = (r: { errors?: { extensions?: { code?: string } }[] }) => + r.errors?.[0]?.extensions?.code; + +describe('deleteReview', () => { + it('sletter egen anmeldelse og returnerer id og oppdaterte aggregater', async () => { + const mine = await add(USER_A, 5); + const theirs = await add(USER_B, 3); + const res = await env.gql(DELETE, { id: mine }, USER_A); + expect(res.errors).toBeUndefined(); + expect(res.data!.deleteReview).toEqual({ + deletedId: mine, + title: { id: titleId, userRating: 3, reviewCount: 1 }, + }); + const after = await env.gql(REVIEW_IDS, { id: titleId }); + expect(after.data!.title.reviews.edges.map((e: any) => e.node.id)).toEqual([theirs]); + await env.gql(DELETE, { id: theirs }, USER_B); + }); + + it('nullstiller userRating når siste anmeldelse slettes', async () => { + const id = await add(USER_A, 4); + const res = await env.gql(DELETE, { id }, USER_A); + expect(res.data!.deleteReview.title).toEqual({ id: titleId, userRating: null, reviewCount: 0 }); + }); + + it('gir NOT_FOUND for andres anmeldelse og lar den stå', async () => { + const theirs = await add(USER_B, 2); + const res = await env.gql(DELETE, { id: theirs }, USER_A); + expect(code(res)).toBe('NOT_FOUND'); + expect(res.data).toBeNull(); + const rows = await env.pool.query('SELECT 1 FROM reviews WHERE id = $1', [theirs]); + expect(rows.rowCount).toBe(1); + await env.gql(DELETE, { id: theirs }, USER_B); + }); + + it('gir samme svar for ukjent id som for andres (lekker ikke eksistens)', async () => { + const theirs = await add(USER_B, 2); + const other = await env.gql(DELETE, { id: theirs }, USER_A); + const unknown = await env.gql(DELETE, { id: '999999999' }, USER_A); + expect(code(unknown)).toBe('NOT_FOUND'); + expect(unknown.errors![0]!.message).toBe(other.errors![0]!.message); + await env.gql(DELETE, { id: theirs }, USER_B); + }); + + it('kan ikke slette samme anmeldelse to ganger', async () => { + const id = await add(USER_A, 1); + expect((await env.gql(DELETE, { id }, USER_A)).errors).toBeUndefined(); + expect(code(await env.gql(DELETE, { id }, USER_A))).toBe('NOT_FOUND'); + }); + + it.each(['abc', '', '0', '-1', '1.5', '1; DROP TABLE reviews', '9'.repeat(30), '٣'])( + 'avviser ugyldig id %j med BAD_USER_INPUT', + async (id) => { + expect(code(await env.gql(DELETE, { id }, USER_A))).toBe('BAD_USER_INPUT'); + }, + ); + + it('gir UNAUTHENTICATED uten gyldig x-user-id', async () => { + const id = await add(USER_A, 4); + expect(code(await env.gql(DELETE, { id }))).toBe('UNAUTHENTICATED'); + expect(code(await env.gql(DELETE, { id }, 'ugyldig'))).toBe('UNAUTHENTICATED'); + expect((await env.gql(DELETE, { id }, USER_A)).errors).toBeUndefined(); + }); +}); diff --git a/backend/test/facets.test.ts b/backend/test/facets.test.ts index 78ddb39..4267e8d 100644 --- a/backend/test/facets.test.ts +++ b/backend/test/facets.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { TITLES, TITLE_COUNT } from './fixtures.js'; import { setupApi } from './helpers.js'; @@ -113,4 +113,48 @@ describe('facets', () => { const all = [...new Set(TITLES.flatMap((t) => t.genres))].sort(); expect(res.data!.genres).toEqual(all); }); + + // Fasettene uten søketekst er like for alle og koster fire aggregeringer over hele tabellen, så + // appen holder dem i minnet (facetCache.ts). Her sjekkes at det faktisk sparer databasen. + it('henter fasetter uten søketekst fra minnet etter første kall, også med filtre', async () => { + // Appen og cachen deles av hele filen; tøm den så tidligere tester ikke har fylt nøkkelen. + env.facetCache.clear(); + const spy = vi.spyOn(env.pool, 'query'); + try { + const first = await facets({ filters: { genres: ['Western'] } }); + const callsAfterFirst = spy.mock.calls.length; + expect(callsAfterFirst).toBeGreaterThan(0); + + const second = await facets({ filters: { genres: ['Western'] } }); + expect(second.raw).toEqual(first.raw); + expect(spy.mock.calls.length).toBe(callsAfterFirst); + + // Hvert filter er en del av nøkkelen: å endre ett av dem må telle på nytt. + let calls = callsAfterFirst; + const variants = [ + { genres: ['Western'], types: ['MOVIE'] }, + { genres: ['Western'], minRating: 7 }, + { genres: ['Western'], availableOnly: true }, + ]; + for (const filters of variants) { + await facets({ filters }); + expect(spy.mock.calls.length).toBeGreaterThan(calls); + calls = spy.mock.calls.length; + } + } finally { + spy.mockRestore(); + } + }); + + it('caches ikke fasetter med søketekst', async () => { + const spy = vi.spyOn(env.pool, 'query'); + try { + await facets({ query: 'dark' }); + const callsAfterFirst = spy.mock.calls.length; + await facets({ query: 'dark' }); + expect(spy.mock.calls.length).toBe(callsAfterFirst * 2); + } finally { + spy.mockRestore(); + } + }); }); diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts index d1f2194..2a62f0e 100644 --- a/backend/test/helpers.ts +++ b/backend/test/helpers.ts @@ -3,7 +3,10 @@ import { afterAll, beforeAll } from 'vitest'; import { ArtworkService } from '../src/artwork.js'; import { createApp } from '../src/app.js'; import { config } from '../src/config.js'; +import { FacetCache } from '../src/facetCache.js'; +import type { FacetsResult } from '../src/search.js'; import { createPool, type Pool } from '../src/db.js'; +import type { RateLimitOptions } from '../src/rateLimit.js'; import { resetData } from './fixtures.js'; export const USER_A = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'; @@ -16,6 +19,8 @@ export interface GqlResult> { export interface TestEnv { pool: Pool; + /** Appens fasettcache; tøm den i tester som teller databasekall, så rekkefølgen ikke spiller inn. */ + facetCache: FacetCache; gql: ( query: string, variables?: Record, @@ -27,15 +32,19 @@ export interface TestEnv { * Felles oppsett for API-tester: egen pool mot testdatabasen, friskt datasett før filen, * og spørringer via yoga.fetch (ingen nettverksport nødvendig). */ -export function setupApi(): TestEnv { +export function setupApi(rateLimits: Partial = {}): TestEnv { const env = {} as TestEnv; beforeAll(async () => { env.pool = createPool(config.testDatabaseUrl); + env.facetCache = new FacetCache(); await resetData(env.pool); const yoga = createApp({ pool: env.pool, production: false, + facetCache: env.facetCache, corsOrigin: 'http://localhost:5173', + // Så høye grenser at funksjonelle tester aldri treffer dem; rate-limit.test.ts bruker egne. + rateLimits: { reviewsPerMinute: 10_000, mutationsPerMinute: 10_000, ...rateLimits }, // Uten nøkkel: testene skal aldri kunne nå et ekte TMDB, selv om utvikleren har en i .env. artwork: new ArtworkService({ pool: env.pool, apiKey: undefined }), }); diff --git a/backend/test/import-imdb.test.ts b/backend/test/import-imdb.test.ts new file mode 100644 index 0000000..8ad6ef3 --- /dev/null +++ b/backend/test/import-imdb.test.ts @@ -0,0 +1,96 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { gzipSync } from 'node:zlib'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { config } from '../src/config.js'; +import { createPool, type Pool } from '../src/db.js'; +import { runImport } from '../scripts/import-imdb.js'; +import { resetData } from './fixtures.js'; + +let pool: Pool; +let dir: string; + +// Egne id-er langt unna fixturene, så testen kan rydde etter seg uten å røre resten. +const IDS = ['tt9100001', 'tt9100002', 'tt9100003']; + +const writeData = (rows: { id: string; votes: number; genres: string; title?: string }[]) => { + const basics = [ + 'tconst\ttitleType\tprimaryTitle\toriginalTitle\tisAdult\tstartYear\tendYear\truntimeMinutes\tgenres', + ...rows.map( + (r) => + `${r.id}\tmovie\t${r.title ?? `Tittel ${r.id}`}\tOriginal ${r.id}\t0\t1999\t\\N\t100\t${r.genres}`, + ), + ].join('\n'); + const ratings = [ + 'tconst\taverageRating\tnumVotes', + ...rows.map((r) => `${r.id}\t7.5\t${r.votes}`), + ].join('\n'); + writeFileSync(path.join(dir, 'title.basics.tsv.gz'), gzipSync(basics)); + writeFileSync(path.join(dir, 'title.ratings.tsv.gz'), gzipSync(ratings)); +}; + +const xmins = async () => + ( + await pool.query<{ id: string; xmin: string }>( + 'SELECT id, xmin::text FROM titles WHERE id = ANY($1) ORDER BY id', + [IDS], + ) + ).rows; + +const genresOf = async (id: string) => + ( + await pool.query<{ name: string }>( + `SELECT g.name FROM title_genres tg JOIN genres g ON g.id = tg.genre_id + WHERE tg.title_id = $1 ORDER BY g.name`, + [id], + ) + ).rows.map((r) => r.name); + +beforeAll(async () => { + pool = createPool(config.testDatabaseUrl); + await resetData(pool); + dir = mkdtempSync(path.join(tmpdir(), 'import-test-')); +}); +afterAll(async () => { + await pool.query('DELETE FROM titles WHERE id = ANY($1)', [IDS]); + await pool.end(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('runImport', () => { + it('uendret omkjøring skriver ingen rader (xmin uendret) og beholder sjangerkoblingene', async () => { + const data = IDS.map((id) => ({ id, votes: 5000, genres: 'Drama,Crime' })); + writeData(data); + await runImport(pool, { dataDir: dir, minVotes: 100 }); + const first = await xmins(); + expect(first).toHaveLength(3); + expect(await genresOf('tt9100001')).toEqual(['Crime', 'Drama']); + + await runImport(pool, { dataDir: dir, minVotes: 100 }); + // Samme xmin = ingen ny radversjon, altså ingen bloat og ingen omberegning av GIN-indeksene. + expect(await xmins()).toEqual(first); + expect(await genresOf('tt9100001')).toEqual(['Crime', 'Drama']); + }); + + it('oppdaterer bare titlene som faktisk er endret, inkludert sjangrene', async () => { + const before = await xmins(); + writeData([ + { id: 'tt9100001', votes: 5000, genres: 'Drama,Crime' }, + { id: 'tt9100002', votes: 7000, genres: 'Comedy' }, + { id: 'tt9100003', votes: 5000, genres: 'Drama,Crime' }, + ]); + await runImport(pool, { dataDir: dir, minVotes: 100 }); + const after = await xmins(); + const changed = after.filter((a, i) => a.xmin !== before[i]!.xmin).map((r) => r.id); + expect(changed).toEqual(['tt9100002']); + expect(await genresOf('tt9100002')).toEqual(['Comedy']); + expect(await genresOf('tt9100001')).toEqual(['Crime', 'Drama']); + }); + + it('sletter ikke titler som faller under minVotes ved omkjøring', async () => { + writeData([{ id: 'tt9100001', votes: 5, genres: 'Drama' }]); + await runImport(pool, { dataDir: dir, minVotes: 100 }); + expect((await xmins()).map((r) => r.id)).toEqual(IDS); + }); +}); diff --git a/backend/test/mutations.test.ts b/backend/test/mutations.test.ts index 3c9917e..867b3f2 100644 --- a/backend/test/mutations.test.ts +++ b/backend/test/mutations.test.ts @@ -227,6 +227,11 @@ describe('toggleList og myList', () => { expect(code(await env.gql(MY_LIST, { after: 'tull' }, USER_A))).toBe('BAD_USER_INPUT'); const bad = Buffer.from('{"s":"myList","v":["ikke-dato","x"]}').toString('base64url'); expect(code(await env.gql(MY_LIST, { after: bad }, USER_A))).toBe('BAD_USER_INPUT'); + // Riktig format, men ikke en ekte dato: ga tidligere Postgres-feil 22008 og maskert 500. + const badDate = Buffer.from('{"s":"myList","v":["2026-13-45 00:00:00","tt0000001"]}').toString( + 'base64url', + ); + expect(code(await env.gql(MY_LIST, { after: badDate }, USER_A))).toBe('BAD_USER_INPUT'); }); it('krever x-user-id og eksisterende tittel', async () => { diff --git a/backend/test/rate-limit.test.ts b/backend/test/rate-limit.test.ts new file mode 100644 index 0000000..fc7cdba --- /dev/null +++ b/backend/test/rate-limit.test.ts @@ -0,0 +1,208 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- dynamiske GraphQL-svar */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createApp } from '../src/app.js'; +import { config } from '../src/config.js'; +import { createPool, type Pool } from '../src/db.js'; +import { MutationLimiter, TokenBuckets, clientIp, isLoopback } from '../src/rateLimit.js'; +import { resetData } from './fixtures.js'; +import { USER_A, USER_B } from './helpers.js'; + +describe('TokenBuckets', () => { + it('slipper gjennom `capacity` kall og avviser det neste med ventetid', () => { + let now = 0; + const b = new TokenBuckets(3, () => now); + expect([b.take('k'), b.take('k'), b.take('k')]).toEqual([0, 0, 0]); + // 3 per minutt = ett token per 20 s. + expect(b.take('k')).toBe(20); + now += 10_000; + expect(b.take('k')).toBe(10); + now += 10_000; + expect(b.take('k')).toBe(0); + }); + + it('fyller aldri over kapasiteten, og nøklene er uavhengige', () => { + let now = 0; + const b = new TokenBuckets(2, () => now); + b.take('a'); + b.take('a'); + expect(b.take('a')).toBeGreaterThan(0); + expect(b.take('b')).toBe(0); + now += 10 * 60_000; + expect([b.take('a'), b.take('a'), b.take('a')].map((w) => w > 0)).toEqual([false, false, true]); + }); +}); + +describe('clientIp med forbindelsesadresse', () => { + const req = (h: string) => new Request('http://x/', { headers: { 'x-forwarded-for': h } }); + it('stoler på X-Forwarded-For bare når forbindelsen kommer fra loopback (proxyen)', () => { + for (const proxy of ['127.0.0.1', '127.1.2.3', '::1', '::ffff:127.0.0.1']) { + expect(clientIp(req('6.6.6.6, 203.0.113.9'), proxy)).toBe('203.0.113.9'); + } + }); + it('bruker forbindelsens adresse og ignorerer headeren ellers (port 3001 nådd direkte)', () => { + expect(clientIp(req('1.2.3.4'), '203.0.113.50')).toBe('203.0.113.50'); + expect(clientIp(req('1.2.3.4'), '2001:db8::1')).toBe('2001:db8::1'); + // Uten header og fra utsiden: forbindelsen er likevel kjent. + expect(clientIp(new Request('http://x/'), '203.0.113.50')).toBe('203.0.113.50'); + }); + it('isLoopback gjenkjenner ikke tilsvarende utenfor 127/8', () => { + expect(isLoopback('128.0.0.1')).toBe(false); + expect(isLoopback('10.0.0.1')).toBe(false); + expect(isLoopback('::2')).toBe(false); + }); +}); + +describe('clientIp', () => { + const ip = (h?: string) => + clientIp(new Request('http://x/', h ? { headers: { 'x-forwarded-for': h } } : {})); + it('bruker siste ledd, som proxyen la til', () => { + expect(ip('6.6.6.6, 203.0.113.9')).toBe('203.0.113.9'); + expect(ip('203.0.113.9')).toBe('203.0.113.9'); + expect(ip()).toBeNull(); + expect(ip(' ')).toBeNull(); + }); +}); + +describe('MutationLimiter', () => { + it('begrenser per IP på tvers av brukere', () => { + const l = new MutationLimiter({ reviewsPerMinute: 2, mutationsPerMinute: 2, ipFactor: 2 }); + // IP-grensen er 4. Hver bruker holder seg under sin egen grense på 2. + for (const u of ['u1', 'u2']) { + l.check('review', u, '1.1.1.1'); + l.check('review', u, '1.1.1.1'); + } + expect(() => l.check('review', 'u3', '1.1.1.1')).toThrow(/For mange/); + expect(() => l.check('review', 'u3', '2.2.2.2')).not.toThrow(); + }); + + it('avvist forespørsel på IP bruker ikke opp brukerens kvote', () => { + const l = new MutationLimiter({ reviewsPerMinute: 1, mutationsPerMinute: 1, ipFactor: 1 }); + l.check('review', 'u1', '1.1.1.1'); + expect(() => l.check('review', 'u2', '1.1.1.1')).toThrow(); + expect(() => l.check('review', 'u2', '9.9.9.9')).not.toThrow(); + }); +}); + +describe('begrensning av mutations via API-et', () => { + let pool: Pool; + beforeAll(async () => { + pool = createPool(config.testDatabaseUrl); + await resetData(pool); + }); + afterAll(async () => { + await pool.end(); + }); + + const call = async ( + yoga: ReturnType, + query: string, + user: string, + headers: Record = {}, + remoteAddress?: string, + ) => { + const res = await yoga.fetch( + 'http://localhost/graphql', + { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-user-id': user, ...headers }, + body: JSON.stringify({ query }), + }, + // Det node:http gir Yoga som `req`; uten det (som i yoga.fetch) finnes ingen forbindelse. + remoteAddress ? { req: { socket: { remoteAddress } } } : {}, + ); + return (await res.json()) as { data?: any; errors?: { message: string; extensions: any }[] }; + }; + const add = (rating = 4) => + `mutation { addReview(input: {titleId: "tt0000001", author: "A", rating: ${rating}, text: ""}) { id } }`; + const toggle = 'mutation { toggleList(titleId: "tt0000002") { inMyList } }'; + + it('gir RATE_LIMITED med forståelig melding og retryAfterSeconds når kvoten er brukt opp', async () => { + const yoga = createApp({ pool, rateLimits: { reviewsPerMinute: 3 } }); + for (let i = 0; i < 3; i++) expect((await call(yoga, add(), USER_A)).errors).toBeUndefined(); + const res = await call(yoga, add(), USER_A); + expect(res.data).toBeNull(); + expect(res.errors![0]!.extensions.code).toBe('RATE_LIMITED'); + expect(res.errors![0]!.message).toMatch(/For mange forsøk\. Vent \d+ sekunder/); + // 3 per minutt = ett token per 20 s. + expect(res.errors![0]!.extensions.retryAfterSeconds).toBeGreaterThan(0); + expect(res.errors![0]!.extensions.retryAfterSeconds).toBeLessThanOrEqual(20); + // Avviste forsøk lagrer ingenting. + const { rows } = await pool.query('SELECT count(*)::int AS n FROM reviews WHERE user_id = $1', [ + USER_A, + ]); + expect(rows[0].n).toBe(3); + }); + + it('er per bruker: en annen bruker påvirkes ikke', async () => { + const yoga = createApp({ pool, rateLimits: { reviewsPerMinute: 1 } }); + expect((await call(yoga, add(), USER_A)).errors).toBeUndefined(); + expect((await call(yoga, add(), USER_A)).errors![0]!.extensions.code).toBe('RATE_LIMITED'); + expect((await call(yoga, add(), USER_B)).errors).toBeUndefined(); + }); + + it('teller også ugyldige forsøk, men ikke uautentiserte', async () => { + const yoga = createApp({ pool, rateLimits: { reviewsPerMinute: 2 } }); + const invalid = add(9); + expect((await call(yoga, invalid, USER_A)).errors![0]!.extensions.code).toBe('BAD_USER_INPUT'); + expect((await call(yoga, invalid, USER_A)).errors![0]!.extensions.code).toBe('BAD_USER_INPUT'); + expect((await call(yoga, invalid, USER_A)).errors![0]!.extensions.code).toBe('RATE_LIMITED'); + // Uten gyldig bruker svarer vi UNAUTHENTICATED uten å røre noen kvote. + const anon = await call(yoga, add(), 'ikke-uuid'); + expect(anon.errors![0]!.extensions.code).toBe('UNAUTHENTICATED'); + }); + + it('toggleList og deleteReview deler en egen kvote, atskilt fra anmeldelser', async () => { + const yoga = createApp({ pool, rateLimits: { mutationsPerMinute: 2, reviewsPerMinute: 100 } }); + expect((await call(yoga, toggle, USER_A)).errors).toBeUndefined(); + const del = await call(yoga, 'mutation { deleteReview(id: "999999") { deletedId } }', USER_A); + expect(del.errors![0]!.extensions.code).toBe('NOT_FOUND'); + expect((await call(yoga, toggle, USER_A)).errors![0]!.extensions.code).toBe('RATE_LIMITED'); + // Anmeldelser har egen bøtte og er upåvirket. + expect((await call(yoga, add(), USER_A)).errors).toBeUndefined(); + }); + + it('begrenser per IP (X-Forwarded-For) selv om brukerne roterer', async () => { + const yoga = createApp({ pool, rateLimits: { reviewsPerMinute: 1, ipFactor: 2 } }); + const xff = { 'x-forwarded-for': '203.0.113.7' }; + const uuid = (n: number) => `00000000-0000-4000-8000-00000000000${n}`; + expect((await call(yoga, add(), uuid(1), xff)).errors).toBeUndefined(); + expect((await call(yoga, add(), uuid(2), xff)).errors).toBeUndefined(); + expect((await call(yoga, add(), uuid(3), xff)).errors![0]!.extensions.code).toBe( + 'RATE_LIMITED', + ); + // En annen IP er upåvirket. + const other = await call(yoga, add(), uuid(3), { 'x-forwarded-for': '203.0.113.8' }); + expect(other.errors).toBeUndefined(); + }); + + it('IP-grensen kan ikke omgås med falsk X-Forwarded-For når forbindelsen er direkte', async () => { + const yoga = createApp({ pool, rateLimits: { reviewsPerMinute: 1, ipFactor: 2 } }); + const uuid = (n: number) => `00000000-0000-4000-8000-0000000001${n}0`; + const outside = '203.0.113.50'; + // Ny falsk IP og ny bruker for hver forespørsel: tredje skal likevel avvises. + const r = []; + for (let i = 1; i <= 3; i++) { + r.push(await call(yoga, add(), uuid(i), { 'x-forwarded-for': `9.9.9.${i}` }, outside)); + } + expect(r[0]!.errors).toBeUndefined(); + expect(r[1]!.errors).toBeUndefined(); + expect(r[2]!.errors![0]!.extensions.code).toBe('RATE_LIMITED'); + // Fra proxyen (loopback) er headeren derimot gyldig, og ulike klienter er uavhengige. + for (let i = 4; i <= 6; i++) { + const res = await call( + yoga, + add(), + uuid(i), + { 'x-forwarded-for': `8.8.8.${i}` }, + '127.0.0.1', + ); + expect(res.errors).toBeUndefined(); + } + }); + + it('standardgrensene tillater en rask runde på noen få anmeldelser og listeklikk', async () => { + const yoga = createApp({ pool }); + for (let i = 0; i < 5; i++) expect((await call(yoga, add(), USER_A)).errors).toBeUndefined(); + for (let i = 0; i < 10; i++) expect((await call(yoga, toggle, USER_A)).errors).toBeUndefined(); + }); +}); diff --git a/backend/test/search-index.test.ts b/backend/test/search-index.test.ts new file mode 100644 index 0000000..2c8155f --- /dev/null +++ b/backend/test/search-index.test.ts @@ -0,0 +1,90 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { config } from '../src/config.js'; +import { createPool, type Pool } from '../src/db.js'; +import { searchTitles } from '../src/search.js'; +import { validateFilters } from '../src/validation.js'; +import { resetData } from './fixtures.js'; + +/** + * Fixturen er for liten til at planleggeren velger indekser av seg selv. Med seq scan slått av + * ser vi i stedet om spørringene KAN bruke indeksene, dvs. at uttrykkene i search.ts passer med + * migrasjonen. Uten dette ville en skrivefeil bare vist seg som en treg søkeside i produksjon. + */ +let pool: Pool; +beforeAll(async () => { + pool = createPool(config.testDatabaseUrl); + await resetData(pool); +}); +afterAll(async () => { + await pool.end(); +}); + +async function planFor(query: string, field: 'RELEVANCE' | 'RATING' = 'RELEVANCE') { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query('SET LOCAL enable_seqscan = off'); + // Utgir seg for å være poolen: hver spørring søket sender blir en EXPLAIN i stedet. + const plans: string[] = []; + const explainer = { + query: async (sql: string, values: unknown[]) => { + const res = await client.query(`EXPLAIN ${sql}`, values); + plans.push(res.rows.map((r) => r['QUERY PLAN']).join('\n')); + return { rows: [] }; + }, + } as unknown as Pool; + const conn = await searchTitles(explainer, { + query, + filters: validateFilters(null), + field, + direction: 'DESC', + first: 20, + after: null, + }); + await conn.totalCount(); + return plans.join('\n'); + } finally { + await client.query('ROLLBACK'); + client.release(); + } +} + +describe('f_unaccent', () => { + it('fjerner aksenter og er markert IMMUTABLE (kreves for indekser og genererte kolonner)', async () => { + const { rows } = await pool.query( + `SELECT f_unaccent('Amélie Café Ça Ångström') AS v, + (SELECT provolatile FROM pg_proc WHERE proname = 'f_unaccent') AS vol`, + ); + expect(rows[0].v).toBe('Amelie Cafe Ca Angstrom'); + expect(rows[0].vol).toBe('i'); + }); + + it('de genererte kolonnene følger tittelen', async () => { + const { rows } = await pool.query( + "SELECT primary_title_norm AS p, original_title_norm AS o FROM titles WHERE id = 'tt0000005'", + ); + expect(rows[0]).toEqual({ p: 'amelie', o: "le fabuleux destin d'amelie poulain" }); + }); +}); + +describe('indeksbruk', () => { + it.each(['dark', 'amelie', 'cafe soc'])( + 'delstrengsøk «%s» kan bruke trigramindeksene', + async (q) => { + const plan = await planFor(q); + expect(plan).toMatch(/titles_primary_title_trgm_idx/); + expect(plan).toMatch(/titles_original_title_trgm_idx/); + }, + ); + + it.each(['a', 'da', 'ça'])('kort søk «%s» bruker ordprefiksindeksen, ikke trigram', async (q) => { + const plan = await planFor(q); + expect(plan).toMatch(/titles_title_words_idx/); + expect(plan).not.toMatch(/trgm_idx/); + }); + + it('søket regner ikke likhet for korte søk', async () => { + expect(await planFor('da')).not.toMatch(/similarity/); + expect(await planFor('dar')).toMatch(/similarity/); + }); +}); diff --git a/backend/test/search.test.ts b/backend/test/search.test.ts index 17e690c..3f267d8 100644 --- a/backend/test/search.test.ts +++ b/backend/test/search.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { TITLES, TITLE_COUNT } from './fixtures.js'; import { setupApi } from './helpers.js'; -import { SEARCH } from './queries.js'; +import { collectIds, SEARCH } from './queries.js'; const env = setupApi(); @@ -43,7 +43,8 @@ describe('search: tekst', () => { it('søker i originaltittel', async () => { expect((await search({ query: 'fabuleux' })).titles).toEqual(['Amélie']); - expect((await search({ query: 'Ça' })).titles).toEqual(['It']); + // «Ça» er et kort søk (prefiks, uten aksenter) og treffer derfor også «Café Society». + expect((await search({ query: 'Ça' })).titles).toEqual(['It', 'Café Society']); }); it.each([null, '', ' ', '\t\n'])('tomt søk %j gir alle titler', async (q) => { @@ -63,14 +64,13 @@ describe('search: tekst', () => { }); it.each([ - ['%', ['100% Pure']], - ['_', ['Under_score Story']], ['0% P', ['100% Pure']], ['er_sc', ['Under_score Story']], - ["'", ['Amélie', "Don't Look Up", "O'Brien's Odyssey"]], - ['"', ['Say "Cheese"']], - ['\\', ['Back\\Slash']], - ['🎬', ['Emoji 🎬 Night']], + ["n's O", ["O'Brien's Odyssey"]], + ['y "C', ['Say "Cheese"']], + ['Back\\', ['Back\\Slash']], + ['k\\S', ['Back\\Slash']], + ['🎬 N', ['Emoji 🎬 Night']], ['amélie', ['Amélie']], ['CAFÉ', ['Café Society']], ["Don't", ["Don't Look Up"]], @@ -105,6 +105,182 @@ describe('search: tekst', () => { }); }); +/** Samme normalisering som i SQL (lower + unaccent), til å utlede forventede treff fra fixturen. */ +const fold = (s: string) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase(); +const titleHaystack = (t: (typeof TITLES)[number]) => + [t.primary, t.original ?? t.primary].map(fold); + +describe('search: aksentuavhengig', () => { + it.each([ + ['amelie', ['Amélie']], + ['AMELIE', ['Amélie']], + ['amélie', ['Amélie']], + ['AMÉLIE', ['Amélie']], + ['amèlie', ['Amélie']], + ['cafe', ['Café Society']], + ['Cafe soc', ['Café Society']], + ['café', ['Café Society']], + ['fabuleux destin d amelie', []], + ["destin d'amelie", ['Amélie']], + ])('%j finner %j', async (q, expected) => { + const r = await search({ query: q }); + expect(r.res.errors).toBeUndefined(); + expect(r.titles).toEqual(expected); + expect(r.total).toBe(expected.length); + }); + + it('rangerer treff uten aksent likt som med (relevans bruker samme normalisering)', async () => { + const [plain, accented] = await Promise.all([ + search({ query: 'amelie', sort: { field: 'RELEVANCE' } }), + search({ query: 'amélie', sort: { field: 'RELEVANCE' } }), + ]); + expect(plain.ids).toEqual(accented.ids); + expect(plain.titles[0]).toBe('Amélie'); + }); + + it('escaping av %, _ og \\ virker sammen med aksenter', async () => { + expect((await search({ query: 'caf% soc' })).total).toBe(0); + expect((await search({ query: 'caf_ soc' })).total).toBe(0); + expect((await search({ query: 'back\\sl' })).titles).toEqual(['Back\\Slash']); + expect((await search({ query: 'bac\\sl' })).total).toBe(0); + expect((await search({ query: 'Back\\Slash' })).titles).toEqual(['Back\\Slash']); + }); + + // unaccent.rules mapper fullbreddetegn til ASCII. Hvis escaping skjedde FØR normaliseringen, + // ble de jokertegn og traff hele tabellen. + it.each(['caf% soc', 'caf_ soc', '%%%', '___', 'ba\sla%', 'bac\sl', '\\\'])( + 'fullbreddetegn i %j blir bokstavelige og gir ikke jokertegn', + async (q) => { + const r = await search({ query: q }); + expect(r.res.errors).toBeUndefined(); + expect(r.total).toBe(0); + }, + ); + + it('fullbredde-backslash normaliseres til en vanlig backslash', async () => { + expect((await search({ query: 'back\sl' })).titles).toEqual(['Back\\Slash']); + }); + + it('fasettene og totalCount bruker samme normalisering', async () => { + const res = await env.gql( + 'query ($q: String) { facets(query: $q) { available types { value count } } }', + { q: 'cafe' }, + ); + expect(res.errors).toBeUndefined(); + expect(res.data!.facets.types).toEqual([{ value: 'MOVIE', count: 1 }]); + }); +}); + +describe('search: korte søk (1–2 tegn)', () => { + // ordprefiks på normalisert primær- eller originaltittel, rangert etter stemmer og id. + const words = (t: (typeof TITLES)[number]) => + titleHaystack(t) + .flatMap((h) => h.split(/[^\p{L}\p{N}]+/u)) + .filter(Boolean); + const expectedPrefix = (q: string) => + TITLES.filter((t) => words(t).some((w) => w.startsWith(fold(q)))) + .sort((a, b) => b.votes - a.votes || (a.id < b.id ? 1 : -1)) + .map((t) => t.id); + + it.each(['u', 'U', 'f', 'un', 'UN', 'ba', 'br', 'ca', 'ÇA', 'am', 'it', 'e', 'ci', 'ni'])( + 'ordprefiks for %j, sortert etter popularitet', + async (q) => { + const r = await search({ query: q }); + expect(r.res.errors).toBeUndefined(); + expect(expectedPrefix(q).length).toBeGreaterThan(0); + expect(r.ids).toEqual(expectedPrefix(q)); + expect(r.total).toBe(expectedPrefix(q).length); + }, + ); + + it('finner ord midt i tittelen, men ikke midt i et ord', async () => { + // «ba» finner «Breaking Bad» (andre ord), «ci» finner «Dark City». + expect((await search({ query: 'ba' })).titles).toContain('Breaking Bad'); + expect((await search({ query: 'ci' })).titles).toContain('Dark City'); + expect((await search({ query: 'ar' })).titles).not.toContain('Dark'); + expect((await search({ query: 'ark' })).titles).toContain('Dark'); + }); + + it('finner ord i originaltittelen, også uten aksent', async () => { + expect((await search({ query: 'fa' })).titles).toContain('Amélie'); + expect((await search({ query: 'ça' })).titles).toContain('It'); + }); + + it.each(['%', '_', '\\', '%%', '"', "'", '🎬', '🎬🎬', '%', '_', '\', '%%', '__'])( + 'behandler %j (jokertegn, fullbredde, emoji) uten treff og uten feil', + async (q) => { + const r = await search({ query: q }); + expect(r.res.errors).toBeUndefined(); + expect(r.total).toBe(0); + }, + ); + + it.each([ + ':', + '&', + '|', + '!', + '(', + ')', + '<', + '>', + '*', + ':*', + 'a:', + '!a', + 'a&', + '(a', + "a'", + 'a\\', + '-a', + ])('tsquery-tegn i %j gir ikke feil og ignoreres', async (q) => { + const r = await search({ query: q }); + expect(r.res.errors).toBeUndefined(); + const letters = q.replace(/[^a-z]/g, ''); + expect(r.ids).toEqual(letters ? expectedPrefix(letters) : []); + }); + + it('pagineres uten hull eller duplikater, og totalCount stemmer', async () => { + const all = await collectIds(env, { query: 'f' }, 5); + expect(all.ids).toEqual(expectedPrefix('f')); + expect(all.pages).toBeGreaterThan(3); + expect(all.total).toBe(all.ids.length); + }); + + it('kombineres med filtre og alle sorteringer', async () => { + const series = await search({ query: 'f', filters: { types: ['SERIES'] } }); + const want = expectedPrefix('f').filter( + (id) => TITLES.find((t) => t.id === id)!.type === 'series', + ); + expect(series.ids).toEqual(want); + for (const field of ['RATING', 'YEAR', 'TITLE']) { + const r = await collectIds(env, { query: 'f', sort: { field } }, 7); + expect([...r.ids].sort()).toEqual([...expectedPrefix('f')].sort()); + } + }); + + it('cursor fra et kort søk kan ikke brukes på et langt søk, og omvendt', async () => { + const short = await env.gql(SEARCH, { query: 'f', first: 2 }); + const long = await env.gql(SEARCH, { query: 'fil', first: 2 }); + const shortCursor = short.data!.search.pageInfo.endCursor; + const longCursor = long.data!.search.pageInfo.endCursor; + const a = await env.gql(SEARCH, { query: 'fil', first: 2, after: shortCursor }); + const b = await env.gql(SEARCH, { query: 'f', first: 2, after: longCursor }); + expect(a.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + expect(b.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }); + + it('grensen går mellom 2 og 3 tegn', async () => { + // «da» (prefiks): Dark, Dark City, Dark Waters, Darkman, Dark Knight, Don't? nei. + const two = await search({ query: 'da' }); + expect(two.titles).toEqual( + expectedPrefix('da').map((id) => TITLES.find((t) => t.id === id)!.primary), + ); + // «ark» (delstreng): treffer også titler der «ark» står inni. + expect((await search({ query: 'ark' })).total).toBeGreaterThan(0); + }); +}); + describe('search: filtre', () => { it('genres krever ALLE valgte sjangre', async () => { const drama = await search({ filters: { genres: ['Drama'] } }); diff --git a/backend/test/server.test.ts b/backend/test/server.test.ts index 45aa49e..cd56d27 100644 --- a/backend/test/server.test.ts +++ b/backend/test/server.test.ts @@ -1,5 +1,18 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { buildSchema, parse, specifiedRules, validate } from 'graphql'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { createApp } from '../src/app.js'; +import { + createApp, + MAX_COST, + MAX_FIELDS, + MAX_QUERY_DEPTH, + MAX_ROOT_FIELDS, + MAX_TOKENS, +} from '../src/app.js'; +import { complexityLimit } from '../src/complexityLimit.js'; +import { depthLimit } from '../src/depthLimit.js'; +import { typeDefs } from '../src/typeDefs.js'; import { config } from '../src/config.js'; import { createPool, type Pool } from '../src/db.js'; import { resetData } from './fixtures.js'; @@ -24,6 +37,40 @@ afterAll(async () => { await pool.end(); }); +describe('GET /health', () => { + it('svarer 200 {status:"ok"} når databasen svarer', async () => { + const res = await createApp({ pool }).fetch('http://localhost/health'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: 'ok' }); + }); + + it('svarer 503 {status:"db-unavailable"} når databasen ikke svarer', async () => { + // Port 1 avviser tilkoblinger umiddelbart, som en nedlagt Postgres. + const dead = createPool('postgres://postgres:x@127.0.0.1:1/none'); + try { + const res = await createApp({ pool: dead }).fetch('http://localhost/health'); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ status: 'db-unavailable' }); + } finally { + await dead.end(); + } + }); +}); + +describe('database nede mens Node kjører', () => { + it('gir SERVICE_UNAVAILABLE uten interne detaljer', async () => { + const dead = createPool('postgres://postgres:x@127.0.0.1:1/none'); + try { + const res = await post(createApp({ pool: dead }), '{ genres }'); + const body = await res.json(); + expect(body.errors[0].extensions.code).toBe('SERVICE_UNAVAILABLE'); + expect(JSON.stringify(body)).not.toMatch(/127\.0\.0\.1|ECONNREFUSED|postgres/); + } finally { + await dead.end(); + } + }); +}); + describe('servergrenser', () => { it('slår av introspeksjon i produksjon, men ikke i utvikling', async () => { const dev = createApp({ pool, production: false }); @@ -65,6 +112,115 @@ describe('servergrenser', () => { expect((await preflight(without)).headers.get('access-control-allow-origin')).toBeNull(); }); + describe('kompleksitetsgrense', () => { + const run = async (query: string) => + (await post(createApp({ pool }), query, {})).json() as Promise<{ + data?: unknown; + errors?: { message: string; extensions?: { code?: string } }[]; + }>; + + // Vaktbikkje: alle frontendens operasjoner må være gyldige mot skjemaet OG innenfor grensene. Vi + // leser operations.ts som tekst i stedet for å importere den: backendens tsconfig har + // rootDir = backend, og importen ville dratt inn Apollo Client og frontendens typer. De to + // delte tekstfragmentene (`${...}`) er enkle konstanter, så de kan limes inn direkte. Reglene + // og grensene er de samme som createApp bruker (app.ts), så testen ikke kan drive fra appen. + // Begynner den å feile, er enten skjemaet endret uten at klienten fulgte med, eller grensene + // har blitt for stramme for den faktiske klienten. + // buildSchema fra samme graphql-instans som validate: Yogas ferdige schema kommer fra en annen + // modulinstans i Vitest og avvises. Validering trenger ingen resolvere. + const schema = buildSchema(typeDefs); + const operationsSource = readFileSync( + fileURLToPath(new URL('../../frontend/src/graphql/operations.ts', import.meta.url)), + 'utf8', + ); + const fragments = new Map( + [...operationsSource.matchAll(/^const (\w+) = `([^`]*)`;/gm)].map((m) => [m[1]!, m[2]!]), + ); + const documents = [ + ...operationsSource.matchAll(/^export const (\w+)\b[^=]*= gql`([^`]*)`;/gm), + ].map((m) => ({ + name: m[1]!, + source: m[2]!.replace(/\$\{(\w+)\}/g, (all, key: string) => fragments.get(key) ?? all), + })); + + it('finner alle frontendens operasjoner', () => { + // Hver `gql`-mal i fila må bli funnet. Et fast minimum ville latt en ny operasjon i et format + // regexen ikke kjenner, gli forbi uten å bli validert. Alle operasjoner skal ligge i + // operations.ts. + expect(documents.length).toBeGreaterThan(0); + expect(documents.length).toBe([...operationsSource.matchAll(/gql`/g)].length); + for (const d of documents) expect(d.source, d.name).not.toContain('${'); + }); + + it('slipper gjennom frontendens operasjoner', () => { + for (const { name, source } of documents) { + const errors = validate(schema, parse(source), [ + ...specifiedRules, + depthLimit(MAX_QUERY_DEPTH), + complexityLimit({ + maxRootFields: MAX_ROOT_FIELDS, + maxFields: MAX_FIELDS, + maxCost: MAX_COST, + }), + ]); + expect( + errors.map((e) => e.message), + name, + ).toEqual([]); + } + }); + + it('holder frontendens operasjoner godt under tokengrensen (minst 4 x margin)', () => { + for (const { name, source } of documents) { + expect(() => parse(source, { maxTokens: MAX_TOKENS / 4 }), name).not.toThrow(); + } + }); + + it('avviser mange aliasede søk i ett dokument (under tokengrensen)', async () => { + const q = `{ ${Array.from({ length: 50 }, (_, i) => `s${i}: search(query: "a") { totalCount }`).join(' ')} }`; + const res = await run(q); + expect(res.data).toBeUndefined(); + expect(res.errors).toHaveLength(1); + expect(res.errors![0]!.extensions?.code).toBe('BAD_USER_INPUT'); + expect(res.errors![0]!.message).toMatch(/rotfelt/); + }); + + it('avviser mange aliasede TMDB-felt på én tittel', async () => { + const q = `{ title(id: "tt0000001") { ${Array.from({ length: 160 }, (_, i) => `p${i}: posterUrl`).join(' ')} } }`; + const res = await run(q); + expect(res.data).toBeUndefined(); + expect(res.errors![0]!.extensions?.code).toBe('BAD_USER_INPUT'); + expect(res.errors![0]!.message).toMatch(/felt/); + }); + + it('godtar akkurat grensen for rotfelt (8) og avviser 9', async () => { + const roots = (n: number) => + `{ ${Array.from({ length: n }, (_, i) => `g${i}: genres`).join(' ')} }`; + expect((await run(roots(8))).errors).toBeUndefined(); + expect((await run(roots(9))).errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }); + }); + + describe('CPU-grenser ved parsing', () => { + it('avviser 8 000 duplikate felt raskt med en forståelig feil', async () => { + // Uten tokengrensen brukte valideringen (kvadratisk) flere sekunder på dette dokumentet. + const q = `{ ${'genres '.repeat(8000)}}`; + const start = performance.now(); + const res = await post(createApp({ pool }), q); + const elapsed = performance.now() - start; + const body = await res.json(); + expect(body.data).toBeUndefined(); + expect(body.errors).toHaveLength(1); + expect(body.errors[0].message).toMatch(/token/i); + expect(elapsed).toBeLessThan(200); + }); + + it('avviser for store forespørselskropper med 413', async () => { + const res = await post(createApp({ pool }), `{ genres #${'x'.repeat(70_000)}\n }`); + expect(res.status).toBe(413); + }); + }); + it('tåler ugyldig JSON-body og manglende query', async () => { const yoga = createApp({ pool }); const bad = await yoga.fetch('http://localhost/graphql', { diff --git a/deploy/apache-project2.conf b/deploy/apache-project2.conf index 5fdcc44..cd682b6 100644 --- a/deploy/apache-project2.conf +++ b/deploy/apache-project2.conf @@ -7,9 +7,19 @@ ProxyPreserveHost On ProxyPass /project2/graphql http://127.0.0.1:3001/graphql ProxyPassReverse /project2/graphql http://127.0.0.1:3001/graphql +# Helsesjekk (SELECT 1 mot databasen): lar sjekk.sh og overvåking skille «API nede» fra «database nede». +ProxyPass /project2/health http://127.0.0.1:3001/health +ProxyPassReverse /project2/health http://127.0.0.1:3001/health Alias /project2 /var/www/html/project2 +# Verktøy som Lighthouse og søkemotorer leter etter robots.txt på domeneroten, ikke under +# /project2/. Filen publiseres uansett til /project2/robots.txt (Vite kopierer frontend/public), +# så roten peker på samme fil i stedet for en egen kopi som kunne blitt utdatert. Alias står utenfor +# /, og tilgangen styres av -blokken under, som dekker filen. +# Katalogen må være lik WEB_DIR i deploy/setup-vm.sh. +Alias /robots.txt /var/www/html/project2/robots.txt + Options -Indexes AllowOverride None @@ -31,12 +41,23 @@ Alias /project2 /var/www/html/project2 Header always set X-Frame-Options "DENY" Header always set X-Content-Type-Options "nosniff" Header always set Referrer-Policy "strict-origin-when-cross-origin" - Header always set Cross-Origin-Opener-Policy "same-origin" + # Cross-Origin-Opener-Policy er bevisst utelatt: nettleserne ignorerer den over ren http (VM-en + # har ikke https) og logger bare en konsollmelding om det. Legg den til igjen hvis siden får https. + Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" + # Content-Security-Policy: appen har bare to eksterne opphav, begge passive medier. Plakater + # kommer fra TMDB (image.tmdb.org). Filmer og undertekster kommer fra Internet Archive, som + # sender nedlastinger videre til lagringsverter som ia800000.us.archive.org (derfor wildcard). + # Alt annet, inkludert GraphQL-kallene, går til samme opphav. Vite-bygget har ingen inline + # skript eller stiler, så script-src og style-src trenger ikke 'unsafe-inline'. + # E2E-testen e2e/csp.spec.ts leser denne linjen, så ikke del den over flere linjer. + Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https://image.tmdb.org; media-src 'self' https://archive.org https://*.archive.org; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" # Komprimer tekstressurser – store besparelser på JS/CSS/JSON for lite CPU. - AddOutputFilterByType DEFLATE text/html text/css text/plain application/javascript application/json image/svg+xml + # graphql-response+json er det Apollo Client 4 ber om og Yoga svarer med; uten den ble hvert + # GraphQL-svar sendt ukomprimert. text/javascript er standard MIME for .js i nyere Apache-oppsett. + AddOutputFilterByType DEFLATE text/html text/css text/plain text/javascript application/javascript application/json application/graphql-response+json image/svg+xml # Vite gir filene i assets/ innholdshash i navnet, så de kan caches «for alltid». @@ -47,6 +68,14 @@ Alias /project2 /var/www/html/project2 Header set Cache-Control "no-cache" +# theme-init.js har ikke hash i navnet og blokkerer første maling; en dags cache sparer en +# revalidering per sidelasting, og innholdet endres nesten aldri. + + Header set Cache-Control "public, max-age=86400" + Header set Cache-Control "no-store" + + Header set Cache-Control "no-store" + diff --git a/deploy/mal-ytelse.sh b/deploy/mal-ytelse.sh new file mode 100755 index 0000000..c0a8195 --- /dev/null +++ b/deploy/mal-ytelse.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Måler søkeytelsen på det ekte datasettet og skriver en markdown-tabell til stdout, klar til å +# limes inn i docs/ytelse.md: bash deploy/mal-ytelse.sh > ytelse-vm.md +# Fremdrift og feil går til stderr, så fila bare inneholder tabellen. Leser bare fra databasen +# (EXPLAIN ANALYZE på SELECT, i en skrivebeskyttet økt) og kan trygt kjøres mens siden er i bruk, +# men kjør den helst når det er rolig: målingene konkurrerer om CPU med besøkende. +# Skriptet måler en Ubuntu-server; på Windows/macOS ville det feile halvveis. +if [ "$(uname -s)" != "Linux" ] || ! command -v apt-get >/dev/null; then + echo "Dette skriptet skal kjøres på VM-en, ikke på din egen maskin." >&2 + echo "Logg inn først: ssh @it2810-17.idi.ntnu.no" >&2 + exit 1 +fi +set -euo pipefail + +APP_DIR="${APP_DIR:-/opt/project2}" +ENV_FILE="$APP_DIR/.env" + +if [ ! -f "$ENV_FILE" ]; then + echo "Fant ikke $ENV_FILE. Kjør bash deploy/setup-vm.sh først." >&2 + exit 1 +fi +# Kopien i $APP_DIR oppdateres av setup-vm.sh/oppdater.sh. Er den eldre enn denne fila, mangler +# målerskriptet der og npm gir en uforståelig feil. +if [ ! -f "$APP_DIR/backend/scripts/measure-performance.ts" ]; then + echo "$APP_DIR har ikke målerskriptet ennå. Kjør bash deploy/oppdater.sh først." >&2 + exit 1 +fi + +# Samme uthenting som setup-vm.sh bruker for andre verdier i .env. Passordet i URL-en skrives aldri ut. +DATABASE_URL="$(sed -n 's/^DATABASE_URL=//p' "$ENV_FILE" | tr -d '\r' | tail -n 1)" +if [ -z "$DATABASE_URL" ]; then + echo "DATABASE_URL mangler i $ENV_FILE." >&2 + exit 1 +fi +export DATABASE_URL + +# -s: uten npms egne overskrifter på stdout, som ellers havner i markdown-fila. +cd "$APP_DIR" +npm run -s db:measure -w backend diff --git a/deploy/project2-backend.service b/deploy/project2-backend.service index 2fad3b6..80b5e0c 100644 --- a/deploy/project2-backend.service +++ b/deploy/project2-backend.service @@ -5,6 +5,8 @@ Description=IT2810 gruppe 17 prosjekt 2 – GraphQL-backend # Backenden er ubrukelig uten databasen, så vent på at Postgres er oppe. After=network.target postgresql.service +# Wants (ikke Requires): Requires propagerer bare stopp, ikke start, og postgresql.service er på Ubuntu en +# oneshot-wrapper. Restart=always under holder backenden oppe og får den tilbake når databasen er klar. Wants=postgresql.service [Service] @@ -16,7 +18,8 @@ WorkingDirectory=/opt/project2/backend EnvironmentFile=/opt/project2/.env Environment=NODE_ENV=production ExecStart=/usr/bin/node dist/src/index.js -Restart=on-failure +# always: også etter rene avslutninger (exit 0), slik at en uventet stopp aldri blir stående som nedetid. +Restart=always RestartSec=5 # Herding: prosessen trenger bare å lese koden og snakke med Postgres over nettverket. diff --git a/deploy/setup-vm.sh b/deploy/setup-vm.sh index fcababd..2abb194 100755 --- a/deploy/setup-vm.sh +++ b/deploy/setup-vm.sh @@ -78,14 +78,47 @@ if [ -n "${TMDB_API_KEY:-}" ]; then fi sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME'" | grep -q 1 \ || sudo -u postgres createdb "$DB_NAME" --owner "$DB_USER" -# pg_trgm krever superbruker første gang; migreringen bruker IF NOT EXISTS. -sudo -u postgres psql -d "$DB_NAME" -qc 'CREATE EXTENSION IF NOT EXISTS pg_trgm;' +# pg_trgm og unaccent krever superbruker første gang (begge følger med postgresql-contrib); +# migreringene bruker IF NOT EXISTS. +sudo -u postgres psql -d "$DB_NAME" -qc 'CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE EXTENSION IF NOT EXISTS unaccent;' step "Installerer avhengigheter og bygger" HUSKY=0 npm ci --no-audit --no-fund npm run build +# Migrering 004 skriver om titles og holder en lås som blokkerer all lesing og skriving i noen sekunder (ca. 4 s lokalt, 10–20 s på VM). +# Stopper backend først (ved første kjøring finnes den ikke ennå), så brukere får en ryddig 503 fra +# Apache i stedet for forespørsler som henger på låsen. Den startes igjen rett etter migreringen, +# før de tidkrevende stegene (import, plakater, Archive), slik at nedetiden bare er migreringen. +sudo systemctl stop project2-backend 2>/dev/null || true npm run db:migrate +step "Starter backend som systemd-tjeneste" +# Tjenesten kjører som project2 og må kunne lese .env (chmod 600 og eid av oss ville gitt «permission +# denied»). Gruppen får lesetilgang i stedet for at vi flytter eierskap av hele katalogen: importene +# under kjører som oss og skriver til data/. +sudo chgrp project2 .env +chmod 640 .env +sudo cp deploy/project2-backend.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable project2-backend +sudo systemctl restart project2-backend +# Backenden trenger et par sekunder på å starte; prøv noen ganger før vi gir opp. Vi stopper her, og +# ikke etter flere minutter med import, hvis den ikke kommer opp. +check() { + curl -fsS -m 5 -X POST "$1" -H 'content-type: application/json' \ + -d '{"query":"{ search(first: 1) { totalCount } }"}' +} +for i in 1 2 3 4 5 6 7 8 9 10; do + check http://127.0.0.1:3001/graphql >/dev/null 2>&1 && break + sleep 2 +done +# `if !` i stedet for `cmd && echo`: set -e overser feil i &&-lister, og da ville skriptet +# fortsatt selv om API-et ikke svarte. +if ! check http://127.0.0.1:3001/graphql >/dev/null; then + echo "FEIL: backenden svarer ikke på port 3001. Se: sudo journalctl -u project2-backend -n 50" >&2 + exit 1 +fi + step "Laster ned IMDb-data og importerer (tar noen minutter)" mkdir -p data for f in title.basics.tsv.gz title.ratings.tsv.gz; do @@ -103,15 +136,12 @@ fi step "Henter lovlige filmer fra Internet Archive" # Kobler IMDb-titler til gratis public domain-/Creative Commons-versjoner. Feil her skal ikke # stoppe deployen: appen fungerer, bare uten «Se filmen» for titler som mangler. -npm run db:archive || echo "Advarsel: henting fra Internet Archive feilet – prøv igjen senere med npm run db:archive." - -step "Starter backend som systemd-tjeneste" -sudo chown -R project2:project2 "$APP_DIR" -sudo cp deploy/project2-backend.service /etc/systemd/system/ -sudo systemctl daemon-reload -sudo systemctl enable --now project2-backend -sudo systemctl restart project2-backend +# ARCHIVE_LIMIT er antall koblede titler før innsamlingen stopper (standard 500, så hver deploy +# ikke skanner hele Archive). ARCHIVE_LIMIT=0 gir full skanning, f.eks. ved første fylling. +ARCHIVE_LIMIT="${ARCHIVE_LIMIT:-500}" npm run db:archive || echo "Advarsel: henting fra Internet Archive feilet – prøv igjen senere med npm run db:archive." +# Importene over er upsert-trygge og kjørte mens backenden svarte, så siden var oppe hele tiden. +# Apache lastes først nå, når frontend og data er på plass. step "Publiserer frontend og konfigurerer Apache" sudo mkdir -p "$WEB_DIR" sudo rsync -a --delete frontend/dist/ "$WEB_DIR/" @@ -122,22 +152,7 @@ sudo apachectl configtest sudo systemctl reload apache2 step "Sjekker at alt svarer" -# Backenden trenger et par sekunder på å starte; prøv noen ganger før vi gir opp. -check() { - curl -fsS -m 5 -X POST "$1" -H 'content-type: application/json' \ - -d '{"query":"{ search(first: 1) { totalCount } }"}' -} -for i in 1 2 3 4 5 6 7 8 9 10; do - check http://127.0.0.1:3001/graphql >/dev/null 2>&1 && break - sleep 2 -done -# `if !` i stedet for `cmd && echo`: set -e overser feil i &&-lister, og da ville skriptet -# meldt «Ferdig!» selv om API-et ikke svarte. -if ! check http://127.0.0.1:3001/graphql; then - echo "FEIL: backenden svarer ikke på port 3001. Se: sudo journalctl -u project2-backend -n 50" >&2 - exit 1 -fi -echo +# API-et ble sjekket rett etter start; her gjenstår veien via Apache. if ! check http://localhost/project2/graphql; then echo "FEIL: backenden svarer, men ikke via Apache. Se: sudo tail -n 50 /var/log/apache2/error.log" >&2 exit 1 diff --git a/deploy/sjekk.sh b/deploy/sjekk.sh index 130a6af..e7604d5 100755 --- a/deploy/sjekk.sh +++ b/deploy/sjekk.sh @@ -16,6 +16,10 @@ curl -sS -m 10 -X POST http://127.0.0.1:3001/graphql -H 'content-type: applicati line "2. Via Apache (/project2/graphql)" curl -sS -m 10 -i -X POST http://localhost/project2/graphql -H 'content-type: application/json' -d "$Q" | head -n 20 +line "2b. Helsesjekk (backend direkte og via Apache)" +curl -sS -m 5 -w ' (HTTP %{http_code})\n' http://127.0.0.1:3001/health +curl -sS -m 5 -w ' (HTTP %{http_code})\n' http://localhost/project2/health + line "7. Samme spørringer som forsiden sender (via Apache)" UID_HDR='x-user-id: 00000000-0000-4000-8000-000000000000' curl -sS -m 20 -w '\n(HTTP %{http_code}, %{time_total}s)\n' -X POST http://localhost/project2/graphql \ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..bae3cc0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,30 @@ +# Dokumentasjon + +Indeks over alt som ligger i `docs/`. Start med [`forklaring.md`](forklaring.md) hvis du vil forstå +hvordan appen virker, eller [`oppsett.md`](oppsett.md) hvis du vil kjøre den. + +## Bruk og drift + +- [`oppsett.md`](oppsett.md): kjør prosjektet lokalt, fra rask start til ekte IMDb-data, plakater og gratisfilmer. +- [`deploy.md`](deploy.md): oppsett og oppdatering på VM-en (PostgreSQL, systemd, Apache, CSP, CI). + +## Teknisk + +- [`forklaring.md`](forklaring.md): gjennomgang av hvordan appen virker, fra tastetrykk til database, med sannsynlige sensorspørsmål. +- [`api.md`](api.md): GraphQL-kontrakten (typer, spørringer, mutations, feilkoder og grenser). +- [`beslutninger.md`](beslutninger.md): tekniske valg med begrunnelse, komponentoversikt, biblioteker og hvorfor vi ikke bruker SSR. +- [`ytelse.md`](ytelse.md): målinger av søk og indekser (`EXPLAIN ANALYZE`) og Lighthouse. +- [`readme-fakta.md`](readme-fakta.md): faktagrunnlag for README (krav mot filer, tall, hovedvalg, installasjon). README skrives av gruppa. + +## Prosess og KI + +- [`prosess/plan.md`](prosess/plan.md): milepælene (M0–M8) agentene jobbet mot. +- [`prosess/status.md`](prosess/status.md): hva som er gjort, hva som gjenstår og hva gruppa må gjøre. +- [`prosess/blokkeringer.md`](prosess/blokkeringer.md): ting agentene ikke kunne løse alene. +- [`prosess/review-guide.md`](prosess/review-guide.md): hva Sturla, Brage og Daniel skal gå gjennom av koden, med øvelser, sjekklister og sensorspørsmål. +- [`prosess/grener-og-pr.md`](prosess/grener-og-pr.md): grenmodell og tabell over issues og pull requests. +- [`prosess/gitlab.md`](prosess/gitlab.md): flytting fra GitHub til NTNU GitLab, CI, VM-en, og hvilke GitHub-issues og PR-er som hører til hvilke merge-commits. +- [`ki/ki-deklarasjon.md`](ki/ki-deklarasjon.md): hvordan KI er brukt, hva mennesker bestemte og kontrollerte, og hvilke feil KI-en gjorde. +- [`ki/ki-logg.md`](ki/ki-logg.md): én linje per oppgave utført av agentene. + +Bilder til README ligger i `img/`. diff --git a/docs/api.md b/docs/api.md index 31e09e6..7d69d3c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,7 +15,7 @@ enum TitleType { } enum SortField { - RELEVANCE # likhet med søketeksten; uten søketekst: flest stemmer først + RELEVANCE # vektet word_similarity/similarity på tittel + popularitet; 1–2 tegn og uten søketekst: popularitet RATING YEAR TITLE @@ -128,8 +128,14 @@ input AddReviewInput { text: String! } +type DeleteReviewPayload { + deletedId: ID! # id-en som ble slettet; frontend fjerner Review: fra cachen + title: Title! # tittelen med oppdaterte userRating og reviewCount +} + type Query { - # query: tom/null = alle titler. Case-insensitivt delstrengsøk i primær- og originaltittel. + # query: tom/null = alle titler. Case- og aksentuavhengig delstrengsøk («amelie» finner «Amélie») + # i primær- og originaltittel. Søk på 1–2 tegn er ordprefiks («ma» finner «The Matrix») og rangeres etter popularitet. search( query: String filters: SearchFilters @@ -148,6 +154,8 @@ type Query { type Mutation { addReview(input: AddReviewInput!): Review! toggleList(titleId: ID!): Title! # returnerer tittelen med oppdatert inMyList + # Sletter bare brukerens egne anmeldelser. Andres og ukjente id-er gir begge NOT_FOUND. + deleteReview(id: ID!): DeleteReviewPayload! } ``` @@ -156,8 +164,23 @@ type Mutation { - `first` må være 1–50, ellers `BAD_USER_INPUT`. `query` maks 200 tegn. Spørredybde maks 6. - Cursorer er ugjennomsiktige strenger (base64). Ugyldig cursor gir `BAD_USER_INPUT`. - Feil returneres som GraphQL-feil med `extensions.code`: `BAD_USER_INPUT`, `NOT_FOUND`, - `UNAUTHENTICATED` (mangler `x-user-id` på mutation), `INTERNAL_SERVER_ERROR` (uten detaljer). + `UNAUTHENTICATED` (mangler `x-user-id` på mutation), `RATE_LIMITED` (se under), + `SERVICE_UNAVAILABLE` (databasen kan ikke nås; generisk melding uten detaljer), + `INTERNAL_SERVER_ERROR` (uten detaljer). +- Maks 8 rotfelt (aliaser telles hver for seg) og 150 felt totalt per operasjon (fragmenter + ekspandert), ellers `BAD_USER_INPUT`. Dessuten en vektet kostnad maks 2 500, der feltene under `search`, `myList` og `reviews` teller `first` ganger (standard 20/20/10; variabel `first` regnes som 50). Frontendens største spørring har ca. 45 felt og kostnad ca. 1 100. +- `deleteReview(id)`: `id` må være heltallsstrengen fra `Review.id` (ellers `BAD_USER_INPUT`). + Hører anmeldelsen til en annen `x-user-id`, eller finnes den ikke, er svaret likt: `NOT_FOUND` + («Fant ikke anmeldelsen.»). Svaret har `deletedId` (fjern `Review:` fra Apollo-cachen) og + `title` med nye `userRating`/`reviewCount`, som oppdaterer `Title:` i cachen automatisk. +- Begrensning av mutations (token bucket i prosessminnet, per `x-user-id` og per IP, se + `docs/beslutninger.md`): `addReview` maks 10 per minutt per bruker, `toggleList` og `deleteReview` + maks 60 per minutt per bruker; IP-grensen er tre ganger så høy. Over grensen gir `RATE_LIMITED` + med `extensions.retryAfterSeconds` og meldingen «For mange forsøk. Vent N sekunder og prøv igjen.» - All søk, filtrering, sortering og paginering skjer i SQL. +- `GET /health` (utenfor GraphQL) gjør `SELECT 1` mot databasen: 200 `{"status":"ok"}` eller 503 + `{"status":"db-unavailable"}`, alltid med `cache-control: no-store`. Brukes av `deploy/sjekk.sh`, + Apache og drift; frontend kaller den ikke. ## Bilder (TMDB) diff --git a/docs/beslutninger.md b/docs/beslutninger.md index 5c7973d..a99b84c 100644 --- a/docs/beslutninger.md +++ b/docs/beslutninger.md @@ -6,13 +6,17 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst. lint-støtte framfor nyeste kompilator. - **ESLint 9 i stedet for 10.** `eslint-plugin-jsx-a11y` støtter ikke ESLint 10 ennå, og tilgjengelighetslinting er et krav. -- **Git-grener.** CLAUDE.md sier at arbeidet skal skje på grener fra `mvp`. Sky-sesjonen kan bare - pushe til én gren (`claude/webutvikling-projekt-2-k5lq6q`), så den grenen spiller rollen til - `mvp`. Hver oppgave lages på en lokal `feat/…`-gren og merges inn med `--no-ff`, slik at historikken - viser grenene. Ingenting pushes til `main`. -- **Ingen GitLab-issues/MR-er.** `glab` er ikke tilgjengelig i miljøet, så issue- og review-flyten - loggføres i `docs/ki-logg.md` i stedet, slik CLAUDE.md beskriver. -- **Syntetisk testdatasett.** IMDb-nedlasting er blokkert i miljøet (se `BLOCKERS.md`). Et skript +- **Git-grener.** CLAUDE.md sier at arbeidet skal skje på grener fra `mvp`. Sky-miljøet kan bare + pushe til én gren om gangen, så en integrasjonsgren spiller rollen til `mvp`. I september var + den `claude/webutvikling-projekt-2-k5lq6q`, og arbeidet gikk inn i `main` via PR-er. I oktober + er den `claude/adoring-brown-3nct5k`, som et gruppemedlem merger til `main`; `main` deployes. + Hver oppgave lages på en egen gren (`feat/…`, `fix/…`, `docs/…`) og merges inn med PR eller + `--no-ff`, slik at historikken viser grenene. Agentene pusher aldri til `main` og aldri med force. +- **Issues og PR-er på GitHub, ikke GitLab.** I september var `glab` ikke tilgjengelig, så + issue- og review-flyten ble loggført i `docs/ki/ki-logg.md`. Fra oktober ligger issues (#13–#17, #23) + og PR-er (#18–#22 og #24–#31; nummer 23 er et issue) på GitHub (`Nicolaykopaas/T17-Project2`), og hver PR har review fra en + kritiker-agent. Repoet flyttes til NTNU GitLab ved innlevering. +- **Syntetisk testdatasett.** IMDb-nedlasting er blokkert i miljøet (se `docs/prosess/blokkeringer.md`). Et skript genererer filer i nøyaktig samme TSV-format, så importkoden er den samme for ekte og syntetiske data. - **Egen migreringsrunner.** `backend/scripts/migrate.ts` (tabell `schema_migrations`, én transaksjon per @@ -23,10 +27,9 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst. enklere og raskere uten join, og sjangre til en hel resultatside følger med raden (ingen N+1). `start_decade` er en generert kolonne slik at tiårfilter er en likhet på en indeksert kolonne. Prisen er at importen skriver sjangre to steder (i samme transaksjon). -- **Søk med `lower(col) LIKE lower('%…%')` og trigram-GIN på `lower(...)`-uttrykk,** ikke `ILIKE`. - Indeksuttrykket må være identisk med spørringen for å bli brukt. Brukerens `%`, `_` og `\` escapes - slik at de er vanlige tegn. Aksenter foldes ikke (`cafe` finner ikke `Café`); `unaccent` - er ikke tatt med for å slippe en ekstra utvidelse. +- **Søk med `LIKE` mot normaliserte tittelkolonner og trigram-GIN,** ikke `ILIKE`. Kolonnene + `primary_title_norm`/`original_title_norm` er `lower(f_unaccent(tittel))` (se «Aksentuavhengig søk» + under), og indeksene ligger på dem. Brukerens `%`, `_` og `\` escapes slik at de er vanlige tegn. - **Keyset-paginering med radsammenligning.** Alle sorteringsnøkler går i samme retning (`(a, b, id) < ($1,$2,$3)`), siste nøkkel er `id` (unik), og nullable kolonner erstattes av sentinel via `COALESCE` (rating → -1, år → 0). Det gir stabil paginering uten hull/duplikater og lar @@ -35,8 +38,13 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst. Cursoren inneholder en signatur (sortering/retning/om det finnes søketekst) og avvises (`BAD_USER_INPUT`) hvis den brukes med en annen sortering. Tidsstempler i cursorer går som tekst (mikrosekund-presisjon; JS `Date` har bare millisekunder). -- **Relevans = `GREATEST(similarity(primær), similarity(original))`,** tiebreak `num_votes`, så `id`. - Uten søketekst betyr «relevans» flest stemmer først. +- **Relevans = `0,6 * word_similarity + 0,2 * similarity + 0,2 * popularitet`,** tiebreak `num_votes`, + så `id`. Begge likhetsmålene tas som `GREATEST` over primær- og originaltittel. Ren likhet rangerte + obskure titler med identisk navn («Dark Knight», 1 500 stemmer) over klassikeren brukeren mener + («The Dark Knight»). `word_similarity` gir full score når søkeordene står i tittelen, `similarity` + belønner eksakte treff, og popularitet (`log10(stemmer) / 7`, mettet ved 10 mill.) skiller resten. + Uten søketekst betyr «relevans» flest stemmer først. Formelen står i `sortKeys` i + `backend/src/search.ts`. - **`totalCount` er lazy** (beregnes bare hvis feltet etterspørres) og kjøres som egen `count(*)`. `userRating`/`reviewCount`/`inMyList` hentes med en liten per-request batch-loader (`loaders.ts`), så en side med 20 titler koster 1 spørring per felttype i stedet for 20. @@ -70,8 +78,9 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst. sortering/sidebytte ikke henter fasetter på nytt. Forrige fasetter vises mens nye lastes. - **Frontend: ny anmeldelse vises via `refetch` av tittelen** etter `addReview` (serveren er kilden til snitt og antall). «Min liste» bruker `cache-and-network` og fjerner rader lokalt i cachen ved «Fjern». -- **Frontend: ingen nye biblioteker.** Bundle ca. 166 kB gzip initielt (React + Apollo + React Router); - detalj- og listesiden er lazy (ca. 4 kB gzip til sammen). +- **Frontend: ingen nye biblioteker.** Målt 2026-10-06 (`npm run build -w frontend`): initial JS ca. 170 kB gzip + (react 68,3 + apollo 58,8 + router 31,5 + app 10,6 + 0,4 kB; vendor-chunks, se `docs/ytelse.md`) pluss 5,5 kB CSS. Detalj-, spiller-, liste- og + søkevisningen er lazy (3,9 + 4,0 + 1,3 + 2,9 + 0,3 kB, ca. 12,4 kB gzip til sammen). Tidligere tall var ca. 172 kB (2026-10-06 før Lighthouse-runden) og 166 kB (2026-09-30). - **Bruker-ID uten `crypto.randomUUID`.** VM-en serverer appen over http, som ikke er en sikker kontekst, og der finnes ikke `crypto.randomUUID`. Alle GraphQL-requests feilet derfor i produksjon, selv om alle tester (som kjører på `localhost`, som regnes som sikker) var grønne. @@ -87,7 +96,7 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst. (5 min negativ cache i minnet), og 401/403 slår av oppslag i 10 min. Ugyldig `width` (≤ 0) gir `BAD_USER_INPUT`; gyldige verdier snappes til nærmeste tillatte størrelse. Stier fra TMDB valideres mot et strengt mønster før de brukes i en URL. Testes mot en lokal falsk TMDB - (`scripts/tmdb-mock.ts`), siden internett ikke er tilgjengelig i CI. + (`backend/scripts/tmdb-mock.ts`), siden internett ikke er tilgjengelig i CI. - **Frontend: kinoaktig redesign uten nye biblioteker.** Ren CSS med custom properties i `global.css`. Mørkt er standard, lyst følger `prefers-color-scheme: light` med samme layout. Header, heltebilder og plakat-overlegg er alltid mørke (egne `--on-media`-tokens), fordi de ligger over bilder; det gjør at @@ -96,7 +105,7 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst. - **Forsiden har to moduser, styrt av URL-en.** Uten `q`, filtre og sortering (`isBrowseState`) vises «bla-modus»: hero + horisontale rader (`lib/browseRows.ts`). Ellers vises trefflisten som før, som rutenett av plakatkort. Hver rads variabler og «Se alle»-lenke utledes fra samme `SearchState`, så de - ikke kan bli ulike, og «Se alle» treffer Apollo-cachen. All sortering/filtrering er GraphQL-variabler. + ikke kan bli ulike, og «Se alle» henter første side på nytt, med antall (radenes `ROW_QUERY` har ikke `totalCount`). All sortering/filtrering er GraphQL-variabler. - **Hero og toppraden deler én request.** `Featured`-spørringen er `search` med samme cache-nøkkel som raden «Mest populære», bare med `overview` og bakgrunnsbilder på alle 20 titler (ca. 6 kB ekstra). Det er billigere enn en egen `first: 1`-request som ville kollidert med radens cache-oppføring. @@ -128,7 +137,7 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst. serveren kan byttes (mock/ekte) uten ny import. Element-id må matche `^[A-Za-z0-9._-]+$` (og ikke `..`), filnavn kan ikke ha `/`, `\`, `..` eller kontrolltegn; ellers blir `stream` `null`, og filnavnet URL-enkodes. -- **Lisensregel for importen (`isLawful` i `scripts/import-archive.ts`).** Et Archive-element er +- **Lisensregel for importen (`isLawful` i `backend/scripts/import-archive.ts`).** Et Archive-element er lovlig hvis (a) `licenseurl` har verten `creativecommons.org` (eller subdomene), eller URL-en inneholder `publicdomain` (CC0, Public Domain Mark, usa.gov-erklæringen), eller (b) elementet ligger i en kuratert public domain-samling: `feature_films`, `film_noir` eller `silent_films`. Alt annet @@ -155,7 +164,23 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst. kallstart), 20 s tidsgrense per kall, ett nytt forsøk ved 5xx/429/timeout/nettverksfeil (ikke ved 404), upsert per tittel, og `--limit`/`ARCHIVE_LIMIT` som stopper innsamlingen etter N koblede titler. Et element som feiler teller som `feilet` uten å stoppe resten; skriptet avslutter da med kode 1. -- **Falsk Archive (`scripts/archive-mock.ts`) serverer WebM.** Testvideoen er `.webm`, så de spillbare +- **Archive-importen lagrer underveis, har todelt avbrudd og et smalere søk.** Fullskanning mot ekte + Archive tok 10-30 minutter (259 000 elementer) og lagret først etter hele skanningen, så Ctrl+C ga + «0 lagret» selv med ca. 700 koblede kandidater. Nå hentes filliste og upsert per Scrape-side. + Determinismen består: en tittelkobling som allerede er lagret overskrives av et senere IMDb-treff + for samme tittel (upsert), og utfallet telles per tittel. Første Ctrl+C (`stopCollecting`) stopper + innsamlingen og lagrer siden som er under behandling; andre (`signal`) stopper også lagringen. + `CANDIDATE_QUERY` er snevret inn: lisensgrenen (`licenseurl:*creativecommons*` / `*publicdomain*`) + krever nå også `external-identifier:*imdb*`, og `classic_tv` er fjernet (lovlig bare med lisens-URL, + og dermed dekket av lisensgrenen). Kuraterte samlinger (`feature_films`, `film_noir`, + `silent_films`) er uendret. Begrunnelse: under 1 % av CC-elementene ble koblet til en IMDb-film, og + nesten alle koblede hadde IMDb-ID. Pris: CC-elementer utenfor kuraterte samlinger som bare kunne + kobles på tittel + år, uten IMDb-ID, blir ikke lenger funnet (tittel + år på hjemmevideoer ga + uansett størst fare for feilkobling). `isLawful` og koblingsreglene er uendret. `setup-vm.sh` setter + `ARCHIVE_LIMIT=500` som standard (`0` = full skanning). Det nye søket er ikke prøvd mot ekte + Archive fra utviklingsmiljøet (ingen nettilgang); `external-identifier:*imdb*` følger samme + jokertegn-syntaks som `licenseurl:*…*`, men må bekreftes på VM-en. +- **Falsk Archive (`backend/scripts/archive-mock.ts`) serverer WebM.** Testvideoen er `.webm`, så de spillbare filene i mocken heter `.webm` (ellers ville `Content-Type: video/mp4` løyet om bytene). Filvalg mellom mp4/webm/ogv dekkes av enhetstester på `chooseFiles`. @@ -169,3 +194,276 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst. - **Volum og demping huskes i `localStorage`** (`filmsok:player`); ødelagt verdi gir standard. - **Tilbake-lenken på spillersiden går alltid til `/title/:id`** (deterministisk, også ved direkte åpning). - **Mobil skjuler volumslideren** (maskinvareknapper); demp-knappen finnes. + +## Backend-herding: kostnadsgrenser, rate limit og søk (#15) + +- **Feltgrense i tillegg til dybdegrensen.** `complexityLimit` (`backend/src/complexityLimit.ts`) avviser + operasjoner med mer enn 8 rotfelt eller 150 felt totalt (aliaser og ekspanderte fragmenter telt + hver for seg). Dybdegrensen stoppet bare nøsting, så ett flatt dokument med hundrevis av aliasede + `search` (én SQL-spørring hver) eller `posterUrl` (TMDB) slapp gjennom. Frontendens største spørring + (Title-detaljer) har ca. 45 felt og ett rotfelt, så grensene har rundt tre ganger slingringsmonn; + `server.test.ts` har kopier av de to største spørringene som vaktbikkje. Fragmenttellingen er + memoisert, slik at en fragmentkjede med eksponentiell utvidelse ikke kan brukes til å låse CPU-en + under selve valideringen. +- **Begrensning av mutations: token bucket i prosessminnet** (`backend/src/rateLimit.ts`), per + `x-user-id` og i tillegg per IP (siste ledd i `X-Forwarded-For`, som Apache legger til; for + tilkoblinger som ikke kommer fra loopback brukes i stedet `remoteAddress`, og IP-grensen hoppes bare + over for loopback uten header). Én bøtte for `addReview` (10/min) og én for `toggleList` og + `deleteReview` (60/min); IP-grensen er 3 ganger brukergrensen. Per-IP trengs fordi `x-user-id` er + anonym og trivielt å rotere. Overskridelse gir `RATE_LIMITED` med `retryAfterSeconds`. + Begrensning: tilstanden er per prosess og nullstilles ved restart. Det er greit for én + systemd-tjeneste på VM-en; flere instanser ville krevd delt lager (Redis/tabell), som er overkill her. + Grensene er romslige nok for E2E-testene og vanlig bruk. Minnet er avgrenset (50 000 nøkler per bøtte). +- **`deleteReview(id)` returnerer `{ deletedId, title }`.** Eierskapet sjekkes i selve + `DELETE ... WHERE id AND user_id`, og både «andres» og «finnes ikke» gir `NOT_FOUND` med lik melding + slik at mutationen ikke kan brukes til å lete etter anmeldelser. `title` gir ferdig oppdaterte + `userRating`/`reviewCount` som Apollo skriver inn i `Title:` uten refetch; `deletedId` brukes til + å evicte `Review:`. Ugyldig id (ikke 1–18 siffer) gir `BAD_USER_INPUT`. +- **Tittel-id valideres som `^tt\d{7,10}$`** (`isPlausibleTitleId`). Fixture, testdata og + Archive-mock bruker bare 7 siffer; IMDb har 7–8 i dag, 10 gir slingringsmonn. +- **Aksentuavhengig søk («Aksentuavhengig søk»).** `unaccent` via en IMMUTABLE innpakning + `f_unaccent(text)` (migrering 004). Normalisert tittel lagres som genererte kolonner + `primary_title_norm`/`original_title_norm` (`lower(f_unaccent(tittel))`) med trigram-GIN på dem, i + stedet for å legge uttrykket i indeksen og i hver spørring. Grunnen er målt: uttrykket kjører + `unaccent` på nytt for hver rad i recheck, filter og relevans og ga opptil 2x tregere brede søk + (tall i `docs/ytelse.md`). Kolonnene koster ca. 5 MB ekstra på 120 000 titler. Innpakningen peker på + ordlisten med fullt kvalifisert navn; endres ordlisten må kolonner og indekser bygges på nytt. + `unaccent` følger med `postgresql-contrib` sammen med `pg_trgm` og er «trusted» fra PostgreSQL 13, så + `deploy/setup-vm.sh` og `docs/oppsett.md` trenger bare å nevne den ved siden av `pg_trgm`. + Konsekvens: «ø», «å» og «æ» foldes til «o», «a» og «ae». +- **Korte søk (1–2 tegn) er ordprefiks og rangeres etter popularitet.** Delstrengsøk på 1–2 tegn + har ingen trigrammer og traff opptil 70 % av tabellen, og relevansberegningen (`similarity`) på + alle treff tok ca. 0,3 s for «a». Nå brukes en generert `tsvector` (`title_words`, `'simple'`) med + GIN og `to_tsquery('simple', ':*')`; relevans er stemmer, så id. «ma» finner «The Matrix», + men «ar» finner ikke «Dark». Søketeksten strippes for tegnsetting før den settes inn i + tsquery-syntaksen, så operatortegn ikke kan gi syntaksfeil, og blir ingenting igjen gir det null + treff. Alternativene vi vurderte: (1) beholde delstreng og bare droppe likhetsberegningen (hindrer + ikke full skanning), (2) prefiks av hele tittelen med `text_pattern_ops`-btree, som vi først + implementerte og målte (like rask) men som ikke fant «The Matrix» på «ma». Den tidligere påstanden + om at ordprefiks krever regex uten indeks var feil: tsvector med GIN gir ordprefiks med indeks. + Cursoren har en egen signatur for korte søk, siden sorteringsnøklene er annerledes. +- **Søketeksten normaliseres før den escapes, begge deler i SQL.** `unaccent` mapper fullbreddetegn + (`%`, `_`, `\`) til `%`, `_` og `\`; escapet vi i JavaScript først, ble de jokertegn i LIKE og + traff hele tabellen. Mønsteret bygges derfor som `'%' || regexp_replace(lower(f_unaccent($1)), +'([\\%_])', '\\\1', 'g') || '%'`. +- **Kompleksitetsgrensen veier lister med `first`.** I tillegg til rotfelt og totalt antall felt har + `complexityLimit` en vektet kostnad: feltene under `search`, `myList` og `reviews` teller `first` + ganger (standard 20/20/10 når `first` utelates, 50 når `first` er en variabel, også når den + har en lavere standardverdi, siden klienten kan overstyre den i `variables`). 8 x `search(first: 50) { reviews(first: 50) }` er under feltgrensene, men koster + millioner og avvises. Grensen er 2 500; frontendens største spørring koster ca. 1 100. + Regelen er memoisert per fragment (også `depthLimit`), siden en kjede av fragmenter som hver + spres ti ganger i det neste ellers tok minutter CPU under validering. +- **Backend lytter på `127.0.0.1` i produksjon, og `X-Forwarded-For` stoles bare på fra loopback.** + Uten det kunne noen nå port 3001 direkte og sette en ny falsk IP per forespørsel og dermed omgå + IP-grensen. `HOST` kan overstyre. Apache og `deploy/` bruker allerede `127.0.0.1`. + +## Feiltilstand når API-et ikke nås (#13) + +- **Ingen mock-data i produksjon; forklarende banner i stedet.** Sensor og brukere skal se ekte data fra databasen eller en ærlig feil. Mock-data ville skjult at serveren er utilgjengelig (backend/DB nede, appen kjørt uten backend, eller utenfor NTNU-nettet uten VPN) og at appen ikke viser det den utgir seg for. Mock-servere finnes allerede for utvikling og E2E (`tmdb:mock`, `archive:mock`). +- **Én global tilstand, ikke én feil per rad.** Error-linken i `apollo/client.ts` setter reactive var `apiUnavailable` (og årsaken i `apiFailureKind`: `network` eller `service`) ved nettverksfeil (fetch feilet, HTTP 502/503/504, ikke-JSON-svar) og nullstiller den ved første svar fra serveren. GraphQL-feil teller ikke: da svarte serveren, og feilen er vår bug eller brukerinput. `ApiUnavailableBanner` i `Layout` kunngjør nedetiden én gang (`role="alert"`, knappen ligger utenfor alert-regionen) og gir kort hjelp: prøv igjen, start backend hvis du kjører appen selv, og VPN utenfor NTNU-nett. VPN-hintet utelates ved `SERVICE_UNAVAILABLE` (backend svarer, databasen er nede). Radene viser stille skjelett mens banneret står; andre feil viser fortsatt per-rad-feil. +- **`rxjs` er lagt til som direkte avhengighet i `frontend`.** Apollo 4 har den som obligatorisk peer-avhengighet (allerede i bundlen); vi bruker bare `tap` i linkkjeden. +- **`GET /health` gjør `SELECT 1` med 2 s tidsgrense** (200 `ok`, 503 `db-unavailable`). Yogas egen `/health` (alltid «alive») er flyttet, fordi den ikke sier noe om databasen. Systemd-enheten har `Restart=always` og `Wants=postgresql.service` (+ `After`). + +## Frontend-gjennomgang: tilgjengelighet, tema og sletting (#16) + +- **«Legg i min liste»-knappen har dynamisk etikett og ingen `aria-pressed`.** Kombinasjonen ga motstridende opplesning («Fjern fra min liste, trykket»). Et fast navn («Min liste» + `aria-pressed`) ville latt brukeren tolke en tilstand («trykket») i stedet for å få vite hva et trykk gjør. En handlingsetikett er tydeligere for både seende og skjermleser, og utfallet kunngjøres i `role="status"`. +- **Rutebytte flytter fokus til sidens første `h1`, ellers `
`.** Skjermleseren leser da opp hvor man er. Siden h1 ofte kommer først etter lazy-lasting eller datahenting, følger `Layout` med på innholdet i 3 s og flytter fokus til gjeldende h1 så lenge brukeren ikke har flyttet det selv. Skip-linken fokuserer fortsatt `
`. +- **Forsideradene bruker `ROW_QUERY` uten `totalCount`.** Antallet vises aldri der, og hver `totalCount` er en `count(*)`; forsiden sparer åtte slike. Samme `search`-felt og cache-nøkkel, men siden raden mangler `totalCount` gir «Se alle» cache-miss, og søkespørringen henter første side på nytt, nå med antallet. +- **Brukervalgt tema: bryter med fast navn «Mørkt tema» og `aria-pressed`.** Ikonet er eneste synlige innhold, så navnet («Mørkt tema») er fast og tilstanden bæres av `aria-pressed`; valget synkroniseres mellom faner via `storage`-eventet. Uten valg følger siden systemet (`prefers-color-scheme`); første klikk setter `data-theme` på `` og lagrer i `localStorage` (`filmsok:theme`, try/catch). Det er ingen «tilbake til system»-tilstand, for å holde knappen enkel. `public/theme-init.js` lastes synkront i `` (uten `defer`/`module`) og setter `data-theme` før første maling (ingen blink). De lyse fargene står to ganger i `global.css` (systemvalg og eksplisitt valg); `theme.test.ts` feiler hvis blokkene blir ulike. +- **CSP:** temaskriptet ligger i en egen fil og ingen inline-skript trengs, så `script-src 'self'` holder (ingen hash å vedlikeholde). En test sjekker at skriptet bruker samme `localStorage`-nøkkel som `THEME_KEY`. + +## Sluttgjennomgang av backend + +- **Tokengrense (1 000) og kroppsgrense (64 kB) mot CPU-DoS i validering.** graphql-js sin regel + `OverlappingFieldsCanBeMerged` er kvadratisk i antall felt, og `complexityLimit` stopper ikke de + andre reglene: `{ genres genres … }` med 8 000 felt (55 kB) blokkerte event-loopen i ca. 5 s. En + `onParse`-plugin parser derfor med `maxTokens`, så dokumentet avvises før validering. Frontendens + største operasjon har 128 tokens (GraphiQLs introspeksjonsspørring ca. 180), så grensen har åtte + ganger slingringsmonn; en test krever minst fire ganger. Yogas `maxRequestBodySize` er satt til + 64 kB (standard er 25 MB). +- **Variabel `first` regnes alltid som 50 i kostnadsgrensen.** Standardverdien (`$n: Int = 1`) kan + overstyres i `variables`, så å bruke den lot en angriper be om 50 per side og betale for 1. +- **`facets` har en fast tilleggskostnad på 500.** Hver `facets` kjører fire aggregeringer over + hele tabellen; åtte aliasede ga 32 parallelle helskanninger mot en pool på 10. Med grensen 2 500 + rommer en operasjon fire, og frontend sender én. Vi la ikke til en minnecache for fasetter uten + søk (60 s TTL): kostnadsgrensen fjerner angrepsflaten, og en cache ville gitt gammel telling rett + etter import uten at noen målt gevinst krever det. +- **Migreringer og importer kjører uten API-ets `statement_timeout`.** Poolens 15 s-grense er et + vern mot løpske spørringer fra API-et. `ALTER TABLE … ADD COLUMN … STORED` (migrering 004) tar + 10–20 s eller mer på VM-en med ca. 190 000 titler, og en avbrutt migrering ruller tilbake og kan + aldri bli ferdig. `migrate()` setter `statement_timeout = 0` og `lock_timeout = '30s'` på sin + klient og nullstiller begge etterpå (tilkoblingen går tilbake til poolen). `import-imdb` bruker en + pool med 10 minutters grense, siden en batch på 2 000 rader oppdaterer tre GIN-indekser. + `import-archive` og `db:artwork` gjør bare små upserts og ett enkelt utvalg, og beholder + standardgrensen. +- **TMDB-oppslagskøen er begrenset til 200 ventende.** Scraping av søk og detaljsider kunne fylle + den delte, FIFO-baserte køen med titusenvis av ventende oppslag og sulte vanlige brukere. + Oppslag utover grensen avvises (`busy`) uten TMDB-kall, uten lagring og uten negativ cache, så + bildet hentes som vanlig neste gang det er plass. Plakater er valgfrie, så avvisning gir `null`. +- **Cursor med umulig dato gir `BAD_USER_INPUT`.** Mønsteret slapp gjennom `2026-13-45 00:00:00`, + som Postgres avviser (22008) og dermed ga maskert 500. Komponentene valideres mot kalender i JS. + Vi bruker ikke `Date.parse` på strengen, siden Postgres' tidsstempelformat (`+00`, mikrosekunder) + ikke er ISO 8601 og ikke-ISO-parsing varierer mellom JS-motorer. +- **IMDb-importen skriver bare endrede rader.** `ON CONFLICT DO UPDATE … WHERE (kolonner) IS DISTINCT +FROM (EXCLUDED.kolonner)` hindrer at hver omkjøring lager en ny radversjon per tittel (bloat), regner + ut de genererte kolonnene på nytt og oppdaterer GIN-indeksene. `title_genres` bygges bare på nytt + for rader som ble satt inn eller endret (`RETURNING id`). **Titler som faller under `minVotes` ved en + senere import slettes ikke**: importen er bare upsert, og sletting ville også fjernet brukernes + anmeldelser og lister (fremmednøkler med `ON DELETE CASCADE`). Vil man rydde, må det gjøres bevisst. +- **Keep-alive 65 s på Node-serveren.** Node lukker ledige forbindelser etter 5 s, mens Apache + gjenbruker dem lenger, noe som ga sporadiske 502. `headersTimeout` er satt litt høyere (66 s). +- **`setup-vm.sh` starter backend rett etter migreringen.** Tidligere var siden nede under hele + importen, plakathentingen og Archive-skanningen (minutter). Importene er upsert-trygge og kan + kjøre mens backend svarer. Tjenesten kjører som `project2`, så `.env` får gruppen `project2` og + modus 640 i stedet for at hele katalogen skifter eier (importene kjører som deploy-brukeren og + skriver til `data/`). Apache lastes på nytt til slutt. + +## Hvorfor ikke SSR + +Appen er en klient-rendret React-app (Vite SPA) som Apache serverer som statiske filer. Vi vurderte +server-rendering, men valgte det bort. + +**Prisen vi betaler.** Lighthouse mobil Performance ligger på 95–97 under simulert 4G og når ikke 100. +Første maling og LCP venter på kjeden HTML, JS, GraphQL-kall og render av heltebanneret. Så lenge innholdet +bygges i nettleseren, kan ikke kjeden kortes ned til mindre enn dette. Desktop er 100. + +**Hvorfor vi ikke gjorde det.** SSR eller streaming (for eksempel React Router i framework mode eller Vite +SSR) ville krevd: + +- Node-rendering av alle ruter (forside, søk, detalj, spiller, min liste), også de som avhenger av + nettleserens anonyme bruker-ID i `localStorage`, som serveren ikke kjenner. +- Hydrering som må gi nøyaktig samme HTML som serveren, med tilhørende feilkilder (hydreringsfeil, flimmer + ved tema og URL-styrt søketilstand). +- Dobbel datahenting eller serialisering av Apollo-cachen fra server til klient, og en server som snakker + med GraphQL-laget i tillegg til nettleseren. +- Endret deploy: i dag er frontend statiske filer bak Apache. Med SSR må en Node-prosess til å kjøre i + tillegg til backend, med egen systemd-enhet, minne- og feilhåndtering på en liten VM. + +Det er mer kompleksitet og flere ting som kan gå galt enn et prosjekt av dette omfanget har nytte av, +for noen få poeng i en syntetisk måling, og tiden gjorde mer nytte på søk, tilgjengelighet og testing. + +**Hva vi gjorde i stedet.** Et statisk app-skall i `index.html` (header og plassholder for heltebanneret), +`startTransition` rundt første render og `useDeferredValue` for radene slik at React tidsdeler arbeidet, +lazy-lastet søkevisning, vendor-chunks som kan ligge i nettleserens cache, `preconnect` til TMDB, fast høyde +på heltebanneret (ingen layoutskift) og gzip av JS, CSS og GraphQL-svar. Mål og tall står i +[`ytelse.md`](ytelse.md#lighthouse). + +## Datasett, komponenter og biblioteker + +Denne delen er flyttet hit fra README.md, slik at README kan være kort. + +### Datasett + +[IMDb non-commercial datasets](https://developer.imdb.com/non-commercial-datasets/) er åpne, store +og realistiske. Vi tar med filmer og serier med minst 100 stemmer, som gir 190 607 titler på VM-en. +IMDb har ingen bilder, så plakater og handling hentes fra TMDB første gang en tittel vises, og +lagres i databasen. Uten TMDB-nøkkel vises plassholdere. + +### Egne komponenter og hooks + +Komponentene er delt etter ansvar, slik at logikk som er vanskelig å få riktig (debounce, fokus, +lazy-lasting, paginering) ligger ett sted og kan testes isolert. + +| Komponent / hook | Ansvar og hvorfor den er skilt ut | +| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `useSearchState` (+ `lib/searchState.ts`) | Leser og skriver søk, filtre og sortering i URL-en. Eneste kilde til søketilstand, så alle komponenter er enige | +| `SearchBox`, `HeaderSearch`, `useDebouncedCallback` | `SearchBox` debouncer (300 ms) og hopper over tomme og uendrede søk. `HeaderSearch` kobler den til URL-en: på forsiden endres bare `q`, fra andre sider sendes brukeren til forsiden med søket | +| `FilterPanel`, `ActiveFilters`, `SortControls` | Fasetter med antall treff, aktive filtre som chips og sortering. Endrer bare URL-en; serveren gjør resten | +| `SearchResults`, `useInfiniteScroll` | Resultatliste med uendelig scroll og «Last flere», `aria-live` for antall treff og alle tom-, feil- og lastetilstander | +| `TitleRow`, `LazyRow`, `useNearViewport` | Forsiderader som henter data først når de nærmer seg skjermen. Piltaster flytter fokus mellom kortene i raden | +| `CategoryNav` | Hopp til kategori på forsiden, med fokus på raden også når den er tom | +| `PosterCard` | Plakat med `srcset`. Tittellenken strekkes over hele kortet, så hvert kort er ett tabulatorstopp i stedet for to | +| `Layout` | Header, skip-link, fokus til sidens `h1` ved rutebytte, og plassen for API-banneret | +| `ApiUnavailableBanner` (+ `apollo/apiStatus.ts`) | Ett globalt banner når API-et ikke kan nås, styrt av en reactive var som Apollo-linken setter | +| `ReviewForm`, `ReviewList`, `DeleteReviewButton` | Anmeldelser med validering som speiler backenden (tegn telles som kodepunkter) og sletting med bekreftelse på stedet | +| `ListToggleButton` | Legg i / fjern fra Min liste, med kunngjøring av utfallet | +| `VideoPlayer` | Egen spiller på `