Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/services/adminSettingsValidation.server.ts
T

218 lines
7.4 KiB
TypeScript

import { Prisma } from "@toolctrl/db/prisma-browser";
export type AdminSettingsSection = "general" | "authentication" | "analytics" | "cookies";
export type AdminSettingsValidationErrors = Record<string, string[]>;
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;
}
}