Phase B2: mehrere Domains fuer Kurz-URLs
Eine Domain kann mehreren Gruppen dienen (n:m ueber DomainGrant), und der Slug eines Codes ist nur noch je Domain eindeutig - derselbe sprechende Slug darf also auf zwei Domains liegen. Sicherheitsrelevanter Kern: die Weiterleitung liest den Hostnamen aus X-Forwarded-Host bzw. Host und prueft ihn gegen die Domain-Tabelle. Ein unbekannter Hostname endet mit 404, faellt also auf die Standard-Domain zurueck und findet dort nichts. Ueber einen gefaelschten Header laesst sich damit keine fremde Domain ansprechen. Auch der Hostname fuer die Vorschau kommt aus der Datenbank, nicht aus der Anfrage. - Migration legt die Spalte erst nullable an, fuellt sie mit der Standard-Domain und macht sie danach verpflichtend; der globale Slug-Index weicht dem zusammengesetzten aus (domainId, slug). - Verwaltung -> Domains: registrieren, Gruppen zuteilen, Voreinstellung je Gruppe, abschalten. Domains mit Codes lassen sich weder loeschen noch einer Gruppe entziehen, die noch Codes darunter hat - beides wuerde gedruckte Codes ins Leere laufen lassen. Ein Fehler dabei gefunden und behoben: die Migration kennt BASE_URL nicht und legt die Standard-Domain mit einem Platzhalter-Hostnamen an. Korrigiert wurde er bisher nur beim Oeffnen der Domain-Verwaltung - auf einer frischen Installation waeren also Kurz-URLs auf 'standard.invalid' entstanden. ensureDefaultDomain() laeuft jetzt vor jeder Verwendung. Geprueft: 34 neue Pruefungen fuer Host-Aufloesung, Spoofing, Slug je Domain und Zuteilung; dazu 280 bestehende bei ausgeschalteter Anmeldung.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
-- Mehrere Domains für Kurz-URLs.
|
||||
--
|
||||
-- Zwei Dinge sind hier heikel und deshalb ausdrücklich schrittweise gelöst:
|
||||
--
|
||||
-- 1. `QrCode.domainId` ist verpflichtend, es gibt aber schon Codes. Die
|
||||
-- Spalte entsteht zuerst ohne Einschränkung, wird gefüllt und erst danach
|
||||
-- auf NOT NULL gesetzt.
|
||||
-- 2. `slug` war global eindeutig und ist es künftig nur noch je Domain. Der
|
||||
-- alte Index muss weichen, bevor der neue greifen kann.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Domain" (
|
||||
"id" TEXT NOT NULL,
|
||||
"hostname" TEXT NOT NULL,
|
||||
"label" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Domain_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DomainGrant" (
|
||||
"id" TEXT NOT NULL,
|
||||
"domainId" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DomainGrant_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Domain_hostname_key" ON "Domain"("hostname");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DomainGrant_groupId_idx" ON "DomainGrant"("groupId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DomainGrant_domainId_groupId_key" ON "DomainGrant"("domainId", "groupId");
|
||||
|
||||
-- Standard-Domain. Der Hostname ist zunächst ein Platzhalter: die
|
||||
-- Migration kennt BASE_URL nicht. Beim ersten Start ersetzt
|
||||
-- ensureDefaultDomain() ihn durch den Hostnamen aus BASE_URL.
|
||||
INSERT INTO "Domain" ("id", "hostname", "label", "isActive", "createdAt", "updatedAt")
|
||||
VALUES (
|
||||
'dom_standard',
|
||||
'standard.invalid',
|
||||
'Standard-Domain',
|
||||
true,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Alle bestehenden Gruppen bekommen die Standard-Domain als Voreinstellung.
|
||||
INSERT INTO "DomainGrant" ("id", "domainId", "groupId", "isDefault", "createdAt")
|
||||
SELECT 'dg_std_' || "Group"."id", 'dom_standard', "Group"."id", true, CURRENT_TIMESTAMP
|
||||
FROM "Group";
|
||||
|
||||
-- AlterTable: erst nullable …
|
||||
ALTER TABLE "QrCode" ADD COLUMN "domainId" TEXT;
|
||||
|
||||
-- … bestehende Codes der Standard-Domain zuordnen …
|
||||
UPDATE "QrCode" SET "domainId" = 'dom_standard' WHERE "domainId" IS NULL;
|
||||
|
||||
-- … und erst jetzt verpflichtend machen.
|
||||
ALTER TABLE "QrCode" ALTER COLUMN "domainId" SET NOT NULL;
|
||||
|
||||
-- Eindeutigkeit des Slugs von global auf „je Domain“ umstellen.
|
||||
DROP INDEX "QrCode_slug_key";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "QrCode_domainId_slug_key" ON "QrCode"("domainId", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "QrCode_domainId_idx" ON "QrCode"("domainId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DomainGrant" ADD CONSTRAINT "DomainGrant_domainId_fkey" FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DomainGrant" ADD CONSTRAINT "DomainGrant_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "QrCode" ADD CONSTRAINT "QrCode_domainId_fkey" FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
+48
-3
@@ -74,8 +74,9 @@ model Group {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
memberships Membership[]
|
||||
codes QrCode[]
|
||||
memberships Membership[]
|
||||
codes QrCode[]
|
||||
domainGrants DomainGrant[]
|
||||
}
|
||||
|
||||
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
|
||||
@@ -94,6 +95,42 @@ model Membership {
|
||||
@@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[]
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -114,7 +151,10 @@ model QrTypeSetting {
|
||||
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
||||
model QrCode {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
/// 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.
|
||||
slug String
|
||||
type String
|
||||
label String?
|
||||
data Json
|
||||
@@ -128,6 +168,9 @@ model QrCode {
|
||||
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
|
||||
@@ -137,6 +180,8 @@ model QrCode {
|
||||
createdById String?
|
||||
createdBy User? @relation("QrCodeCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([domainId, slug])
|
||||
@@index([createdAt])
|
||||
@@index([groupId])
|
||||
@@index([domainId])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user