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 { 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 { 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 { // 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), })), ); }