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