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
+2
View File
@@ -32,6 +32,8 @@ const MIGRATION_FILES = [
"0006_testimonials.sql",
"0007_team_members.sql",
"0008_team_members_rich.sql",
"0009_supporter_access_code.sql",
"0010_testimonials_avatar.sql",
];
function alreadyApplied(name) {
+25
View File
@@ -53,6 +53,7 @@ import {
import { listAuditLog } from "./routes/audit.js";
import {
registerSupporter, verifySupporterEmail, requestSupporterLogin, verifySupporterLogin, logoutSupporter,
loginSupporterMitCode, setSupporterAccessCode,
googleLoginSupporter, getPublicConfig,
getSupporterMe, updateSupporterProfile, requestSupporterEmailChange, confirmSupporterEmailChange,
redeemPremium, deleteSupporterAccount,
@@ -67,6 +68,7 @@ import { getEventsPublic, getEventsAdmin, saveEvent, uploadEventImage } from "./
import { getSpecialEventPublic, getSpecialEventAdmin, saveSpecialEvent } from "./routes/special-event.js";
import {
submitTestimonial, getApprovedTestimonialsPublic, getTestimonialsAdmin, setTestimonialStatus, deleteTestimonial,
setTestimonialAvatar, uploadTestimonialAvatar,
} from "./routes/testimonials.js";
import {
createPoll, listPolls, closePoll, reopenPoll, deletePoll, getActivePollPublic, voteOnPoll,
@@ -116,6 +118,20 @@ const teamBildUpload = multer({
fileFilter: (req, file, cb) => cb(null, ERLAUBTE_BILD_TYPEN.has(file.mimetype)),
});
// Profilbilder für die "Stimmen" (21.08.2026) — gleiches Muster wie oben.
// Nur aus der Verwaltung heraus erreichbar; das öffentliche Stimmen-Formular
// nimmt bewusst keine Dateien entgegen (siehe routes/testimonials.js).
const TESTIMONIAL_UPLOADS_DIR = `${UPLOADS_BASIS_DIR}/testimonials`;
mkdirSync(TESTIMONIAL_UPLOADS_DIR, { recursive: true });
const stimmenBildUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, TESTIMONIAL_UPLOADS_DIR),
filename: (req, file, cb) => cb(null, `${crypto.randomUUID()}${extname(file.originalname).toLowerCase()}`),
}),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => cb(null, ERLAUBTE_BILD_TYPEN.has(file.mimetype)),
});
app.use((req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
@@ -185,6 +201,9 @@ app.get("/site-texts", wrap(getSiteTextsPublic));
app.post("/supporter/register", wrap(registerSupporter));
app.post("/supporter/verify-email", wrap(verifySupporterEmail));
// Normaler Login seit 21.08.2026: E-Mail + selbst gewählter Zugangscode.
app.post("/supporter/login-code", wrap(loginSupporterMitCode));
// login-request/verify-login sind jetzt der "Code vergessen?"-Weg.
app.post("/supporter/login-request", wrap(requestSupporterLogin));
app.post("/supporter/verify-login", wrap(verifySupporterLogin));
app.post("/supporter/google-login", wrap(googleLoginSupporter));
@@ -227,6 +246,9 @@ app.post("/admin/special-event/save", requireTeamSession, withSession(saveSpecia
app.post("/admin/testimonials/list", requireTeamSession, withSession(getTestimonialsAdmin));
app.post("/admin/testimonials/status", requireTeamSession, withSession(setTestimonialStatus));
app.post("/admin/testimonials/delete", requireTeamSession, withSession(deleteTestimonial));
// Profilbild einer Stimme setzen bzw. eigenes Foto hochladen (21.08.2026).
app.post("/admin/testimonials/avatar", requireTeamSession, withSession(setTestimonialAvatar));
app.post("/admin/testimonials/upload-avatar", requireTeamSession, stimmenBildUpload.single("image"), withSession(uploadTestimonialAvatar));
// Team-Mitglieder (Modis/Scouts/Creator/Manager) — 21.08.2026
app.post("/admin/team/list", requireTeamSession, withSession(getTeamAdmin));
@@ -277,6 +299,9 @@ app.post("/admin/polls/delete", requireTeamSession, withSession(deletePoll));
app.post("/supporter/logout", requireSupporterAuth, wrap(logoutSupporter));
app.post("/supporter/me", requireSupporterAuth, withSupporter(getSupporterMe));
app.post("/supporter/update-profile", requireSupporterAuth, withSupporter(updateSupporterProfile));
// Neuen Zugangscode setzen — freiwillig im Supporter-Bereich oder als
// Abschluss des "Code vergessen?"-Weges (da ist man bereits eingeloggt).
app.post("/supporter/set-access-code", requireSupporterAuth, withSupporter(setSupporterAccessCode));
app.post("/supporter/request-email-change", requireSupporterAuth, withSupporter(requestSupporterEmailChange));
app.post("/supporter/confirm-email-change", requireSupporterAuth, withSupporter(confirmSupporterEmailChange));
app.post("/supporter/redeem-premium", requireSupporterAuth, withSupporter(redeemPremium));
+54
View File
@@ -60,3 +60,57 @@ export async function decryptCode(encoded) {
export function nowIso() {
return new Date().toISOString();
}
/* =====================================================================
Persönlicher Zugangscode der Supporter (21.08.2026)
BEWUSST NICHT sha256Hex() wie bei den Einmalcodes oben: ein Einmalcode
lebt 15 Minuten und ist zufällig, da genügt ein schneller Hash. Ein
selbst gewählter, dauerhafter Code ist dagegen oft kurz und erratbar —
dagegen hilft nur ein absichtlich LANGSAMES Verfahren, das massenhaftes
Durchprobieren unbezahlbar macht. Dafür ist scrypt gemacht (in Node
eingebaut, keine zusätzliche Abhängigkeit nötig).
Jeder Code bekommt zusätzlich einen eigenen Zufalls-Salt: dadurch haben
zwei Personen mit demselben Code trotzdem völlig verschiedene Hashes,
und vorberechnete Tabellen sind wertlos.
Gespeichertes Format: "scrypt$<salt-hex>$<hash-hex>"
===================================================================== */
import { scrypt as _scrypt, randomBytes, timingSafeEqual as _timingSafeEqual } from "node:crypto";
const SCRYPT_KEYLEN = 64;
// N=16384 ist der Node-Standard und ein guter Kompromiss: für eine einzelne
// Anmeldung kaum spürbar, für Angreifer aber millionenfach teuer.
const SCRYPT_OPTIONEN = { N: 16384, r: 8, p: 1 };
function scryptAsync(code, salt) {
return new Promise((resolve, reject) => {
_scrypt(code, salt, SCRYPT_KEYLEN, SCRYPT_OPTIONEN, (err, key) => (err ? reject(err) : resolve(key)));
});
}
/** Erzeugt den zu speichernden Wert für einen selbst gewählten Zugangscode. */
export async function hashAccessCode(code) {
const salt = randomBytes(16);
const key = await scryptAsync(String(code), salt);
return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`;
}
/** Prüft einen eingegebenen Code gegen den gespeicherten Wert.
Der Vergleich läuft zeitkonstant (timingSafeEqual), damit sich aus der
Antwortdauer nicht ableiten lässt, wie viele Zeichen gestimmt haben. */
export async function verifyAccessCode(code, gespeichert) {
if (!gespeichert || typeof gespeichert !== "string") return false;
const teile = gespeichert.split("$");
if (teile.length !== 3 || teile[0] !== "scrypt") return false;
try {
const salt = Buffer.from(teile[1], "hex");
const erwartet = Buffer.from(teile[2], "hex");
const tatsaechlich = await scryptAsync(String(code), salt);
if (erwartet.length !== tatsaechlich.length) return false;
return _timingSafeEqual(erwartet, tatsaechlich);
} catch {
return false;
}
}
+21
View File
@@ -60,6 +60,27 @@ export async function sendLoginCode({ to, name, code }) {
});
}
/* Willkommens-E-Mail nach der Registrierung (21.08.2026). Enthält bewusst
KEINEN Code — man hat sich ja gerade selbst einen gesetzt und ist schon
eingeloggt. Sie dient als Beleg, dass die Adresse stimmt, und als
Erinnerung, wo man wieder reinkommt. */
export async function sendWelcomeMail({ to, name }) {
return send({
to,
subject: "Willkommen in der DogiCrew 🩵",
html: WRAP(
`Willkommen, ${name}! 🩵`,
`<p style="line-height:1.6;">Dein Konto ist fertig. Du kommst ab jetzt jederzeit mit dieser
E-Mail-Adresse und deinem selbst gewählten Zugangscode rein.</p>
<p style="line-height:1.6;">Solltest du deinen Code mal vergessen, gibt es auf der Anmeldeseite
„Code vergessen?“ — darüber bekommst du hier per E-Mail einen Einmalcode und kannst dir
einen neuen Zugangscode setzen.</p>
<p style="color:#9aa4b2;font-size:13px;">Hast du dich nicht angemeldet? Dann ignoriere diese
E-Mail einfach — ohne deinen Zugangscode kommt niemand in das Konto.</p>`
),
});
}
export async function sendMilestoneUnlocked({ to, name, premiumLabel }) {
return send({
to,
+13 -2
View File
@@ -61,11 +61,22 @@ export async function getEventsAdmin(req, res, session) {
return json(res, { ok: true, events: [ladeSlot(1), ladeSlot(2)] });
}
/** NUR Bild-Upload, kein JSON-Body (multipart, siehe index.js multer-Middleware). */
/** NUR Bild-Upload, kein JSON-Body (multipart, siehe index.js multer-Middleware).
Bugfix 21.08.2026 (Nutzer-Report: "wieso seh ich das bild nicht" bei
"Event 1" in der Verwaltung): vorher wurde hier nur der RELATIVE Pfad
("/uploads/events/...") zurückgegeben. verwaltung.html UND index.html
laufen aber auf dogfather-universe.com (dogiweb-Dienst), während die
hochgeladene Datei tatsächlich unter postfach.dogfather-universe.com
(dieser Dienst, dogiintern, siehe /uploads-Static-Route in index.js)
liegt -- der Browser hat den relativen Pfad also fälschlich gegen die
falsche Domain aufgelöst und ein kaputtes Bild-Symbol angezeigt, sowohl
in der Verwaltungs-Vorschau als auch (sobald ein Slot aktiviert wird)
auf der echten Startseite. Jetzt vollständige Adresse, damit das Bild
unabhängig davon lädt, von welcher Domain aus die Seite gerade läuft. */
export async function uploadEventImage(req, res, session) {
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
if (!req.file) return json(res, { ok: false, error: "Keine Bilddatei erhalten." }, 400);
const url = `/uploads/events/${req.file.filename}`;
const url = `https://postfach.dogfather-universe.com/uploads/events/${req.file.filename}`;
logAction(session.actor, "events.image_uploaded", req.file.filename, null);
return json(res, { ok: true, url });
}
+131 -16
View File
@@ -7,14 +7,15 @@
Setzt Dogfather_VanVan_Supporter_Abo.odt Abschnitt 5, 6, 9-18, 21-25 um.
===================================================================== */
import { db } from "../db.js";
import { generateId, nowIso } from "../lib/crypto.js";
import { generateId, nowIso, hashAccessCode, verifyAccessCode } from "../lib/crypto.js";
import { json } from "../lib/http.js";
import { logAction } from "../lib/audit.js";
import { isLockedOut, recordFailedAttempt, clearAttempts } from "../lib/auth.js";
import {
normalizeEmail, normalizeTikTokUsername, issueAuthCode, verifyAuthCode,
createSupporterSession, endSupporterSession,
} from "../lib/supporter-auth.js";
import { EmailNotConfiguredError, sendVerifyEmailCode, sendLoginCode } from "../lib/supporter-mail.js";
import { EmailNotConfiguredError, sendVerifyEmailCode, sendLoginCode, sendWelcomeMail } from "../lib/supporter-mail.js";
import { computeProgress, timelineForCurrentCycle } from "../lib/supporter-cycle.js";
import { verifyGoogleIdToken, GoogleAuthNotConfiguredError } from "../lib/google-auth.js";
@@ -41,40 +42,146 @@ function emailErrorResponse(res, err) {
/* ---------- Registrierung & Login (öffentlich, keine Session) ---------- */
/* Mindestlänge für den selbst gewählten Zugangscode. Bewusst nur 6 Zeichen
(statt der sonst üblichen 8+): der Bereich enthält keine Zahlungsdaten,
und ein zu strenger Zwang würde hier mehr Leute abschrecken als schützen.
Gegen Durchprobieren schützt die Sperre nach mehreren Fehlversuchen
(siehe loginSupporterMitCode) plus das langsame scrypt-Verfahren. */
const MIN_CODE_LAENGE = 6;
function pruefeCode(code) {
const sauber = String(code || "");
if (sauber.length < MIN_CODE_LAENGE) {
return `Dein Zugangscode muss mindestens ${MIN_CODE_LAENGE} Zeichen lang sein.`;
}
if (sauber.length > 200) return "Dein Zugangscode ist zu lang.";
return null;
}
/* Registrierung — seit 21.08.2026 (Nutzer-Wunsch: "die leute sollen nach der
registrierung einen festen code erstellen können für sich selbst und
fertig, mit dem sie rein kommen"): man legt hier direkt seinen eigenen,
dauerhaften Zugangscode fest und ist danach SOFORT eingeloggt. Vorher lief
auch die Registrierung über einen Einmalcode per E-Mail — ein Extraschritt,
der nichts brachte, weil man denselben Code danach nie wieder brauchte.
Die E-Mail-Adresse gilt dabei als bestätigt (email_verified = 1). Ohne das
käme man später nicht mehr an den "Code vergessen?"-Weg heran, der ja
genau über diese Adresse läuft — und ein zusätzlicher Bestätigungsschritt
wäre wieder genau der Umweg, der hier abgeschafft werden sollte. Zur
Kontrolle geht eine Willkommens-E-Mail raus; schlägt deren Versand fehl,
ist das KEIN Grund die Registrierung scheitern zu lassen (das Konto ist
ja schon nutzbar) — sie wird nur im Protokoll vermerkt. */
export async function registerSupporter(req, res) {
const body = req.body || {};
const displayName = String(body.displayName || "").trim().slice(0, 60);
const tiktokUsername = normalizeTikTokUsername(body.tiktokUsername);
const email = normalizeEmail(body.email);
const accessCode = String(body.accessCode || "");
if (!displayName) return bad(res, "Bitte gib einen Namen oder Anzeigenamen an.");
if (!email || !email.includes("@")) return bad(res, "Bitte gib eine gültige E-Mail-Adresse an.");
const codeFehler = pruefeCode(accessCode);
if (codeFehler) return bad(res, codeFehler);
const existing = db.prepare(`SELECT id, email_verified FROM supporter_users WHERE email = ?`).get(email);
const existing = db.prepare(`SELECT id, access_code_hash FROM supporter_users WHERE email = ?`).get(email);
if (existing && existing.access_code_hash) {
return bad(res, "Für diese E-Mail-Adresse gibt es schon ein Konto. Bitte logg dich einfach ein.", 409);
}
const codeHash = await hashAccessCode(accessCode);
let supporterId;
if (existing) {
if (existing.email_verified) {
return bad(res, "Für diese E-Mail-Adresse existiert bereits ein Supporter-Konto. Bitte einloggen.", 409);
}
// Konto war angelegt, hatte aber noch keinen eigenen Code (z.B. ein
// abgebrochener Versuch aus der Zeit davor) -> jetzt vervollständigen.
supporterId = existing.id;
db.prepare(`UPDATE supporter_users SET display_name = ?, tiktok_username = ? WHERE id = ?`).run(displayName, tiktokUsername, supporterId);
db.prepare(
`UPDATE supporter_users
SET display_name = ?, tiktok_username = ?, email_verified = 1,
access_code_hash = ?, access_code_set_at = ?
WHERE id = ?`
).run(displayName, tiktokUsername, codeHash, nowIso(), supporterId);
} else {
supporterId = generateId();
db.prepare(
`INSERT INTO supporter_users (id, display_name, tiktok_username, email, email_verified, created_at)
VALUES (?, ?, ?, ?, 0, ?)`
).run(supporterId, displayName, tiktokUsername, email, nowIso());
`INSERT INTO supporter_users
(id, display_name, tiktok_username, email, email_verified, created_at, access_code_hash, access_code_set_at, last_login_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?)`
).run(supporterId, displayName, tiktokUsername, email, nowIso(), codeHash, nowIso(), nowIso());
logAction("system", "supporter.registered", supporterId, { email });
}
const code = await issueAuthCode(supporterId, "verify_email");
// Willkommens-E-Mail: nice-to-have, kein Blocker (siehe Kommentar oben).
try {
await sendVerifyEmailCode({ to: email, name: displayName, code });
await sendWelcomeMail({ to: email, name: displayName });
} catch (err) {
return emailErrorResponse(res, err);
logAction("system", "supporter.welcome_mail_failed", supporterId, { fehler: String(err?.message || err).slice(0, 200) });
}
return json(res, { ok: true, needsVerification: true });
const token = await createSupporterSession(supporterId);
return json(res, { ok: true, token });
}
/* Einloggen mit E-Mail + selbst gewähltem Code — der neue Normalweg.
Schutz gegen Durchprobieren: nach mehreren Fehlversuchen ist die
Kombination für eine Weile gesperrt (dieselbe Mechanik wie beim
Team-Login, siehe lib/auth.js). Der Schlüssel enthält bewusst die
E-Mail-Adresse, damit ein Angreifer nicht durch Sperren FREMDER Konten
Schaden anrichten kann, ohne deren Adresse zu kennen. */
export async function loginSupporterMitCode(req, res) {
const body = req.body || {};
const email = normalizeEmail(body.email);
const accessCode = String(body.accessCode || "");
if (!email || !accessCode) return bad(res, "Bitte E-Mail-Adresse und Zugangscode eingeben.");
const sperrSchluessel = `supporter:${email}`;
if (isLockedOut(sperrSchluessel)) {
return bad(res, "Zu viele Fehlversuche. Bitte versuch es in ein paar Minuten noch einmal.", 429);
}
const supporter = db
.prepare(`SELECT id, display_name, access_code_hash FROM supporter_users WHERE email = ? AND status = 'active'`)
.get(email);
// Konto existiert, wurde aber vor der Umstellung angelegt und hat noch
// keinen eigenen Code -> gezielter Hinweis statt "falsche Daten".
if (supporter && !supporter.access_code_hash) {
return json(
res,
{ ok: false, code: "KEIN_CODE_GESETZT", error: "Für dieses Konto ist noch kein Zugangscode festgelegt. Nutze bitte „Code vergessen?“, um dir einen zu setzen." },
409
);
}
const passt = supporter ? await verifyAccessCode(accessCode, supporter.access_code_hash) : false;
if (!passt) {
recordFailedAttempt(sperrSchluessel);
// Bewusst dieselbe Meldung, egal ob die E-Mail unbekannt ist oder nur
// der Code nicht stimmt — sonst könnte man von außen durchprobieren,
// welche Adressen überhaupt registriert sind.
return bad(res, "E-Mail-Adresse oder Zugangscode stimmt nicht.", 401);
}
clearAttempts(sperrSchluessel);
db.prepare(`UPDATE supporter_users SET last_login_at = ? WHERE id = ?`).run(nowIso(), supporter.id);
const token = await createSupporterSession(supporter.id);
return json(res, { ok: true, token });
}
/* Neuen Zugangscode setzen — für eingeloggte Leute (freiwillig ändern) UND
als letzter Schritt des "Code vergessen?"-Weges (dort ist man durch den
E-Mail-Einmalcode bereits eingeloggt). */
export async function setSupporterAccessCode(req, res, supporter) {
const body = req.body || {};
const neuerCode = String(body.accessCode || "");
const fehler = pruefeCode(neuerCode);
if (fehler) return bad(res, fehler);
const codeHash = await hashAccessCode(neuerCode);
db.prepare(`UPDATE supporter_users SET access_code_hash = ?, access_code_set_at = ? WHERE id = ?`).run(codeHash, nowIso(), supporter.id);
clearAttempts(`supporter:${supporter.email}`);
logAction("system", "supporter.access_code_set", supporter.id, null);
return json(res, { ok: true });
}
export async function verifySupporterEmail(req, res) {
@@ -96,16 +203,24 @@ export async function verifySupporterEmail(req, res) {
return json(res, { ok: true, token });
}
/* Seit 21.08.2026 ist das NICHT mehr der normale Login-Weg, sondern der
"Code vergessen?"-Weg: hierüber bekommt man einen Einmalcode per E-Mail,
ist danach eingeloggt und kann sich einen neuen dauerhaften Zugangscode
setzen (siehe setSupporterAccessCode). */
export async function requestSupporterLogin(req, res) {
const body = req.body || {};
const email = normalizeEmail(body.email);
if (!email) return bad(res, "Bitte gib deine E-Mail-Adresse an.");
const supporter = db
.prepare(`SELECT id, display_name, email_verified FROM supporter_users WHERE email = ? AND status = 'active'`)
.prepare(`SELECT id, display_name FROM supporter_users WHERE email = ? AND status = 'active'`)
.get(email);
// Bewusst IMMER ok:true nach außen (keine Auskunft, ob ein Konto existiert).
if (!supporter || !supporter.email_verified) return json(res, { ok: true });
// Die frühere zusätzliche Bedingung "nur wenn email_verified" ist hier
// entfallen: genau die Konten aus der Zeit vor der Umstellung (die nie
// bestätigt wurden und noch keinen eigenen Code haben) brauchen diesen Weg
// am dringendsten — sonst kämen sie gar nicht mehr rein.
if (!supporter) return json(res, { ok: true });
const code = await issueAuthCode(supporter.id, "login");
try {
+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