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.
114 lines
3.7 KiB
JavaScript
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'),
|
|
};
|