From 2a80f15e209e7b105176abf4ad4431fa4281e357 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 14 Aug 2026 13:10:12 +0200 Subject: [PATCH] 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. --- README.md | 25 +- .../20260814110000_multi_domain/migration.sql | 86 ++++ prisma/schema.prisma | 51 ++- src/app/api/admin/domains/[id]/route.ts | 160 ++++++++ src/app/api/admin/domains/route.ts | 107 +++++ src/app/api/codes/[id]/qr/route.ts | 7 +- src/app/api/codes/[id]/route.ts | 1 + src/app/api/codes/route.ts | 10 + src/app/r/[slug]/route.ts | 25 +- src/app/verwaltung/domains/page.tsx | 39 ++ src/app/verwaltung/page.tsx | 10 +- src/components/domain-admin.tsx | 387 ++++++++++++++++++ src/lib/codes.ts | 5 +- src/lib/domains.ts | 228 +++++++++++ src/lib/dto.ts | 2 + src/lib/env.ts | 21 +- src/lib/qr/request.ts | 19 +- 17 files changed, 1168 insertions(+), 15 deletions(-) create mode 100644 prisma/migrations/20260814110000_multi_domain/migration.sql create mode 100644 src/app/api/admin/domains/[id]/route.ts create mode 100644 src/app/api/admin/domains/route.ts create mode 100644 src/app/verwaltung/domains/page.tsx create mode 100644 src/components/domain-admin.tsx create mode 100644 src/lib/domains.ts diff --git a/README.md b/README.md index 01ec206..ee461e3 100644 --- a/README.md +++ b/README.md @@ -209,9 +209,28 @@ docker compose up -d > aufrufen. Steht dort weiterhin `http://192.168.1.50:3000`, zeigen neue > dynamische Codes ins interne Netz statt auf die Domain. -Mehrere Domains gleichzeitig unterstützt diese Version noch nicht — das kommt -in Phase B (siehe [Ausblick](#ausblick-version-2)). Zurzeit gilt genau eine -`BASE_URL`. +### Mehrere Domains + +Sollen Kurz-URLs unter verschiedenen Adressen laufen — etwa je Kunde eine —, +lege sie unter **Verwaltung → Domains** an und richte im Proxy für jede einen +eigenen Proxy Host auf dieselbe Anwendung ein. + +Die Weiterleitung erkennt am `Host`-Header, welche Domain gemeint ist, und +prüft ihn gegen die registrierten Domains. Ein unbekannter Hostname endet mit +404 — über einen gefälschten Header lässt sich also keine fremde Domain +ansprechen. + +Zwei Dinge, die daraus folgen: + +- Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende + Slug darf auf zwei Domains liegen und zeigt dort auf verschiedene Ziele. +- Eine Domain kann **mehreren Gruppen** dienen. Das heißt aber nicht, dass + diese die Codes der jeweils anderen sehen — die Übersicht bleibt strikt + nach Gruppe gefiltert. + +`BASE_URL` bleibt dabei wichtig: sie liefert die Standard-Domain für Aufrufe +über eine nicht registrierte Adresse (etwa direkt über die IP im LAN) und +bestimmt, ob Kurz-URLs mit `http` oder `https` gebildet werden. --- diff --git a/prisma/migrations/20260814110000_multi_domain/migration.sql b/prisma/migrations/20260814110000_multi_domain/migration.sql new file mode 100644 index 0000000..39c5408 --- /dev/null +++ b/prisma/migrations/20260814110000_multi_domain/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ce59739..3b09a79 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) } diff --git a/src/app/api/admin/domains/[id]/route.ts b/src/app/api/admin/domains/[id]/route.ts new file mode 100644 index 0000000..f1ea850 --- /dev/null +++ b/src/app/api/admin/domains/[id]/route.ts @@ -0,0 +1,160 @@ +import type { NextRequest } from "next/server"; +import { z } from "zod"; + +import { getActor } from "@/lib/auth/actor"; +import { requirePermission } from "@/lib/auth/policy"; +import { prisma } from "@/lib/db"; +import { DEFAULT_DOMAIN_ID } from "@/lib/domains"; +import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; +import { QrRequestError } from "@/lib/qr/type-module"; + +export const dynamic = "force-dynamic"; + +type Context = { params: Promise<{ id: string }> }; + +const updateSchema = z.object({ + label: z.string().trim().max(80).nullable().optional(), + isActive: z.boolean().optional(), + /** + * Vollständige Liste der Gruppen, die diese Domain nutzen dürfen. Wer + * fehlt, verliert die Zuteilung. + */ + grants: z + .array( + z.object({ + groupId: z.string().trim().min(1), + isDefault: z.boolean().default(false), + }), + ) + .optional(), +}); + +export async function PATCH( + request: NextRequest, + context: Context, +): Promise { + try { + requirePermission(await getActor(), "instance:manage"); + const { id } = await context.params; + + const domain = await prisma.domain.findUnique({ where: { id } }); + if (!domain) { + throw new QrRequestError("Diese Domain existiert nicht (mehr).", 404); + } + + const parsed = updateSchema.safeParse(await request.json()); + if (!parsed.success) { + return jsonError(firstZodMessage(parsed.error)); + } + + if (parsed.data.label !== undefined || parsed.data.isActive !== undefined) { + await prisma.domain.update({ + where: { id }, + data: { + ...(parsed.data.label !== undefined + ? { label: parsed.data.label || null } + : {}), + ...(parsed.data.isActive !== undefined + ? { isActive: parsed.data.isActive } + : {}), + }, + }); + } + + if (parsed.data.grants !== undefined) { + const grants = parsed.data.grants; + + const known = await prisma.group.findMany({ + where: { id: { in: grants.map((g) => g.groupId) } }, + select: { id: true }, + }); + if (known.length !== grants.length) { + throw new QrRequestError( + "Mindestens eine der ausgewählten Gruppen existiert nicht mehr.", + ); + } + + // Eine Gruppe, die diese Domain noch für Codes verwendet, darf die + // Zuteilung nicht verlieren — sonst zeigten deren Kurz-URLs auf eine + // Domain, die der Gruppe gar nicht mehr gehört. + const stillInUse = await prisma.qrCode.findMany({ + where: { domainId: id, groupId: { notIn: grants.map((g) => g.groupId) } }, + select: { group: { select: { name: true } } }, + distinct: ["groupId"], + }); + if (stillInUse.length > 0) { + const names = stillInUse.map((entry) => entry.group.name).join(", "); + throw new QrRequestError( + `Diese Gruppen haben noch Codes unter dieser Domain: ${names}. Solange die Codes bestehen, muss die Zuteilung bleiben.`, + ); + } + + await prisma.$transaction([ + prisma.domainGrant.deleteMany({ + where: { domainId: id, groupId: { notIn: grants.map((g) => g.groupId) } }, + }), + ...grants.map((grant) => + prisma.domainGrant.upsert({ + where: { + domainId_groupId: { domainId: id, groupId: grant.groupId }, + }, + create: { + domainId: id, + groupId: grant.groupId, + isDefault: grant.isDefault, + }, + update: { isDefault: grant.isDefault }, + }), + ), + // Je Gruppe darf nur eine Domain die Voreinstellung sein. + ...grants + .filter((grant) => grant.isDefault) + .map((grant) => + prisma.domainGrant.updateMany({ + where: { groupId: grant.groupId, domainId: { not: id } }, + data: { isDefault: false }, + }), + ), + ]); + } + + return Response.json({ ok: true }); + } catch (error) { + return errorResponse(error); + } +} + +export async function DELETE( + _request: NextRequest, + context: Context, +): Promise { + try { + requirePermission(await getActor(), "instance:manage"); + const { id } = await context.params; + + if (id === DEFAULT_DOMAIN_ID) { + throw new QrRequestError( + "Die Standard-Domain lässt sich nicht löschen — sie fängt Aufrufe über nicht registrierte Adressen ab.", + ); + } + + const domain = await prisma.domain.findUnique({ + where: { id }, + include: { _count: { select: { codes: true } } }, + }); + if (!domain) { + throw new QrRequestError("Diese Domain existiert nicht (mehr).", 404); + } + + if (domain._count.codes > 0) { + throw new QrRequestError( + `Unter dieser Domain liegen noch ${domain._count.codes} ${domain._count.codes === 1 ? "Code" : "Codes"}. Sie zu löschen würde gedruckte Codes ins Leere laufen lassen — schalte sie stattdessen ab, wenn sie nicht mehr genutzt werden soll.`, + ); + } + + await prisma.domain.delete({ where: { id } }); + return new Response(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/src/app/api/admin/domains/route.ts b/src/app/api/admin/domains/route.ts new file mode 100644 index 0000000..8f91d7a --- /dev/null +++ b/src/app/api/admin/domains/route.ts @@ -0,0 +1,107 @@ +import type { NextRequest } from "next/server"; +import { z } from "zod"; + +import { getActor } from "@/lib/auth/actor"; +import { requirePermission } from "@/lib/auth/policy"; +import { prisma } from "@/lib/db"; +import { ensureDefaultDomain, normalizeHostname } from "@/lib/domains"; +import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; +import { QrRequestError } from "@/lib/qr/type-module"; + +export const dynamic = "force-dynamic"; + +/** + * Ein Hostname, optional mit Port. Bewusst ohne Schema und ohne Pfad — das + * ist der Wert, den ein Browser im `Host`-Header sendet. + */ +const hostnameSchema = z + .string() + .trim() + .min(1, { error: "Bitte gib einen Hostnamen ein." }) + .max(253, { error: "Der Hostname ist zu lang." }) + .transform(normalizeHostname) + .refine((value) => /^[a-z0-9.-]+(:\d{1,5})?$/.test(value), { + error: + "Das ist kein gültiger Hostname. Erwartet wird z. B. qr.example.com — ohne https:// und ohne Pfad.", + }) + .refine((value) => value.includes(".") || value.startsWith("localhost"), { + error: "Bitte gib den vollständigen Hostnamen an, z. B. qr.example.com", + }); + +const createSchema = z.object({ + hostname: hostnameSchema, + label: z.string().trim().max(80).optional(), +}); + +export async function GET(): Promise { + try { + requirePermission(await getActor(), "instance:manage"); + + // Sorgt dafür, dass die Standard-Domain den Hostnamen aus BASE_URL trägt. + await ensureDefaultDomain(); + + const domains = await prisma.domain.findMany({ + orderBy: { hostname: "asc" }, + include: { + grants: { include: { group: { select: { id: true, name: true } } } }, + _count: { select: { codes: true } }, + }, + }); + + const groups = await prisma.group.findMany({ + orderBy: { name: "asc" }, + select: { id: true, name: true }, + }); + + return Response.json({ + domains: domains.map((domain) => ({ + id: domain.id, + hostname: domain.hostname, + label: domain.label, + isActive: domain.isActive, + codeCount: domain._count.codes, + grants: domain.grants.map((grant) => ({ + groupId: grant.group.id, + groupName: grant.group.name, + isDefault: grant.isDefault, + })), + })), + groups, + }); + } catch (error) { + return errorResponse(error); + } +} + +export async function POST(request: NextRequest): Promise { + try { + requirePermission(await getActor(), "instance:manage"); + + const parsed = createSchema.safeParse(await request.json()); + if (!parsed.success) { + return jsonError(firstZodMessage(parsed.error)); + } + + const existing = await prisma.domain.findUnique({ + where: { hostname: parsed.data.hostname }, + select: { id: true }, + }); + if (existing) { + throw new QrRequestError("Diese Domain ist bereits registriert.", 409); + } + + const domain = await prisma.domain.create({ + data: { + hostname: parsed.data.hostname, + label: parsed.data.label || null, + }, + }); + + return Response.json( + { domain: { id: domain.id, hostname: domain.hostname } }, + { status: 201 }, + ); + } catch (error) { + return errorResponse(error); + } +} diff --git a/src/app/api/codes/[id]/qr/route.ts b/src/app/api/codes/[id]/qr/route.ts index df549f8..a6984c2 100644 --- a/src/app/api/codes/[id]/qr/route.ts +++ b/src/app/api/codes/[id]/qr/route.ts @@ -29,7 +29,10 @@ export async function GET( request.nextUrl.searchParams.get("format") === "png" ? "png" : "svg"; const isDownload = request.nextUrl.searchParams.get("download") === "1"; - const code = await prisma.qrCode.findUnique({ where: { id } }); + const code = await prisma.qrCode.findUnique({ + where: { id }, + include: { domain: { select: { hostname: true } } }, + }); if (!code) { throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404); } @@ -46,7 +49,7 @@ export async function GET( margin: code.margin, }); - const content = shortUrl(code.slug); + const content = shortUrl(code.slug, code.domain.hostname); const body = format === "png" ? new Uint8Array(await renderPng(content, design)) diff --git a/src/app/api/codes/[id]/route.ts b/src/app/api/codes/[id]/route.ts index 247b2fe..3c36f8e 100644 --- a/src/app/api/codes/[id]/route.ts +++ b/src/app/api/codes/[id]/route.ts @@ -18,6 +18,7 @@ type Context = { params: Promise<{ id: string }> }; const WITH_RELATIONS = { group: { select: { name: true } }, + domain: { select: { hostname: true } }, createdBy: { select: { name: true, email: true } }, } as const; diff --git a/src/app/api/codes/route.ts b/src/app/api/codes/route.ts index d2b2445..6b8b5b9 100644 --- a/src/app/api/codes/route.ts +++ b/src/app/api/codes/route.ts @@ -6,6 +6,7 @@ import { resolveTargetGroup } from "@/lib/auth/groups"; import { requirePermission, visibleGroupIds } from "@/lib/auth/policy"; import { toJsonInput, toQrCodeDto } from "@/lib/codes"; import { prisma } from "@/lib/db"; +import { resolveTargetDomain } from "@/lib/domains"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { requireAvailableQrType } from "@/lib/qr/availability"; import { designSchema } from "@/lib/qr/design"; @@ -26,6 +27,8 @@ const createSchema = z.object({ .optional(), /** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */ groupId: z.string().trim().min(1).optional(), + /** Unter welcher Domain die Kurz-URL entsteht. */ + domainId: z.string().trim().min(1).optional(), }); function isUniqueViolation(error: unknown): boolean { @@ -51,6 +54,7 @@ export async function GET(): Promise { orderBy: { createdAt: "desc" }, include: { group: { select: { name: true } }, + domain: { select: { hostname: true } }, createdBy: { select: { name: true, email: true } }, }, }); @@ -77,6 +81,10 @@ export async function POST(request: NextRequest): Promise { const groupId = await resolveTargetGroup(actor, parsed.data.groupId); requirePermission(actor, "codes:create", { groupId }); + // Die Domain bestimmt, unter welchem Hostnamen die Kurz-URL erreichbar + // ist — und der Slug ist nur je Domain eindeutig. + const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId); + const qrType = await requireAvailableQrType(type); const validated = qrType.validate(parsed.data.data); @@ -97,11 +105,13 @@ export async function POST(request: NextRequest): Promise { label: label && label.length > 0 ? label : null, data: toJsonInput(validated.data), groupId, + domainId, createdById: actor.kind === "user" ? actor.user.id : null, ...design, }, include: { group: { select: { name: true } }, + domain: { select: { hostname: true } }, createdBy: { select: { name: true, email: true } }, }, }); diff --git a/src/app/r/[slug]/route.ts b/src/app/r/[slug]/route.ts index 40f1629..ad197c5 100644 --- a/src/app/r/[slug]/route.ts +++ b/src/app/r/[slug]/route.ts @@ -1,6 +1,7 @@ import type { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; +import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains"; import { getQrType } from "@/lib/qr/registry"; import { isValidSlug } from "@/lib/slug"; @@ -34,6 +35,10 @@ function notFound(message: string): Response { * * Bewusst 302 (temporär): Das Ziel ist jederzeit änderbar, ein permanenter * Redirect würde in Browsern und Zwischenspeichern hängen bleiben. + * + * Der Slug ist nur je Domain eindeutig, deshalb wird zuerst die Domain aus + * dem `Host`-Header bestimmt. Ein unbekannter Hostname endet hier — sonst + * ließe sich über einen gefälschten Header ein fremder Code ansprechen. */ export async function GET( request: NextRequest, @@ -45,7 +50,25 @@ export async function GET( return notFound("Diese Kurz-Adresse ist ungültig."); } - const code = await prisma.qrCode.findUnique({ where: { slug } }); + const matched = await resolveRequestDomain(request.headers); + + // Aufruf über eine nicht registrierte Adresse — etwa direkt über die IP im + // LAN. Dann gilt die Standard-Domain aus BASE_URL. + const domain = matched ?? { + ...(await ensureDefaultDomain()), + isActive: true, + }; + + if (!domain.isActive) { + return notFound( + "Diese Adresse ist zurzeit abgeschaltet. Bitte wende dich an denjenigen, der den Code erstellt hat.", + ); + } + + const code = await prisma.qrCode.findUnique({ + where: { domainId_slug: { domainId: domain.id, slug } }, + }); + if (!code) { return notFound( "Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an denjenigen, der ihn erstellt hat.", diff --git a/src/app/verwaltung/domains/page.tsx b/src/app/verwaltung/domains/page.tsx new file mode 100644 index 0000000..1aa0db4 --- /dev/null +++ b/src/app/verwaltung/domains/page.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { DomainAdmin } from "@/components/domain-admin"; +import { Icon } from "@/components/icons"; +import { requirePagePermission } from "@/lib/auth/guard"; +import { baseUrlHostname } from "@/lib/domains"; + +export const metadata: Metadata = { title: "Domains" }; +export const dynamic = "force-dynamic"; + +export default async function DomainsPage() { + await requirePagePermission("instance:manage"); + + return ( +
+ + + Verwaltung + + +
+

+ Domains +

+

+ Unter welchen Adressen Kurz-URLs ausgeliefert werden. Eine Domain + kann mehreren Gruppen dienen; der Slug eines Codes ist je Domain + eindeutig. +

+
+ + +
+ ); +} diff --git a/src/app/verwaltung/page.tsx b/src/app/verwaltung/page.tsx index 66ef5b7..fff7635 100644 --- a/src/app/verwaltung/page.tsx +++ b/src/app/verwaltung/page.tsx @@ -45,10 +45,11 @@ function Tile({ export default async function AdminPage() { await requirePagePermission("instance:manage"); - const [userCount, groupCount, codeCount] = await Promise.all([ + const [userCount, groupCount, codeCount, domainCount] = await Promise.all([ prisma.user.count(), prisma.group.count(), prisma.qrCode.count(), + prisma.domain.count(), ]); return ( @@ -95,6 +96,13 @@ export default async function AdminPage() { description="Gruppen anlegen und Mitglieder mit Rollen zuordnen." hint={`${groupCount} ${groupCount === 1 ? "Gruppe" : "Gruppen"}, ${codeCount} ${codeCount === 1 ? "Code" : "Codes"}`} /> + (null); + const [groups, setGroups] = useState([]); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + + const [creating, setCreating] = useState(false); + const [form, setForm] = useState({ hostname: "", label: "" }); + const [formError, setFormError] = useState(null); + + const [editing, setEditing] = useState(null); + const [grants, setGrants] = useState([]); + const [grantError, setGrantError] = useState(null); + + const [deleting, setDeleting] = useState(null); + + const load = useCallback(async () => { + try { + const result = await apiJson<{ + domains: AdminDomain[]; + groups: GroupOption[]; + }>("/api/admin/domains"); + setDomains(result.domains); + setGroups(result.groups); + setError(null); + } catch (caught) { + setDomains([]); + setError( + caught instanceof ApiError + ? caught.message + : "Die Domains konnten nicht geladen werden.", + ); + } + }, []); + + useEffect(() => { + // setState folgt erst nach dem await der Netzwerkantwort. + // eslint-disable-next-line react-hooks/set-state-in-effect + void load(); + }, [load]); + + const create = async () => { + setFormError(null); + try { + await apiJson("/api/admin/domains", { method: "POST", json: form }); + setCreating(false); + setForm({ hostname: "", label: "" }); + await load(); + } catch (caught) { + setFormError( + caught instanceof ApiError + ? caught.message + : "Die Domain konnte nicht angelegt werden.", + ); + } + }; + + const patch = async (id: string, body: unknown, onDone?: () => void) => { + setBusy(id); + setError(null); + try { + await apiJson(`/api/admin/domains/${id}`, { method: "PATCH", json: body }); + await load(); + onDone?.(); + return true; + } catch (caught) { + const message = + caught instanceof ApiError + ? caught.message + : "Die Änderung konnte nicht gespeichert werden."; + if (onDone) setGrantError(message); + else setError(message); + return false; + } finally { + setBusy(null); + } + }; + + const remove = async () => { + if (!deleting) return; + try { + await apiJson(`/api/admin/domains/${deleting.id}`, { method: "DELETE" }); + setDeleting(null); + await load(); + } catch (caught) { + setError( + caught instanceof ApiError + ? caught.message + : "Die Domain konnte nicht gelöscht werden.", + ); + setDeleting(null); + } + }; + + if (domains === null) { + return

Domains werden geladen …

; + } + + return ( +
+ {error ? {error} : null} + + + Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung + weiterleitet. Die Weiterleitung erkennt sie am{" "} + Host-Header und prüft ihn + gegen diese Liste — ein unbekannter Hostname endet mit 404. + + +
+ +
+ + {domains.map((domain) => ( + +
+
+
+

+ {domain.hostname} +

+ {domain.hostname === baseUrlHostname ? ( + aus BASE_URL + ) : null} + {!domain.isActive ? ( + abgeschaltet + ) : null} + + {domain.codeCount} {domain.codeCount === 1 ? "Code" : "Codes"} + +
+ + {domain.label ? ( +

{domain.label}

+ ) : null} + +

+ {domain.grants.length === 0 + ? "Keiner Gruppe zugeteilt — niemand kann hier Codes anlegen." + : domain.grants + .map( + (grant) => + `${grant.groupName}${grant.isDefault ? " (Voreinstellung)" : ""}`, + ) + .join(" · ")} +

+ +
+ + + +
+
+
+
+ ))} + + {/* --------------------------------------------------- Registrieren */} + setCreating(false)} + footer={ + <> + + + + } + > +
+ {formError ? {formError} : null} + + + setForm({ ...form, hostname: event.target.value }) + } + /> + + + setForm({ ...form, label: event.target.value })} + /> + +
+
+ + {/* ------------------------------------------------------- Zuteilung */} + setEditing(null)} + footer={ + <> + + + + } + > +
+ {grantError ? {grantError} : null} +

+ Mehrere Gruppen dürfen dieselbe Domain nutzen. Die Voreinstellung + bestimmt, welche Domain beim Anlegen eines Codes vorausgewählt ist. +

+ +
    + {groups.map((group) => { + const grant = grants.find((entry) => entry.groupId === group.id); + return ( +
  • + + setGrants((current) => + event.target.checked + ? [ + ...current, + { + groupId: group.id, + groupName: group.name, + isDefault: false, + }, + ] + : current.filter((entry) => entry.groupId !== group.id), + ) + } + /> + {grant ? ( + + ) : null} +
  • + ); + })} +
+
+
+ + {/* -------------------------------------------------------- Löschen */} + setDeleting(null)} + footer={ + <> + + + + } + > + {deleting ? ( +

+ + {deleting.hostname} + {" "} + wird entfernt. + {deleting.codeCount > 0 + ? ` Unter dieser Domain liegen noch ${deleting.codeCount} ${deleting.codeCount === 1 ? "Code" : "Codes"} — das Löschen wird deshalb abgelehnt. Schalte sie stattdessen ab.` + : " Es liegen keine Codes darunter."} +

+ ) : null} +
+
+ ); +} diff --git a/src/lib/codes.ts b/src/lib/codes.ts index 4391d6b..58f55fa 100644 --- a/src/lib/codes.ts +++ b/src/lib/codes.ts @@ -29,6 +29,7 @@ function asRecord(value: unknown): Record { /** Ein Code samt der Beziehungen, die für die Anzeige gebraucht werden. */ export type QrCodeWithRelations = QrCode & { group?: { name: string } | null; + domain?: { hostname: string } | null; createdBy?: { name: string | null; email: string } | null; }; @@ -57,7 +58,7 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto { type: code.type as QrTypeId, label: code.label, data, - shortUrl: shortUrl(code.slug), + shortUrl: shortUrl(code.slug, code.domain?.hostname), target, summary, design: { @@ -73,6 +74,8 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto { updatedAt: code.updatedAt.toISOString(), groupId: code.groupId, groupName: code.group?.name ?? null, + domainId: code.domainId, + domainHostname: code.domain?.hostname ?? null, createdByName: code.createdBy ? (code.createdBy.name?.trim() || code.createdBy.email) : null, diff --git a/src/lib/domains.ts b/src/lib/domains.ts new file mode 100644 index 0000000..f344e26 --- /dev/null +++ b/src/lib/domains.ts @@ -0,0 +1,228 @@ +import "server-only"; + +import { prisma } from "@/lib/db"; +import type { Actor } from "@/lib/auth/actor"; +import { can, visibleGroupIds, writableGroupIds } from "@/lib/auth/policy"; +import { getBaseUrl } from "@/lib/env"; +import { QrRequestError } from "@/lib/qr/type-module"; + +/** + * Domains für Kurz-URLs. + * + * Die Anwendung läuft hinter einem Reverse Proxy und kann mehrere Domains + * bedienen. Welche gemeint ist, verrät der `Host`-Header der Anfrage. Weil + * dieser Header fälschbar ist, wird er **immer** gegen diese Tabelle geprüft: + * ein unbekannter Hostname führt zu 404. Damit bringt Fälschen nichts — + * ansprechen lässt sich nur, was ohnehin registriert ist. + */ + +/** Von der Migration angelegte Standard-Domain. */ +export const DEFAULT_DOMAIN_ID = "dom_standard"; +const PLACEHOLDER_HOSTNAME = "standard.invalid"; + +/** Normalisiert einen Hostnamen: klein, ohne Schema, ohne abschließenden Punkt. */ +export function normalizeHostname(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/^[a-z][a-z0-9+.-]*:\/\//, "") + .replace(/\/.*$/, "") + .replace(/\.$/, ""); +} + +/** Hostname (ggf. mit Port) aus BASE_URL. */ +export function baseUrlHostname(): string { + try { + return new URL(getBaseUrl()).host.toLowerCase(); + } catch { + return "localhost:3000"; + } +} + +/** + * Sorgt dafür, dass es die Standard-Domain gibt und ihr Hostname zu BASE_URL + * passt. Die Migration kann BASE_URL nicht kennen und setzt deshalb erst + * einen Platzhalter. + */ +export async function ensureDefaultDomain(): Promise<{ + id: string; + hostname: string; +}> { + const hostname = baseUrlHostname(); + + const byHostname = await prisma.domain.findUnique({ + where: { hostname }, + select: { id: true, hostname: true }, + }); + if (byHostname) return byHostname; + + const standard = await prisma.domain.findUnique({ + where: { id: DEFAULT_DOMAIN_ID }, + select: { id: true, hostname: true }, + }); + + // Nur den unveränderten Platzhalter überschreiben — hat jemand die + // Standard-Domain bewusst umbenannt, bleibt das so. + if (standard && standard.hostname === PLACEHOLDER_HOSTNAME) { + return prisma.domain.update({ + where: { id: DEFAULT_DOMAIN_ID }, + data: { hostname }, + select: { id: true, hostname: true }, + }); + } + if (standard) return standard; + + return prisma.domain.create({ + data: { id: DEFAULT_DOMAIN_ID, hostname, label: "Standard-Domain" }, + select: { id: true, hostname: true }, + }); +} + +/** + * Ermittelt die Domain, unter der eine Anfrage hereinkam. + * + * `X-Forwarded-Host` hat Vorrang, weil hinter einem Reverse Proxy dort der + * ursprüngliche Hostname steht. Beide Werte werden gegen die Domain-Tabelle + * geprüft — ohne Treffer gibt es kein Ergebnis. + */ +export async function resolveRequestDomain( + headers: Headers, +): Promise<{ id: string; hostname: string; isActive: boolean } | null> { + const candidates = [ + headers.get("x-forwarded-host"), + headers.get("host"), + ] + .filter((value): value is string => Boolean(value)) + // Bei mehreren Proxies steht in X-Forwarded-Host eine Liste. + .map((value) => normalizeHostname(value.split(",")[0])); + + for (const hostname of candidates) { + if (!hostname) continue; + const domain = await prisma.domain.findUnique({ + where: { hostname }, + select: { id: true, hostname: true, isActive: true }, + }); + if (domain) return domain; + } + return null; +} + +export type DomainOption = { + id: string; + hostname: string; + label: string | null; + isActive: boolean; + isDefault: boolean; +}; + +/** Domains, die einer Gruppe zugeteilt sind. */ +export async function listDomainsForGroup( + groupId: string, +): Promise { + const grants = await prisma.domainGrant.findMany({ + where: { groupId, domain: { isActive: true } }, + include: { domain: true }, + orderBy: [{ isDefault: "desc" }, { domain: { hostname: "asc" } }], + }); + + return grants.map((grant) => ({ + id: grant.domain.id, + hostname: grant.domain.hostname, + label: grant.domain.label, + isActive: grant.domain.isActive, + isDefault: grant.isDefault, + })); +} + +/** Alle Domains, die der Actor in Auswahllisten sehen darf. */ +export async function listDomainsForActor( + actor: Actor, +): Promise { + const visible = visibleGroupIds(actor); + + const domains = await prisma.domain.findMany({ + where: + visible === null + ? undefined + : { grants: { some: { groupId: { in: visible } } } }, + orderBy: { hostname: "asc" }, + include: visible === null ? undefined : { grants: true }, + }); + + return domains.map((domain) => ({ + id: domain.id, + hostname: domain.hostname, + label: domain.label, + isActive: domain.isActive, + isDefault: domain.id === DEFAULT_DOMAIN_ID, + })); +} + +/** + * Bestimmt die Domain für einen neuen Code und prüft, dass die Gruppe sie + * überhaupt nutzen darf. + */ +export async function resolveTargetDomain( + actor: Actor, + groupId: string, + requestedDomainId?: string, +): Promise { + // Muss vor jeder Verwendung laufen: die Migration legt die Standard-Domain + // mit einem Platzhalter-Hostnamen an, weil sie BASE_URL nicht kennt. Ohne + // diesen Aufruf entstünden auf einer frischen Installation Kurz-URLs auf + // "standard.invalid" — also gedruckte Codes, die ins Leere führen. + await ensureDefaultDomain(); + + const allowed = await listDomainsForGroup(groupId); + + if (requestedDomainId) { + const match = allowed.find((entry) => entry.id === requestedDomainId); + if (!match) { + // Instanz-Administratoren dürfen jede aktive Domain verwenden. + if (can(actor, "instance:manage")) { + const domain = await prisma.domain.findUnique({ + where: { id: requestedDomainId }, + select: { id: true, isActive: true }, + }); + if (domain?.isActive) return domain.id; + } + throw new QrRequestError( + "Diese Domain steht der gewählten Gruppe nicht zur Verfügung.", + 403, + ); + } + return match.id; + } + + const preferred = allowed.find((entry) => entry.isDefault) ?? allowed[0]; + if (preferred) return preferred.id; + + // Der Gruppe wurde noch keine Domain zugeteilt — die Standard-Domain + // springt ein, damit das Anlegen nicht ohne Not scheitert. + const fallback = await ensureDefaultDomain(); + return fallback.id; +} + +/** Gruppen, in denen der Actor schreiben darf — für die Auswahl im Generator. */ +export async function writableGroupsWithDomains(actor: Actor): Promise< + { + id: string; + name: string; + domains: DomainOption[]; + }[] +> { + const writable = writableGroupIds(actor); + + const groups = await prisma.group.findMany({ + where: writable === null ? undefined : { id: { in: writable } }, + orderBy: { name: "asc" }, + select: { id: true, name: true }, + }); + + return Promise.all( + groups.map(async (group) => ({ + ...group, + domains: await listDomainsForGroup(group.id), + })), + ); +} diff --git a/src/lib/dto.ts b/src/lib/dto.ts index d696582..46543b1 100644 --- a/src/lib/dto.ts +++ b/src/lib/dto.ts @@ -31,6 +31,8 @@ export type QrCodeDto = { /** Gruppe, der der Code gehört. */ groupId: string; groupName: string | null; + domainId: string; + domainHostname: string | null; /** Wer ihn angelegt hat — leer bei Codes aus der Zeit ohne Anmeldung. */ createdByName: string | null; }; diff --git a/src/lib/env.ts b/src/lib/env.ts index 64f8ee5..bbe44ed 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -15,9 +15,24 @@ export function getBaseUrl(): string { return raw.replace(/\/+$/, ""); } -/** Kurz-URL eines dynamischen Codes. */ -export function shortUrl(slug: string): string { - return `${getBaseUrl()}/r/${slug}`; +/** + * Kurz-URL eines dynamischen Codes. + * + * Ohne Hostname gilt BASE_URL. Mit Hostname — dem der Code zugeordnet ist — + * wird dessen Domain verwendet; das Schema (http/https) kommt weiter aus + * BASE_URL, weil die Anwendung selbst nicht weiß, ob ein Proxy davor TLS + * beendet. + */ +export function shortUrl(slug: string, hostname?: string): string { + if (!hostname) return `${getBaseUrl()}/r/${slug}`; + + let scheme = "http"; + try { + scheme = new URL(getBaseUrl()).protocol.replace(":", ""); + } catch { + // Unbrauchbare BASE_URL — http ist die verträglichere Annahme. + } + return `${scheme}://${hostname}/r/${slug}`; } export function getDatabaseUrl(): string { diff --git a/src/lib/qr/request.ts b/src/lib/qr/request.ts index 77f0d9a..a497aab 100644 --- a/src/lib/qr/request.ts +++ b/src/lib/qr/request.ts @@ -2,6 +2,7 @@ import "server-only"; import { z } from "zod"; +import { prisma } from "@/lib/db"; import { requireAvailableQrType } from "@/lib/qr/availability"; import { designSchema } from "@/lib/qr/design"; import { @@ -33,6 +34,8 @@ export const qrRequestSchema = z.object({ mode: qrModeSchema.default("static"), /** Slug eines bereits gespeicherten dynamischen Codes. */ slug: z.string().trim().optional(), + /** Domain, unter der die Kurz-URL entsteht. */ + domainId: z.string().trim().optional(), }); export type QrRequest = z.infer; @@ -97,10 +100,24 @@ export async function resolveQrRequest( if (slug !== undefined && !isValidSlug(slug)) { throw new QrRequestError("Ungültige Kurz-URL."); } + // Der Hostname kommt aus der Datenbank, nicht aus der Anfrage: sonst + // ließe sich eine beliebige Fremddomain in einen Code kodieren. + let hostname: string | undefined; + if (request.domainId) { + const domain = await prisma.domain.findUnique({ + where: { id: request.domainId }, + select: { hostname: true }, + }); + if (!domain) { + throw new QrRequestError("Diese Domain gibt es nicht.", 404); + } + hostname = domain.hostname; + } + return { qrType, data: validated.data, - content: shortUrl(slug ?? PREVIEW_SLUG), + content: shortUrl(slug ?? PREVIEW_SLUG, hostname), }; }