Bei der Bestandsaufnahme als "keine Route hat eine Bremse" gemeldet -- das war falsch (grep suchte nach rateLimit/bremse, im Code heißen sie Kontingent/Sperre/Fehlversuche). Fast alle öffentlichen Routen SIND gebremst: Anmeldung, Supporter-Login, Upload, Kundenanfragen. Übrig blieben genau zwei schreibende Routen: Bewerbungen und Stimmen. Ohne Bremse könnte ein Skript die Datenbank mit Müll fluten. Kein Sicherheitsleck (beide landen in einer Warteschlange, nichts wird ungesehen veröffentlicht), aber eine sinnvolle Härtung. Statt das vorhandene Muster ein drittes Mal zu kopieren: ein Baustein lib/kontingent.js, den nun alle drei Routen nutzen. Zwei Verbesserungen gegenüber dem Original in testimonials.js: - getrennte Töpfe je Zweck (ein Bild-Upload verbraucht kein Bewerbungs-Kontingent) - Selbstreinigung: die alte Zähler-Map ließ jede IP für immer im Speicher stehen (langsames Leck), die neue räumt abgelaufene Einträge auf Grenzen: Uploads 10/Stunde/IP (belegen Plattenplatz), Text-Einreichungen 20/Stunde/IP (großzügig für geteilte Anschlüsse, stoppt Fluten). Tests: pruef-kontingent.mjs (Baustein, 7/7), test-kontingent-routen.mjs (echte Routen liefern 429 ab Grenze, getrennte Töpfe, IPs unabhängig, 4/4). Bestehende Tests unverändert grün (37/37). NOCH NICHT LIVE: server-internal läuft aus /home/dogiintern (kein Zugriff), wird mit den übrigen Server-Änderungen in einem Deploy live geschaltet. Co-Authored-By: Claude Opus 5 <[email protected]>
226 lines
11 KiB
JavaScript
226 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";
|
|
import { kontingentFrei } from "../lib/kontingent.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);
|
|
}
|
|
|
|
/* Zwei Obergrenzen pro IP (Stunde). Beide über denselben Baustein
|
|
lib/kontingent.js, in getrennten Töpfen -- ein Bild-Upload verbraucht
|
|
also kein Kontingent für Einreichungen und umgekehrt.
|
|
Upload strenger (10), weil er Speicher auf der Platte belegt; die
|
|
reine Text-Einreichung großzügiger (20). */
|
|
const UPLOAD_MAX_PRO_STUNDE = 10;
|
|
const STIMME_MAX_PRO_STUNDE = 20;
|
|
|
|
/* Ö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 (!kontingentFrei("stimme-upload", ip, { maxProFenster: UPLOAD_MAX_PRO_STUNDE })) {
|
|
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 ip = req.ip || req.socket?.remoteAddress || "unbekannt";
|
|
if (!kontingentFrei("stimme", ip, { maxProFenster: STIMME_MAX_PRO_STUNDE })) {
|
|
return json(res, { ok: false, error: "Zu viele Nachrichten in kurzer Zeit. Bitte versuch es später noch einmal." }, 429);
|
|
}
|
|
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 });
|
|
}
|