Files
dogfather-universe/server-internal/routes/testimonials.js
T
DogFatherGitandClaude Opus 5 5d0ea11570 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]>
2026-08-21 17:35:14 +02:00

177 lines
7.9 KiB
JavaScript

/* =====================================================================
routes/testimonials.js — "Stimmen" auf stimmen.html (20.08.2026,
Nutzer-Wunsch: "eine richtig geile kachel ... wo die leute mit namen
und tiktok namen mir eine nachricht schreiben können die ich in die
verwaltung kriege, und dann kann ich die ausgewählten über die
verwaltungsseite auf die website hinzufügen").
Öffentlich: jede/jeder darf eine Nachricht einreichen (kein Login
nötig, absichtlich niedrigschwellig -- "mega persönlich zu den Fans").
Landet als Entwurf ("eingereicht"), wird NIE automatisch veröffentlicht.
Nur Dogi/VanVan (TESTIMONIALS_MANAGE) sehen die Warteschlange und
entscheiden pro Eintrag: freigeben (-> erscheint auf stimmen.html),
ablehnen oder löschen. ===================================================================== */
import { db } from "../db.js";
import { generateId, nowIso } from "../lib/crypto.js";
import { hasPermission } from "../lib/permissions.js";
import { json } from "../lib/http.js";
import { logAction } from "../lib/audit.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);
}
/* 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,
};
}
function vollesFeld(row) {
return {
id: row.id,
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,
entschiedenVon: row.entschieden_von,
};
}
/** Öffentlich, keine Session: eine Nachricht einreichen. Bewusst simpel
gehalten (kein E-Mail-Nachweis, keine Registrierung) -- die einzige
Schutzschicht gegen Missbrauch ist die manuelle Freigabe durch Dogi,
kein Eintrag wird je ungesehen veröffentlicht. */
export async function submitTestimonial(req, res) {
const body = req.body || {};
const name = String(body.name || "").trim().slice(0, 60);
const tiktokUsername = String(body.tiktokUsername || "").trim().replace(/^@/, "").slice(0, 40) || null;
const nachricht = String(body.nachricht || "").trim().slice(0, 500);
if (!name) return json(res, { ok: false, error: "Bitte gib deinen Namen an." }, 400);
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, 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
.prepare(`SELECT * FROM testimonials WHERE status = 'freigegeben' ORDER BY entschieden_am DESC LIMIT 60`)
.all();
return json(res, { ok: true, testimonials: rows.map(oeffentlichesFeld) });
}
/** Für die Verwaltung: ALLE Einreichungen, neueste zuerst -- offene zuerst
einsortiert, damit die Warteschlange sofort ins Auge fällt. */
export async function getTestimonialsAdmin(req, res, session) {
if (!can(session, "TESTIMONIALS_MANAGE")) return forbidden(res);
const rows = db
.prepare(
`SELECT * FROM testimonials
ORDER BY CASE status WHEN 'eingereicht' THEN 0 ELSE 1 END, created_at DESC`
)
.all();
return json(res, { ok: true, testimonials: rows.map(vollesFeld) });
}
export async function setTestimonialStatus(req, res, session) {
if (!can(session, "TESTIMONIALS_MANAGE")) return forbidden(res);
const id = String(req.body?.id || "");
const status = String(req.body?.status || "");
if (!["freigegeben", "abgelehnt", "eingereicht"].includes(status)) {
return json(res, { ok: false, error: "Ungültiger Status." }, 400);
}
const info = db
.prepare(`UPDATE testimonials SET status = ?, entschieden_am = ?, entschieden_von = ? WHERE id = ?`)
.run(status, nowIso(), session.actor, id);
if (info.changes === 0) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
logAction(session.actor, "testimonial.status_changed", id, { status });
return json(res, { ok: true });
}
export async function deleteTestimonial(req, res, session) {
if (!can(session, "TESTIMONIALS_MANAGE")) return forbidden(res);
const id = String(req.body?.id || "");
const info = db.prepare(`DELETE FROM testimonials WHERE id = ?`).run(id);
if (info.changes === 0) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
logAction(session.actor, "testimonial.deleted", id, null);
return json(res, { ok: true });
}