"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:
2026-08-20 15:09:54 +02:00
co-authored by Claude Opus 5
parent 3f3ba835a9
commit f86b96a22f
8 changed files with 423 additions and 2 deletions
+38
View File
@@ -27,6 +27,9 @@ import "dotenv/config";
import express from "express";
import cors from "cors";
import cron from "node-cron";
import multer from "multer";
import { mkdirSync } from "node:fs";
import { extname } from "node:path";
import { initDb } from "./db.js";
import { json } from "./lib/http.js";
@@ -60,6 +63,7 @@ import {
import {
listSupporters, getSupporterDetail, updatePremium, listCollections, upsertCollection, getAdminOverview,
} from "./routes/supporter-admin.js";
import { getEventsPublic, saveEvent, uploadEventImage } from "./routes/events.js";
initDb();
@@ -68,6 +72,27 @@ const app = express();
app.set("trust proxy", 1);
app.disable("x-powered-by"); // verrät sonst unnötig den eingesetzten Technik-Stack
/* Bild-Uploads für admin-gepflegte Inhalte (aktuell: "Event des Jahres",
20.08.2026). Liegt bewusst NEBEN der Datenbank unter /var/lib/... statt im
Git-Repo-Ordner — Uploads sollen einen `git pull`/Deploy unbeschadet
überstehen und nicht versehentlich mit eingecheckt werden. */
const UPLOADS_BASIS_DIR = process.env.UPLOADS_DIR || "/var/lib/dogfather-internal/uploads";
const EVENTS_UPLOADS_DIR = `${UPLOADS_BASIS_DIR}/events`;
mkdirSync(EVENTS_UPLOADS_DIR, { recursive: true });
const ERLAUBTE_BILD_TYPEN = new Set(["image/jpeg", "image/png", "image/webp"]);
const bildUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, EVENTS_UPLOADS_DIR),
// Zufälliger Dateiname (crypto.randomUUID) statt des Originalnamens —
// verhindert Pfad-/Namenskollisionen und Path-Traversal-Versuche über
// einen manipulierten Dateinamen komplett, ganz ohne Validierung nötig.
filename: (req, file, cb) => cb(null, `${crypto.randomUUID()}${extname(file.originalname).toLowerCase()}`),
}),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => cb(null, ERLAUBTE_BILD_TYPEN.has(file.mimetype)),
});
app.use((req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
@@ -76,6 +101,10 @@ app.use((req, res, next) => {
});
app.use(cors());
// Bild-Uploads oeffentlich ausliefern (index.html zeigt "Event des Jahres"-Bilder
// JEDER Besucherin/JEDEM Besucher, ohne Login) — nur lesend, Schreiben bleibt
// ausschliesslich ueber /admin/events/upload-image mit EVENTS_MANAGE-Berechtigung.
app.use("/uploads", express.static(UPLOADS_BASIS_DIR));
app.use(
express.json({
limit: "2mb",
@@ -124,6 +153,7 @@ app.get("/health", (req, res) => json(res, { ok: true, service: "dogfather-unive
app.post("/submit", wrap(submitApplication));
app.get("/live-status", wrap(handleLiveStatusGet));
app.post("/auth/login", wrap(login));
app.get("/events-of-year", wrap(getEventsPublic));
app.post("/supporter/register", wrap(registerSupporter));
app.post("/supporter/verify-email", wrap(verifySupporterEmail));
@@ -156,6 +186,10 @@ app.post("/admin/notes/delete", requireTeamSession, withSession(deleteNote));
app.post("/admin/drafts/list", requireTeamSession, withSession(listDrafts));
app.post("/admin/drafts/save", requireTeamSession, withSession(saveDraft));
// Startseite: "Event des Jahres" (20.08.2026)
app.post("/admin/events/save", requireTeamSession, withSession(saveEvent));
app.post("/admin/events/upload-image", requireTeamSession, bildUpload.single("image"), withSession(uploadEventImage));
app.post("/admin/roles/list", requireTeamSession, withSession(listRoles));
app.post("/admin/roles/create", requireTeamSession, withSession(createRole));
app.post("/admin/roles/update", requireTeamSession, withSession(updateRole));
@@ -212,6 +246,10 @@ app.use((err, req, res, next) => {
if (err?.type === "entity.too.large") {
return json(res, { ok: false, error: "Die Anfrage ist zu groß." }, 413);
}
if (err instanceof multer.MulterError) {
const nachricht = err.code === "LIMIT_FILE_SIZE" ? "Das Bild ist zu groß (maximal 5 MB)." : "Bild-Upload fehlgeschlagen.";
return json(res, { ok: false, error: nachricht }, 400);
}
console.error(`[fehler] ${req.method} ${req.originalUrl}:`, err?.stack || err);
if (res.headersSent) return;
return json(res, { ok: false, error: "Interner Fehler." }, 500);
+3
View File
@@ -45,6 +45,8 @@ export const PERMISSIONS = {
SUPPORTER_VIEW: "Supporter-Abonnenten ansehen",
SUPPORTER_MANAGE_PREMIUMS: "Supporter-Prämien & Versand verwalten",
SUPPORTER_MANAGE_COLLECTIONS: "Supporter-Kollektionen verwalten",
EVENTS_MANAGE: "Startseite: \"Event des Jahres\" verwalten",
};
export const PERMISSION_GROUPS = [
@@ -55,6 +57,7 @@ export const PERMISSION_GROUPS = [
{ label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] },
{ label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] },
{ label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS"] },
{ label: "Startseite", keys: ["EVENTS_MANAGE"] },
];
export const OWNER_ONLY_ACTIONS = [
+58
View File
@@ -0,0 +1,58 @@
/* =====================================================================
lib/translate.js — automatische Übersetzung für admin-gepflegte Texte
(aktuell nur "Event des Jahres" auf der Startseite, 20.08.2026).
Bewusste Entscheidung (Nutzer-Rückfrage 20.08.2026): Dogi tippt Titel/Text
nur auf Deutsch in die Verwaltung ein, die anderen Sprachen entstehen
automatisch beim Speichern — NICHT die sonst auf der ganzen Seite geltende
Regel "immer echte, geprüfte Übersetzungen", sondern eine bewusste
Ausnahme für diesen einen admin-gepflegten Bereich. Maschinen-Übersetzung
ist NIE so gut wie eine echte — für schnell änderbare Startseiten-Teaser
akzeptabel, für Rechtstexte/feste Seiteninhalte weiterhin NICHT verwenden.
de-CH bekommt bewusst NICHT automatisch übersetzt: der Website-Dialekt
(Solothurn/Zürich-nahe Schreibweise, siehe main.js-Kommentar zu I18N_CORE)
ist keine von Übersetzungs-APIs unterstützte Zielsprache — eine
automatische "Übersetzung" nach de-CH würde bestenfalls Standarddeutsch
zurückgeben, das wäre nicht ehrlich als "Dialekt" beschriftet. de-CH
bekommt stattdessen exakt denselben Text wie de.
MyMemory (https://mymemory.translated.net) statt Google/DeepL: völlig
kostenlos, kein API-Key/Anmeldung nötig (passt zu "Kosten möglichst 0€"),
für kurze Teaser-Texte ausreichende Qualität. Absichtlich KEIN lokal
gehosteter Übersetzungsdienst (z.B. LibreTranslate) — bei der seltenen
Nutzung (2 kurze Texte, gelegentlich geändert) wäre ein dauerhaft
laufender zusätzlicher Dienst auf dem ohnehin schon mehrfach belegten
Server unverhältnismäßig viel Aufwand für den tatsächlichen Bedarf. */
const SPRACHEN_ZIEL = ["en", "fr", "pt"];
async function uebersetzeEinzeln(text, ziel) {
if (!text || !text.trim()) return "";
const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=de|${ziel}`;
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (!res.ok) throw new Error(`Übersetzungsdienst antwortete mit ${res.status}`);
const data = await res.json();
const uebersetzt = data?.responseData?.translatedText;
if (!uebersetzt || typeof uebersetzt !== "string") throw new Error("Keine Übersetzung erhalten.");
return uebersetzt;
}
/** Übersetzt einen deutschen Text nach en/fr/pt, de-CH = 1:1 Kopie von de.
Bei Fehlern (Dienst nicht erreichbar etc.) fällt die jeweilige Sprache
auf den deutschen Text zurück, statt die ganze Speicherung scheitern zu
lassen — "fail closed" mit sichtbarem Ergebnis statt kaputter Anzeige. */
export async function uebersetzeAlleSprachen(textDe) {
const ergebnis = { de: textDe || "", "de-CH": textDe || "" };
await Promise.all(
SPRACHEN_ZIEL.map(async (ziel) => {
try {
ergebnis[ziel] = await uebersetzeEinzeln(textDe, ziel);
} catch (err) {
console.error(`[translate] ${ziel} fehlgeschlagen, falle auf Deutsch zurück:`, err?.message || err);
ergebnis[ziel] = textDe || "";
}
})
);
return ergebnis;
}
+1
View File
@@ -14,6 +14,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3"
}
}
+93
View File
@@ -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 });
}