Compare commits
14
Commits
v0.7.0
...
824cc7764c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
824cc7764c | ||
|
|
c0579c6f61 | ||
|
|
58a1d177df | ||
|
|
d04248bec9 | ||
|
|
605afe556d | ||
|
|
39098cd201 | ||
|
|
fd1ab2918a | ||
|
|
40fb49fb21 | ||
|
|
5673055216 | ||
|
|
853d56f764 | ||
|
|
9d4e419cd1 | ||
|
|
a853b066b5 | ||
|
|
4ad5b244bc | ||
|
|
19f8003945 |
@@ -4,6 +4,7 @@ node_modules
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
data
|
data
|
||||||
|
backups
|
||||||
docs/screenshots
|
docs/screenshots
|
||||||
*.log
|
*.log
|
||||||
test-results
|
test-results
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ AUTH_SECRET=bitte-durch-zufallswert-ersetzen
|
|||||||
# Ablage für Bild-Cache und sonstige Laufzeitdaten.
|
# Ablage für Bild-Cache und sonstige Laufzeitdaten.
|
||||||
DATA_DIR=/app/data
|
DATA_DIR=/app/data
|
||||||
|
|
||||||
|
# Port, auf dem Flimmer auf dem Host lauscht.
|
||||||
|
APP_PORT=3000
|
||||||
|
|
||||||
# Zeitzone für Kalender, Statistiken und geplante Läufe
|
# Zeitzone für Kalender, Statistiken und geplante Läufe
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
@@ -68,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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ next-env.d.ts
|
|||||||
|
|
||||||
# Produktion / Laufzeit
|
# Produktion / Laufzeit
|
||||||
data/
|
data/
|
||||||
|
backups/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Umgebung — niemals committen!
|
# Umgebung — niemals committen!
|
||||||
|
|||||||
+28
-1
@@ -7,6 +7,32 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [0.7.1] — 2026-09-01
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **`update.sh`** für den Betrieb unter Linux — ausführbar im Repository
|
||||||
|
hinterlegt, also direkt nach dem Klonen startklar. Der Ablauf: Datenbank
|
||||||
|
sichern, Änderungen holen, Image bauen, neu starten, warten bis die Instanz
|
||||||
|
wieder antwortet.
|
||||||
|
|
||||||
|
Grundhaltung: erst sichern, dann anfassen. Schlägt die Sicherung fehl, bricht
|
||||||
|
das Skript ab, statt weiterzumachen. Geht danach etwas schief, nennt es den
|
||||||
|
**konkreten** Weg zurück — mit dem Commit, zu dem man zurückspringt, und der
|
||||||
|
Datei, die man einspielt, nicht bloß dem Rat, ein Backup zu haben.
|
||||||
|
|
||||||
|
Weitere Eigenheiten:
|
||||||
|
- Lokale Änderungen im Arbeitsverzeichnis werden erkannt; `git pull`
|
||||||
|
unterbleibt dann, statt sie zu überfahren.
|
||||||
|
- Erreichbarkeit wird über den veröffentlichten Port geprüft und, falls der
|
||||||
|
nicht nach außen geht (Reverse Proxy im selben Docker-Netz), über den
|
||||||
|
Healthcheck des Containers.
|
||||||
|
- Alte Sicherungen werden aufgeräumt, die letzten zehn bleiben (`--behalte N`).
|
||||||
|
- Kommt mit `docker compose` und dem alten `docker-compose` zurecht.
|
||||||
|
|
||||||
|
- `APP_PORT` in der `.env.example` — der Port stand bisher nur in der
|
||||||
|
Compose-Datei als Vorgabewert.
|
||||||
|
|
||||||
## [0.7.0] — 2026-09-01
|
## [0.7.0] — 2026-09-01
|
||||||
|
|
||||||
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
|
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
|
||||||
@@ -321,7 +347,8 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
|
|||||||
dass weitere Sprachen als zusätzliche Datei dazukommen können.
|
dass weitere Sprachen als zusätzliche Datei dazukommen können.
|
||||||
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
|
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
|
||||||
|
|
||||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.7.0...HEAD
|
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.7.1...HEAD
|
||||||
|
[0.7.1]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.1
|
||||||
[0.7.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.0
|
[0.7.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.0
|
||||||
[0.6.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.0
|
[0.6.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.0
|
||||||
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
|
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|
||||||
|
|||||||
@@ -114,14 +114,31 @@ flimmer.example.com {
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd flimmer
|
cd flimmer
|
||||||
git pull
|
./update.sh
|
||||||
docker compose build
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Datenbankmigrationen laufen automatisch beim Start des Containers — ein eigener
|
Das Skript sichert zuerst die Datenbank, holt die Änderungen, baut das Image neu
|
||||||
Handgriff ist nicht nötig. **Vor** einem Update ein Backup zu ziehen ist
|
und wartet, bis die Instanz wieder antwortet. Schlägt etwas fehl, nennt es den
|
||||||
trotzdem eine gute Angewohnheit.
|
konkreten Weg zurück — mit dem Befehl und der Sicherungsdatei, nicht bloß dem
|
||||||
|
Rat, ein Backup zu haben.
|
||||||
|
|
||||||
|
```
|
||||||
|
./update.sh --ohne-backup ohne Datenbanksicherung
|
||||||
|
./update.sh --ohne-pull nur neu bauen und starten
|
||||||
|
./update.sh --behalte 20 wie viele Sicherungen aufgehoben werden (Standard: 10)
|
||||||
|
./update.sh --hilfe
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Sicherungen landen unter `backups/` und sind gzip-komprimierte `pg_dump`s.
|
||||||
|
|
||||||
|
Von Hand geht es genauso:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull && docker compose build && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Datenbankmigrationen laufen in beiden Fällen automatisch beim Start des
|
||||||
|
Containers — ein eigener Handgriff ist nicht nötig.
|
||||||
|
|
||||||
## Backup
|
## Backup
|
||||||
|
|
||||||
@@ -161,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+27
-1
@@ -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
|
||||||
@@ -149,11 +162,24 @@ 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
|
||||||
|
|
||||||
|
|||||||
Generated
+298
-2
@@ -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",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flimmer",
|
"name": "flimmer",
|
||||||
"version": "0.7.0",
|
"version": "0.7.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Flimmer 0.7.1
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **`update.sh`** für den Betrieb unter Linux — ausführbar im Repository
|
||||||
|
hinterlegt, also direkt nach dem Klonen startklar. Der Ablauf: Datenbank
|
||||||
|
sichern, Änderungen holen, Image bauen, neu starten, warten bis die Instanz
|
||||||
|
wieder antwortet.
|
||||||
|
|
||||||
|
Grundhaltung: erst sichern, dann anfassen. Schlägt die Sicherung fehl, bricht
|
||||||
|
das Skript ab, statt weiterzumachen. Geht danach etwas schief, nennt es den
|
||||||
|
**konkreten** Weg zurück — mit dem Commit, zu dem man zurückspringt, und der
|
||||||
|
Datei, die man einspielt, nicht bloß dem Rat, ein Backup zu haben.
|
||||||
|
|
||||||
|
Weitere Eigenheiten:
|
||||||
|
- Lokale Änderungen im Arbeitsverzeichnis werden erkannt; `git pull`
|
||||||
|
unterbleibt dann, statt sie zu überfahren.
|
||||||
|
- Erreichbarkeit wird über den veröffentlichten Port geprüft und, falls der
|
||||||
|
nicht nach außen geht (Reverse Proxy im selben Docker-Netz), über den
|
||||||
|
Healthcheck des Containers.
|
||||||
|
- Alte Sicherungen werden aufgeräumt, die letzten zehn bleiben (`--behalte N`).
|
||||||
|
- Kommt mit `docker compose` und dem alten `docker-compose` zurecht.
|
||||||
|
|
||||||
|
- `APP_PORT` in der `.env.example` — der Port stand bisher nur in der
|
||||||
|
Compose-Datei als Vorgabewert.
|
||||||
@@ -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">
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{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}
|
{env.oidc.buttonLabel}
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,8 +57,10 @@ export default async function LibraryPage({
|
|||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<SearchPendingProvider>
|
||||||
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
|
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
|
||||||
|
|
||||||
|
<SearchResults>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="fl-card p-8 text-center">
|
<div className="fl-card p-8 text-center">
|
||||||
@@ -89,6 +92,8 @@ export default async function LibraryPage({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</SearchResults>
|
||||||
|
</SearchPendingProvider>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,6 +55,7 @@ export default async function SearchPage({
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<SearchPendingProvider>
|
||||||
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
|
<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)]">
|
||||||
@@ -63,11 +65,12 @@ export default async function SearchPage({
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<SearchResults>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
{!env.tmdb.isConfigured ? (
|
{!env.tmdb.isConfigured ? (
|
||||||
<Notice tone="warning">
|
<Notice tone="warning">
|
||||||
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in der{" "}
|
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in
|
||||||
<code>.env</code> ein und starte die Instanz neu.
|
der <code>.env</code> ein und starte die Instanz neu.
|
||||||
</Notice>
|
</Notice>
|
||||||
) : query.length < 2 ? (
|
) : query.length < 2 ? (
|
||||||
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
|
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
|
||||||
@@ -75,6 +78,8 @@ export default async function SearchPage({
|
|||||||
<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)]">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,8 +74,9 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
|||||||
: ""}
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{showProgress && (
|
|
||||||
<div className="pt-0.5">
|
<div className="pt-0.5">
|
||||||
|
{showProgress ? (
|
||||||
|
<>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
percent={item.percent}
|
percent={item.percent}
|
||||||
label={`${item.watched} von ${item.total} Episoden gesehen`}
|
label={`${item.watched} von ${item.total} Episoden gesehen`}
|
||||||
@@ -83,9 +84,19 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
|||||||
<p className="mt-1 text-xs text-[var(--fl-faint)]">
|
<p className="mt-1 text-xs text-[var(--fl-faint)]">
|
||||||
{item.watched}/{item.total} Episoden
|
{item.watched}/{item.total} Episoden
|
||||||
</p>
|
</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"> </p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +71,7 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
@@ -70,6 +80,8 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
|||||||
aria-label="Bibliothek durchsuchen"
|
aria-label="Bibliothek durchsuchen"
|
||||||
className="fl-input"
|
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 })}>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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. */
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Flimmer aktualisieren.
|
||||||
|
#
|
||||||
|
# ./update.sh sichern, holen, bauen, neu starten
|
||||||
|
# ./update.sh --ohne-backup ohne Datenbanksicherung
|
||||||
|
# ./update.sh --ohne-pull nur neu bauen und starten (kein git pull)
|
||||||
|
# ./update.sh --hilfe
|
||||||
|
#
|
||||||
|
# Grundhaltung: Erst sichern, dann anfassen. Bricht etwas ab, steht am Ende,
|
||||||
|
# wie der Weg zurück aussieht — mit dem konkreten Befehl und der konkreten
|
||||||
|
# Sicherungsdatei, nicht bloß dem Rat, ein Backup zu haben.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Ausgabe -----------------------------------------------------------------
|
||||||
|
|
||||||
|
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||||
|
AKZENT=$'\033[38;5;214m'; GUT=$'\033[32m'; WARN=$'\033[33m'
|
||||||
|
FEHLER=$'\033[31m'; LEISE=$'\033[2m'; AUS=$'\033[0m'
|
||||||
|
else
|
||||||
|
AKZENT=""; GUT=""; WARN=""; FEHLER=""; LEISE=""; AUS=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
schritt() { printf '\n%s▸ %s%s\n' "$AKZENT" "$1" "$AUS"; }
|
||||||
|
info() { printf ' %s\n' "$1"; }
|
||||||
|
leise() { printf ' %s%s%s\n' "$LEISE" "$1" "$AUS"; }
|
||||||
|
gut() { printf ' %s✓ %s%s\n' "$GUT" "$1" "$AUS"; }
|
||||||
|
warnung() { printf ' %s! %s%s\n' "$WARN" "$1" "$AUS"; }
|
||||||
|
abbruch() { printf '\n%s✗ %s%s\n' "$FEHLER" "$1" "$AUS" >&2; exit 1; }
|
||||||
|
|
||||||
|
hilfe() {
|
||||||
|
cat <<'ENDE'
|
||||||
|
Flimmer aktualisieren
|
||||||
|
|
||||||
|
./update.sh [OPTIONEN]
|
||||||
|
|
||||||
|
Ablauf: Datenbank sichern → Änderungen holen → Image bauen → neu starten →
|
||||||
|
warten, bis die Instanz wieder antwortet. Die Datenbankmigrationen spielt der
|
||||||
|
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:
|
||||||
|
--ohne-backup Keine Sicherung anlegen (nicht empfohlen)
|
||||||
|
--ohne-pull Nichts aus dem Repository holen, nur neu bauen und starten
|
||||||
|
--behalte N Wie viele Sicherungen aufgehoben werden (Standard: 10)
|
||||||
|
--hilfe, -h Diese Übersicht
|
||||||
|
|
||||||
|
Die Sicherungen liegen unter backups/ und sind gzip-komprimierte pg_dumps.
|
||||||
|
ENDE
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Optionen ----------------------------------------------------------------
|
||||||
|
|
||||||
|
MIT_BACKUP=1
|
||||||
|
MIT_PULL=1
|
||||||
|
BEHALTE=10
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--ohne-backup) MIT_BACKUP=0 ;;
|
||||||
|
--ohne-pull) MIT_PULL=0 ;;
|
||||||
|
--behalte) shift; BEHALTE="${1:-10}" ;;
|
||||||
|
--hilfe|-h) hilfe; exit 0 ;;
|
||||||
|
*) abbruch "Unbekannte Option: $1 (./update.sh --hilfe)" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Voraussetzungen ---------------------------------------------------------
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
[ -f docker-compose.yml ] || abbruch "Hier liegt keine docker-compose.yml. Das Skript gehört ins Flimmer-Verzeichnis."
|
||||||
|
[ -f .env ] || abbruch "Es fehlt die .env. Lege sie an: cp .env.example .env"
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || abbruch "docker ist nicht installiert oder nicht im Pfad."
|
||||||
|
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
COMPOSE="docker compose"
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
COMPOSE="docker-compose"
|
||||||
|
else
|
||||||
|
abbruch "Weder 'docker compose' noch 'docker-compose' gefunden."
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker info >/dev/null 2>&1 || abbruch "Der Docker-Dienst antwortet nicht. Läuft er?"
|
||||||
|
|
||||||
|
# Werte aus der .env lesen, ohne sie auszuführen.
|
||||||
|
env_wert() {
|
||||||
|
local schluessel="$1" standard="${2:-}"
|
||||||
|
local zeile
|
||||||
|
zeile=$(grep -E "^[[:space:]]*${schluessel}=" .env 2>/dev/null | tail -1 || true)
|
||||||
|
if [ -z "$zeile" ]; then printf '%s' "$standard"; return; fi
|
||||||
|
zeile="${zeile#*=}"
|
||||||
|
zeile="${zeile%\"}"; zeile="${zeile#\"}"
|
||||||
|
zeile="${zeile%\'}"; zeile="${zeile#\'}"
|
||||||
|
printf '%s' "$zeile"
|
||||||
|
}
|
||||||
|
|
||||||
|
DB_USER=$(env_wert POSTGRES_USER flimmer)
|
||||||
|
DB_NAME=$(env_wert POSTGRES_DB flimmer)
|
||||||
|
PORT=$(env_wert APP_PORT 3000)
|
||||||
|
|
||||||
|
printf '%s\n' "Flimmer aktualisieren"
|
||||||
|
leise "Verzeichnis: $(pwd)"
|
||||||
|
leise "Compose: $COMPOSE"
|
||||||
|
|
||||||
|
# --- Stand festhalten --------------------------------------------------------
|
||||||
|
|
||||||
|
IST_GIT=0
|
||||||
|
VORHER_REF=""
|
||||||
|
if [ -d .git ] && command -v git >/dev/null 2>&1; then
|
||||||
|
IST_GIT=1
|
||||||
|
VORHER_REF=$(git rev-parse --short HEAD 2>/dev/null || echo "unbekannt")
|
||||||
|
fi
|
||||||
|
|
||||||
|
VORHER_VERSION=$(grep -m1 '"version"' package.json 2>/dev/null | sed 's/.*"version"[^"]*"\([^"]*\)".*/\1/' || echo "?")
|
||||||
|
leise "Aktuell: Version $VORHER_VERSION${VORHER_REF:+ (${VORHER_REF})}"
|
||||||
|
|
||||||
|
# --- Sicherung ---------------------------------------------------------------
|
||||||
|
|
||||||
|
SICHERUNG=""
|
||||||
|
if [ "$MIT_BACKUP" -eq 1 ]; then
|
||||||
|
schritt "Datenbank sichern"
|
||||||
|
|
||||||
|
# -q gibt es in beiden Compose-Fassungen; --status erst ab v2.
|
||||||
|
if [ -z "$($COMPOSE ps -q db 2>/dev/null)" ]; then
|
||||||
|
warnung "Die Datenbank läuft nicht — es gibt also auch nichts zu sichern."
|
||||||
|
else
|
||||||
|
mkdir -p backups
|
||||||
|
SICHERUNG="backups/flimmer-$(date +%Y-%m-%d-%H%M%S).sql.gz"
|
||||||
|
|
||||||
|
if $COMPOSE exec -T db pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists \
|
||||||
|
| gzip > "$SICHERUNG" 2>/dev/null && [ -s "$SICHERUNG" ]; then
|
||||||
|
gut "$SICHERUNG ($(du -h "$SICHERUNG" | cut -f1))"
|
||||||
|
else
|
||||||
|
rm -f "$SICHERUNG"
|
||||||
|
SICHERUNG=""
|
||||||
|
abbruch "Die Sicherung ist fehlgeschlagen. Abbruch — ohne Sicherung wird hier nichts aktualisiert.
|
||||||
|
Wenn du es trotzdem willst: ./update.sh --ohne-backup"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ältere Sicherungen aufräumen, die jüngsten bleiben.
|
||||||
|
if [ "$BEHALTE" -gt 0 ]; then
|
||||||
|
ANZAHL=$(ls -1 backups/flimmer-*.sql.gz 2>/dev/null | wc -l)
|
||||||
|
if [ "$ANZAHL" -gt "$BEHALTE" ]; then
|
||||||
|
ls -1t backups/flimmer-*.sql.gz | tail -n +$((BEHALTE + 1)) | xargs -r rm --
|
||||||
|
leise "Ältere Sicherungen entfernt, die letzten $BEHALTE bleiben."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warnung "Ohne Sicherung — auf eigenes Risiko."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Änderungen holen --------------------------------------------------------
|
||||||
|
|
||||||
|
if [ "$MIT_PULL" -eq 1 ]; then
|
||||||
|
schritt "Änderungen holen"
|
||||||
|
|
||||||
|
if [ "$IST_GIT" -eq 0 ]; then
|
||||||
|
warnung "Kein Git-Repository — übersprungen."
|
||||||
|
elif [ -n "$(git status --porcelain 2>/dev/null)" ]; then
|
||||||
|
warnung "Es gibt lokale Änderungen; git pull wird übersprungen."
|
||||||
|
leise "Ansehen mit: git status"
|
||||||
|
else
|
||||||
|
ZWEIG=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
if git pull --ff-only 2>&1 | sed 's/^/ /'; then
|
||||||
|
NACHHER_REF=$(git rev-parse --short HEAD)
|
||||||
|
if [ "$NACHHER_REF" = "$VORHER_REF" ]; then
|
||||||
|
gut "Schon auf dem neuesten Stand ($ZWEIG)."
|
||||||
|
else
|
||||||
|
gut "$VORHER_REF → $NACHHER_REF"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
abbruch "git pull ist fehlgeschlagen. Vermutlich sind Zweig und Gegenstelle auseinandergelaufen."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
NEUE_VERSION=$(grep -m1 '"version"' package.json 2>/dev/null | sed 's/.*"version"[^"]*"\([^"]*\)".*/\1/' || echo "?")
|
||||||
|
|
||||||
|
# --- Bauen und starten -------------------------------------------------------
|
||||||
|
|
||||||
|
schritt "Image bauen"
|
||||||
|
info "Das dauert beim ersten Mal einige Minuten."
|
||||||
|
$COMPOSE build 2>&1 | sed 's/^/ /' || abbruch "Der Build ist fehlgeschlagen. Die laufende Instanz ist unangetastet."
|
||||||
|
gut "Gebaut."
|
||||||
|
|
||||||
|
schritt "Neu starten"
|
||||||
|
$COMPOSE up -d 2>&1 | sed 's/^/ /' || abbruch "Der Start ist fehlgeschlagen. Log ansehen: $COMPOSE logs -f app"
|
||||||
|
gut "Container laufen."
|
||||||
|
|
||||||
|
# --- Warten, bis die Instanz antwortet ---------------------------------------
|
||||||
|
|
||||||
|
schritt "Auf die Instanz warten"
|
||||||
|
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
|
||||||
|
UEBER_CONTAINER=0
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1; then
|
||||||
|
GESUND=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$(container_zustand)" = "healthy" ]; then
|
||||||
|
GESUND=1
|
||||||
|
UEBER_CONTAINER=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$GESUND" -eq 1 ]; then
|
||||||
|
if [ "$UEBER_CONTAINER" -eq 1 ]; then
|
||||||
|
gut "Der Container meldet sich gesund."
|
||||||
|
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"
|
||||||
|
if [ "$VORHER_VERSION" != "$NEUE_VERSION" ]; then
|
||||||
|
info "Was sich geändert hat, steht in CHANGELOG.md."
|
||||||
|
fi
|
||||||
|
if [ -n "$SICHERUNG" ]; then
|
||||||
|
leise "Sicherung: $SICHERUNG"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Fehlschlag: konkreter Weg zurück ----------------------------------------
|
||||||
|
|
||||||
|
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:"
|
||||||
|
printf ' %s logs --tail 50 app\n\n' "$COMPOSE"
|
||||||
|
|
||||||
|
if [ -n "$SICHERUNG" ] || [ -n "$VORHER_REF" ]; then
|
||||||
|
info "Weg zurück:"
|
||||||
|
if [ "$IST_GIT" -eq 1 ] && [ -n "$VORHER_REF" ]; then
|
||||||
|
printf ' git checkout %s && %s up -d --build\n' "$VORHER_REF" "$COMPOSE"
|
||||||
|
fi
|
||||||
|
if [ -n "$SICHERUNG" ]; then
|
||||||
|
printf ' gunzip -c %s | %s exec -T db psql -U %s -d %s\n' \
|
||||||
|
"$SICHERUNG" "$COMPOSE" "$DB_USER" "$DB_NAME"
|
||||||
|
fi
|
||||||
|
printf '\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 1
|
||||||
Reference in New Issue
Block a user