218 lines
7.4 KiB
TypeScript
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;
|
|
}
|
|
}
|