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:
Florian Wilde
2026-08-14 13:10:12 +02:00
parent f1cdb3da13
commit 2a80f15e20
17 changed files with 1168 additions and 15 deletions
+160
View File
@@ -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<Response> {
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<Response> {
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);
}
}
+107
View File
@@ -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<Response> {
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<Response> {
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);
}
}
+5 -2
View File
@@ -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))
+1
View File
@@ -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;
+10
View File
@@ -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<Response> {
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<Response> {
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<Response> {
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 } },
},
});
+24 -1
View File
@@ -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.",
+39
View File
@@ -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 (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/verwaltung"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Verwaltung
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Domains
</h1>
<p className="mt-2 text-slate-600">
Unter welchen Adressen Kurz-URLs ausgeliefert werden. Eine Domain
kann mehreren Gruppen dienen; der Slug eines Codes ist je Domain
eindeutig.
</p>
</div>
<DomainAdmin baseUrlHostname={baseUrlHostname()} />
</div>
);
}
+9 -1
View File
@@ -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"}`}
/>
<Tile
href="/verwaltung/domains"
icon="globe"
title="Domains"
description="Adressen, unter denen Kurz-URLs ausgeliefert werden."
hint={`${domainCount} ${domainCount === 1 ? "Domain" : "Domains"} registriert`}
/>
<Tile
href="/verwaltung/typen"
icon="qr"