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), }; }