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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user