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]>
This commit is contained in:
@@ -68,6 +68,7 @@ import { getEventsPublic, getEventsAdmin, saveEvent, uploadEventImage } from "./
|
||||
import { getSpecialEventPublic, getSpecialEventAdmin, saveSpecialEvent } from "./routes/special-event.js";
|
||||
import {
|
||||
submitTestimonial, getApprovedTestimonialsPublic, getTestimonialsAdmin, setTestimonialStatus, deleteTestimonial,
|
||||
uploadEigenesTestimonialBild,
|
||||
setTestimonialAvatar, uploadTestimonialAvatar,
|
||||
} from "./routes/testimonials.js";
|
||||
import {
|
||||
@@ -195,6 +196,14 @@ app.post("/auth/login", wrap(login));
|
||||
app.get("/events-of-year", wrap(getEventsPublic));
|
||||
app.get("/special-event", wrap(getSpecialEventPublic));
|
||||
app.post("/testimonials/submit", wrap(submitTestimonial));
|
||||
/* Öffentlich, ohne Login: eigenes Profilbild für die eigene Stimme
|
||||
hochladen (21.08.2026). Bewusst mit derselben multer-Härtung wie der
|
||||
Verwaltungs-Upload (nur JPG/PNG/WebP, max. 5 MB, zufälliger Dateiname);
|
||||
zusätzlich bremst die Route selbst Massen-Uploads pro IP aus (siehe
|
||||
uploadEigenesTestimonialBild in routes/testimonials.js). Sichtbar wird
|
||||
ein hochgeladenes Bild erst, wenn die zugehörige Stimme freigegeben
|
||||
wird -- Einreichungen landen immer zuerst in der Warteschlange. */
|
||||
app.post("/testimonials/upload-avatar", stimmenBildUpload.single("image"), wrap(uploadEigenesTestimonialBild));
|
||||
app.get("/testimonials", wrap(getApprovedTestimonialsPublic));
|
||||
app.get("/team", wrap(getTeamPublic));
|
||||
app.get("/site-texts", wrap(getSiteTextsPublic));
|
||||
|
||||
@@ -36,10 +36,65 @@ function forbidden(res) {
|
||||
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",
|
||||
];
|
||||
/* 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 {
|
||||
@@ -83,7 +138,7 @@ export async function submitTestimonial(req, res) {
|
||||
// (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 avatar = ERLAUBTE_AVATARE.includes(gewaehlt) || istEigenesHochgeladenesBild(gewaehlt) ? gewaehlt : null;
|
||||
|
||||
const id = generateId();
|
||||
db.prepare(
|
||||
|
||||
Reference in New Issue
Block a user