import { Prisma } from "@toolctrl/db/prisma-browser"; export type AdminSettingsSection = "general" | "authentication" | "analytics" | "cookies"; export type AdminSettingsValidationErrors = Record; export type AdminSettingsValidationResult = | { ok: true; section: AdminSettingsSection; data: Prisma.AppConfigurationUpdateInput } | { ok: false; status: 400; errors: AdminSettingsValidationErrors }; const MAX_SHORT_TEXT_LENGTH = 255; const MAX_SCRIPT_LENGTH = 20_000; const MAX_BRANDING_VALUE_LENGTH = 10_000; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // EU-Sterilisation (R-P1a): googleAnalyticsTrackingId aus den Admin-Analytics-Settings entfernt. const SECRET_FIELD_NAMES = new Set(["headScripts", "bodyScripts"]); export function parseAdminSettingsForm(section: AdminSettingsSection, form: FormData): AdminSettingsValidationResult { const errors: AdminSettingsValidationErrors = {}; const action = getString(form, "action"); if (action !== "update") { addError(errors, "action", "Invalid action."); return invalid(errors); } switch (section) { case "general": return parseGeneralSettings(form, errors); case "authentication": return parseAuthenticationSettings(form, errors); case "analytics": return parseAnalyticsSettings(form, errors); case "cookies": return parseCookiesSettings(form, errors); default: addError(errors, "section", "Invalid settings section."); return invalid(errors); } } export function redactSettingsAuditDetails(section: AdminSettingsSection, input: Prisma.AppConfigurationUpdateInput): string { const details = { section, changedFields: Object.keys(input).sort(), values: Object.fromEntries( Object.entries(input) .sort(([left], [right]) => left.localeCompare(right)) .map(([field, value]) => [field, redactFieldValue(field, value)]) ), }; return JSON.stringify(details).slice(0, 4_000); } function parseGeneralSettings(form: FormData, errors: AdminSettingsValidationErrors): AdminSettingsValidationResult { const name = requireString(form, "name", errors); const theme = optionalString(form, "theme", errors); const logo = optionalString(form, "logo", errors, MAX_BRANDING_VALUE_LENGTH); const logoDarkMode = optionalString(form, "logoDarkMode", errors, MAX_BRANDING_VALUE_LENGTH); const icon = optionalString(form, "icon", errors, MAX_BRANDING_VALUE_LENGTH); const iconDarkMode = optionalString(form, "iconDarkMode", errors, MAX_BRANDING_VALUE_LENGTH); const favicon = optionalString(form, "favicon", errors, MAX_BRANDING_VALUE_LENGTH); const headScripts = optionalString(form, "headScripts", errors, MAX_SCRIPT_LENGTH); const bodyScripts = optionalString(form, "bodyScripts", errors, MAX_SCRIPT_LENGTH); if (Object.keys(errors).length > 0) { return invalid(errors); } return valid("general", { name, brandingLogo: logo, brandingLogoDarkMode: logoDarkMode, brandingIcon: icon, brandingIconDarkMode: iconDarkMode, brandingFavicon: favicon, theme, headScripts, bodyScripts, }); } function parseAuthenticationSettings(form: FormData, errors: AdminSettingsValidationErrors): AdminSettingsValidationResult { if (Object.keys(errors).length > 0) { return invalid(errors); } return valid("authentication", { authRequireEmailVerification: getBoolean(form, "requireEmailVerification"), authRequireOrganization: getBoolean(form, "requireOrganization"), authRequireName: getBoolean(form, "requireName"), subscriptionRequired: getBoolean(form, "required"), subscriptionAllowSubscribeBeforeSignUp: getBoolean(form, "allowSubscribeBeforeSignUp"), subscriptionAllowSignUpBeforeSubscribe: getBoolean(form, "allowSignUpBeforeSubscribe"), }); } function parseAnalyticsSettings(form: FormData, errors: AdminSettingsValidationErrors): AdminSettingsValidationResult { if (Object.keys(errors).length > 0) { return invalid(errors); } return valid("analytics", { analyticsEnabled: getBoolean(form, "enabled"), analyticsSimpleAnalytics: getBoolean(form, "simpleAnalytics"), analyticsPlausibleAnalytics: getBoolean(form, "plausibleAnalytics"), }); } function parseCookiesSettings(form: FormData, errors: AdminSettingsValidationErrors): AdminSettingsValidationResult { const enabled = getOptionalBoolean(form, "enabled", errors); if (Object.keys(errors).length > 0) { return invalid(errors); } return valid("cookies", { cookiesEnabled: enabled, }); } function requireString(form: FormData, field: string, errors: AdminSettingsValidationErrors, maxLength = MAX_SHORT_TEXT_LENGTH) { const value = optionalString(form, field, errors, maxLength); if (!value) { addError(errors, field, "This field is required."); } return value; } function optionalString(form: FormData, field: string, errors: AdminSettingsValidationErrors, maxLength = MAX_SHORT_TEXT_LENGTH) { const value = getString(form, field).trim(); if (value.length > maxLength) { addError(errors, field, `Must be ${maxLength} characters or fewer.`); } if (field.toLowerCase().includes("email") && value && !EMAIL_PATTERN.test(value)) { addError(errors, field, "Email address is invalid."); } return value; } function getString(form: FormData, field: string) { const value = form.get(field); return typeof value === "string" ? value : ""; } function getBoolean(form: FormData, field: string) { const value = form.get(field); return value === "on" || value === "true" || value === "1"; } function getOptionalBoolean(form: FormData, field: string, errors: AdminSettingsValidationErrors) { const value = form.get(field); if (value === null) { return false; } if (value === "on" || value === "true" || value === "1") { return true; } if (value === "off" || value === "false" || value === "0") { return false; } addError(errors, field, "Boolean value is invalid."); return false; } function addError(errors: AdminSettingsValidationErrors, field: string, message: string) { errors[field] = [...(errors[field] ?? []), message]; } function invalid(errors: AdminSettingsValidationErrors): AdminSettingsValidationResult { return { ok: false, status: 400, errors }; } function valid(section: AdminSettingsSection, data: Prisma.AppConfigurationUpdateInput): AdminSettingsValidationResult { return { ok: true, section, data }; } function redactFieldValue(field: string, value: unknown) { if (typeof value === "boolean") { return value; } if (typeof value === "string") { if (SECRET_FIELD_NAMES.has(field) || isSensitiveFieldName(field)) { return { redacted: true, length: value.length, source: value ? "provided" : "empty" }; } if (looksLikeUrl(value)) { return { redacted: true, source: "url", origin: safeUrlOrigin(value), hasQuery: hasUrlQuery(value) }; } return { redacted: true, length: value.length, source: value ? "provided" : "empty" }; } if (value === null || value === undefined) { return { source: "empty" }; } return { redacted: true, source: "complex" }; } function isSensitiveFieldName(field: string) { return /token|password|secret|key|script|tracking|url/i.test(field); } function looksLikeUrl(value: string) { return /^https?:\/\//i.test(value); } function safeUrlOrigin(value: string) { try { return new URL(value).origin; } catch { return "invalid-url"; } } function hasUrlQuery(value: string) { try { return new URL(value).search.length > 0; } catch { return false; } }