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]>
177 lines
7.9 KiB
JavaScript
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 });
|
|
}
|