larpaso-website/admin/lib/validate.js
Pascal Scholz 93178a24e3 Add login-protected admin editor for content and design
Adds a self-service editor at admin.larpaso.de so texts, images, links,
Impressum/Datenschutz, fonts, font sizes and colors can be changed without
a developer. All homepage/legal content moves into content/site.json and
a new content/theme.json drives every CSS color/font/size token; the
Astro pages read from these at build time.

The admin/ service (Node/Express) provides bcrypt+session login with
rate limiting, a schema-driven form UI (including a hex/RGB/alpha color
picker), image upload, and a publish action that runs the Astro build
and republishes into a volume shared with the nginx website container -
so edits go live without redeploying that container.

See DEPLOYMENT_ADMIN.md for the server-side setup steps.
2026-09-21 21:35:09 +02:00

114 lines
3.7 KiB
JavaScript

const schema = require('./schema');
function isPlainObject(v) {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function isFiniteNumber(v) {
return typeof v === 'number' && Number.isFinite(v);
}
function validateItemFields(item, itemFields, pathLabel, errors) {
if (!isPlainObject(item)) {
errors.push(`${pathLabel}: Eintrag muss ein Objekt sein.`);
return;
}
for (const field of itemFields) {
validateField(item[field.key], field, `${pathLabel}.${field.key}`, errors);
}
}
function validateField(value, field, pathLabel, errors) {
switch (field.type) {
case 'text':
case 'textarea':
case 'richtext':
case 'email':
case 'image':
if (typeof value !== 'string') errors.push(`${pathLabel}: erwartet Text.`);
break;
case 'select':
if (typeof value !== 'string') errors.push(`${pathLabel}: erwartet Text.`);
else if (field.options && !field.options.includes(value)) {
errors.push(`${pathLabel}: unbekannter Wert "${value}".`);
}
break;
case 'size':
if (typeof value !== 'string' || !/^-?\d+(\.\d+)?(rem|px|em)$/.test(value.trim())) {
errors.push(`${pathLabel}: erwartet eine CSS-Größe (z. B. "1.25rem").`);
}
break;
case 'color':
if (!isPlainObject(value)) {
errors.push(`${pathLabel}: erwartet eine Farbe.`);
break;
}
for (const channel of ['r', 'g', 'b']) {
if (!isFiniteNumber(value[channel]) || value[channel] < 0 || value[channel] > 255) {
errors.push(`${pathLabel}.${channel}: erwartet eine Zahl von 0 bis 255.`);
}
}
if (!isFiniteNumber(value.a) || value.a < 0 || value.a > 1) {
errors.push(`${pathLabel}.a: erwartet eine Zahl von 0 bis 1.`);
}
break;
case 'boolean':
if (typeof value !== 'boolean') errors.push(`${pathLabel}: erwartet Ja/Nein.`);
break;
case 'list-string':
case 'list-richtext':
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
errors.push(`${pathLabel}: erwartet eine Liste von Texten.`);
}
break;
case 'list-object':
if (!Array.isArray(value)) {
errors.push(`${pathLabel}: erwartet eine Liste.`);
break;
}
value.forEach((item, i) => validateItemFields(item, field.itemFields, `${pathLabel}[${i}]`, errors));
break;
case 'group':
validateItemFields(value, field.itemFields, pathLabel, errors);
break;
default:
break;
}
}
/** Validates a candidate content.json or theme.json payload against the
* admin schema's sections for the given file ('content' or 'theme').
* Returns an array of human-readable error strings (empty = valid). */
function validateAgainstSchema(payload, fileKind) {
const errors = [];
if (!isPlainObject(payload)) {
return ['Inhalt muss ein Objekt sein.'];
}
const sections = schema.filter((s) => (s.file || 'content') === fileKind);
for (const section of sections) {
const value = payload[section.key];
if (section.type === 'array-root') {
if (!Array.isArray(value)) {
errors.push(`${section.key}: erwartet eine Liste.`);
continue;
}
value.forEach((item, i) => validateItemFields(item, section.itemFields, `${section.key}[${i}]`, errors));
} else {
if (!isPlainObject(value)) {
errors.push(`${section.key}: erwartet ein Objekt.`);
continue;
}
for (const field of section.fields) {
validateField(value[field.key], field, `${section.key}.${field.key}`, errors);
}
}
}
return errors;
}
module.exports = {
validateContent: (payload) => validateAgainstSchema(payload, 'content'),
validateTheme: (payload) => validateAgainstSchema(payload, 'theme'),
};