13 Commits
Author SHA1 Message Date
florian.wilde 824cc7764c Merge #12: Anmeldung per Passkey (WebAuthn)
CI / pruefen (push) Canceled after 0s
Closes #6
2026-09-02 06:59:27 +00:00
Florian Wilde c0579c6f61 Anmeldung per Passkey (WebAuthn)
CI / pruefen (pull_request) Canceled after 0s
Ein Passkey ist der dritte Weg in denselben Trichter: finishLogin() prueft die
Antwort und gibt die Kennung zurueck, der Endpunkt legt damit ueber
createSession() dieselbe Sitzung an wie bei Passwort und OIDC. Die
Sitzungsschicht bleibt unangetastet.

Die Trennung zwischen Pruefung und Sitzung ist Absicht: in passkeys.ts steht
nur WebAuthn, das Setzen von Cookie und Sitzungszeile gehoert zur HTTP-Schicht.

Die Challenge liegt in einem signierten, fuenf Minuten gueltigen httpOnly-
Cookie statt in einer Tabelle — das spart Schema und Aufraeumjob fuer alles,
was liegen bleibt, wenn jemand den Dialog abbricht.

Die RP ID kommt aus APP_URL, nicht aus dem Host-Header. Hinter einem Reverse
Proxy hinge sie sonst am falschen Namen, und ein Passkey ist fest an diesen
Namen gebunden. Ueber nacktes HTTP verschwindet die Verwaltung mit einem
Hinweis statt einen kryptischen Browserfehler zu zeigen.

Den letzten verbleibenden Anmeldeweg laesst sich nicht entfernen: wer kein
Passwort und keine OIDC-Verknuepfung hat, behaelt seinen letzten Passkey.

Die Bibliothek prueft den Signaturzaehler selbst und wirft dabei gewoehnliche
Error. Die werden jetzt uebersetzt, damit eine erwartete Ablehnung nicht als
unerwarteter Serverfehler im Log landet. Die eigene Zaehlerpruefung bleibt als
zusaetzliche Sicherung fuer den Fall, den die Bibliothek durchlaesst.

Geprueft mit virtuellem Authenticator gegen einen Produktionsbau: anlegen,
abmelden, Anmeldung allein per Passkey, Sitzungscookie gesetzt,
Challenge-Cookie verbraucht, Passwortweg unveraendert. Mit kuenstlich
hochgesetztem Zaehler wird die Anmeldung abgelehnt und der Zaehler bleibt
stehen. Dazu sieben Tests fuer Verwaltung, Rechte und die Aussperrsperre.

Refs #6
2026-09-02 07:04:45 +02:00
florian.wilde 58a1d177df Merge #11: Rueckmeldung beim Tippen in beiden Suchen
CI / pruefen (push) Canceled after 0s
Closes #3
2026-09-02 04:33:28 +00:00
Florian Wilde d04248bec9 Rueckmeldung beim Tippen in beiden Suchen
CI / pruefen (pull_request) Canceled after 0s
Die Bibliotheksfilterleiste erzeugte das Signal laengst und warf es weg:
`const [, startTransition] = useTransition()`. Waehrend der Suche blieben die
alten Treffer unveraendert stehen — ohne jeden Hinweis, dass gearbeitet wird.

Beide Suchen teilen sich jetzt eine schmale Klammer (SearchPending): die
Filterleiste meldet den Ladezustand, die Trefferliste daneben — eine
Server-Komponente — wird gedaempft und auf aria-busy gesetzt. Die alten
Treffer bleiben sichtbar; sie verschwinden zu lassen liesse die Liste bei
jedem Tastendruck springen. Der Hinweis „sucht …" steht jetzt auch in der
Bibliothek, nicht nur unter /suche.

Sammelverzoegerung in beiden auf 250 ms angeglichen (vorher 350 und 300).

Zwei Vermutungen aus dem Issue haben sich beim Nachmessen NICHT bestaetigt
und bleiben deshalb unangetastet:

Die Facetten kosten nichts. Gemessen an einer eigens angelegten Bibliothek
mit 2000 Eintraegen (Median aus 9 Laeufen): getLibrary 50 ms,
getLibraryFacets 17 ms, beide parallel 61 ms. Ein Wegfall spaerte rund 12 ms
— eine Zwischenspeicherung braechte dafuer das Risiko veralteter Zaehler.

Langsame Bilder blockieren die Trefferanzeige nicht. Mit kuenstlich um
1500 ms verzoegerten /api/image-Antworten stehen die Treffer nach 465 ms im
DOM, 891 ms vor den Bildern; die Rueckfallkacheln zeigen derweil die Titel.
Ein Umbau des Bild-Endpunkts waere also Aufwand ohne Wirkung.

Refs #3
2026-09-02 00:23:04 +02:00
florian.wilde 605afe556d Merge #10: Update-Skript erkennt den Container-Zustand, Merkposten ergaenzt
CI / pruefen (push) Canceled after 0s
2026-09-01 22:13:49 +00:00
Florian Wilde 39098cd201 chore: Update-Skript erkennt Container-Zustand, Merkposten ergaenzt
CI / pruefen (pull_request) Canceled after 0s
Das Update-Skript prüfte allein http://127.0.0.1:APP_PORT/api/health. Hinter
einem Reverse Proxy ist der App-Port oft gar nicht auf dem Host
veröffentlicht — dann meldete das Skript nach zwei Minuten einen Fehlschlag,
obwohl das Update sauber durchgelaufen war.

Schlägt die Abfrage vom Host fehl, zählt jetzt der HEALTHCHECK des Containers.
Den bringt das Image schon mit; er fragt /api/health von innen ab und ist
damit unabhängig von der Portveröffentlichung.

Im Fehlerfall nennt das Skript zusätzlich den Container-Zustand und ordnet ihn
ein: kein Container (Start fehlgeschlagen), startet noch (Migrationen dauern),
läuft aber ungesund (Fehler in der Anwendung), oder ein älteres Image ohne
Healthcheck. Ohne diese Auskunft sah ein nicht veröffentlichter Port genauso
aus wie eine abgestürzte Anwendung.

container_zustand() gibt bewusst immer 0 zurück — unter set -e dürfte die
Diagnose nicht selbst abbrechen, bevor der Weg zurück ausgegeben ist.

Dazu drei Merkposten in CLAUDE.md, die diese Sitzung Zeit gekostet haben:
Klickwege müssen gegen einen Produktionsbau geprüft werden (im
Entwicklungsmodus hydratisiert unter headless Chromium nichts, weil der
HMR-Socket scheitert — ohne jede Fehlermeldung), vor npm start den Port
prüfen, und dass scripts/shot.mjs sich nicht anmeldet.
2026-09-02 00:11:22 +02:00
florian.wilde fd1ab2918a Merge #9: Filmsichtung laesst sich wieder zuruecknehmen
CI / pruefen (push) Canceled after 0s
Closes #5
2026-09-01 22:06:54 +00:00
Florian Wilde 40fb49fb21 Filmsichtung laesst sich wieder zuruecknehmen
CI / pruefen (pull_request) Canceled after 0s
War ein Film einmal als gesehen markiert, gab es keinen Weg zurueck: im
Zustand "gesehen" stand statt eines Knopfes ein <p> — ein Etikett ohne
Klickziel. Der Statuswechsel konnte das nicht heilen, weil "gesehen" aus den
Sichtungen abgeleitet wird und setStatus() die absichtlich nicht anfasst.
Und eine Gegenfunktion fuer Filme gab es nicht: unmarkEpisodes() und
unmarkSeason() waren da, ein unmarkMovie() nie geschrieben.

unmarkMovie() ist auf run: entry.currentRun und episodeId: null eingegrenzt.
Wer einen Film dreimal gesehen hat, hat drei Sichtungen — ohne die
Eingrenzung loeschte das Zuruecknehmen alle drei und verfaelschte
rueckwirkend die Statistik.

Die gruene Plakette bleibt stehen, ist jetzt aber klickbar, so wie sich
Episodenhaekchen auch abwaehlen lassen. Ueber die Schnittstelle geht es mit
DELETE {"action":"movie"} als Gegenstueck zum gleichnamigen POST, ohne dass
man die watchIds kennen muss.

Drei Tests: Zuruecknehmen samt erneutem Abhaken, die Eingrenzung auf den
laufenden Durchlauf, und dass Serien abgewiesen werden. Klickweg und
Endpunkt gegen einen Produktionsbau geprueft.

Der im Issue vermutete Nebenpunkt trifft nicht zu: report() blendet das
Zuruecknehmen nur bei ids.length > 0 ein, ein wirkungsloses Angebot gab es
also nie.

Refs #5
2026-09-02 00:04:27 +02:00
florian.wilde 5673055216 Merge #8: Standardreihenfolge je Universum festlegen
CI / pruefen (push) Canceled after 0s
Closes #4
2026-09-01 21:53:34 +00:00
Florian Wilde 853d56f764 Standardreihenfolge je Universum festlegen
CI / pruefen (pull_request) Canceled after 0s
Ein Universum kann mehrere Reihenfolgen haben, aber welche beim Oeffnen
erschien, liess sich nicht bestimmen — es war immer die
Erscheinungsreihenfolge. Das Feld isDefault gab es schon und wurde beim
Oeffnen auch ausgewertet; es fehlte nur der Weg, es zu setzen.

Der Standard gehoert dem Universum, nicht der einzelnen Person: wer
Schreibrecht hat, legt ihn fuer alle fest — so wie die Reihenfolgen selbst
auch geteilt sind. Die Rangfolge bleibt dadurch die schlichte: die Adresse
schlaegt den Standard schlaegt die erste.

setDefaultOrder() setzt zurueck und neu in einer Transaktion, damit nie zwei
Standards nebeneinander stehen. deleteOrder() faengt jetzt den Fall ab, dass
die Standardreihenfolge geloescht wird — dann uebernimmt die
Erscheinungsreihenfolge, statt dass die Auswahl auf "die erste" faellt.

Ueber die Schnittstelle geht es per PATCH /api/v1/universen/{id}. Der
Endpunkt prueft, dass die Reihenfolge zu genau diesem Universum gehoert:
ohne das setzte ein PATCH auf A den Standard in B, weil die Domaenenschicht
das Universum aus der Reihenfolge ableitet.

Vier Tests dazu: Umstellen samt Eindeutigkeit, Vorrang der Adresse,
Rueckfall beim Loeschen, Rechtepruefung. Klickweg und Endpunkt gegen einen
Produktionsbau geprueft.

Refs #4
2026-09-01 23:49:52 +02:00
florian.wilde 9d4e419cd1 Merge #7: Titelraster auf eine feste Zeilenhoehe bringen
CI / pruefen (push) Canceled after 0s
Closes #2
2026-09-01 21:31:43 +00:00
Florian Wilde a853b066b5 Titelraster auf eine feste Zeilenhoehe bringen
CI / pruefen (pull_request) Canceled after 0s
Der Titelblock hatte mit line-clamp-2 zwar eine Ober-, aber keine
Untergrenze. Einzeilige Titel belegten dadurch 19 statt 39 Pixel, und alles
darunter rutschte um eine Zeilenhoehe nach oben — neben Karten mit
zweizeiligem Titel wirkte das Raster ausgefranst.

Der Titelblock bekommt jetzt mit min-h-[2lh] immer zwei Zeilen. Die Einheit
lh haengt an der tatsaechlichen Zeilenhoehe, damit der Wert nicht von einer
Aenderung an leading-snug abgehaengt wird.

In der Bibliothekskarte kam hinzu, dass der Fortschrittsbalken nur bei
Serien erscheint. Sein Platz wird jetzt auch dann freigehalten, wenn er
leer bleibt — als unsichtbarer Nachbau statt einer festen Pixelhoehe, damit
er sich von selbst nachzieht.

Nachgemessen bei 1400 px: Metazeilen einer Reihe liegen jetzt alle auf
derselben Oberkante (Suche 711 px, Bibliothek 715/1117/1520 px), und alle
Karten sind gleich hoch (353 bzw. 387 px). Vorher: 691 gegen 711 px und
Kartenhoehen von 333 bis 367 px. Bei Handybreite ebenfalls sauber.

