Eine Domain zu registrieren ist der kleinste Teil der Arbeit — DNS, Reverse Proxy und Zertifikat liegen ausserhalb dieser Anwendung. Fehlt dort etwas, merkt man es am gedruckten Code, der nicht aufgeht. Der Knopf "Pruefen" geht den Weg der Scanner nach: Namen aufloesen, Zertifikat ansehen, Adresse aufrufen — und sich beweisen lassen, dass am anderen Ende diese Installation steht. Der Beweis ist ein HMAC ueber eine frische Zufallszahl; das Geheimnis dafuer entsteht beim ersten Check und verlaesst den Server nie. Geprueft wird nur ein Hostname aus der Datenbank, nie einer aus der Anfrage — sonst waere die Route ein Werkzeug zum Abklopfen fremder Adressen. Scheitert schon DNS, fallen die drei Folgeschritte aus, statt dreimal dasselbe zu melden. Die Grenze der Aussage steht im Bericht selbst: Der Aufruf startet dort, wo der Container laeuft. Refs #7
336 lines
13 KiB
Plaintext
336 lines
13 KiB
Plaintext
// QR-Code-Generator — Datenmodell
|
|
// Doku: https://pris.ly/d/prisma-schema
|
|
|
|
generator client {
|
|
provider = "prisma-client"
|
|
output = "../src/generated/prisma"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
}
|
|
|
|
/// Rolle eines Benutzers innerhalb einer Gruppe.
|
|
///
|
|
/// Darüber liegt noch eine Ebene: `User.isInstanceAdmin`. Sie darf Gruppen
|
|
/// und Benutzer überhaupt erst anlegen — ohne sie gäbe es niemanden, der die
|
|
/// erste Gruppe erstellt.
|
|
enum Role {
|
|
/// Darf zusätzlich Mitglieder und Rollen der Gruppe verwalten.
|
|
ADMIN
|
|
/// Darf Codes anlegen, ändern und löschen.
|
|
EDITOR
|
|
/// Darf nur lesen und herunterladen.
|
|
VIEWER
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
name String?
|
|
/// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort.
|
|
/// Leer bei Konten, die sich ausschließlich über den externen Anbieter
|
|
/// anmelden: dort gibt es hier kein Passwort zu prüfen.
|
|
passwordHash String?
|
|
/// Stabile Kennung beim externen Anbieter (OIDC `sub`). Bleibt leer, solange
|
|
/// sich das Konto nur mit Passwort anmeldet.
|
|
oidcSubject String? @unique
|
|
/// Darf Gruppen, Benutzer und Domains verwalten.
|
|
isInstanceAdmin Boolean @default(false)
|
|
/// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen
|
|
/// werden beim nächsten Zugriff verworfen.
|
|
isActive Boolean @default(true)
|
|
failedLogins Int @default(0)
|
|
lockedUntil DateTime?
|
|
lastLoginAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
sessions Session[]
|
|
memberships Membership[]
|
|
createdCodes QrCode[] @relation("QrCodeCreator")
|
|
|
|
@@index([email])
|
|
}
|
|
|
|
/// Angemeldete Sitzung. In der Datenbank statt in einem signierten Cookie,
|
|
/// damit sie sich serverseitig beenden lässt — etwa wenn ein Benutzer
|
|
/// deaktiviert wird.
|
|
model Session {
|
|
id String @id @default(cuid())
|
|
/// SHA-256 des Cookie-Werts. Der Wert selbst steht nirgends in der Datenbank:
|
|
/// wer sie liest, kann sich damit trotzdem nicht anmelden.
|
|
tokenHash String @unique
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
/// Läuft ohne Aktivität ab; wird beim Arbeiten nachgeführt.
|
|
expiresAt DateTime
|
|
/// Harte Obergrenze ab dem Anmelden. Ohne sie könnte eine gestohlene
|
|
/// Sitzung durch regelmäßige Nutzung unbegrenzt weiterleben.
|
|
absoluteExpiresAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP + INTERVAL '90 days'"))
|
|
lastSeenAt DateTime @default(now())
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId])
|
|
@@index([expiresAt])
|
|
@@index([absoluteExpiresAt])
|
|
}
|
|
|
|
/// Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören.
|
|
model Group {
|
|
id String @id @default(cuid())
|
|
name String @unique
|
|
description String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
memberships Membership[]
|
|
codes QrCode[]
|
|
domainGrants DomainGrant[]
|
|
}
|
|
|
|
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
|
|
/// kann in mehreren Gruppen sein — mit unterschiedlicher Rolle je Gruppe.
|
|
model Membership {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
groupId String
|
|
role Role
|
|
createdAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, groupId])
|
|
@@index([groupId])
|
|
}
|
|
|
|
/// Eine Domain, unter der Kurz-URLs ausgeliefert werden.
|
|
///
|
|
/// Die Weiterleitung erkennt am `Host`-Header, welche Domain gemeint ist, und
|
|
/// prüft ihn gegen diese Tabelle. Ein unbekannter Hostname führt zu 404 —
|
|
/// damit lässt sich über einen gefälschten Header keine fremde Domain
|
|
/// ansprechen.
|
|
model Domain {
|
|
id String @id @default(cuid())
|
|
/// Kleingeschrieben, ohne Schema, ggf. mit Port — z. B. "qr.example.com".
|
|
hostname String @unique
|
|
label String?
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
grants DomainGrant[]
|
|
codes QrCode[]
|
|
aliases QrCodeAlias[]
|
|
}
|
|
|
|
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
|
|
/// dienen; welche davon die Voreinstellung der Gruppe ist, steht in
|
|
/// `isDefault`.
|
|
model DomainGrant {
|
|
id String @id @default(cuid())
|
|
domainId String
|
|
groupId String
|
|
isDefault Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
|
|
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
|
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([domainId, groupId])
|
|
@@index([groupId])
|
|
}
|
|
|
|
/// Freigabe eines QR-Typs durch die Verwaltung.
|
|
///
|
|
/// Es gibt nur Zeilen für Typen, die bewusst umgeschaltet wurden — fehlt ein
|
|
/// Eintrag, gilt der Typ als freigegeben. So bleibt eine frische Installation
|
|
/// ohne Vorbelegung nutzbar, und neue Typen aus späteren Versionen sind
|
|
/// automatisch sichtbar.
|
|
model QrTypeSetting {
|
|
/// Kennung aus der Typ-Registry, z. B. "website".
|
|
type String @id
|
|
enabled Boolean
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
|
|
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
|
|
///
|
|
/// `type` + `data` sind bewusst generisch gehalten: die Typ-Module legen ihr
|
|
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
|
model QrCode {
|
|
id String @id @default(cuid())
|
|
/// Statisch oder dynamisch. Ein statischer Code traegt seinen Inhalt selbst
|
|
/// und wird nur abgelegt, damit man ihn wiederfindet und erneut
|
|
/// herunterladen kann — er hat weder Kurz-Link noch Scan-Zaehler.
|
|
mode String @default("dynamic")
|
|
/// Kurz-Link. Nur je Domain eindeutig — derselbe sprechende Slug darf auf
|
|
/// zwei Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
|
|
/// diese Eindeutigkeit über Gruppengrenzen hinweg.
|
|
///
|
|
/// Leer bei statischen Codes: Die fuehren auf nichts, was dieser Server
|
|
/// ausliefert. In PostgreSQL schliessen sich mehrere NULL in einem
|
|
/// eindeutigen Index nicht aus, die Bedingung bleibt also gueltig.
|
|
slug String?
|
|
type String
|
|
label String?
|
|
data Json
|
|
fgColor String @default("#000000")
|
|
bgColor String @default("#FFFFFF")
|
|
size Int @default(512)
|
|
ecLevel String @default("M")
|
|
margin Int @default(4)
|
|
scans Int @default(0)
|
|
lastScanAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
/// Domain, unter der die Kurz-URL dieses Codes erreichbar ist.
|
|
domainId String
|
|
domain Domain @relation(fields: [domainId], references: [id])
|
|
/// Jeder Code gehört einer Gruppe — auch bei ausgeschalteter Anmeldung.
|
|
/// Dann ist es die beim ersten Start angelegte Standardgruppe.
|
|
groupId String
|
|
group Group @relation(fields: [groupId], references: [id])
|
|
/// Wer den Code angelegt hat. Leer für Codes aus der Zeit vor der
|
|
/// Anmeldung und für Codes, deren Ersteller gelöscht wurde.
|
|
createdById String?
|
|
createdBy User? @relation("QrCodeCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
|
|
|
/// Dateien, die zu diesem Code gehören — beim Löschen gehen sie mit.
|
|
uploads Upload[]
|
|
/// Rückmeldungen, die über die Landingpage eingegangen sind.
|
|
feedback Feedback[]
|
|
/// Frühere Kurz-Links, die weiterhin hierher führen.
|
|
aliases QrCodeAlias[]
|
|
|
|
@@unique([domainId, slug])
|
|
@@index([createdAt])
|
|
@@index([groupId])
|
|
@@index([domainId])
|
|
@@index([mode])
|
|
}
|
|
|
|
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
|
|
/// einem Bild-Code.
|
|
///
|
|
/// Der Inhalt liegt in der Datenbank, nicht auf der Platte. Damit gibt es
|
|
/// weiterhin genau ein Volumen zu sichern: Wer die Datenbank sichert, hat
|
|
/// alles. Bei Dateigrößen im einstelligen Megabyte-Bereich und einer
|
|
/// Installation dieser Größenordnung ist das der bessere Tausch als ein
|
|
/// zweites Volumen, das beim Sichern vergessen werden kann.
|
|
model Upload {
|
|
id String @id @default(cuid())
|
|
/// Ursprünglicher Dateiname, für den Download.
|
|
filename String
|
|
mimeType String
|
|
/// Größe in Bytes — als Angabe für die Oberfläche, ohne die Datei zu laden.
|
|
size Int
|
|
data Bytes
|
|
createdAt DateTime @default(now())
|
|
|
|
/// „code" gehört zu einem QR-Code, „branding" zur Oberfläche. Nur die
|
|
/// erste Art wird als herrenlos weggeräumt.
|
|
purpose String @default("code")
|
|
|
|
/// Der Code, zu dem die Datei gehört. Leer, solange sie nur hochgeladen,
|
|
/// der Code aber noch nicht gespeichert wurde; solche Reste räumt
|
|
/// `src/lib/uploads.ts` nach einer Weile weg.
|
|
qrCodeId String?
|
|
qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
|
|
|
brandingLogo Branding? @relation("BrandingLogo")
|
|
brandingFavicon Branding? @relation("BrandingFavicon")
|
|
|
|
@@index([qrCodeId])
|
|
@@index([createdAt])
|
|
@@index([purpose])
|
|
}
|
|
|
|
/// Verhalten der Installation, das sich nicht aus dem Code ergibt.
|
|
///
|
|
/// Genau eine Zeile mit fester Id — wie `Branding`, nur für Verhalten statt
|
|
/// Aussehen. Ein eigenes Modell statt Schlüssel-Wert-Paaren, damit die Felder
|
|
/// ihren Typ behalten.
|
|
model InstanceSetting {
|
|
id String @id @default("instance")
|
|
/// Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern?
|
|
///
|
|
/// Aus, weil ein Kurz-Link eine öffentliche Adresse ist: Wer ihn ändert,
|
|
/// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
|
|
/// jemand anderes gebraucht hätte. Administratoren können das freigeben.
|
|
editorsMaySetSlug Boolean @default(false)
|
|
/// Geheimnis fuer den Domain-Check.
|
|
///
|
|
/// Die Pruefung ruft die Domain von aussen auf und laesst sich einen HMAC
|
|
/// ueber eine Zufallszahl zurueckgeben. Nur wer dieses Geheimnis kennt,
|
|
/// kann ihn bilden — so steht fest, dass wirklich diese Installation
|
|
/// geantwortet hat und nicht irgendein anderer Server. Es verlaesst den
|
|
/// Server nie und entsteht beim ersten Check.
|
|
checkSecret String?
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
/// Ein früherer Kurz-Link, der weiterhin auf seinen Code führt.
|
|
///
|
|
/// Ohne das würde jedes Umbenennen die bereits gedruckten Codes entwerten —
|
|
/// und genau deren Unveränderlichkeit ist der Sinn eines dynamischen Codes.
|
|
/// Freigeben lässt sich ein solcher Eintrag von Hand, etwa nach einem
|
|
/// Tippfehler, den noch niemand gedruckt hat.
|
|
model QrCodeAlias {
|
|
id String @id @default(cuid())
|
|
/// Mitgeführt, damit die Eindeutigkeit je Domain gilt — wie beim Code selbst.
|
|
domainId String
|
|
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
|
|
slug String
|
|
qrCodeId String
|
|
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([domainId, slug])
|
|
@@index([qrCodeId])
|
|
}
|
|
|
|
/// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe.
|
|
///
|
|
/// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner
|
|
/// Schlüssel-Wert-Paare, damit die Felder ihren Typ behalten und die
|
|
/// Beziehungen zu den hochgeladenen Dateien echte Fremdschlüssel sind.
|
|
model Branding {
|
|
id String @id @default("branding")
|
|
appName String @default("QR-Code-Generator")
|
|
/// Zeile in der Fußzeile, hinter dem Namen.
|
|
footerText String @default("statische und dynamische QR-Codes")
|
|
/// Akzentfarbe als Hex. Übernommen werden daraus Farbton und Sättigung;
|
|
/// die Helligkeitsstufen stammen aus der Vorlage — siehe
|
|
/// src/lib/branding-colors.ts.
|
|
accentColor String @default("#4F46E5")
|
|
|
|
logoUploadId String? @unique
|
|
logoUpload Upload? @relation("BrandingLogo", fields: [logoUploadId], references: [id], onDelete: SetNull)
|
|
faviconUploadId String? @unique
|
|
faviconUpload Upload? @relation("BrandingFavicon", fields: [faviconUploadId], references: [id], onDelete: SetNull)
|
|
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
/// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes
|
|
/// abgegeben hat.
|
|
///
|
|
/// Bewusst ohne jeden Bezug zur Person: keine IP-Adresse, kein Kennzeichen
|
|
/// des Geräts. Wer einen Zettel an der Wand scannt und eine Note vergibt,
|
|
/// soll nicht wiedererkennbar sein.
|
|
model Feedback {
|
|
id String @id @default(cuid())
|
|
qrCodeId String
|
|
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
|
/// 1 bis 5.
|
|
rating Int
|
|
comment String?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([qrCodeId, createdAt])
|
|
}
|