Arbeit der parallelen Session in git gesichert (war nur auf dem Server)

Diese Änderungen liefen bereits live auf dem Server, lagen dort aber
ausschließlich als nicht eingecheckte Arbeitskopie — bei jedem Deploy
(stash/pull/pop) und bei jedem Serverproblem wären sie verloren gewesen.
Deshalb hier unverändert in git übernommen, bevor darauf aufgebaut wird.

Enthalten (nicht von mir gebaut, nur gesichert):
- Supporter: eigener fester Zugangscode statt Einmalcode-Login
  (Migration 0009, lib/crypto.js scrypt-Hash, routes/supporter.js,
  abonnieren.html, supporter.html, i18n-abonnieren/-supporter)
- Stimmen: Profilbilder (Migration 0010, routes/testimonials.js)
- Event-Bild-Upload: voller Pfad statt relativem (routes/events.js)
- Neue/überarbeitete Hintergrundbilder für viele Seiten
- Teilen-Funktion (streamplan.js, i18n-index/-streamplan, main.css)

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-21 17:35:14 +02:00
co-authored by Claude Opus 5
parent 4b294f6c6b
commit 5d0ea11570
65 changed files with 992 additions and 139 deletions
+60 -2
View File
@@ -27,12 +27,27 @@ function forbidden(res) {
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
}
/* Erlaubte Auswahlbilder (21.08.2026). Muss mit der Liste in
assets/js/data-stimmen-avatare.js übereinstimmen.
Warum überhaupt eine Liste auf dem Server? Weil /testimonials/submit
öffentlich und ohne Login erreichbar ist. Ohne diese Prüfung könnte man
in das Feld einen beliebigen Text schreiben (z.B. eine fremde Bild-
Adresse oder etwas, das später als HTML/Adresse ausgewertet wird) und so
auf einer freigegebenen Stimme fremde Inhalte einbetten. Es werden
deshalb ausschließlich diese festen Ids angenommen. */
const ERLAUBTE_AVATARE = [
"husky-hoodie", "hase-hoodie", "husky-kopf", "husky-kosmos",
"hase-kosmos", "casper", "casper-blick", "logo",
];
function oeffentlichesFeld(row) {
return {
id: row.id,
name: row.name,
tiktokUsername: row.tiktok_username,
nachricht: row.nachricht,
avatar: row.avatar || null,
};
}
@@ -42,6 +57,7 @@ function vollesFeld(row) {
name: row.name,
tiktokUsername: row.tiktok_username,
nachricht: row.nachricht,
avatar: row.avatar || null,
status: row.status,
createdAt: row.created_at,
entschiedenAm: row.entschieden_am,
@@ -63,15 +79,57 @@ export async function submitTestimonial(req, res) {
if (!nachricht) return json(res, { ok: false, error: "Bitte schreib eine Nachricht." }, 400);
if (nachricht.length < 10) return json(res, { ok: false, error: "Die Nachricht ist noch sehr kurz -- magst du noch etwas mehr schreiben?" }, 400);
// Selbst gewähltes Auswahlbild. Unbekannte Werte werden still verworfen
// (statt die Einreichung abzulehnen) — die Nachricht ist das Wichtige,
// und ein fehlendes Bild ist kein Grund, jemanden abzuweisen.
const gewaehlt = String(body.avatar || "").trim();
const avatar = ERLAUBTE_AVATARE.includes(gewaehlt) ? gewaehlt : null;
const id = generateId();
db.prepare(
`INSERT INTO testimonials (id, name, tiktok_username, nachricht, status, created_at) VALUES (?, ?, ?, ?, 'eingereicht', ?)`
).run(id, name, tiktokUsername, nachricht, nowIso());
`INSERT INTO testimonials (id, name, tiktok_username, nachricht, avatar, status, created_at) VALUES (?, ?, ?, ?, ?, 'eingereicht', ?)`
).run(id, name, tiktokUsername, nachricht, avatar, nowIso());
logAction("anonym", "testimonial.submitted", id, { name });
return json(res, { ok: true });
}
/** Verwaltung: Profilbild einer Stimme setzen oder entfernen.
Erlaubt hier ZUSÄTZLICH zu den Auswahlbildern eine hochgeladene Datei
(siehe uploadTestimonialAvatar) — anders als beim öffentlichen Formular
ist hier eine angemeldete, berechtigte Person am Werk. */
export async function setTestimonialAvatar(req, res, session) {
if (!can(session, "TESTIMONIALS_MANAGE")) return forbidden(res);
const id = String(req.body?.id || "");
const roh = String(req.body?.avatar || "").trim();
let avatar = null;
if (roh) {
const istEigenesFoto = roh.startsWith("https://postfach.dogfather-universe.com/uploads/testimonials/");
if (!ERLAUBTE_AVATARE.includes(roh) && !istEigenesFoto) {
return json(res, { ok: false, error: "Unbekanntes Profilbild." }, 400);
}
avatar = roh;
}
const info = db.prepare(`UPDATE testimonials SET avatar = ? WHERE id = ?`).run(avatar, id);
if (info.changes === 0) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
logAction(session.actor, "testimonial.avatar_changed", id, { avatar });
return json(res, { ok: true });
}
/** NUR Datei-Upload (multipart, siehe index.js). Für den Fall, dass jemand
Dogi sein Foto auf anderem Weg geschickt hat (TikTok, WhatsApp …) —
das öffentliche Formular selbst nimmt bewusst KEINE Dateien an, sonst
könnte ohne Anmeldung beliebiges Material hochgeladen werden. */
export async function uploadTestimonialAvatar(req, res, session) {
if (!can(session, "TESTIMONIALS_MANAGE")) return forbidden(res);
if (!req.file) return json(res, { ok: false, error: "Keine Bilddatei erhalten." }, 400);
const url = `https://postfach.dogfather-universe.com/uploads/testimonials/${req.file.filename}`;
logAction(session.actor, "testimonial.avatar_uploaded", req.file.filename, null);
return json(res, { ok: true, url });
}
/** Öffentlich, keine Session: nur freigegebene Stimmen, neueste zuerst. */
export async function getApprovedTestimonialsPublic(req, res) {
const rows = db