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"
+387
View File
@@ -0,0 +1,387 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import {
Badge,
Button,
Card,
Checkbox,
Field,
Notice,
TextInput,
} from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
type Grant = { groupId: string; groupName: string; isDefault: boolean };
type AdminDomain = {
id: string;
hostname: string;
label: string | null;
isActive: boolean;
codeCount: number;
grants: Grant[];
};
type GroupOption = { id: string; name: string };
export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
const [domains, setDomains] = useState<AdminDomain[] | null>(null);
const [groups, setGroups] = useState<GroupOption[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({ hostname: "", label: "" });
const [formError, setFormError] = useState<string | null>(null);
const [editing, setEditing] = useState<AdminDomain | null>(null);
const [grants, setGrants] = useState<Grant[]>([]);
const [grantError, setGrantError] = useState<string | null>(null);
const [deleting, setDeleting] = useState<AdminDomain | null>(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 <p className="text-sm text-slate-500">Domains werden geladen …</p>;
}
return (
<div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null}
<Notice tone="info">
Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung
weiterleitet. Die Weiterleitung erkennt sie am{" "}
<code className="font-mono text-xs">Host</code>-Header und prüft ihn
gegen diese Liste — ein unbekannter Hostname endet mit 404.
</Notice>
<div className="flex justify-end">
<Button type="button" onClick={() => setCreating(true)}>
Domain registrieren
</Button>
</div>
{domains.map((domain) => (
<Card key={domain.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-mono font-semibold text-slate-900">
{domain.hostname}
</h2>
{domain.hostname === baseUrlHostname ? (
<Badge tone="popular">aus BASE_URL</Badge>
) : null}
{!domain.isActive ? (
<Badge tone="soon">abgeschaltet</Badge>
) : null}
<Badge tone="neutral">
{domain.codeCount} {domain.codeCount === 1 ? "Code" : "Codes"}
</Badge>
</div>
{domain.label ? (
<p className="mt-1 text-sm text-slate-600">{domain.label}</p>
) : null}
<p className="mt-2 text-sm text-slate-600">
{domain.grants.length === 0
? "Keiner Gruppe zugeteilt — niemand kann hier Codes anlegen."
: domain.grants
.map(
(grant) =>
`${grant.groupName}${grant.isDefault ? " (Voreinstellung)" : ""}`,
)
.join(" · ")}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === domain.id}
onClick={() => {
setEditing(domain);
setGrants(domain.grants);
setGrantError(null);
}}
>
<Icon name="pencil" className="size-3.5" />
Gruppen zuteilen
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === domain.id}
onClick={() => void patch(domain.id, { isActive: !domain.isActive })}
>
{domain.isActive ? "Abschalten" : "Einschalten"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
disabled={busy === domain.id}
onClick={() => setDeleting(domain)}
>
<Icon name="trash" className="size-3.5" />
Löschen
</Button>
</div>
</div>
</div>
</Card>
))}
{/* --------------------------------------------------- Registrieren */}
<Modal
open={creating}
title="Domain registrieren"
onClose={() => setCreating(false)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setCreating(false)}>
Abbrechen
</Button>
<Button type="button" onClick={() => void create()}>
Registrieren
</Button>
</>
}
>
<div className="space-y-4">
{formError ? <Notice tone="error">{formError}</Notice> : null}
<Field
label="Hostname"
htmlFor="new-domain-host"
hint="Ohne https:// und ohne Pfad — genau so, wie er im Browser steht."
>
<TextInput
id="new-domain-host"
className="font-mono"
placeholder="qr.example.com"
value={form.hostname}
onChange={(event) =>
setForm({ ...form, hostname: event.target.value })
}
/>
</Field>
<Field label="Bezeichnung (optional)" htmlFor="new-domain-label">
<TextInput
id="new-domain-label"
placeholder="Kundenprojekt Nord"
value={form.label}
onChange={(event) => setForm({ ...form, label: event.target.value })}
/>
</Field>
</div>
</Modal>
{/* ------------------------------------------------------- Zuteilung */}
<Modal
open={editing !== null}
title={`Gruppen für ${editing?.hostname ?? ""}`}
onClose={() => setEditing(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setEditing(null)}>
Abbrechen
</Button>
<Button
type="button"
disabled={busy === editing?.id}
onClick={() =>
editing &&
void patch(
editing.id,
{
grants: grants.map((grant) => ({
groupId: grant.groupId,
isDefault: grant.isDefault,
})),
},
() => setEditing(null),
)
}
>
Speichern
</Button>
</>
}
>
<div className="space-y-4">
{grantError ? <Notice tone="error">{grantError}</Notice> : null}
<p className="text-sm text-slate-600">
Mehrere Gruppen dürfen dieselbe Domain nutzen. Die Voreinstellung
bestimmt, welche Domain beim Anlegen eines Codes vorausgewählt ist.
</p>
<ul className="space-y-2">
{groups.map((group) => {
const grant = grants.find((entry) => entry.groupId === group.id);
return (
<li
key={group.id}
className="flex flex-wrap items-center gap-3 rounded-lg border border-slate-200 p-2.5"
>
<Checkbox
id={`grant-${group.id}`}
label={group.name}
checked={Boolean(grant)}
onChange={(event) =>
setGrants((current) =>
event.target.checked
? [
...current,
{
groupId: group.id,
groupName: group.name,
isDefault: false,
},
]
: current.filter((entry) => entry.groupId !== group.id),
)
}
/>
{grant ? (
<label className="ml-auto flex items-center gap-1.5 text-xs text-slate-600">
<input
type="checkbox"
className="size-3.5 rounded border-slate-300 accent-indigo-600"
checked={grant.isDefault}
onChange={(event) =>
setGrants((current) =>
current.map((entry) =>
entry.groupId === group.id
? { ...entry, isDefault: event.target.checked }
: entry,
),
)
}
/>
Voreinstellung
</label>
) : null}
</li>
);
})}
</ul>
</div>
</Modal>
{/* -------------------------------------------------------- Löschen */}
<Modal
open={deleting !== null}
title="Domain löschen?"
onClose={() => setDeleting(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setDeleting(null)}>
Abbrechen
</Button>
<Button type="button" variant="danger" onClick={() => void remove()}>
Endgültig löschen
</Button>
</>
}
>
{deleting ? (
<p className="text-sm text-slate-600">
<strong className="font-mono text-slate-900">
{deleting.hostname}
</strong>{" "}
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."}
</p>
) : null}
</Modal>
</div>
);
}
+4 -1
View File
@@ -29,6 +29,7 @@ function asRecord(value: unknown): Record<string, unknown> {
/** 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,
+228
View File
@@ -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<DomainOption[]> {
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<DomainOption[]> {
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<string> {
// 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),
})),
);
}
+2
View File
@@ -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;
};
+18 -3
View File
@@ -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 {
+18 -1
View File
@@ -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<typeof qrRequestSchema>;
@@ -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),
};
}