Files
dogfather-universe/server-internal/routes/testimonials.js
T
DogFatherGitandClaude Opus 5 6910112d97 Stimmen: nur noch die zwei Originalfiguren + eigenes Bild hochladen
Nutzer-Wunsch 21.08.2026: "benutz da bitte nur die originalen husky und
hasen. mach nur die zwei. und die auswahl wo die leute selbst ein bild
rein setzen können."

- Auswahl von acht auf zwei reduziert (DogFather-Husky, HasiDog). Die
  übrigen Bilddateien bleiben liegen, falls sie je zurücksollen -- es
  genügt, die Zeile in data-stimmen-avatare.js und die Id in
  ERLAUBTE_AVATARE wieder zu ergänzen.
- Neue Kachel "eigenes Bild" (gestrichelter Rand + Plus), die den
  Dateidialog öffnet, das Bild sofort hochlädt und als Vorschau in der
  Kachel zeigt.

Bereits freigegebene Stimmen mit einer der entfernten Figuren zeigen
wieder den Anfangsbuchstaben statt eines kaputten Bildes -- die
Auflösung unbekannter Ids liefert null, das war schon so vorgesehen.

Sicherheit des öffentlichen Uploads (bisher war Hochladen bewusst nur
der Verwaltung erlaubt):
- Gleiche multer-Härtung wie der Verwaltungs-Upload: nur JPG/PNG/WebP,
  max. 5 MB, zufälliger UUID-Dateiname (kein Originalname).
- Der Server nimmt im Avatar-Feld weiterhin NUR bekannte Ids an oder
  eine Adresse, die exakt auf den eigenen Upload-Ordner zeigt und danach
  nur aus UUID + Bildendung besteht. Gegengetestet: fremde Domains,
  "../"-Ausbruch, .svg/.html, javascript:, angehängte Skripte und http
  statt https werden alle abgelehnt.
- Missbrauchsbremse gegen Vollschreiben der Festplatte: max. 10 Uploads
  pro Stunde und IP.
- Sichtbar wird ein Bild ohnehin erst, wenn die Stimme freigegeben wird.

Mit Playwright end-to-end geprüft (10 Tests) plus 9 Sicherheitsfälle.

Cache-Busting-Version auf 20260821q erhöht.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 18:23:17 +02:00

232 lines
11 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. */
/* 21.08.2026 auf die zwei Originalfiguren reduziert (Nutzer-Wunsch, siehe
data-stimmen-avatare.js). Muss mit der Liste dort übereinstimmen.
Bereits freigegebene Stimmen mit einem der entfernten Bilder behalten
ihren Eintrag in der Datenbank -- sie zeigen dann wieder den
Anfangsbuchstaben, weil stimmeAvatarBild() die Id nicht mehr auflöst
(siehe dort: unbekannt -> null -> Buchstaben-Medaillon). Kein Fehler,
keine kaputten Bilder. */
const ERLAUBTE_AVATARE = ["husky-hoodie", "hase-hoodie"];
/* Eigenes hochgeladenes Bild (21.08.2026, Nutzer-Wunsch: "und die auswahl
wo die leute selbst ein bild rein setzen können").
Akzeptiert wird ausschließlich eine Adresse, die exakt auf unseren
eigenen Upload-Ordner zeigt und danach nur noch aus einem zufälligen
Dateinamen besteht (UUID + Endung, so legt multer sie an, siehe
index.js). Damit kann über dieses Feld weder eine fremde Adresse
eingeschleust noch per "../" aus dem Ordner ausgebrochen werden --
dieselbe Schutzabsicht wie bei der festen Id-Liste oben. */
const UPLOAD_BASIS = "https://postfach.dogfather-universe.com/uploads/testimonials/";
function istEigenesHochgeladenesBild(wert) {
if (!wert.startsWith(UPLOAD_BASIS)) return false;
const datei = wert.slice(UPLOAD_BASIS.length);
return /^[a-f0-9-]{36}\.(jpe?g|png|webp)$/i.test(datei);
}
/* Einfache Missbrauchsbremse für den öffentlichen Bild-Upload (kein Login
nötig). Ohne sie könnte jemand den Endpunkt in einer Schleife aufrufen
und die Festplatte des Servers vollschreiben. Bewusst schlicht im
Arbeitsspeicher gehalten: es gibt hier nur einen Serverprozess, und ein
Neustart (der den Zähler leert) ist kein Schaden. */
const UPLOAD_MAX_PRO_STUNDE = 10;
const uploadZaehler = new Map(); // ip -> { anzahl, fensterStart }
function uploadKontingentFrei(ip) {
const jetzt = Date.now();
const eintrag = uploadZaehler.get(ip);
if (!eintrag || jetzt - eintrag.fensterStart > 60 * 60 * 1000) {
uploadZaehler.set(ip, { anzahl: 1, fensterStart: jetzt });
return true;
}
if (eintrag.anzahl >= UPLOAD_MAX_PRO_STUNDE) return false;
eintrag.anzahl += 1;
return true;
}
/* Öffentlich (ohne Login): Profilbild für die eigene Einreichung hochladen.
Die Datei ist danach zwar unter ihrer zufälligen Adresse abrufbar, taucht
aber NIRGENDS auf, solange Dogi/VanVan die zugehörige Stimme nicht
freigeben -- jede Einreichung geht zuerst in die Warteschlange. Typ und
Größe begrenzt multer (nur JPG/PNG/WebP, max. 5 MB, zufälliger Name). */
export async function uploadEigenesTestimonialBild(req, res) {
const ip = req.ip || req.socket?.remoteAddress || "unbekannt";
if (!uploadKontingentFrei(ip)) {
return json(res, { ok: false, error: "Zu viele Uploads in kurzer Zeit. Bitte versuch es später nochmal." }, 429);
}
if (!req.file) return json(res, { ok: false, error: "Keine Bilddatei erhalten." }, 400);
const url = `${UPLOAD_BASIS}${req.file.filename}`;
logAction("anonym", "testimonial.eigenes_bild_hochgeladen", req.file.filename, null);
return json(res, { ok: true, url });
}
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) || istEigenesHochgeladenesBild(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 });
}