CI / pruefen (pull_request) Canceled after 0s
Ein Passkey ist der dritte Weg in denselben Trichter: finishLogin() prueft die Antwort und gibt die Kennung zurueck, der Endpunkt legt damit ueber createSession() dieselbe Sitzung an wie bei Passwort und OIDC. Die Sitzungsschicht bleibt unangetastet. Die Trennung zwischen Pruefung und Sitzung ist Absicht: in passkeys.ts steht nur WebAuthn, das Setzen von Cookie und Sitzungszeile gehoert zur HTTP-Schicht. Die Challenge liegt in einem signierten, fuenf Minuten gueltigen httpOnly- Cookie statt in einer Tabelle — das spart Schema und Aufraeumjob fuer alles, was liegen bleibt, wenn jemand den Dialog abbricht. Die RP ID kommt aus APP_URL, nicht aus dem Host-Header. Hinter einem Reverse Proxy hinge sie sonst am falschen Namen, und ein Passkey ist fest an diesen Namen gebunden. Ueber nacktes HTTP verschwindet die Verwaltung mit einem Hinweis statt einen kryptischen Browserfehler zu zeigen. Den letzten verbleibenden Anmeldeweg laesst sich nicht entfernen: wer kein Passwort und keine OIDC-Verknuepfung hat, behaelt seinen letzten Passkey. Die Bibliothek prueft den Signaturzaehler selbst und wirft dabei gewoehnliche Error. Die werden jetzt uebersetzt, damit eine erwartete Ablehnung nicht als unerwarteter Serverfehler im Log landet. Die eigene Zaehlerpruefung bleibt als zusaetzliche Sicherung fuer den Fall, den die Bibliothek durchlaesst. Geprueft mit virtuellem Authenticator gegen einen Produktionsbau: anlegen, abmelden, Anmeldung allein per Passkey, Sitzungscookie gesetzt, Challenge-Cookie verbraucht, Passwortweg unveraendert. Mit kuenstlich hochgesetztem Zaehler wird die Anmeldung abgelehnt und der Zaehler bleibt stehen. Dazu sieben Tests fuer Verwaltung, Rechte und die Aussperrsperre. Refs #6
551 lines
14 KiB
Plaintext
551 lines
14 KiB
Plaintext
// Flimmer — Datenmodell
|
||
//
|
||
// Aufbau in vier Schichten:
|
||
// 1. Konten & Zugang (User, Session, ApiKey, OidcAccount, AppSetting)
|
||
// 2. Katalog (Title, Season, Episode, WatchProvider …)
|
||
// — geteilt zwischen allen Nutzern, dedupliziert über die TMDB-ID
|
||
// 3. Persönliches Tracking (LibraryEntry, Watch, Tag …)
|
||
// 4. Universen (Universe, UniverseItem, UniverseOrder …)
|
||
|
||
generator client {
|
||
provider = "prisma-client"
|
||
output = "../src/generated/prisma"
|
||
runtime = "nodejs"
|
||
}
|
||
|
||
datasource db {
|
||
provider = "postgresql"
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1. Konten & Zugang
|
||
// ---------------------------------------------------------------------------
|
||
|
||
enum Role {
|
||
ADMIN
|
||
USER
|
||
}
|
||
|
||
model User {
|
||
id String @id @default(cuid())
|
||
email String @unique
|
||
name String
|
||
passwordHash String?
|
||
role Role @default(USER)
|
||
isActive Boolean @default(true)
|
||
|
||
/// Region für die Streaming-Verfügbarkeit (ISO-3166-1, z. B. "DE")
|
||
region String @default("DE")
|
||
locale String @default("de")
|
||
|
||
/// Öffentliche Profilseite unter /u/<profileSlug>
|
||
publicProfile Boolean @default(false)
|
||
profileSlug String? @unique
|
||
|
||
// Benachrichtigungswünsche
|
||
notifyNewEpisode Boolean @default(true)
|
||
notifyRelease Boolean @default(true)
|
||
notifyAvailability Boolean @default(false)
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
sessions Session[]
|
||
apiKeys ApiKey[]
|
||
oidcAccounts OidcAccount[]
|
||
passkeys Passkey[]
|
||
entries LibraryEntry[]
|
||
watches Watch[]
|
||
tags Tag[]
|
||
universes Universe[] @relation("UniverseOwner")
|
||
memberships UniverseMember[]
|
||
channels NotificationChannel[]
|
||
notifications NotificationLog[]
|
||
|
||
@@index([createdAt])
|
||
}
|
||
|
||
model Session {
|
||
id String @id @default(cuid())
|
||
|
||
/// SHA-256 des Cookie-Tokens — das Klartext-Token verlässt den Server nie erneut
|
||
tokenHash String @unique
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
expiresAt DateTime
|
||
userAgent String?
|
||
ip String?
|
||
createdAt DateTime @default(now())
|
||
lastSeen DateTime @default(now())
|
||
|
||
@@index([userId])
|
||
@@index([expiresAt])
|
||
}
|
||
|
||
model ApiKey {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
name String
|
||
/// Sichtbares Präfix zur Wiedererkennung, z. B. "flm_a1b2c3d4"
|
||
prefix String @unique
|
||
keyHash String @unique
|
||
|
||
lastUsedAt DateTime?
|
||
revokedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([userId])
|
||
}
|
||
|
||
model OidcAccount {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
issuer String
|
||
subject String
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([issuer, subject])
|
||
@@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 …)
|
||
model AppSetting {
|
||
key String @id
|
||
value Json
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2. Katalog
|
||
// ---------------------------------------------------------------------------
|
||
|
||
enum TitleType {
|
||
SERIES
|
||
MOVIE
|
||
}
|
||
|
||
model Title {
|
||
id String @id @default(cuid())
|
||
type TitleType
|
||
|
||
/// TMDB-ID; null bei manuell angelegten Titeln ohne TMDB-Treffer
|
||
tmdbId Int?
|
||
imdbId String?
|
||
|
||
title String
|
||
originalTitle String?
|
||
overview String?
|
||
tagline String?
|
||
posterPath String?
|
||
backdropPath String?
|
||
|
||
/// Erstausstrahlung (Serie) bzw. Kinostart (Film)
|
||
releaseDate DateTime?
|
||
lastAirDate DateTime?
|
||
|
||
/// Laufzeit in Minuten: bei Filmen die Filmlänge, bei Serien die
|
||
/// durchschnittliche Episodenlänge (Rückfallwert für Episoden ohne Angabe)
|
||
runtime Int?
|
||
|
||
/// TMDB-Status: "Returning Series", "Ended", "Released", "Planned" …
|
||
status String?
|
||
inProduction Boolean @default(false)
|
||
|
||
genres String[]
|
||
originalLang String?
|
||
homepage String?
|
||
voteAverage Float?
|
||
|
||
seasonCount Int @default(0)
|
||
episodeCount Int @default(0)
|
||
|
||
/// Offizielle TMDB-Collection (z. B. „Spider-Man Collection")
|
||
tmdbCollectionId Int?
|
||
tmdbCollectionName String?
|
||
|
||
/// Zeitpunkt des letzten TMDB-Abgleichs
|
||
syncedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
seasons Season[]
|
||
episodes Episode[]
|
||
entries LibraryEntry[]
|
||
watches Watch[]
|
||
providers TitleProvider[]
|
||
universeItems UniverseItem[]
|
||
|
||
@@unique([type, tmdbId])
|
||
@@index([type])
|
||
@@index([releaseDate])
|
||
@@index([title])
|
||
@@index([tmdbCollectionId])
|
||
}
|
||
|
||
model Season {
|
||
id String @id @default(cuid())
|
||
titleId String
|
||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||
|
||
tmdbId Int?
|
||
seasonNumber Int
|
||
name String
|
||
overview String?
|
||
posterPath String?
|
||
airDate DateTime?
|
||
episodeCount Int @default(0)
|
||
|
||
episodes Episode[]
|
||
|
||
@@unique([titleId, seasonNumber])
|
||
@@index([titleId])
|
||
}
|
||
|
||
model Episode {
|
||
id String @id @default(cuid())
|
||
titleId String
|
||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||
seasonId String
|
||
season Season @relation(fields: [seasonId], references: [id], onDelete: Cascade)
|
||
|
||
tmdbId Int?
|
||
seasonNumber Int
|
||
episodeNumber Int
|
||
name String
|
||
overview String?
|
||
stillPath String?
|
||
airDate DateTime?
|
||
runtime Int?
|
||
|
||
watches Watch[]
|
||
|
||
@@unique([titleId, seasonNumber, episodeNumber])
|
||
@@index([titleId])
|
||
@@index([seasonId])
|
||
@@index([airDate])
|
||
}
|
||
|
||
/// Streamingdienst laut TMDB/JustWatch
|
||
model WatchProvider {
|
||
id String @id @default(cuid())
|
||
tmdbId Int @unique
|
||
name String
|
||
logoPath String?
|
||
/// Akzentfarbe für Badges im UI (Hex); fällt sonst auf einen Standardton zurück
|
||
color String?
|
||
|
||
titles TitleProvider[]
|
||
}
|
||
|
||
enum ProviderKind {
|
||
FLATRATE
|
||
RENT
|
||
BUY
|
||
ADS
|
||
FREE
|
||
}
|
||
|
||
model TitleProvider {
|
||
id String @id @default(cuid())
|
||
titleId String
|
||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||
providerId String
|
||
provider WatchProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
|
||
region String
|
||
kind ProviderKind
|
||
/// Für die Benachrichtigung „ist jetzt verfügbar"
|
||
firstSeenAt DateTime @default(now())
|
||
|
||
@@unique([titleId, providerId, region, kind])
|
||
@@index([titleId, region])
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 3. Persönliches Tracking
|
||
// ---------------------------------------------------------------------------
|
||
|
||
enum EntryStatus {
|
||
WATCHING
|
||
PAUSED
|
||
COMPLETED
|
||
DROPPED
|
||
WATCHLIST
|
||
}
|
||
|
||
model LibraryEntry {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
titleId String
|
||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||
|
||
status EntryStatus @default(WATCHLIST)
|
||
|
||
/// Eigene Bewertung 1–10
|
||
rating Int?
|
||
notes String?
|
||
|
||
/// „Wo schaue ich das" — Vorschlag aus der Verfügbarkeit, überschreibbar
|
||
whereToWatch String?
|
||
|
||
/// Liegt der Titel in der eigenen Sammlung? Unabhängig davon, ob ihn gerade
|
||
/// ein Streamingdienst führt — gekaufte Filme laufen irgendwann nirgends mehr.
|
||
owned Boolean @default(false)
|
||
|
||
/// Wo genau: „Jellyfin", „Blu-ray", „NAS", „Plex" …
|
||
ownedSource String?
|
||
|
||
favorite Boolean @default(false)
|
||
|
||
/// Aktueller Durchlauf: 0 = Erstsichtung, 1 = erster Rewatch …
|
||
currentRun Int @default(0)
|
||
|
||
startedAt DateTime?
|
||
finishedAt DateTime?
|
||
|
||
/// Papierkorb: 30 Tage aufbewahren statt hart löschen
|
||
deletedAt DateTime?
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
tags TagOnEntry[]
|
||
watches Watch[]
|
||
|
||
@@unique([userId, titleId])
|
||
@@index([userId, status])
|
||
@@index([userId, updatedAt])
|
||
@@index([userId, owned])
|
||
@@index([deletedAt])
|
||
}
|
||
|
||
model Tag {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
name String
|
||
color String?
|
||
|
||
entries TagOnEntry[]
|
||
|
||
@@unique([userId, name])
|
||
}
|
||
|
||
model TagOnEntry {
|
||
tagId String
|
||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||
entryId String
|
||
entry LibraryEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
|
||
|
||
@@id([tagId, entryId])
|
||
@@index([entryId])
|
||
}
|
||
|
||
/// Ein Sichtungsvorgang: entweder eine Episode oder ein Film.
|
||
/// Rewatches werden über `run` gezählt (0 = Erstsichtung).
|
||
model Watch {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
entryId String?
|
||
entry LibraryEntry? @relation(fields: [entryId], references: [id], onDelete: SetNull)
|
||
|
||
titleId String
|
||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||
episodeId String?
|
||
episode Episode? @relation(fields: [episodeId], references: [id], onDelete: Cascade)
|
||
|
||
run Int @default(0)
|
||
watchedAt DateTime @default(now())
|
||
|
||
/// Tatsächlich angerechnete Minuten — friert die Laufzeit zum Zeitpunkt der
|
||
/// Sichtung ein, damit spätere TMDB-Korrekturen die Statistik nicht verbiegen.
|
||
minutes Int @default(0)
|
||
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([userId, episodeId, run])
|
||
@@index([userId, watchedAt])
|
||
@@index([userId, titleId])
|
||
@@index([entryId])
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 4. Universen
|
||
// ---------------------------------------------------------------------------
|
||
|
||
model Universe {
|
||
id String @id @default(cuid())
|
||
ownerId String
|
||
owner User @relation("UniverseOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||
name String
|
||
slug String
|
||
description String?
|
||
coverPath String?
|
||
|
||
deletedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
items UniverseItem[]
|
||
orders UniverseOrder[]
|
||
members UniverseMember[]
|
||
|
||
@@unique([ownerId, slug])
|
||
@@index([ownerId])
|
||
}
|
||
|
||
enum SharePermission {
|
||
READ
|
||
WRITE
|
||
}
|
||
|
||
model UniverseMember {
|
||
id String @id @default(cuid())
|
||
universeId String
|
||
universe Universe @relation(fields: [universeId], references: [id], onDelete: Cascade)
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
permission SharePermission @default(READ)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([universeId, userId])
|
||
@@index([userId])
|
||
}
|
||
|
||
model UniverseItem {
|
||
id String @id @default(cuid())
|
||
universeId String
|
||
universe Universe @relation(fields: [universeId], references: [id], onDelete: Cascade)
|
||
titleId String
|
||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||
note String?
|
||
createdAt DateTime @default(now())
|
||
|
||
positions UniverseOrderItem[]
|
||
|
||
@@unique([universeId, titleId])
|
||
@@index([universeId])
|
||
}
|
||
|
||
enum OrderKind {
|
||
/// Automatisch aus den Erscheinungsdaten berechnet
|
||
RELEASE
|
||
/// Frei sortierbar (Story-Chronologie, Einsteiger-Reihenfolge …)
|
||
CUSTOM
|
||
}
|
||
|
||
model UniverseOrder {
|
||
id String @id @default(cuid())
|
||
universeId String
|
||
universe Universe @relation(fields: [universeId], references: [id], onDelete: Cascade)
|
||
name String
|
||
kind OrderKind @default(CUSTOM)
|
||
isDefault Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
|
||
items UniverseOrderItem[]
|
||
|
||
@@index([universeId])
|
||
}
|
||
|
||
model UniverseOrderItem {
|
||
id String @id @default(cuid())
|
||
orderId String
|
||
order UniverseOrder @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
itemId String
|
||
item UniverseItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
position Int
|
||
|
||
@@unique([orderId, itemId])
|
||
@@index([orderId, position])
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Benachrichtigungen & Hintergrundläufe
|
||
// ---------------------------------------------------------------------------
|
||
|
||
enum ChannelKind {
|
||
EMAIL
|
||
NTFY
|
||
GOTIFY
|
||
WEBHOOK
|
||
}
|
||
|
||
model NotificationChannel {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
kind ChannelKind
|
||
name String
|
||
/// Kanalspezifische Einstellungen (URL, Topic, Token …)
|
||
config Json
|
||
enabled Boolean @default(true)
|
||
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([userId])
|
||
}
|
||
|
||
model NotificationLog {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
/// Verhindert Doppelversand, z. B. "episode:<episodeId>"
|
||
dedupeKey String
|
||
subject String
|
||
body String
|
||
sentAt DateTime @default(now())
|
||
ok Boolean @default(true)
|
||
error String?
|
||
|
||
@@unique([userId, dedupeKey])
|
||
@@index([userId, sentAt])
|
||
}
|
||
|
||
model SyncRun {
|
||
id String @id @default(cuid())
|
||
kind String
|
||
startedAt DateTime @default(now())
|
||
finishedAt DateTime?
|
||
ok Boolean @default(false)
|
||
message String?
|
||
stats Json?
|
||
|
||
@@index([kind, startedAt])
|
||
}
|