"Event des Jahres" jetzt in der Verwaltung pflegbar (Bild, Text, Link)
Nutzer-Wunsch 20.08.2026: "will ich in der admin seite das selbst gestalten können, mit bild und text und am besten auch einen link." Auf Rueckfrage entschieden: Titel/Text nur auf Deutsch eingeben, die anderen 4 Sprachen werden beim Speichern automatisch uebersetzt (MyMemory, 0€, kein API-Key) -- bewusste Ausnahme von der sonst geltenden "immer echte Uebersetzung"-Regel, klar dokumentiert und im Admin-UI selbst als Hinweis sichtbar. de-CH bekommt denselben deutschen Text (Dialekt ist keine von Uebersetzungs-APIs unterstuetzte Zielsprache). Backend (server-internal): - routes/events.js: getEventsPublic (oeffentlich, keine Session), saveEvent + uploadEventImage (beide hinter neuer EVENTS_MANAGE- Berechtigung, Owner immer erlaubt). Speichert in der bereits bestehenden app_settings-Tabelle (2 feste Slots) statt einer neuen Tabelle -- es gibt nie mehr als genau 2 Events. - lib/translate.js: MyMemory-Anbindung mit "fail closed auf Deutsch" pro Sprache, falls der Dienst mal nicht antwortet. - Bild-Upload per multer, zufaelliger Dateiname (crypto.randomUUID, verhindert Path-Traversal ueber den Originalnamen komplett), 5 MB Limit, nur jpeg/png/webp, Ablage unter /var/lib/dogfather-internal/ uploads/events (NICHT im Git-Ordner -- uebersteht Deploys), oeffentlich ausgeliefert unter /uploads. - Neue Berechtigung EVENTS_MANAGE im Katalog (Gruppe "Startseite"). Frontend: - index.html: laedt /events-of-year beim Aufruf, ueberschreibt pro Slot Datum/Titel/Text/Bild/Link NUR wenn dort tatsaechlich etwas gespeichert ist -- bleibt der Abruf aus oder ist ein Slot leer, bleibt der bisherige fest eingebaute Standardinhalt (dieselben zwei echten Events) stehen. Kein Blocker, kein sichtbarer Fehler bei Ausfall. - verwaltung.html: neue Sektion "🏆 Event des Jahres" (nur mit EVENTS_MANAGE sichtbar), 2 Karten mit Bild-Upload+Vorschau, Datum, Titel, Text, Link, eigenem Speichern-Knopf pro Event. Ausfuehrlich getestet, weil server-internal wegen fehlender Visual- Studio-Build-Tools auf dieser Windows-Maschine nicht lokal mit echtem better-sqlite3 laufen kann: routes/events.js komplett isoliert gegen eine Fake-DB getestet (11 Szenarien: oeffentlicher Abruf, fehlende Session, Session ohne Recht, Owner, Rolle MIT EVENTS_MANAGE, alle Validierungen, Bild-Upload inkl. falscher Dateityp, Abruf des hochgeladenen Bilds). index.html per Playwright mit echtem Netzwerk-Mocking gegen zwei Szenarien getestet (API nicht erreichbar -> Standardinhalt bleibt; API liefert echte Daten -> nur der befuellte Slot wird ueberschrieben, der leere bleibt Standard). Dabei einen echten CSS-Bug gefunden und behoben (display:block auf .jahres-event-bild überschrieb die [hidden]-Regel des Browsers, leeres Bild waere immer sichtbar gewesen). verwaltung.html per Playwright mit gemocktem Login+API end-to-end getestet: Formular wird korrekt vorbefuellt, Bild-Upload + Speichern senden die richtigen Daten. WICHTIG: server-internal laeuft unter einem eigenen Systembenutzer (dogiintern), auf den ich (claudian) bewusst KEINEN Zugriff habe -- diese Aenderung kann ich anders als sonst nicht selbst bis auf den Server bringen. Dogi muss den Deploy-Schritt fuer server-internal selbst ausfuehren (git pull + npm install + Neustart des dogiintern-Dienstes). Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/* =====================================================================
|
||||
routes/events.js — "Event des Jahres" auf der Startseite (index.html),
|
||||
admin-pflegbar aus der Verwaltung heraus (Nutzer-Wunsch 20.08.2026:
|
||||
"will ich in der admin seite das selbst gestalten können, mit bild und
|
||||
text und am besten auch einen link"). Zeigt IMMER genau 2 feste Slots
|
||||
(1 und 2) — kein wachsender Katalog, deshalb genügt die bereits
|
||||
bestehende app_settings-Tabelle (key/value) statt einer eigenen Tabelle.
|
||||
|
||||
Öffentlicher Lesezugriff (getEventsPublic) ohne Session, damit
|
||||
index.html die Inhalte für jede Besucherin/jeden Besucher laden kann —
|
||||
Schreibzugriff nur mit EVENTS_MANAGE-Berechtigung (Owner immer erlaubt,
|
||||
siehe can()). ===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { uebersetzeAlleSprachen } from "../lib/translate.js";
|
||||
|
||||
function can(session, key) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return hasPermission(session.permissions, session.overrides, key);
|
||||
}
|
||||
|
||||
function forbidden(res) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
|
||||
function settingsKey(slot) {
|
||||
return `event_des_jahres_${slot}`;
|
||||
}
|
||||
|
||||
function ladeSlot(slot) {
|
||||
const row = db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(settingsKey(slot));
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Öffentlich, keine Session: liefert beide Slots (oder null, wenn noch nie
|
||||
gespeichert -- index.html behält dann seinen eingebauten Standardinhalt). */
|
||||
export async function getEventsPublic(req, res) {
|
||||
return json(res, { ok: true, events: [ladeSlot(1), ladeSlot(2)] });
|
||||
}
|
||||
|
||||
/** NUR Bild-Upload, kein JSON-Body (multipart, siehe index.js multer-Middleware). */
|
||||
export async function uploadEventImage(req, res, session) {
|
||||
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
|
||||
if (!req.file) return json(res, { ok: false, error: "Keine Bilddatei erhalten." }, 400);
|
||||
const url = `/uploads/events/${req.file.filename}`;
|
||||
logAction(session.actor, "events.image_uploaded", req.file.filename, null);
|
||||
return json(res, { ok: true, url });
|
||||
}
|
||||
|
||||
export async function saveEvent(req, res, session) {
|
||||
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const slot = Number(body.slot);
|
||||
if (slot !== 1 && slot !== 2) return json(res, { ok: false, error: "Ungültiger Slot (nur 1 oder 2)." }, 400);
|
||||
|
||||
const datum = String(body.datum || "").trim().slice(0, 80);
|
||||
const titelDe = String(body.titel || "").trim().slice(0, 120);
|
||||
const textDe = String(body.text || "").trim().slice(0, 400);
|
||||
const link = String(body.link || "").trim().slice(0, 300) || null;
|
||||
const bild = String(body.bild || "").trim().slice(0, 300) || null;
|
||||
|
||||
if (!titelDe || !textDe) {
|
||||
return json(res, { ok: false, error: "Titel und Text sind Pflicht." }, 400);
|
||||
}
|
||||
// Nur echte http(s)-Links zulassen (kein javascript:-Link etc.)
|
||||
if (link && !/^https?:\/\//i.test(link)) {
|
||||
return json(res, { ok: false, error: "Link muss mit http:// oder https:// beginnen." }, 400);
|
||||
}
|
||||
|
||||
const [titel, text] = await Promise.all([uebersetzeAlleSprachen(titelDe), uebersetzeAlleSprachen(textDe)]);
|
||||
|
||||
const eintrag = {
|
||||
datum, titel, text, link, bild,
|
||||
aktualisiertVon: session.actor,
|
||||
aktualisiertAm: new Date().toISOString(),
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
||||
).run(settingsKey(slot), JSON.stringify(eintrag), eintrag.aktualisiertAm);
|
||||
|
||||
logAction(session.actor, "events.saved", String(slot), { titel: titelDe });
|
||||
return json(res, { ok: true, event: eintrag });
|
||||
}
|
||||
Reference in New Issue
Block a user