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.
108 lines
3.2 KiB
TypeScript
108 lines
3.2 KiB
TypeScript
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);
|
|
}
|
|
}
|