diff --git a/assets/css/main.css b/assets/css/main.css index b0ddb7db..c25e6c13 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -1629,6 +1629,23 @@ header.site-header { .jahres-event-card h3 { margin: 0 0 .4rem; font-size: 1.15rem; } .jahres-event-card p { margin: 0 0 .6rem; color: var(--text-muted); font-size: .93rem; line-height: 1.55; } .jahres-event-mehr { color: var(--accent); font-weight: 600; font-size: .88rem; } +/* Optionales Admin-Bild pro Event (20.08.2026, "Event des Jahres" jetzt in + der Verwaltung pflegbar) — bleibt per [hidden] unsichtbar, solange kein + Bild gesetzt ist, damit die textbasierten Standard-Karten unverändert + aussehen. Bugfix (per Playwright-Test gefunden): "display:block" allein + hätte dieselbe Spezifität wie die eingebaute [hidden]-Regel des Browsers + und würde sie im Zweifel überschreiben (leerer Bild-Rahmen wäre auch OHNE + gesetztes Bild sichtbar gewesen) — deshalb hier zusätzlich explizit + :not([hidden]) statt nur der Klasse allein. */ +.jahres-event-bild[hidden] { display: none; } +.jahres-event-bild:not([hidden]) { + display: block; + width: 100%; + aspect-ratio: 16/9; + object-fit: cover; + border-radius: calc(var(--radius) - 4px); + margin-bottom: .9rem; +} /* ---------- Cards ---------- */ .card { diff --git a/index.html b/index.html index acca36b4..1decc608 100644 --- a/index.html +++ b/index.html @@ -91,13 +91,15 @@ 🏆 Event des Jahres
DogFather stellt sich einer besonderen Herausforderung und streamt 28 Stunden am Stück.
Zur Zeitreise → - + +Gemeinsam mit Cidem organisiert DogFather den Creator Cup für die Streamer von Spicy Media.
@@ -355,6 +357,55 @@ document.addEventListener("DOMContentLoaded", () => { }); } + /* "Event des Jahres": seit 20.08.2026 in der Verwaltung pflegbar (Bild, + Text, Link je Slot, siehe verwaltung.html + server-internal/routes/ + events.js). Lädt beim Seitenaufruf, was Dogi/VanVan zuletzt gespeichert + haben — bleibt der Fetch aus (noch nie gespeichert, Dienst nicht + erreichbar) oder ist ein Slot noch leer, bleibt bewusst der fest + eingebaute Standardinhalt aus dem HTML stehen (dieselben zwei echten + Events wie bisher) statt einer kaputten/leeren Kachel. */ + function wendeJahresEventAn(slot, daten) { + if (!daten) return; // kein gespeicherter Eintrag -> Standardinhalt im HTML bleibt + const karte = document.getElementById(`jahres-event-${slot}`); + if (!karte) return; + const feld = (x) => (window.dogiFeld ? window.dogiFeld(x) : x || ""); + const bild = karte.querySelector(".jahres-event-bild"); + if (daten.bild) { + bild.src = daten.bild; + bild.alt = feld(daten.titel); + bild.hidden = false; + } + karte.querySelector(".jahres-event-datum").textContent = daten.datum || ""; + karte.querySelector("h3").textContent = feld(daten.titel); + karte.querySelector("p").textContent = feld(daten.text); + if (daten.link) karte.href = daten.link; + } + + if (API_BASE_URL) { + let letzteJahresEvents = null; + const ladeJahresEvents = () => + fetch(`${API_BASE_URL}/events-of-year`, { cache: "no-store" }) + .then((res) => res.json()) + .then((data) => { + if (!data?.ok) return; + letzteJahresEvents = data.events; + wendeJahresEventAn(1, data.events[0]); + wendeJahresEventAn(2, data.events[1]); + }) + .catch(() => { + /* Standardinhalt aus dem HTML bleibt stehen -- kein sichtbarer Fehler nötig. */ + }); + ladeJahresEvents(); + // Bei Sprachwechsel mit den zuletzt geladenen Daten neu rendern (kein + // erneuter Server-Aufruf nötig) — Titel/Text stehen ja bereits in allen + // Sprachen im geladenen Objekt. + document.addEventListener("dogi-sprache-geaendert", () => { + if (!letzteJahresEvents) return; + wendeJahresEventAn(1, letzteJahresEvents[0]); + wendeJahresEventAn(2, letzteJahresEvents[1]); + }); + } + // Stats: Team-Größe aus den Datenobjekten berechnen const teamCount = (window.MODIS || []).length + (window.SCOUTS || []).length; const statTeam = document.getElementById("stat-team"); diff --git a/server-internal/index.js b/server-internal/index.js index e613c10b..4e753a92 100644 --- a/server-internal/index.js +++ b/server-internal/index.js @@ -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); diff --git a/server-internal/lib/permissions.js b/server-internal/lib/permissions.js index 68bb7610..2d7d8320 100644 --- a/server-internal/lib/permissions.js +++ b/server-internal/lib/permissions.js @@ -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 = [ diff --git a/server-internal/lib/translate.js b/server-internal/lib/translate.js new file mode 100644 index 00000000..2bb8e84e --- /dev/null +++ b/server-internal/lib/translate.js @@ -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; +} diff --git a/server-internal/package.json b/server-internal/package.json index f6080b7d..59a1743b 100644 --- a/server-internal/package.json +++ b/server-internal/package.json @@ -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" } } diff --git a/server-internal/routes/events.js b/server-internal/routes/events.js new file mode 100644 index 00000000..96140c44 --- /dev/null +++ b/server-internal/routes/events.js @@ -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 }); +} diff --git a/verwaltung.html b/verwaltung.html index a0132120..6580d9d8 100644 --- a/verwaltung.html +++ b/verwaltung.html @@ -737,6 +737,27 @@