Refs #2
2026-09-01 23:29:12 +02:00
Florian Wilde 4ad5b244bc fix: eigener Projektname fuer die Entwicklungsdatenbank
CI / pruefen (push) Canceled after 0s
Beide Compose-Dateien leiteten den Projektnamen aus dem Verzeichnis ab. Ein
'docker compose down -v' auf dem Produktivstack nahm dadurch das Volume der
Entwicklungsdatenbank mit — genau das ist beim Testen passiert.
2026-09-01 22:57:22 +02:00
38 changed files with 2027 additions and 104 deletions
+7
View File
@@ -71,6 +71,13 @@ SMTP_PASSWORD=
SMTP_SECURE=false SMTP_SECURE=false
SMTP_FROM=Flimmer <flimmer@example.com> SMTP_FROM=Flimmer <flimmer@example.com>
# --- Passkeys (WebAuthn) -----------------------------------------------------
# Anmeldung per Fingerabdruck, Gesicht oder Sicherheitsschlüssel. Standardmäßig an.
# Passkeys brauchen HTTPS — Ausnahme ist localhost. Sie sind fest an den
# Hostnamen aus APP_URL gebunden: nach einem Domainwechsel müssen alle
# Passkeys neu angelegt werden.
ENABLE_PASSKEYS=true
# --- OIDC / SSO (optional) --------------------------------------------------- # --- OIDC / SSO (optional) ---------------------------------------------------
# Anbindung an Authelia, Keycloak, Authentik … Leer lassen = deaktiviert. # Anbindung an Authelia, Keycloak, Authentik … Leer lassen = deaktiviert.
# Redirect-URI beim Anbieter eintragen: <APP_URL>/api/auth/oidc/callback # Redirect-URI beim Anbieter eintragen: <APP_URL>/api/auth/oidc/callback
+15
View File
@@ -96,6 +96,9 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
- **Dev-Umgebung**: Postgres auf `localhost:5439` (`docker-compose.dev.yml`), - **Dev-Umgebung**: Postgres auf `localhost:5439` (`docker-compose.dev.yml`),
Dev-Server auf **Port 3011** (3000 und 3010 sind von anderen Projekten belegt). Dev-Server auf **Port 3011** (3000 und 3010 sind von anderen Projekten belegt).
- **Screenshots**: `node scripts/shot.mjs <url> <ziel.png> [w] [h] [theme]`. - **Screenshots**: `node scripts/shot.mjs <url> <ziel.png> [w] [h] [theme]`.
Achtung: das Skript meldet sich **nicht** an — bei geschützten Seiten entsteht
kommentarlos ein Bild der Anmeldemaske. Für angemeldete Ansichten den
Login-Weg aus `scripts/screenshots.mjs` nachbauen.
- Next hängt selbst einen Block an diese Datei an — der bleibt drin. - Next hängt selbst einen Block an diese Datei an — der bleibt drin.
## Fortschritt ## Fortschritt
@@ -119,6 +122,18 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
das ASCII-Schlusszeichen beendet den String. In JSX-Text und Kommentaren ok. das ASCII-Schlusszeichen beendet den String. In JSX-Text und Kommentaren ok.
- Heredocs im Bash-Tool sind bei langen TSX-Dateien unzuverlässig — dann das - Heredocs im Bash-Tool sind bei langen TSX-Dateien unzuverlässig — dann das
Write-Werkzeug benutzen. Write-Werkzeug benutzen.
- **Klickwege niemals gegen `npm run dev` prüfen.** Unter headless Chromium
scheitert der HMR-Socket (`ERR_INVALID_HTTP_RESPONSE`), dadurch hydratisiert
**keine** Seite und kein Knopf reagiert — ohne Seitenfehler, ohne
fehlgeschlagene Anfrage, es passiert einfach nichts. Sieht aus wie ein Fehler
im eigenen Code, ist aber die Umgebung. Stattdessen
`npm run build` und `PORT=3012 npm start`, dann dagegen klicken.
Formulare täuschen zusätzlich: die Anmeldung funktioniert auch ohne
JavaScript, wirkt also intakt.
- Vor `npm start` prüfen, ob der Port noch von einem früheren Lauf belegt ist —
sonst startet der neue Server gar nicht und man testet den alten Bau.
- Testdaten nach Klickproben wieder herstellen: die Proben schreiben in die
geseedete Entwicklungsdatenbank.
<!-- BEGIN:nextjs-agent-rules --> <!-- BEGIN:nextjs-agent-rules -->
+25
View File
@@ -178,6 +178,31 @@ mit Erklärungen steht in [`.env.example`](.env.example). Die wichtigsten:
| `ENABLE_SCHEDULER` | Eingebauter Hintergrundlauf für Sync und Benachrichtigungen | | `ENABLE_SCHEDULER` | Eingebauter Hintergrundlauf für Sync und Benachrichtigungen |
| `SMTP_*` | E-Mail-Versand | | `SMTP_*` | E-Mail-Versand |
| `OIDC_*` | Optionale SSO-Anbindung (Authelia, Keycloak, Authentik …) | | `OIDC_*` | Optionale SSO-Anbindung (Authelia, Keycloak, Authentik …) |
| `ENABLE_PASSKEYS` | Anmeldung per Passkey (Standard `true`) |
### Passkeys
Statt Passwort geht auch ein Passkey — Windows Hello, Face ID, Touch ID oder ein
Sicherheitsschlüssel. Anlegen unter **Einstellungen → Passkeys**, anmelden über
„Mit Passkey anmelden" ohne vorherige E-Mail-Eingabe. Das Passwort bleibt
daneben bestehen; den letzten verbleibenden Anmeldeweg lässt Flimmer nicht
entfernen.
Drei Dinge, die beim Selbsthosten zählen:
- **HTTPS ist Pflicht.** Der Browser gibt Passkeys nur über eine verschlüsselte
Verbindung frei; einzige Ausnahme ist `localhost`. Läuft deine Instanz über
einfaches HTTP, verschwindet die Passkey-Verwaltung mit einem Hinweis, statt
einen kryptischen Browserfehler zu zeigen.
- **Der Hostname kommt aus `APP_URL`**, nicht aus dem `Host`-Header. Hinter
einem Reverse Proxy muss `APP_URL` also stimmen, sonst hängen die Passkeys am
falschen Namen.
- **Ein Domainwechsel entwertet alle Passkeys.** Wer von `flimmer.fritz.box` auf
`flimmer.example.com` umzieht, muss sie neu anlegen — sie sind fest an den
Hostnamen gebunden. Halte vorher einen zweiten Anmeldeweg bereit.
Ein Passkey, der nur auf einem Gerät liegt (nicht synchronisiert), ist mit dem
Gerät verloren. Die Übersicht in den Einstellungen sagt dazu, was Sache ist.
## Entwicklung ## Entwicklung
+6
View File
@@ -1,5 +1,11 @@
# Nur die Datenbank — die App läuft bei der Entwicklung direkt über `npm run dev`. # Nur die Datenbank — die App läuft bei der Entwicklung direkt über `npm run dev`.
# docker compose -f docker-compose.dev.yml up -d # docker compose -f docker-compose.dev.yml up -d
#
# Eigener Projektname: sonst teilt sich dieser Stack den Namen mit der
# Produktiv-Compose (beide leiten ihn sonst aus dem Verzeichnis ab), und ein
# `docker compose down -v` dort nimmt die Entwicklungsdatenbank mit.
name: flimmer-dev
services: services:
db: db:
image: postgres:17-alpine image: postgres:17-alpine
+31 -5
View File
@@ -116,6 +116,19 @@ curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/jso
Alternativ nimmt `DELETE` auch `{"episodeIds":["…"]}` — dann verschwindet die Alternativ nimmt `DELETE` auch `{"episodeIds":["…"]}` — dann verschwindet die
Markierung dieser Episoden im aktuellen Durchlauf. Markierung dieser Episoden im aktuellen Durchlauf.
Für Filme gibt es `{"action":"movie"}` als Gegenstück zum gleichnamigen POST;
dafür braucht man die `watchIds` nicht zu kennen:
```bash
curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"action":"movie"}' \
https://flimmer.example.com/api/v1/bibliothek/$ENTRY/sichtungen
```
Zurückgenommen wird dabei **nur der laufende Durchlauf**. Wer einen Film dreimal
gesehen hat, verliert also nicht die Sichtungen der früheren Durchläufe — die
zählen weiter in die Statistik.
### Suche ### Suche
```http ```http
@@ -145,15 +158,28 @@ Liefert `totals`, `monthly`, `providers`, `genres` und `yearReview` auf einmal.
### Universen ### Universen
```http ```http
GET /api/v1/universen GET /api/v1/universen
POST /api/v1/universen POST /api/v1/universen
GET /api/v1/universen/{id}?reihenfolge={orderId} GET /api/v1/universen/{id}?reihenfolge={orderId}
POST /api/v1/universen/{id} POST /api/v1/universen/{id}
PATCH /api/v1/universen/{id}
``` ```
`GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge `GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge
zurück, dazu `progress` und `nextUp`. Ohne `reihenfolge` gilt die zurück, dazu `progress` und `nextUp`. Ohne `reihenfolge` gilt die
Erscheinungsreihenfolge. Standardreihenfolge des Universums — in `orders` trägt sie `isDefault`.
`PATCH` legt fest, welche das ist. Die Reihenfolge muss zu diesem Universum
gehören, und es braucht Schreibrecht:
```bash
curl -X PATCH -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"defaultOrderId": "…"}' \
https://flimmer.example.com/api/v1/universen/$ID
```
Je Universum trägt immer genau eine Reihenfolge den Standard. Wird die
Standardreihenfolge gelöscht, übernimmt die Erscheinungsreihenfolge.
### Export ### Export
+298 -2
View File
@@ -1,16 +1,18 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.6.1", "version": "0.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "flimmer", "name": "flimmer",
"version": "0.6.1", "version": "0.7.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.3",
"bcryptjs": "3.0.3", "bcryptjs": "3.0.3",
"clsx": "2.1.1", "clsx": "2.1.1",
"date-fns": "4.4.0", "date-fns": "4.4.0",
@@ -536,6 +538,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@img/colour": { "node_modules/@img/colour": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -1135,6 +1143,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.3.4", "version": "16.3.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz",
@@ -1281,6 +1295,207 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@peculiar/asn1-android": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz",
"integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz",
"integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"@peculiar/asn1-x509-attr": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz",
"integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz",
"integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz",
"integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.9.4",
"@peculiar/asn1-pkcs8": "^2.9.4",
"@peculiar/asn1-rsa": "^2.9.4",
"@peculiar/asn1-schema": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz",
"integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz",
"integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.9.4",
"@peculiar/asn1-pfx": "^2.9.4",
"@peculiar/asn1-pkcs8": "^2.9.4",
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"@peculiar/asn1-x509-attr": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz",
"integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz",
"integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz",
"integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz",
"integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@prisma/adapter-pg": { "node_modules/@prisma/adapter-pg": {
"version": "7.10.0", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz",
@@ -1645,6 +1860,31 @@
} }
} }
}, },
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT"
},
"node_modules/@simplewebauthn/server": {
"version": "13.3.3",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.3.tgz",
"integrity": "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.1",
"@peculiar/asn1-rsa": "^2.6.1",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"@peculiar/x509": "^1.14.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2585,6 +2825,20 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/aws-ssl-profiles": { "node_modules/aws-ssl-profiles": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@@ -4070,6 +4324,24 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
"integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/rc9": { "node_modules/rc9": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
@@ -4116,6 +4388,12 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/remeda": { "node_modules/remeda": {
"version": "2.33.4", "version": "2.33.4",
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
@@ -4429,6 +4707,24 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+2
View File
@@ -22,6 +22,8 @@
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.3",
"bcryptjs": "3.0.3", "bcryptjs": "3.0.3",
"clsx": "2.1.1", "clsx": "2.1.1",
"date-fns": "4.4.0", "date-fns": "4.4.0",
@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "Passkey" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"credentialId" TEXT NOT NULL,
"publicKey" BYTEA NOT NULL,
"counter" BIGINT NOT NULL DEFAULT 0,
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
"deviceType" TEXT NOT NULL DEFAULT 'singleDevice',
"backedUp" BOOLEAN NOT NULL DEFAULT false,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUsedAt" TIMESTAMP(3),
CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Passkey_credentialId_key" ON "Passkey"("credentialId");
-- CreateIndex
CREATE INDEX "Passkey_userId_idx" ON "Passkey"("userId");
-- AddForeignKey
ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+32
View File
@@ -53,6 +53,7 @@ model User {
sessions Session[] sessions Session[]
apiKeys ApiKey[] apiKeys ApiKey[]
oidcAccounts OidcAccount[] oidcAccounts OidcAccount[]
passkeys Passkey[]
entries LibraryEntry[] entries LibraryEntry[]
watches Watch[] watches Watch[]
tags Tag[] tags Tag[]
@@ -109,6 +110,37 @@ model OidcAccount {
@@index([userId]) @@index([userId])
} }
/// Ein Passkey (WebAuthn) als dritter Anmeldeweg neben Passwort und OIDC.
model Passkey {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Kennung des Authenticators, base64url — die Gegenstelle nennt sie beim Anmelden.
credentialId String @unique
/// Öffentlicher Schlüssel im COSE-Format.
publicKey Bytes
/// Signaturzähler. Zählt er nicht hoch, wurde die Antwort wiedereingespielt.
counter BigInt @default(0)
/// usb, nfc, ble, internal, hybrid — hilft dem Browser bei der Auswahl.
transports String[] @default([])
/// singleDevice oder multiDevice. Ein nicht gesicherter Passkey ist mit dem
/// Gerät verloren — davor warnt die Oberfläche.
deviceType String @default("singleDevice")
backedUp Boolean @default(false)
/// Vom Nutzer vergeben: „iPhone", „Yubikey am Schlüsselbund".
name String
createdAt DateTime @default(now())
lastUsedAt DateTime?
@@index([userId])
}
/// Instanzweite Einstellungen (Registrierung offen/zu, Instanzname …) /// Instanzweite Einstellungen (Registrierung offen/zu, Instanzname …)
model AppSetting { model AppSetting {
key String @id key String @id
+11 -4
View File
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/lib/auth";
import { getSettings, isRegistrationOpen } from "@/lib/settings"; import { getSettings, isRegistrationOpen } from "@/lib/settings";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { AuthSwitch, LoginForm } from "@/components/AuthForm"; import { AuthSwitch, LoginForm } from "@/components/AuthForm";
import { PasskeyLoginButton } from "@/components/settings/Passkeys";
import { loginAction } from "../actions"; import { loginAction } from "../actions";
export const metadata: Metadata = { title: "Anmelden" }; export const metadata: Metadata = { title: "Anmelden" };
@@ -36,16 +37,22 @@ export default async function LoginPage({
<LoginForm action={loginAction} weiter={params.weiter} /> <LoginForm action={loginAction} weiter={params.weiter} />
{env.oidc.isConfigured && ( {(env.passkeys.isAvailable || env.oidc.isConfigured) && (
<> <>
<div className="my-5 flex items-center gap-3 text-xs text-[var(--fl-faint)]"> <div className="my-5 flex items-center gap-3 text-xs text-[var(--fl-faint)]">
<span className="h-px flex-1 bg-[var(--fl-border)]" /> <span className="h-px flex-1 bg-[var(--fl-border)]" />
oder oder
<span className="h-px flex-1 bg-[var(--fl-border)]" /> <span className="h-px flex-1 bg-[var(--fl-border)]" />
</div> </div>
<a href="/api/auth/oidc/start" className="fl-btn fl-btn-ghost w-full">
{env.oidc.buttonLabel} <div className="space-y-2">
</a> {env.passkeys.isAvailable && <PasskeyLoginButton label="Mit Passkey anmelden" />}
{env.oidc.isConfigured && (
<a href="/api/auth/oidc/start" className="fl-btn fl-btn-ghost w-full justify-center">
{env.oidc.buttonLabel}
</a>
)}
</div>
</> </>
)} )}
+8
View File
@@ -17,6 +17,7 @@ import {
startRewatch, startRewatch,
undoWatches, undoWatches,
unmarkEpisodes, unmarkEpisodes,
unmarkMovie,
unmarkSeason, unmarkSeason,
updateEntryDetails, updateEntryDetails,
watchNextEpisode, watchNextEpisode,
@@ -175,6 +176,13 @@ export async function markMovieAction(entryId: string) {
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS); return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS);
} }
export async function unmarkMovieAction(entryId: string) {
return run(async (userId) => {
await unmarkMovie(userId, cuid.parse(entryId));
return { message: "Markierung entfernt" };
}, PATHS);
}
export async function undoAction(entryId: string, watchIds: string[]) { export async function undoAction(entryId: string, watchIds: string[]) {
return run(async (userId) => { return run(async (userId) => {
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds)); await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
+8
View File
@@ -12,6 +12,7 @@ import {
removeTitle, removeTitle,
renameOrder, renameOrder,
renameUniverse, renameUniverse,
setDefaultOrder,
setOrderPositions, setOrderPositions,
shareUniverse, shareUniverse,
trashUniverse, trashUniverse,
@@ -124,6 +125,13 @@ export async function deleteOrderAction(orderId: string) {
}); });
} }
export async function setDefaultOrderAction(orderId: string) {
return run(async (userId) => {
await setDefaultOrder(userId, id.parse(orderId));
return { message: "Standardreihenfolge gesetzt" };
});
}
export async function reorderAction(orderId: string, itemIds: string[]) { export async function reorderAction(orderId: string, itemIds: string[]) {
return run(async (userId) => { return run(async (userId) => {
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds)); await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createSession } from "@/lib/auth";
import { finishLogin, PasskeyError } from "@/lib/passkeys";
export const dynamic = "force-dynamic";
const schema = z.looseObject({
id: z.string().min(1),
rawId: z.string().min(1),
type: z.literal("public-key"),
clientExtensionResults: z.looseObject({}),
response: z.looseObject({}),
});
/** Prüft die Antwort und legt bei Erfolg die Sitzung an. */
export async function POST(request: Request) {
const body = schema.safeParse(await request.json().catch(() => null));
if (!body.success) {
return NextResponse.json({ error: "Die Anfrage war unvollständig." }, { status: 422 });
}
try {
// Die äußere Form ist geprüft; alles Weitere prüft die Bibliothek.
const { userId } = await finishLogin(body.data as unknown as Parameters<typeof finishLogin>[0]);
// Dieselbe Sitzung wie nach einer Passwortanmeldung — gleiche Tabelle,
// gleiches Cookie.
await createSession(userId);
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof PasskeyError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("Passkey-Anmeldung fehlgeschlagen:", error);
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
}
}
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { PasskeyError, startLogin } from "@/lib/passkeys";
export const dynamic = "force-dynamic";
/**
* Optionen für die Anmeldung — bewusst ohne Anmeldung erreichbar und ohne
* Angabe, welche Passkeys es gibt. Der Browser sucht selbst einen passenden.
*/
export async function POST() {
try {
return NextResponse.json(await startLogin());
} catch (error) {
if (error instanceof PasskeyError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("Passkey-Anmeldung konnte nicht beginnen:", error);
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
}
}
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/auth";
import { finishRegistration, PasskeyError } from "@/lib/passkeys";
export const dynamic = "force-dynamic";
const antwortSchema = z.looseObject({
id: z.string().min(1),
rawId: z.string().min(1),
type: z.literal("public-key"),
clientExtensionResults: z.looseObject({}),
response: z.looseObject({}),
});
const schema = z.object({
name: z.string().trim().min(1).max(60),
response: antwortSchema,
});
/** Prüft die Antwort des Authenticators und legt den Passkey an. */
export async function POST(request: Request) {
const user = await getSessionUser();
if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
const body = schema.safeParse(await request.json().catch(() => null));
if (!body.success) {
return NextResponse.json({ error: "Die Anfrage war unvollständig." }, { status: 422 });
}
try {
// Die äußere Form ist geprüft; die Feinheiten der eingebetteten
// Base64-Felder prüft die Bibliothek beim Verifizieren.
const passkey = await finishRegistration(
user.id,
body.data.response as unknown as Parameters<typeof finishRegistration>[1],
body.data.name,
);
return NextResponse.json({ ok: true, passkey });
} catch (error) {
if (error instanceof PasskeyError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("Passkey konnte nicht angelegt werden:", error);
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
}
}
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/auth";
import { PasskeyError, startRegistration } from "@/lib/passkeys";
export const dynamic = "force-dynamic";
/** Optionen zum Anlegen eines Passkeys — nur für angemeldete Nutzer. */
export async function POST() {
const user = await getSessionUser();
if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
try {
return NextResponse.json(await startRegistration(user.id));
} catch (error) {
if (error instanceof PasskeyError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("Passkey-Registrierung konnte nicht beginnen:", error);
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
}
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/auth";
import { deletePasskey, PasskeyError, renamePasskey } from "@/lib/passkeys";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
async function withUser(context: Context, fn: (userId: string, id: string) => Promise<unknown>) {
const user = await getSessionUser();
if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
const { id } = await context.params;
if (!id) return NextResponse.json({ error: "Es fehlt die Kennung." }, { status: 400 });
try {
await fn(user.id, id);
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof PasskeyError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("Passkey-Verwaltung fehlgeschlagen:", error);
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
}
}
/** Entfernt einen Passkey — nie den letzten Anmeldeweg. */
export async function DELETE(_request: Request, context: Context) {
return withUser(context, (userId, id) => deletePasskey(userId, id));
}
const umbenennen = z.object({ name: z.string().trim().min(1).max(60) });
/** Benennt einen Passkey um. */
export async function PATCH(request: Request, context: Context) {
const body = umbenennen.safeParse(await request.json().catch(() => null));
if (!body.success) {
return NextResponse.json({ error: "Es fehlt ein Name." }, { status: 422 });
}
return withUser(context, (userId, id) => renamePasskey(userId, id, body.data.name));
}
@@ -8,6 +8,7 @@ import {
markUpTo, markUpTo,
undoWatches, undoWatches,
unmarkEpisodes, unmarkEpisodes,
unmarkMovie,
watchNextEpisode, watchNextEpisode,
} from "@/lib/library"; } from "@/lib/library";
@@ -75,9 +76,17 @@ export const POST = withApi(async (request, user, context) => {
const deleteSchema = z.union([ const deleteSchema = z.union([
z.object({ watchIds: z.array(z.string().min(1)).min(1).max(500) }), z.object({ watchIds: z.array(z.string().min(1)).min(1).max(500) }),
z.object({ episodeIds: z.array(z.string().min(1)).min(1).max(500) }), z.object({ episodeIds: z.array(z.string().min(1)).min(1).max(500) }),
z.object({ action: z.literal("movie") }),
]); ]);
/** DELETE — Sichtungen zurücknehmen, über watchIds oder episodeIds. */ /**
* DELETE — Sichtungen zurücknehmen.
*
* Drei Wege: gezielt über `watchIds` (die Antwort eines POST), über
* `episodeIds`, oder mit `{"action":"movie"}` als Gegenstück zum
* gleichnamigen POST — dafür braucht man die `watchIds` nicht zu kennen.
* Zurückgenommen wird immer nur der laufende Durchlauf.
*/
export const DELETE = withApi(async (request, user, context) => { export const DELETE = withApi(async (request, user, context) => {
const { entryId } = await context.params; const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400); if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
@@ -86,8 +95,10 @@ export const DELETE = withApi(async (request, user, context) => {
if ("watchIds" in body) { if ("watchIds" in body) {
await undoWatches(user.id, entryId, body.watchIds); await undoWatches(user.id, entryId, body.watchIds);
} else { } else if ("episodeIds" in body) {
await unmarkEpisodes(user.id, entryId, body.episodeIds); await unmarkEpisodes(user.id, entryId, body.episodeIds);
} else {
await unmarkMovie(user.id, entryId);
} }
return { ok: true }; return { ok: true };
}); });
+40
View File
@@ -210,6 +210,11 @@ export function GET() {
}, },
delete: { delete: {
summary: "Sichtungen zurücknehmen", summary: "Sichtungen zurücknehmen",
description:
"Gezielt über watchIds, über episodeIds, oder mit action=movie als Gegenstück " +
"zum gleichnamigen POST — dafür braucht man die watchIds nicht zu kennen. " +
"Zurückgenommen wird immer nur der laufende Durchlauf; frühere Durchläufe " +
"bleiben für die Statistik erhalten.",
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
@@ -226,6 +231,11 @@ export function GET() {
properties: { episodeIds: { type: "array", items: { type: "string" } } }, properties: { episodeIds: { type: "array", items: { type: "string" } } },
required: ["episodeIds"], required: ["episodeIds"],
}, },
{
type: "object",
properties: { action: { const: "movie" } },
required: ["action"],
},
], ],
}, },
}, },
@@ -287,6 +297,9 @@ export function GET() {
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
get: { get: {
summary: "Universum mit Reihenfolge und Fortschritt", summary: "Universum mit Reihenfolge und Fortschritt",
description:
"Ohne 'reihenfolge' gilt die Standardreihenfolge des Universums — sie trägt in " +
"'orders' das Kennzeichen 'isDefault'.",
parameters: [{ name: "reihenfolge", in: "query", schema: { type: "string" } }], parameters: [{ name: "reihenfolge", in: "query", schema: { type: "string" } }],
responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } }, responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } },
}, },
@@ -306,6 +319,33 @@ export function GET() {
}, },
responses: { "200": { description: "Aufgenommen" } }, responses: { "200": { description: "Aufgenommen" } },
}, },
patch: {
summary: "Standardreihenfolge festlegen",
description:
"Bestimmt, welche Reihenfolge beim Öffnen erscheint. Braucht Schreibrecht; je " +
"Universum trägt immer genau eine Reihenfolge den Standard.",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
defaultOrderId: {
type: "string",
description: "Muss eine Reihenfolge dieses Universums sein.",
},
},
required: ["defaultOrderId"],
},
},
},
},
responses: {
"200": { description: "Gesetzt" },
"404": { description: "Die Reihenfolge gehört nicht zu diesem Universum" },
},
},
}, },
"/export": { "/export": {
get: { get: {
+24 -1
View File
@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { apiError, readJson, withApi } from "@/lib/api"; import { apiError, readJson, withApi } from "@/lib/api";
import { addTitle, getUniverse } from "@/lib/universes"; import { prisma } from "@/lib/db";
import { addTitle, getUniverse, setDefaultOrder } from "@/lib/universes";
/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */ /** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */
export const GET = withApi(async (request, user, context) => { export const GET = withApi(async (request, user, context) => {
@@ -25,3 +26,25 @@ export const POST = withApi(async (request, user, context) => {
await addTitle(user.id, id, body.titleId); await addTitle(user.id, id, body.titleId);
return { ok: true }; return { ok: true };
}); });
const patchSchema = z.object({ defaultOrderId: z.string().min(1) });
/** PATCH /api/v1/universen/{id} — festlegen, welche Reihenfolge beim Öffnen erscheint. */
export const PATCH = withApi(async (request, user, context) => {
const { id } = await context.params;
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
const body = await readJson(request, patchSchema);
// Die Reihenfolge muss zu genau diesem Universum gehören. Ohne diese Prüfung
// setzte ein PATCH auf Universum A den Standard in Universum B — die
// Domänenschicht leitet das Universum ja aus der Reihenfolge ab.
const order = await prisma.universeOrder.findFirst({
where: { id: body.defaultOrderId, universeId: id },
select: { id: true },
});
if (!order) return apiError("Diese Reihenfolge gehört nicht zu diesem Universum.", 404);
await setDefaultOrder(user.id, order.id);
return { ok: true };
});
+35 -30
View File
@@ -6,6 +6,7 @@ import { getLibrary, getLibraryFacets, type LibraryFilters as Filters, type Libr
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { LibraryCard } from "@/components/library/LibraryCard"; import { LibraryCard } from "@/components/library/LibraryCard";
import { LibraryFilters } from "@/components/library/LibraryFilters"; import { LibraryFilters } from "@/components/library/LibraryFilters";
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
import type { EntryStatus, TitleType } from "@/generated/prisma/client"; import type { EntryStatus, TitleType } from "@/generated/prisma/client";
export const metadata: Metadata = { title: "Bibliothek" }; export const metadata: Metadata = { title: "Bibliothek" };
@@ -56,39 +57,43 @@ export default async function LibraryPage({
</Link> </Link>
</header> </header>
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />} <SearchPendingProvider>
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
<div className="mt-6"> <SearchResults>
{items.length === 0 ? ( <div className="mt-6">
<div className="fl-card p-8 text-center"> {items.length === 0 ? (
<p className="text-[var(--fl-muted)]"> <div className="fl-card p-8 text-center">
{facets.counts.ALLE === 0 <p className="text-[var(--fl-muted)]">
? "Deine Bibliothek ist noch leer." {facets.counts.ALLE === 0
: hasFilters ? "Deine Bibliothek ist noch leer."
? "Zu dieser Auswahl passt nichts." : hasFilters
: "Hier ist nichts."} ? "Zu dieser Auswahl passt nichts."
</p> : "Hier ist nichts."}
{facets.counts.ALLE === 0 && ( </p>
<Link href="/suche" className="fl-btn fl-btn-primary mt-4"> {facets.counts.ALLE === 0 && (
Ersten Titel suchen <Link href="/suche" className="fl-btn fl-btn-primary mt-4">
</Link> Ersten Titel suchen
</Link>
)}
</div>
) : (
<>
<p className="mb-4 text-sm text-[var(--fl-faint)]">
{items.length} {items.length === 1 ? "Titel" : "Titel"}
</p>
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{items.map((item) => (
<li key={item.entryId}>
<LibraryCard item={item} />
</li>
))}
</ul>
</>
)} )}
</div> </div>
) : ( </SearchResults>
<> </SearchPendingProvider>
<p className="mb-4 text-sm text-[var(--fl-faint)]">
{items.length} {items.length === 1 ? "Titel" : "Titel"}
</p>
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{items.map((item) => (
<li key={item.entryId}>
<LibraryCard item={item} />
</li>
))}
</ul>
</>
)}
</div>
</AppShell> </AppShell>
); );
} }
+28
View File
@@ -3,6 +3,9 @@ import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings"; import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { PasswordForm, ProfileForm } from "@/components/settings/Forms"; import { PasswordForm, ProfileForm } from "@/components/settings/Forms";
import { PasskeyList } from "@/components/settings/Passkeys";
import { listPasskeys } from "@/lib/passkeys";
import { env } from "@/lib/env";
import pkg from "../../../package.json"; import pkg from "../../../package.json";
export const metadata: Metadata = { title: "Einstellungen" }; export const metadata: Metadata = { title: "Einstellungen" };
@@ -28,6 +31,8 @@ export default async function SettingsPage() {
const { passwordHash, ...profile } = user; const { passwordHash, ...profile } = user;
const passkeys = env.passkeys.enabled ? await listPasskeys(session.id) : [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<section className="fl-card p-5"> <section className="fl-card p-5">
@@ -42,6 +47,29 @@ export default async function SettingsPage() {
</section> </section>
)} )}
{env.passkeys.enabled && (
<section className="fl-card p-5">
<h2 className="mb-1 text-lg font-semibold">Passkeys</h2>
<p className="mb-4 text-sm text-[var(--fl-muted)]">
Anmelden ohne Passwort — per Fingerabdruck, Gesicht oder Sicherheitsschlüssel.
</p>
<PasskeyList
passkeys={passkeys.map((passkey) => ({
...passkey,
createdAt: passkey.createdAt.toISOString(),
lastUsedAt: passkey.lastUsedAt?.toISOString() ?? null,
}))}
available={env.passkeys.isAvailable}
hinweis={
env.passkeys.isAvailable
? null
: "Passkeys brauchen eine verschlüsselte Verbindung. Diese Instanz läuft über " +
"einfaches HTTP — richte HTTPS ein, dann erscheint hier die Verwaltung."
}
/>
</section>
)}
<p className="text-xs text-[var(--fl-faint)]"> <p className="text-xs text-[var(--fl-faint)]">
Flimmer {pkg.version} · Instanz {settings.instanceName} Flimmer {pkg.version} · Instanz {settings.instanceName}
</p> </p>
+25 -20
View File
@@ -7,6 +7,7 @@ import { searchMovies, searchMulti, searchSeries, type TmdbSearchResult } from "
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster"; import { Poster } from "@/components/Poster";
import { SearchField } from "@/components/SearchField"; import { SearchField } from "@/components/SearchField";
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
export const metadata: Metadata = { title: "Suchen" }; export const metadata: Metadata = { title: "Suchen" };
@@ -54,27 +55,31 @@ export default async function SearchPage({
</p> </p>
</header> </header>
<SearchField initialQuery={query} filter={filter} filters={FILTERS} /> <SearchPendingProvider>
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
<p className="mt-3 text-sm text-[var(--fl-faint)]"> <p className="mt-3 text-sm text-[var(--fl-faint)]">
Nichts dabei?{" "} Nichts dabei?{" "}
<Link href="/titel/neu" className="text-[var(--fl-accent)] underline-offset-4 hover:underline"> <Link href="/titel/neu" className="text-[var(--fl-accent)] underline-offset-4 hover:underline">
Titel selbst anlegen Titel selbst anlegen
</Link> </Link>
</p> </p>
<div className="mt-8"> <SearchResults>
{!env.tmdb.isConfigured ? ( <div className="mt-8">
<Notice tone="warning"> {!env.tmdb.isConfigured ? (
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in der{" "} <Notice tone="warning">
<code>.env</code> ein und starte die Instanz neu. Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in
</Notice> der <code>.env</code> ein und starte die Instanz neu.
) : query.length < 2 ? ( </Notice>
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice> ) : query.length < 2 ? (
) : ( <Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
<Results query={query} filter={filter} /> ) : (
)} <Results query={query} filter={filter} />
</div> )}
</div>
</SearchResults>
</SearchPendingProvider>
</AppShell> </AppShell>
); );
} }
@@ -119,7 +124,7 @@ async function Results({ query, filter }: { query: string; filter: Filter }) {
> >
<Poster path={item.posterPath} alt={item.title} size="w342" /> <Poster path={item.posterPath} alt={item.title} size="w342" />
<div className="mt-2 space-y-0.5"> <div className="mt-2 space-y-0.5">
<p className="line-clamp-2 text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]"> <p className="line-clamp-2 min-h-[2lh] text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
{item.title} {item.title}
</p> </p>
<p className="text-xs text-[var(--fl-faint)]"> <p className="text-xs text-[var(--fl-faint)]">
+8 -9
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState, useTransition } from "react"; import { useEffect, useRef, useState, useTransition } from "react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending";
/** /**
* Suchfeld mit Typfilter. * Suchfeld mit Typfilter.
@@ -26,6 +27,11 @@ export function SearchField({
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const first = useRef(true); const first = useRef(true);
const reportPending = useReportSearchPending();
useEffect(() => {
reportPending(pending);
}, [pending, reportPending]);
useEffect(() => { useEffect(() => {
// Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die // Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die
// gerade geladene URL durch eine identische. // gerade geladene URL durch eine identische.
@@ -41,7 +47,7 @@ export function SearchField({
startTransition(() => { startTransition(() => {
router.replace(`${pathname}?${params.toString()}`, { scroll: false }); router.replace(`${pathname}?${params.toString()}`, { scroll: false });
}); });
}, 350); }, 250);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [value, filter, pathname, router]); }, [value, filter, pathname, router]);
@@ -68,14 +74,7 @@ export function SearchField({
autoFocus autoFocus
className="fl-input pl-10 text-base" className="fl-input pl-10 text-base"
/> />
{pending && ( <SearchSpinner show={pending} />
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
role="status"
>
sucht …
</span>
)}
</div> </div>
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Typ filtern"> <div className="flex flex-wrap gap-2" role="group" aria-label="Nach Typ filtern">
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
import { clsx } from "clsx";
/**
* Rückmeldung beim Tippen — geteilt von der Suche und der Bibliothek.
*
* Beide Filterleisten wissen über `useTransition()` längst, dass eine neue
* Antwort unterwegs ist. Die Trefferliste weiß es nicht: sie ist eine
* Server-Komponente und steht als `children` daneben. Diese schmale Klammer
* reicht das eine Bit vom Filter zur Liste durch.
*
* Bewusst zwei Kontexte: der Setter behält so über alle Renderdurchgänge
* dieselbe Identität und taugt als Abhängigkeit eines Effekts.
*/
const PendingContext = createContext(false);
const SetPendingContext = createContext<(value: boolean) => void>(() => {});
export function SearchPendingProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState(false);
return (
<SetPendingContext.Provider value={setPending}>
<PendingContext.Provider value={pending}>{children}</PendingContext.Provider>
</SetPendingContext.Provider>
);
}
/** Für die Filterleiste: meldet, ob gerade gesucht wird. */
export function useReportSearchPending() {
return useContext(SetPendingContext);
}
/**
* Dämpft die Treffer, solange die neue Antwort unterwegs ist.
*
* Die alten Treffer bleiben stehen — sie verschwinden zu lassen wäre ein
* Rückschritt, weil die Liste dann bei jedem Tastendruck springt. Gedämpft
* und nicht anklickbar ist aber sichtbar „nicht mehr aktuell".
*/
export function SearchResults({ children }: { children: ReactNode }) {
const pending = useContext(PendingContext);
return (
<div
aria-busy={pending}
className={clsx(
"transition-opacity duration-200 motion-reduce:transition-none",
pending && "pointer-events-none opacity-45",
)}
>
{children}
</div>
);
}
/** Kleiner Hinweis neben dem Eingabefeld, solange gesucht wird. */
export function SearchSpinner({ show }: { show: boolean }) {
if (!show) return null;
return (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
role="status"
>
sucht …
</span>
);
}
+23 -12
View File
@@ -63,7 +63,7 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
</div> </div>
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
<p className="line-clamp-2 text-sm font-medium leading-snug">{item.title}</p> <p className="line-clamp-2 min-h-[2lh] text-sm font-medium leading-snug">{item.title}</p>
<p className="text-xs text-[var(--fl-faint)]"> <p className="text-xs text-[var(--fl-faint)]">
{item.type === "SERIES" ? "Serie" : "Film"} {item.type === "SERIES" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""} {item.year ? ` · ${item.year}` : ""}
@@ -74,17 +74,28 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
: ""} : ""}
</p> </p>
{showProgress && ( <div className="pt-0.5">
<div className="pt-0.5"> {showProgress ? (
<ProgressBar <>
percent={item.percent} <ProgressBar
label={`${item.watched} von ${item.total} Episoden gesehen`} percent={item.percent}
/> label={`${item.watched} von ${item.total} Episoden gesehen`}
<p className="mt-1 text-xs text-[var(--fl-faint)]"> />
{item.watched}/{item.total} Episoden <p className="mt-1 text-xs text-[var(--fl-faint)]">
</p> {item.watched}/{item.total} Episoden
</div> </p>
)} </>
) : (
// Filme und Watchlist-Einträge haben keinen Balken. Der Platz wird
// trotzdem freigehalten — sonst fallen sie kürzer aus als Serien und
// das Raster franst reihenweise aus. Der Nachbau statt einer festen
// Pixelhöhe hält sich von selbst nach, wenn sich der Balken ändert.
<div className="invisible" aria-hidden>
<div className="fl-progress" />
<p className="mt-1 text-xs">&nbsp;</p>
</div>
)}
</div>
</div> </div>
</Link> </Link>
); );
+22 -10
View File
@@ -5,6 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels"; import { STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels";
import type { LibraryFacets } from "@/lib/queries"; import type { LibraryFacets } from "@/lib/queries";
import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending";
/** /**
* Filterleiste der Bibliothek. * Filterleiste der Bibliothek.
@@ -26,11 +27,19 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const params = useSearchParams(); const params = useSearchParams();
const [, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [query, setQuery] = useState(params.get("q") ?? ""); const [query, setQuery] = useState(params.get("q") ?? "");
const first = useRef(true); const first = useRef(true);
// Der Trefferliste mitteilen, dass eine neue Antwort unterwegs ist — ohne
// das Signal blieben die alten Treffer stumm stehen und es sah aus, als
// passiere nichts.
const reportPending = useReportSearchPending();
useEffect(() => {
reportPending(pending);
}, [pending, reportPending]);
function apply(changes: Record<string, string | null>) { function apply(changes: Record<string, string | null>) {
const next = new URLSearchParams(params.toString()); const next = new URLSearchParams(params.toString());
for (const [key, value] of Object.entries(changes)) { for (const [key, value] of Object.entries(changes)) {
@@ -48,7 +57,7 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
first.current = false; first.current = false;
return; return;
} }
const timer = setTimeout(() => apply({ q: query.trim() || null }), 300); const timer = setTimeout(() => apply({ q: query.trim() || null }), 250);
return () => clearTimeout(timer); return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]); }, [query]);
@@ -62,14 +71,17 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<input <div className="relative">
type="search" <input
value={query} type="search"
onChange={(event) => setQuery(event.target.value)} value={query}
placeholder="In der Bibliothek suchen …" onChange={(event) => setQuery(event.target.value)}
aria-label="Bibliothek durchsuchen" placeholder="In der Bibliothek suchen …"
className="fl-input" aria-label="Bibliothek durchsuchen"
/> className="fl-input"
/>
<SearchSpinner show={pending} />
</div>
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Nach Status filtern"> <div className="flex flex-wrap gap-1.5" role="group" aria-label="Nach Status filtern">
<Chip active={!status} onClick={() => apply({ status: null })}> <Chip active={!status} onClick={() => apply({ status: null })}>
+25 -3
View File
@@ -2,7 +2,12 @@
import { useTransition } from "react"; import { useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { markMovieAction, undoAction, watchNextAction } from "@/app/actions/library"; import {
markMovieAction,
undoAction,
unmarkMovieAction,
watchNextAction,
} from "@/app/actions/library";
import { useToasts } from "@/components/Toasts"; import { useToasts } from "@/components/Toasts";
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels"; import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
@@ -93,14 +98,31 @@ export function WatchMovieButton({
router.refresh(); router.refresh();
} }
// Im Zustand „gesehen" bleibt die grüne Plakette stehen, ist aber klickbar —
// so wie sich Episodenhäkchen auch wieder abwählen lassen. Vorher stand hier
// ein <p>: ein Etikett ohne Klickziel, aus dem es kein Zurück gab.
if (watched) { if (watched) {
return ( return (
<p className="inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]"> <button
type="button"
disabled={pending}
title="Markierung wieder entfernen"
onClick={() =>
startTransition(async () => {
report(await unmarkMovieAction(entryId));
router.refresh();
})
}
className="group inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)] transition-colors hover:border-[color-mix(in_oklab,var(--fl-success)_70%,transparent)] hover:bg-[color-mix(in_oklab,var(--fl-success)_20%,transparent)] disabled:opacity-60"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden> <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden>
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" /> <path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
Gesehen Gesehen
</p> <span className="text-xs text-[color-mix(in_oklab,var(--fl-success)_75%,var(--fl-muted))]">
— Markierung entfernen
</span>
</button>
); );
} }
+278
View File
@@ -0,0 +1,278 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
startAuthentication,
startRegistration,
WebAuthnError,
} from "@simplewebauthn/browser";
import { useToasts } from "@/components/Toasts";
import { formatDate } from "@/lib/labels";
/**
* Passkeys anlegen und verwalten.
*
* Der Browser übernimmt den heiklen Teil; hier laufen nur zwei Anfragen —
* Optionen holen, Antwort einreichen. Fehler des Authenticators tragen
* kryptische Namen, deshalb werden die häufigen übersetzt.
*/
export type PasskeyEintrag = {
id: string;
name: string;
deviceType: string;
backedUp: boolean;
createdAt: string;
lastUsedAt: string | null;
};
/** Übersetzt die Fehler des Browsers in Sätze, mit denen man etwas anfangen kann. */
export function passkeyFehlertext(error: unknown): string | null {
if (error instanceof WebAuthnError) {
switch (error.name) {
case "InvalidStateError":
return "Dieses Gerät ist hier schon hinterlegt.";
case "NotAllowedError":
// Auch der Abbruch durch den Nutzer landet hier — dann ist Schweigen
// die richtige Antwort.
return null;
case "SecurityError":
return "Passkeys brauchen HTTPS oder localhost. Über diese Adresse geht es nicht.";
default:
return "Der Passkey konnte nicht verwendet werden.";
}
}
if (error instanceof DOMException && error.name === "NotAllowedError") return null;
return "Das hat nicht geklappt.";
}
export function PasskeyList({
passkeys,
available,
hinweis,
}: {
passkeys: PasskeyEintrag[];
available: boolean;
hinweis: string | null;
}) {
const router = useRouter();
const { show } = useToasts();
const [pending, startTransition] = useTransition();
const [name, setName] = useState("");
async function anlegen() {
try {
const antwort = await fetch("/api/auth/passkey/registrierung/start", { method: "POST" });
const optionen = await antwort.json();
if (!antwort.ok) {
show(optionen.error ?? "Das hat nicht geklappt.", { tone: "error" });
return;
}
const ergebnis = await startRegistration({ optionsJSON: optionen });
const fertig = await fetch("/api/auth/passkey/registrierung/abschluss", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: name.trim() || geraeteVorschlag(), response: ergebnis }),
});
const daten = await fertig.json();
if (!fertig.ok) {
show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" });
return;
}
setName("");
show("Passkey angelegt");
startTransition(() => router.refresh());
} catch (error) {
const text = passkeyFehlertext(error);
if (text) show(text, { tone: "error" });
}
}
async function umbenennen(id: string, alt: string, neu: string) {
const name = neu.trim();
if (!name || name === alt) return;
const antwort = await fetch(`/api/passkeys/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name }),
});
if (!antwort.ok) {
const daten = await antwort.json().catch(() => ({}));
show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" });
return;
}
show("Umbenannt");
startTransition(() => router.refresh());
}
async function entfernen(id: string, wie: string) {
const antwort = await fetch(`/api/passkeys/${id}`, { method: "DELETE" });
const daten = await antwort.json().catch(() => ({}));
if (!antwort.ok) {
show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" });
return;
}
show(`„${wie}" entfernt`);
startTransition(() => router.refresh());
}
if (!available) {
return (
<p className="rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]">
{hinweis}
</p>
);
}
return (
<div className="space-y-4">
{passkeys.length > 0 ? (
<ul className="divide-y divide-[var(--fl-border)]">
{passkeys.map((passkey) => (
<li key={passkey.id} className="flex flex-wrap items-center gap-3 py-2.5">
<div className="min-w-0 flex-1">
{/* Der Name ist direkt überschreibbar — ein eigener
Bearbeiten-Modus wäre für ein einzelnes Feld zu viel. */}
<input
defaultValue={passkey.name}
aria-label={`Name von „${passkey.name}"`}
maxLength={60}
onBlur={(event) => umbenennen(passkey.id, passkey.name, event.target.value)}
className="w-full truncate rounded-md border border-transparent bg-transparent px-1 py-0.5 font-medium transition-colors hover:border-[var(--fl-border)] focus:border-[var(--fl-border-strong)] focus:outline-none"
/>
<p className="px-1 text-xs text-[var(--fl-faint)]">
{passkey.backedUp ? "synchronisiert" : "nur auf diesem Gerät"}
{" · angelegt "}
{formatDate(passkey.createdAt)}
{passkey.lastUsedAt
? ` · zuletzt benutzt ${formatDate(passkey.lastUsedAt)}`
: " · noch nicht benutzt"}
</p>
</div>
<button
type="button"
disabled={pending}
onClick={() => entfernen(passkey.id, passkey.name)}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Entfernen
</button>
</li>
))}
</ul>
) : (
<p className="text-sm text-[var(--fl-muted)]">
Noch kein Passkey hinterlegt. Mit einem kannst du dich ohne Passwort anmelden — per
Fingerabdruck, Gesicht oder Sicherheitsschlüssel.
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={geraeteVorschlag()}
aria-label="Name für den neuen Passkey"
maxLength={60}
className="fl-input w-56 py-1.5 text-sm"
/>
<button type="button" onClick={anlegen} disabled={pending} className="fl-btn fl-btn-primary">
Passkey anlegen
</button>
</div>
{passkeys.some((passkey) => !passkey.backedUp) && (
<p className="text-xs text-[var(--fl-faint)]">
Ein Passkey, der nur auf einem Gerät liegt, ist mit dem Gerät verloren. Halte fürs
Erste einen zweiten Anmeldeweg bereit.
</p>
)}
</div>
);
}
/** Ein brauchbarer Vorschlag, damit niemand „Passkey 1" tippen muss. */
function geraeteVorschlag(): string {
if (typeof navigator === "undefined") return "Mein Gerät";
const ua = navigator.userAgent;
if (/iPhone/.test(ua)) return "iPhone";
if (/iPad/.test(ua)) return "iPad";
if (/Android/.test(ua)) return "Android-Gerät";
if (/Mac OS X/.test(ua)) return "Mac";
if (/Windows/.test(ua)) return "Windows-PC";
if (/Linux/.test(ua)) return "Linux-Rechner";
return "Mein Gerät";
}
/** Knopf auf der Anmeldeseite. */
export function PasskeyLoginButton({ label }: { label: string }) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
async function anmelden() {
setFehler(null);
setPending(true);
try {
const antwort = await fetch("/api/auth/passkey/anmeldung/start", { method: "POST" });
const optionen = await antwort.json();
if (!antwort.ok) {
setFehler(optionen.error ?? "Das hat nicht geklappt.");
return;
}
const ergebnis = await startAuthentication({ optionsJSON: optionen });
const fertig = await fetch("/api/auth/passkey/anmeldung/abschluss", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ergebnis),
});
const daten = await fertig.json();
if (!fertig.ok) {
setFehler(daten.error ?? "Das hat nicht geklappt.");
return;
}
router.replace("/");
router.refresh();
} catch (error) {
setFehler(passkeyFehlertext(error));
} finally {
setPending(false);
}
}
return (
<div className="space-y-2">
<button
type="button"
onClick={anmelden}
disabled={pending}
className="fl-btn fl-btn-ghost w-full justify-center gap-2"
>
<KeyIcon />
{pending ? "Einen Moment …" : label}
</button>
{fehler && (
<p className="text-sm text-[var(--fl-danger)]" role="alert">
{fehler}
</p>
)}
</div>
);
}
function KeyIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<circle cx="8" cy="12" r="3.5" />
<path d="M11.5 12H21m-3 0v3m-3-3v2" strokeLinecap="round" />
</svg>
);
}
@@ -9,6 +9,7 @@ import {
createOrderAction, createOrderAction,
createUniverseAction, createUniverseAction,
deleteOrderAction, deleteOrderAction,
setDefaultOrderAction,
shareUniverseAction, shareUniverseAction,
trashUniverseAction, trashUniverseAction,
unshareUniverseAction, unshareUniverseAction,
@@ -109,6 +110,24 @@ export function OrderTabs({
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined} style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
> >
{order.name} {order.name}
{order.isDefault && (
<>
{/* Häkchen an der Reihenfolge, die beim Öffnen erscheint. Das
Wort dahinter steht nur für Screenreader — sichtbar würde
es die Reiter auseinanderziehen. */}
<svg
viewBox="0 0 24 24"
className="ml-1.5 inline h-3.5 w-3.5 align-[-2px]"
fill="none"
stroke="currentColor"
strokeWidth="3"
aria-hidden
>
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="sr-only"> (Standard)</span>
</>
)}
</button> </button>
); );
})} })}
@@ -158,6 +177,21 @@ export function OrderTabs({
</button> </button>
)} )}
{active && !active.isDefault && (
<button
type="button"
onClick={() =>
startTransition(async () => {
report(await setDefaultOrderAction(active.id));
router.refresh();
})
}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
>
Als Standard festlegen
</button>
)}
{active && active.kind === "CUSTOM" && ( {active && active.kind === "CUSTOM" && (
<button <button
type="button" type="button"
+35
View File
@@ -81,6 +81,41 @@ export const env = {
}, },
}, },
passkeys: {
/**
* Passkeys lassen sich abschalten wie OIDC. Standardmäßig an — sie
* kosten nichts, solange niemand einen anlegt.
*/
enabled: bool("ENABLE_PASSKEYS", true),
/**
* Die RP ID ist der Hostname, an den ein Passkey gebunden ist. Sie kommt
* aus APP_URL und **nicht** aus dem Host-Header: hinter einem Reverse
* Proxy hinge sie sonst am falschen Namen, und alle Passkeys wären
* plötzlich ungültig.
*/
get rpId(): string {
try {
return new URL(env.appUrl).hostname;
} catch {
return "localhost";
}
},
/**
* Der Browser verlangt einen sicheren Kontext. Erlaubt sind HTTPS und
* localhost; eine nackte IP-Adresse ist als RP ID ohnehin unzulässig.
*/
get isAvailable(): boolean {
if (!this.enabled) return false;
try {
const url = new URL(env.appUrl);
if (url.protocol === "https:") return true;
return url.hostname === "localhost" || url.hostname.endsWith(".localhost");
} catch {
return false;
}
},
},
oidc: { oidc: {
issuer: optional("OIDC_ISSUER"), issuer: optional("OIDC_ISSUER"),
clientId: optional("OIDC_CLIENT_ID"), clientId: optional("OIDC_CLIENT_ID"),
+21
View File
@@ -338,6 +338,27 @@ export async function markMovieWatched(
return { watchIds: [watch.id], status, label: "Film als gesehen markiert" }; return { watchIds: [watch.id], status, label: "Film als gesehen markiert" };
} }
/**
* Nimmt die Sichtung eines Films im aktuellen Durchlauf zurück.
*
* Das Gegenstück zu `markMovieWatched()` — und das Pendant zu
* `unmarkEpisodes()` bei Serien.
*
* Die Eingrenzung auf `run` ist wesentlich: wer einen Film dreimal gesehen
* hat, hat drei Sichtungen. Ohne `run` löschte das Zurücknehmen alle drei und
* verfälschte rückwirkend die Statistik. Zurückgenommen wird nur, was im
* laufenden Durchlauf abgehakt wurde.
*/
export async function unmarkMovie(userId: string, entryId: string) {
const entry = await requireEntry(userId, entryId);
if (entry.title.type !== "MOVIE") throw new LibraryError("Das ist keine Filmsichtung.");
await prisma.watch.deleteMany({
where: { userId, entryId, episodeId: null, run: entry.currentRun },
});
return reconcileStatus(userId, entryId);
}
/** Macht eine Sichtungsaktion rückgängig. */ /** Macht eine Sichtungsaktion rückgängig. */
export async function undoWatches(userId: string, entryId: string, watchIds: string[]) { export async function undoWatches(userId: string, entryId: string, watchIds: string[]) {
await requireEntry(userId, entryId); await requireEntry(userId, entryId);
+333
View File
@@ -0,0 +1,333 @@
import "server-only";
import { createHmac, timingSafeEqual } from "node:crypto";
import { cookies } from "next/headers";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
RegistrationResponseJSON,
} from "@simplewebauthn/server";
import { prisma } from "./db";
import { env } from "./env";
/**
* Anmeldung per Passkey (WebAuthn).
*
* Ein Passkey ist der dritte Weg in denselben Trichter: `finishLogin()` prüft
* die Antwort und gibt die Kennung zurück, der Endpunkt legt damit über
* `createSession()` dieselbe Sitzung an wie bei Passwort und OIDC. Die
* Sitzungsschicht weiß nicht, wie jemand hereingekommen ist.
*
* Die Trennung ist Absicht: hier steht die WebAuthn-Prüfung, das Setzen von
* Cookie und Sitzungszeile gehört zur HTTP-Schicht.
*
* Zwei Dinge sind bei einer selbst gehosteten Instanz wichtiger als sonst:
*
* - Die **RP ID** kommt aus `APP_URL`, nicht aus dem `Host`-Header. Hinter
* einem Reverse Proxy hinge sie sonst am falschen Namen — und ein Passkey
* ist fest an diesen Namen gebunden.
* - Ein **Domainwechsel entwertet alle Passkeys.** Das steht in der README;
* hier lässt sich nichts dagegen tun, es ist Teil des Verfahrens.
*/
export class PasskeyError extends Error {}
const CHALLENGE_COOKIE = "flimmer_passkey_challenge";
/** Kurz genug, dass eine liegengebliebene Challenge nicht ewig gilt. */
const CHALLENGE_SECONDS = 300;
function rpId(): string {
return env.passkeys.rpId;
}
/** Der Ursprung, den der Browser in der Antwort mitschickt. */
function expectedOrigin(): string {
return new URL(env.appUrl).origin;
}
// ---------------------------------------------------------------------------
// Challenge — signiertes Cookie statt Tabelle
// ---------------------------------------------------------------------------
/**
* Die Challenge muss den Weg vom ersten zum zweiten Schritt überstehen.
*
* Eine Tabelle bräuchte einen Aufräumjob für alles, was liegen bleibt, wenn
* jemand den Dialog abbricht. Ein signiertes, kurzlebiges Cookie kommt ohne
* beides aus: Fälschen geht nicht, und nach fünf Minuten ist es ohnehin weg.
*/
function sign(payload: string): string {
return createHmac("sha256", env.authSecret).update(payload).digest("base64url");
}
async function storeChallenge(challenge: string, scope: "register" | "login") {
const payload = `${scope}.${challenge}.${Date.now() + CHALLENGE_SECONDS * 1000}`;
const cookieStore = await cookies();
cookieStore.set(CHALLENGE_COOKIE, `${payload}.${sign(payload)}`, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: CHALLENGE_SECONDS,
});
}
async function takeChallenge(scope: "register" | "login"): Promise<string> {
const cookieStore = await cookies();
const raw = cookieStore.get(CHALLENGE_COOKIE)?.value;
// Einmalgebrauch: gleich löschen, egal ob die Prüfung gelingt.
cookieStore.delete(CHALLENGE_COOKIE);
if (!raw) throw new PasskeyError("Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.");
const teile = raw.split(".");
if (teile.length !== 4) throw new PasskeyError("Die Anfrage ist ungültig.");
const [gespeicherterScope, challenge, ablauf, signatur] = teile as [string, string, string, string];
const payload = `${gespeicherterScope}.${challenge}.${ablauf}`;
const erwartet = Buffer.from(sign(payload));
const gegeben = Buffer.from(signatur);
if (erwartet.length !== gegeben.length || !timingSafeEqual(erwartet, gegeben)) {
throw new PasskeyError("Die Anfrage ist ungültig.");
}
if (gespeicherterScope !== scope) throw new PasskeyError("Die Anfrage passt nicht.");
if (Number(ablauf) < Date.now()) {
throw new PasskeyError("Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.");
}
return challenge;
}
function requireAvailable() {
if (!env.passkeys.enabled) throw new PasskeyError("Passkeys sind auf dieser Instanz abgeschaltet.");
if (!env.passkeys.isAvailable) {
throw new PasskeyError(
"Passkeys brauchen HTTPS (oder localhost). Diese Instanz läuft über eine unverschlüsselte Verbindung.",
);
}
}
// ---------------------------------------------------------------------------
// Anlegen
// ---------------------------------------------------------------------------
export async function startRegistration(userId: string) {
requireAvailable();
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { id: true, email: true, name: true },
});
const vorhandene = await prisma.passkey.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
const options = await generateRegistrationOptions({
rpName: env.appName,
rpID: rpId(),
userName: user.email,
userDisplayName: user.name,
// Die Kennung muss über Umbenennungen hinweg stabil bleiben — deshalb die
// interne ID und nicht die E-Mail-Adresse.
userID: new TextEncoder().encode(user.id),
attestationType: "none",
// Schon vorhandene Passkeys ausschließen, damit dasselbe Gerät nicht
// zweimal registriert wird.
excludeCredentials: vorhandene.map((eintrag) => ({
id: eintrag.credentialId,
transports: eintrag.transports as AuthenticatorTransportFuture[],
})),
authenticatorSelection: {
// Auffindbar, damit die Anmeldung ohne vorherige E-Mail-Eingabe geht.
residentKey: "required",
userVerification: "preferred",
},
});
await storeChallenge(options.challenge, "register");
return options;
}
export async function finishRegistration(
userId: string,
response: RegistrationResponseJSON,
name: string,
) {
requireAvailable();
const challenge = await takeChallenge("register");
const geprueft = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: expectedOrigin(),
expectedRPID: rpId(),
requireUserVerification: false,
});
if (!geprueft.verified || !geprueft.registrationInfo) {
throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
}
const { credential, credentialDeviceType, credentialBackedUp } = geprueft.registrationInfo;
const schonVergeben = await prisma.passkey.findUnique({
where: { credentialId: credential.id },
select: { userId: true },
});
if (schonVergeben) {
throw new PasskeyError("Dieser Passkey ist bereits hinterlegt.");
}
return prisma.passkey.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: BigInt(credential.counter),
transports: (credential.transports ?? []) as string[],
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
name: name.trim().slice(0, 60) || "Passkey",
},
select: { id: true, name: true, deviceType: true, backedUp: true },
});
}
// ---------------------------------------------------------------------------
// Anmelden
// ---------------------------------------------------------------------------
export async function startLogin() {
requireAvailable();
// Ohne `allowCredentials`: der Browser sucht selbst einen passenden Passkey.
// Genau das erlaubt die Anmeldung ohne vorherige E-Mail-Eingabe — und
// verrät nebenbei nicht, welche Konten es auf dieser Instanz gibt.
const options = await generateAuthenticationOptions({
rpID: rpId(),
userVerification: "preferred",
});
await storeChallenge(options.challenge, "login");
return options;
}
export async function finishLogin(response: AuthenticationResponseJSON) {
requireAvailable();
const challenge = await takeChallenge("login");
const passkey = await prisma.passkey.findUnique({
where: { credentialId: response.id },
include: { user: { select: { id: true, isActive: true } } },
});
if (!passkey) throw new PasskeyError("Dieser Passkey gehört zu keinem Konto hier.");
if (!passkey.user.isActive) throw new PasskeyError("Dieses Konto ist gesperrt.");
// Die Bibliothek prüft Signatur, Ursprung, RP ID **und** den Zähler. Sie
// wirft dabei gewöhnliche Error — hier übersetzt, damit eine erwartete
// Ablehnung nicht als unerwarteter Serverfehler im Log landet.
let geprueft;
try {
geprueft = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: expectedOrigin(),
expectedRPID: rpId(),
requireUserVerification: false,
credential: {
id: passkey.credentialId,
publicKey: new Uint8Array(passkey.publicKey),
counter: Number(passkey.counter),
transports: passkey.transports as AuthenticatorTransportFuture[],
},
});
} catch (error) {
const text = error instanceof Error ? error.message : "";
if (/counter/i.test(text)) {
throw new PasskeyError(
"Dieser Passkey wurde abgelehnt — der Zähler passt nicht. Melde dich anders an und lege ihn neu an.",
);
}
throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
}
if (!geprueft.verified) throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
// Zusätzliche Sicherung. Die Bibliothek prüft den Zähler nur, wenn beide
// Werte über null liegen; ein Authenticator, der von einem gezählten auf
// einen ungezählten Stand zurückfällt, käme dort durch.
const neuerZaehler = geprueft.authenticationInfo.newCounter;
const alterZaehler = Number(passkey.counter);
if (alterZaehler > 0 && neuerZaehler <= alterZaehler) {
throw new PasskeyError("Dieser Passkey wurde abgelehnt. Bitte melde dich anders an.");
}
await prisma.passkey.update({
where: { id: passkey.id },
data: { counter: BigInt(neuerZaehler), lastUsedAt: new Date() },
});
return { userId: passkey.user.id };
}
// ---------------------------------------------------------------------------
// Verwalten
// ---------------------------------------------------------------------------
export async function listPasskeys(userId: string) {
return prisma.passkey.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: {
id: true,
name: true,
deviceType: true,
backedUp: true,
createdAt: true,
lastUsedAt: true,
},
});
}
export async function renamePasskey(userId: string, passkeyId: string, name: string) {
const treffer = await prisma.passkey.updateMany({
where: { id: passkeyId, userId },
data: { name: name.trim().slice(0, 60) || "Passkey" },
});
if (treffer.count === 0) throw new PasskeyError("Diesen Passkey gibt es nicht.");
}
/**
* Entfernt einen Passkey — aber nie den letzten Anmeldeweg.
*
* Wer ein Konto ohne Passwort hat (etwa über OIDC angelegt) und seinen einzigen
* Passkey löscht, käme sonst nicht mehr herein.
*/
export async function deletePasskey(userId: string, passkeyId: string) {
const [user, anzahl] = await Promise.all([
prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { passwordHash: true, _count: { select: { oidcAccounts: true } } },
}),
prisma.passkey.count({ where: { userId } }),
]);
const andereWege = Boolean(user.passwordHash) || user._count.oidcAccounts > 0;
if (!andereWege && anzahl <= 1) {
throw new PasskeyError(
"Das ist dein letzter Anmeldeweg. Lege zuerst ein Passwort oder einen weiteren Passkey an.",
);
}
const treffer = await prisma.passkey.deleteMany({ where: { id: passkeyId, userId } });
if (treffer.count === 0) throw new PasskeyError("Diesen Passkey gibt es nicht.");
}
+43 -2
View File
@@ -244,13 +244,54 @@ export async function renameOrder(userId: string, orderId: string, name: string)
export async function deleteOrder(userId: string, orderId: string) { export async function deleteOrder(userId: string, orderId: string) {
const order = await prisma.universeOrder.findUniqueOrThrow({ const order = await prisma.universeOrder.findUniqueOrThrow({
where: { id: orderId }, where: { id: orderId },
select: { universeId: true, kind: true }, select: { universeId: true, kind: true, isDefault: true },
}); });
await requireWrite(userId, order.universeId); await requireWrite(userId, order.universeId);
if (order.kind === "RELEASE") { if (order.kind === "RELEASE") {
throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu."); throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu.");
} }
await prisma.universeOrder.delete({ where: { id: orderId } });
await prisma.$transaction(async (tx) => {
await tx.universeOrder.delete({ where: { id: orderId } });
// War die gelöschte der Standard, stünde das Universum ohne einen da und
// die Auswahl fiele auf „die erste" — Zufall statt Absicht. Die
// Erscheinungsreihenfolge gehört immer dazu, sie übernimmt.
if (order.isDefault) {
await tx.universeOrder.updateMany({
where: { universeId: order.universeId, kind: "RELEASE" },
data: { isDefault: true },
});
}
});
}
/**
* Legt fest, welche Reihenfolge beim Öffnen des Universums erscheint.
*
* Der Standard gehört dem Universum, nicht der einzelnen Person: wer
* Schreibrecht hat, setzt ihn für alle, die das Universum sehen — so wie die
* Reihenfolgen selbst auch geteilt sind.
*
* Zurücksetzen und Setzen laufen gemeinsam in einer Transaktion. Sonst stünden
* dazwischen zwei Standards nebeneinander, und die Auswahl in `getUniverse()`
* griffe sich nach Sortierzufall einen davon.
*/
export async function setDefaultOrder(userId: string, orderId: string) {
const order = await prisma.universeOrder.findUniqueOrThrow({
where: { id: orderId },
select: { universeId: true, isDefault: true },
});
await requireWrite(userId, order.universeId);
if (order.isDefault) return;
await prisma.$transaction([
prisma.universeOrder.updateMany({
where: { universeId: order.universeId, isDefault: true },
data: { isDefault: false },
}),
prisma.universeOrder.update({ where: { id: orderId }, data: { isDefault: true } }),
]);
} }
/** Schreibt eine neue Anordnung — die Liste enthält alle Einträge in Wunschfolge. */ /** Schreibt eine neue Anordnung — die Liste enthält alle Einträge in Wunschfolge. */
+54
View File
@@ -21,6 +21,7 @@ import {
startRewatch, startRewatch,
undoWatches, undoWatches,
unmarkEpisodes, unmarkEpisodes,
unmarkMovie,
updateEntryDetails, updateEntryDetails,
watchNextEpisode, watchNextEpisode,
} from "../src/lib/library"; } from "../src/lib/library";
@@ -388,3 +389,56 @@ test("Sammlung: Filter und Zähler in der Bibliothek", async () => {
await prisma.libraryEntry.deleteMany({ where: { id: { in: [movie.id, series.id] } } }); await prisma.libraryEntry.deleteMany({ where: { id: { in: [movie.id, series.id] } } });
}); });
test("Filme: die Sichtung laesst sich wieder zuruecknehmen", async () => {
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
await markMovieWatched(userId, entry.id);
assert.equal((await getProgress(userId, entry.id)).percent, 100);
await unmarkMovie(userId, entry.id);
const zurueck = await getProgress(userId, entry.id);
assert.equal(zurueck.percent, 0, "der Fortschritt steht wieder auf null");
assert.equal(zurueck.minutesWatched, 0);
assert.equal(
await prisma.watch.count({ where: { userId, entryId: entry.id } }),
0,
"die Sichtung ist weg",
);
// Und danach laesst es sich erneut abhaken — kein Einbahnzustand.
const erneut = await markMovieWatched(userId, entry.id);
assert.equal(erneut.watchIds.length, 1);
});
test("Filme: das Zuruecknehmen betrifft nur den laufenden Durchlauf", async () => {
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
await markMovieWatched(userId, entry.id);
await startRewatch(userId, entry.id);
await markMovieWatched(userId, entry.id);
assert.equal(await prisma.watch.count({ where: { userId, entryId: entry.id } }), 2);
await unmarkMovie(userId, entry.id);
const rest = await prisma.watch.findMany({
where: { userId, entryId: entry.id },
select: { run: true },
});
assert.deepEqual(
rest.map((watch) => watch.run),
[0],
"die Sichtung des ersten Durchlaufs bleibt fuer die Statistik stehen",
);
assert.equal(
(await getProgress(userId, entry.id)).percent,
0,
"der laufende Durchlauf steht wieder auf null",
);
});
test("unmarkMovie weist Serien ab", async () => {
const entry = await addToLibrary(userId, seriesTitleId, "WATCHING");
await assert.rejects(() => unmarkMovie(userId, entry.id), /keine Filmsichtung/);
});
+138
View File
@@ -0,0 +1,138 @@
import "./setup";
import assert from "node:assert/strict";
import test, { after, before, beforeEach } from "node:test";
import { prisma } from "../src/lib/db";
import { deletePasskey, listPasskeys, renamePasskey } from "../src/lib/passkeys";
import { env } from "../src/lib/env";
/**
* Prüft die Verwaltung der Passkeys und die Sperre gegen das Aussperren.
*
* Die eigentliche WebAuthn-Prüfung braucht einen echten Authenticator und
* läuft deshalb im Klickweg mit virtuellem Authenticator, nicht hier.
*/
const MIT_PASSWORT = "test-passkey-pw@flimmer.invalid";
const OHNE_PASSWORT = "test-passkey-nopw@flimmer.invalid";
let mitPasswortId: string;
let ohnePasswortId: string;
let zaehler = 0;
async function passkeyAnlegen(userId: string, name: string) {
zaehler += 1;
return prisma.passkey.create({
data: {
userId,
credentialId: `test-credential-${zaehler}-${Date.now()}`,
publicKey: Buffer.from([1, 2, 3]),
name,
},
select: { id: true },
});
}
async function aufraeumen() {
await prisma.user.deleteMany({ where: { email: { in: [MIT_PASSWORT, OHNE_PASSWORT] } } });
}
before(async () => {
await aufraeumen();
mitPasswortId = (
await prisma.user.create({
data: { email: MIT_PASSWORT, name: "Mit Passwort", passwordHash: "$2a$10$platzhalter" },
select: { id: true },
})
).id;
ohnePasswortId = (
await prisma.user.create({
data: { email: OHNE_PASSWORT, name: "Ohne Passwort" },
select: { id: true },
})
).id;
});
beforeEach(async () => {
await prisma.passkey.deleteMany({ where: { userId: { in: [mitPasswortId, ohnePasswortId] } } });
});
after(async () => {
await aufraeumen();
await prisma.$disconnect();
});
test("die RP ID kommt aus APP_URL, nicht aus dem Host-Header", () => {
// Ein Passkey haengt fest an diesem Namen — hinter einem Reverse Proxy waere
// der Host-Header der falsche Ort, ihn herzunehmen.
assert.equal(env.passkeys.rpId, new URL(env.appUrl).hostname);
assert.equal(env.passkeys.rpId, "localhost");
});
test("ueber localhost sind Passkeys erlaubt, ueber nacktes HTTP nicht", () => {
assert.equal(env.passkeys.isAvailable, true, "localhost gilt als sicherer Kontext");
});
test("Passkeys lassen sich auflisten, umbenennen und entfernen", async () => {
const passkey = await passkeyAnlegen(mitPasswortId, "Erstes Geraet");
const liste = await listPasskeys(mitPasswortId);
assert.equal(liste.length, 1);
assert.equal(liste[0]!.name, "Erstes Geraet");
assert.equal(liste[0]!.lastUsedAt, null, "frisch angelegt, noch nicht benutzt");
await renamePasskey(mitPasswortId, passkey.id, " Yubikey am Schluesselbund ");
assert.equal(
(await listPasskeys(mitPasswortId))[0]!.name,
"Yubikey am Schluesselbund",
"Leerraum wird abgeschnitten",
);
await deletePasskey(mitPasswortId, passkey.id);
assert.equal((await listPasskeys(mitPasswortId)).length, 0);
});
test("fremde Passkeys sind unerreichbar", async () => {
const fremd = await passkeyAnlegen(ohnePasswortId, "Fremdes Geraet");
await assert.rejects(() => renamePasskey(mitPasswortId, fremd.id, "Meiner"), /gibt es nicht/);
await assert.rejects(() => deletePasskey(mitPasswortId, fremd.id), /gibt es nicht/);
// Und er steht unveraendert beim Eigentuemer.
const liste = await listPasskeys(ohnePasswortId);
assert.equal(liste.length, 1);
assert.equal(liste[0]!.name, "Fremdes Geraet");
});
test("der letzte Anmeldeweg laesst sich nicht entfernen", async () => {
// Konto ohne Passwort, nur ein Passkey: das Entfernen wuerde aussperren.
const einziger = await passkeyAnlegen(ohnePasswortId, "Einziger Weg");
await assert.rejects(
() => deletePasskey(ohnePasswortId, einziger.id),
/letzter Anmeldeweg/,
"ohne anderen Weg bleibt der Passkey stehen",
);
assert.equal((await listPasskeys(ohnePasswortId)).length, 1);
// Mit einem zweiten Passkey geht es dann.
await passkeyAnlegen(ohnePasswortId, "Zweiter Weg");
await deletePasskey(ohnePasswortId, einziger.id);
assert.equal((await listPasskeys(ohnePasswortId)).length, 1);
});
test("mit Passwort darf auch der einzige Passkey weg", async () => {
const passkey = await passkeyAnlegen(mitPasswortId, "Einziger Passkey");
await deletePasskey(mitPasswortId, passkey.id);
assert.equal((await listPasskeys(mitPasswortId)).length, 0);
});
test("das Loeschen eines Kontos nimmt seine Passkeys mit", async () => {
const email = "test-passkey-weg@flimmer.invalid";
await prisma.user.deleteMany({ where: { email } });
const user = await prisma.user.create({ data: { email, name: "Weg" }, select: { id: true } });
await passkeyAnlegen(user.id, "Verschwindet");
await prisma.user.delete({ where: { id: user.id } });
assert.equal(await prisma.passkey.count({ where: { userId: user.id } }), 0);
});
+71
View File
@@ -12,9 +12,11 @@ import {
addTitle, addTitle,
createOrder, createOrder,
createUniverse, createUniverse,
deleteOrder,
getAccess, getAccess,
getUniverse, getUniverse,
listUniverses, listUniverses,
setDefaultOrder,
setOrderPositions, setOrderPositions,
shareUniverse, shareUniverse,
trashUniverse, trashUniverse,
@@ -280,3 +282,72 @@ test("gelöschte Universen verschwinden aus der Übersicht", async () => {
const list = await listUniverses(ownerId); const list = await listUniverses(ownerId);
assert.equal(list.find((item) => item.id === universe.id), undefined); assert.equal(list.find((item) => item.id === universe.id), undefined);
}); });
// --- Standardreihenfolge ------------------------------------------------------
test("die Standardreihenfolge lässt sich umstellen und bleibt eindeutig", async () => {
const universe = await createUniverse(ownerId, "Standardwechsel");
await addCollection(ownerId, universe.id, COLLECTION_ID);
const order = await createOrder(ownerId, universe.id, "Chronologisch");
// Frisch angelegt führt die Erscheinungsreihenfolge.
const vorher = await getUniverse(ownerId, universe.id);
assert.equal(vorher?.orders.find((o) => o.isDefault)?.kind, "RELEASE");
await setDefaultOrder(ownerId, order.id);
// Ohne Angabe in der Adresse erscheint jetzt die eigene Reihenfolge.
const nachher = await getUniverse(ownerId, universe.id);
assert.equal(nachher?.activeOrderId, order.id);
const standards = nachher!.orders.filter((o) => o.isDefault);
assert.equal(standards.length, 1, "es steht immer genau ein Standard");
assert.equal(standards[0]!.id, order.id, "und zwar der zuletzt gesetzte");
});
test("die Adresse schlägt die Standardreihenfolge", async () => {
const universe = await createUniverse(ownerId, "Adressvorrang");
await addCollection(ownerId, universe.id, COLLECTION_ID);
const order = await createOrder(ownerId, universe.id, "Chronologisch");
await setDefaultOrder(ownerId, order.id);
const release = (await getUniverse(ownerId, universe.id))!.orders.find(
(o) => o.kind === "RELEASE",
)!;
const view = await getUniverse(ownerId, universe.id, release.id);
assert.equal(view?.activeOrderId, release.id, "die angeforderte gewinnt gegen den Standard");
});
test("wird die Standardreihenfolge gelöscht, übernimmt die Erscheinungsreihenfolge", async () => {
const universe = await createUniverse(ownerId, "Rueckfall");
await addCollection(ownerId, universe.id, COLLECTION_ID);
const order = await createOrder(ownerId, universe.id, "Chronologisch");
await setDefaultOrder(ownerId, order.id);
await deleteOrder(ownerId, order.id);
const view = await getUniverse(ownerId, universe.id);
const standards = view!.orders.filter((o) => o.isDefault);
assert.equal(standards.length, 1, "das Universum bleibt nicht ohne Standard zurück");
assert.equal(standards[0]!.kind, "RELEASE");
assert.equal(view?.activeOrderId, standards[0]!.id);
});
test("den Standard setzen darf nur, wer schreiben darf", async () => {
const universe = await createUniverse(ownerId, "Rechteprobe");
await addCollection(ownerId, universe.id, COLLECTION_ID);
const order = await createOrder(ownerId, universe.id, "Chronologisch");
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "READ");
await assert.rejects(() => setDefaultOrder(guestId, order.id), /nur ansehen/);
// Der Standard steht danach unverändert auf der Erscheinungsreihenfolge.
const unveraendert = await getUniverse(ownerId, universe.id);
assert.equal(unveraendert?.orders.find((o) => o.isDefault)?.kind, "RELEASE");
// Mit Schreibrecht geht es dann.
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "WRITE");
await setDefaultOrder(guestId, order.id);
assert.equal((await getUniverse(guestId, universe.id))?.activeOrderId, order.id);
});
+72 -4
View File
@@ -39,6 +39,10 @@ Ablauf: Datenbank sichern → Änderungen holen → Image bauen → neu starten
warten, bis die Instanz wieder antwortet. Die Datenbankmigrationen spielt der warten, bis die Instanz wieder antwortet. Die Datenbankmigrationen spielt der
Container beim Start selbst ein. Container beim Start selbst ein.
Geprüft wird zuerst http://127.0.0.1:APP_PORT/api/health. Ist der Port gar
nicht auf dem Host veröffentlicht — üblich hinter einem Reverse Proxy —,
zählt stattdessen der Healthcheck des Containers.
Optionen: Optionen:
--ohne-backup Keine Sicherung anlegen (nicht empfohlen) --ohne-backup Keine Sicherung anlegen (nicht empfohlen)
--ohne-pull Nichts aus dem Repository holen, nur neu bauen und starten --ohne-pull Nichts aus dem Repository holen, nur neu bauen und starten
@@ -196,31 +200,95 @@ gut "Container laufen."
schritt "Auf die Instanz warten" schritt "Auf die Instanz warten"
info "Die Datenbankmigrationen spielt der Container beim Start selbst ein." info "Die Datenbankmigrationen spielt der Container beim Start selbst ein."
# Der Container meldet seinen Zustand selbst — der HEALTHCHECK im Image fragt
# /api/health von innen ab. Das ist der verlässlichere Weg: hinter einem
# Reverse Proxy ist der App-Port oft gar nicht auf dem Host veröffentlicht,
# und dann sagt ein fehlschlagendes curl nichts über die Instanz aus.
# Gibt immer 0 zurück: die Funktion läuft auch im Fehlerpfad, und unter
# `set -e` dürfte eine Auskunft über den Zustand nicht selbst das Skript
# abbrechen, bevor der Weg zurück ausgegeben wurde.
container_zustand() {
local id
id=$($COMPOSE ps -q app 2>/dev/null || true)
if [ -z "$id" ]; then
printf 'kein-container'
return 0
fi
# Ohne HEALTHCHECK im Image ist .State.Health leer — dann bleibt der
# Laufzustand (running/exited) als grobe Auskunft.
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}ohne-healthcheck:{{.State.Status}}{{end}}' \
"$id" 2>/dev/null || printf 'unbekannt'
return 0
}
GESUND=0 GESUND=0
UEBER_CONTAINER=0
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
if curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1; then if curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1; then
GESUND=1 GESUND=1
break break
fi fi
if [ "$(container_zustand)" = "healthy" ]; then
GESUND=1
UEBER_CONTAINER=1
break
fi
sleep 2 sleep 2
done done
if [ "$GESUND" -eq 1 ]; then if [ "$GESUND" -eq 1 ]; then
ANTWORT=$(curl -fsS "http://127.0.0.1:${PORT}/api/health" 2>/dev/null || true) if [ "$UEBER_CONTAINER" -eq 1 ]; then
gut "Flimmer antwortet auf Port ${PORT}." gut "Der Container meldet sich gesund."
leise "$ANTWORT" leise "Auf Port ${PORT} war nichts zu erreichen — das ist normal, wenn die App"
leise "nur über einen Reverse Proxy erreichbar ist und den Port nicht veröffentlicht."
else
ANTWORT=$(curl -fsS "http://127.0.0.1:${PORT}/api/health" 2>/dev/null || true)
gut "Flimmer antwortet auf Port ${PORT}."
leise "$ANTWORT"
fi
printf '\n%s✓ Fertig — Version %s%s\n' "$GUT" "$NEUE_VERSION" "$AUS" printf '\n%s✓ Fertig — Version %s%s\n' "$GUT" "$NEUE_VERSION" "$AUS"
if [ "$VORHER_VERSION" != "$NEUE_VERSION" ]; then if [ "$VORHER_VERSION" != "$NEUE_VERSION" ]; then
info "Was sich geändert hat, steht in CHANGELOG.md." info "Was sich geändert hat, steht in CHANGELOG.md."
fi fi
[ -n "$SICHERUNG" ] && leise "Sicherung: $SICHERUNG" if [ -n "$SICHERUNG" ]; then
leise "Sicherung: $SICHERUNG"
fi
exit 0 exit 0
fi fi
# --- Fehlschlag: konkreter Weg zurück ---------------------------------------- # --- Fehlschlag: konkreter Weg zurück ----------------------------------------
printf '\n%s✗ Die Instanz antwortet nach zwei Minuten nicht.%s\n\n' "$FEHLER" "$AUS" >&2 printf '\n%s✗ Die Instanz antwortet nach zwei Minuten nicht.%s\n\n' "$FEHLER" "$AUS" >&2
# Der Container-Zustand grenzt ein, wo es klemmt — ohne ihn sähe ein nicht
# veröffentlichter Port genauso aus wie eine abgestürzte App.
ZUSTAND=$(container_zustand)
case "$ZUSTAND" in
kein-container)
info "Es läuft gar kein app-Container. Der Start ist offenbar fehlgeschlagen."
;;
starting)
info "Der Container startet noch. Möglicherweise dauern die Migrationen länger —"
info "sieh im Log nach und gib ihm etwas Zeit."
;;
unhealthy)
info "Der Container läuft, meldet sich aber als nicht gesund. Der Fehler liegt"
info "also in der Anwendung, nicht am Port."
;;
ohne-healthcheck:running)
info "Der Container läuft, das Image bringt aber keinen Healthcheck mit (ältere"
info "Fassung). Ob die App wirklich antwortet, verrät nur das Log."
;;
ohne-healthcheck:*)
info "Der Container ist nicht gelaufen: ${ZUSTAND#ohne-healthcheck:}"
;;
*)
info "Container-Zustand: ${ZUSTAND}"
;;
esac
printf '\n'
info "Log ansehen:" info "Log ansehen:"
printf ' %s logs --tail 50 app\n\n' "$COMPOSE" printf ' %s logs --tail 50 app\n\n' "$COMPOSE"