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:
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user