- Passwortgeschütztes Panel unter /admin (Auto-Passwort 20 Zeichen, per CLI abrufbar: `node server/admin.js password` / `password reset`), In-Memory-Sessions + Cookie, Login-Rate-Limit. - Spieler-Tracking: pro Score IP (CF/XFF/req.ip), Gerät/Browser (ua-parser-js), grober Ort (geoip-lite, offline) -> data/players.json; Aggregation pro Name. - Live-Tuning: configStore (data/config.json) für Timer + Schwierigkeitskurve; Server (makeRound) und Client (/api/config) lesen daraus -> Änderungen ohne Neustart. - Bild-Verwaltung: Upload/Löschen von Füßen & Deko (multer) + rescanLibrary(). - Panel-Aktionen: Spieler bannen/entbannen, Rangliste heute/alles leeren. - Fix: .item object-fit:contain -> nicht-quadratische Bilder werden nicht mehr verzerrt. - trust proxy für korrekte Client-IP hinter Reverse-Proxy/Cloudflare. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
389 lines
15 KiB
JavaScript
389 lines
15 KiB
JavaScript
// fuesse.sexy — "Wanted!" (Fuss-Fahndung)
|
|
// Schlanker Express-Server: liefert das Frontend + die Bildchen aus, stellt die Liste
|
|
// der verfuegbaren Fuesse/Deko bereit und verwaltet eine persistente Rangliste.
|
|
|
|
import express from 'express';
|
|
import multer from 'multer';
|
|
import { dirname, join, basename, extname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { existsSync, readdirSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
import { readLeaderboard, buildBoards, addScore, clearAll, clearToday, deletePlayer } from './leaderboardStore.js';
|
|
import { getMode } from './modeStore.js';
|
|
import { isBanned, banPlayer, unbanPlayer, readBans } from './banStore.js';
|
|
import { getConfig, setConfig, objectCount } from './configStore.js';
|
|
import { logGame, aggregatePlayers } from './playerLog.js';
|
|
import {
|
|
verifyPassword,
|
|
createSession,
|
|
validSession,
|
|
destroySession,
|
|
SESSION_COOKIE,
|
|
SESSION_MAX_AGE,
|
|
} from './adminAuth.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = join(__dirname, '..');
|
|
const PUBLIC_DIR = join(ROOT, 'public');
|
|
const LIBRARY_DIR = join(ROOT, 'library');
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
const IMAGE_EXT = ['.svg', '.png', '.webp', '.jpg', '.jpeg', '.gif'];
|
|
|
|
// ---- Bildchen aus der Library einlesen ----
|
|
function isImage(f) {
|
|
return IMAGE_EXT.some((ext) => f.toLowerCase().endsWith(ext));
|
|
}
|
|
function prettyName(file) {
|
|
return file
|
|
.replace(/\.[^.]+$/, '')
|
|
.replace(/[-_]+/g, ' ')
|
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|
|
function listImages(dir, urlPrefix) {
|
|
if (!existsSync(dir)) return [];
|
|
return readdirSync(dir)
|
|
.filter(isImage)
|
|
.sort()
|
|
.map((f) => ({ id: f.replace(/\.[^.]+$/, ''), name: prettyName(f), url: `${urlPrefix}/${f}` }));
|
|
}
|
|
|
|
let feet = listImages(join(LIBRARY_DIR, 'feet'), '/library/feet');
|
|
let props = listImages(join(LIBRARY_DIR, 'props'), '/library/props');
|
|
|
|
// Nach Upload/Loeschen die Bild-Listen neu einlesen (kein Serverneustart noetig).
|
|
function rescanLibrary() {
|
|
feet = listImages(join(LIBRARY_DIR, 'feet'), '/library/feet');
|
|
props = listImages(join(LIBRARY_DIR, 'props'), '/library/props');
|
|
return { feet: feet.length, props: props.length };
|
|
}
|
|
|
|
if (feet.length < 2) {
|
|
console.warn(`[server] Achtung: nur ${feet.length} Fuss/Fuesse in library/feet — brauche mind. 2.`);
|
|
} else {
|
|
console.log(`[server] ${feet.length} Fuesse, ${props.length} Deko-Bildchen geladen.`);
|
|
}
|
|
|
|
const app = express();
|
|
app.set('trust proxy', true); // korrekte Client-IP hinter Reverse-Proxy/Cloudflare
|
|
app.use(express.json());
|
|
|
|
// Caching komplett verhindern (Browser UND Proxys wie Cloudflare). Sonst laden bei
|
|
// manchen Nutzern nach einem Update alte JS/CSS-Dateien -> "laedt nicht richtig".
|
|
const NO_STORE = 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
|
|
function noStore(res) {
|
|
res.setHeader('Cache-Control', NO_STORE);
|
|
res.setHeader('Pragma', 'no-cache');
|
|
res.setHeader('Expires', '0');
|
|
}
|
|
app.use((req, res, next) => {
|
|
noStore(res);
|
|
next();
|
|
});
|
|
|
|
// Die HTML-Shells nie direkt ausliefern -> immer ueber die Mode-Gates unten
|
|
// (verhindert, dass man den private-Modus per /index.html umgeht).
|
|
app.get(['/index.html', '/blocked.html'], (req, res) => res.redirect(302, '/'));
|
|
|
|
// Statische Assets (CSS/JS/Emoji), aber KEIN automatisches index.html (index:false).
|
|
// etag/lastModified aus + no-store -> keine (bedingten) Caches, auch nicht in Proxys.
|
|
app.use(express.static(PUBLIC_DIR, { index: false, etag: false, lastModified: false, setHeaders: noStore }));
|
|
app.use('/library', express.static(LIBRARY_DIR, { etag: false, lastModified: false, setHeaders: noStore }));
|
|
|
|
// Verfuegbare Bildchen
|
|
app.get('/api/feet', (req, res) => {
|
|
res.json({ feet, props });
|
|
});
|
|
|
|
// ---- Anticheat: server-autoritative Runden ----
|
|
// Der Server bestimmt pro Runde das Ziel + alle Items (mit IDs) und validiert den
|
|
// geklickten Treffer gegen die Ziel-ID der AKTUELLEN Runde. Blindes "Treffer-Spam" bringt
|
|
// nichts mehr — man muss pro Punkt die richtige (server-gewaehlte) Runde loesen.
|
|
// Zusaetzlich: Reaktionszeit-Untergrenze, Mindestabstand, Deckelung nach echter Spielzeit.
|
|
// (Ein eigens gebauter Solver-Bot bleibt in einem Browserspiel theoretisch moeglich —
|
|
// 100% fälschungssicher geht clientseitig nicht.)
|
|
const games = new Map(); // sessionId -> { startedAt, lastHitAt, hits, round }
|
|
const MIN_HIT_INTERVAL_MS = 55; // schneller = verworfen
|
|
const MIN_REACTION_MS = 120; // Runde muss min. so lange sichtbar gewesen sein
|
|
const MAX_SESSION_MS = 15 * 60 * 1000; // Session-Lebensdauer
|
|
const MIN_AVG_SEC_PER_HIT = 0.2; // max. Schnitt-Tempo -> Score-Deckel nach Zeit
|
|
const ABS_CAP = 100000;
|
|
|
|
function shuffleArr(a) {
|
|
for (let i = a.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[a[i], a[j]] = [a[j], a[i]];
|
|
}
|
|
return a;
|
|
}
|
|
const pickRand = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
|
|
|
// Neue Runde: 1 Ziel-Fuss + Ablenker (andere Fuesse + etwas Deko), gemischt.
|
|
// Objekt-Anzahl kommt aus der (live editierbaren) Config.
|
|
function makeRound(hits) {
|
|
const count = objectCount(getConfig(), hits);
|
|
const target = pickRand(feet);
|
|
const otherFeet = feet.filter((f) => f.url !== target.url);
|
|
const items = [{ id: randomUUID(), url: target.url, isTarget: true }];
|
|
for (let i = 0; i < count - 1; i++) {
|
|
const useProp = props.length && Math.random() < 0.22;
|
|
const t = useProp ? pickRand(props) : otherFeet.length ? pickRand(otherFeet) : target;
|
|
items.push({ id: randomUUID(), url: t.url, isTarget: false });
|
|
}
|
|
shuffleArr(items);
|
|
return {
|
|
nonce: randomUUID(),
|
|
targetId: items.find((it) => it.isTarget).id,
|
|
targetUrl: target.url,
|
|
score: hits,
|
|
items: items.map((it) => ({ id: it.id, url: it.url })), // KEINE Ziel-Kennzeichnung
|
|
};
|
|
}
|
|
function issueRound(g, round) {
|
|
g.round = { nonce: round.nonce, targetId: round.targetId, issuedAt: Date.now() };
|
|
}
|
|
function publicRound(r) {
|
|
return { nonce: r.nonce, score: r.score, targetUrl: r.targetUrl, items: r.items };
|
|
}
|
|
|
|
function pruneGames() {
|
|
const now = Date.now();
|
|
for (const [id, g] of games) if (now - g.startedAt > MAX_SESSION_MS) games.delete(id);
|
|
if (games.size > 5000) {
|
|
const oldest = [...games.entries()].sort((a, b) => a[1].startedAt - b[1].startedAt);
|
|
for (let i = 0; i < oldest.length - 5000; i++) games.delete(oldest[i][0]);
|
|
}
|
|
}
|
|
|
|
app.post('/api/game/start', (req, res) => {
|
|
pruneGames();
|
|
const sessionId = randomUUID();
|
|
const g = { startedAt: Date.now(), lastHitAt: 0, hits: 0, round: null };
|
|
const round = makeRound(0);
|
|
issueRound(g, round);
|
|
games.set(sessionId, g);
|
|
res.json({ sessionId, round: publicRound(round) });
|
|
});
|
|
|
|
app.post('/api/game/hit', (req, res) => {
|
|
const { sessionId, nonce, itemId } = req.body || {};
|
|
const g = games.get(sessionId);
|
|
if (!g || !g.round) return res.status(404).json({ error: 'Keine Session.' });
|
|
const now = Date.now();
|
|
if (now - g.startedAt > MAX_SESSION_MS) {
|
|
games.delete(sessionId);
|
|
return res.status(410).json({ error: 'Session abgelaufen.' });
|
|
}
|
|
// Nur die AKTUELLE Runde zaehlt; instant/zu schnelle Klicks werden verworfen
|
|
if (nonce !== g.round.nonce) return res.json({ correct: false, stale: true });
|
|
if (now - g.round.issuedAt < MIN_REACTION_MS) return res.json({ correct: false });
|
|
if (now - g.lastHitAt < MIN_HIT_INTERVAL_MS) return res.json({ correct: false });
|
|
|
|
if (itemId === g.round.targetId) {
|
|
g.hits += 1;
|
|
g.lastHitAt = now;
|
|
const next = makeRound(g.hits);
|
|
issueRound(g, next);
|
|
return res.json({ correct: true, round: publicRound(next) });
|
|
}
|
|
return res.json({ correct: false });
|
|
});
|
|
|
|
// Darf auf dem aktuellen Pfad gespielt werden? (Client sendet nur seinen Pfad-Slug,
|
|
// der geheime Pfad wird NICHT preisgegeben.) Dient zum Abbruch laufender Spiele.
|
|
app.get('/api/status', (req, res) => {
|
|
const m = getMode();
|
|
const slug = String(req.query.path || '').replace(/^\/+/, '');
|
|
res.json({ allowed: m.mode === 'public' || slug === m.path });
|
|
});
|
|
|
|
// Client-IP hinter Reverse-Proxy/Cloudflare ermitteln
|
|
function clientIp(req) {
|
|
return (
|
|
req.headers['cf-connecting-ip'] ||
|
|
(req.headers['x-forwarded-for'] || '').split(',')[0].trim() ||
|
|
req.ip ||
|
|
''
|
|
);
|
|
}
|
|
|
|
// Ranglisten: Allzeit-Top-5 + Heute-Top-10
|
|
app.get('/api/leaderboard', (req, res) => {
|
|
res.json(buildBoards(readLeaderboard()));
|
|
});
|
|
|
|
// Spiel-Parameter fuer den Client (live editierbar ueber das Admin-Panel)
|
|
app.get('/api/config', (req, res) => {
|
|
res.json(getConfig());
|
|
});
|
|
|
|
// Score eintragen -> Score kommt aus der SERVER-Session (Anticheat), nicht vom Client.
|
|
app.post('/api/score', (req, res) => {
|
|
const { name, sessionId } = req.body || {};
|
|
const g = games.get(sessionId);
|
|
if (!g) return res.status(400).json({ error: 'Keine gueltige Spiel-Session.' });
|
|
games.delete(sessionId); // Session ist einmalig einloesbar
|
|
|
|
const elapsedSec = (Date.now() - g.startedAt) / 1000;
|
|
const maxByTime = Math.floor(elapsedSec / MIN_AVG_SEC_PER_HIT);
|
|
const score = Math.max(0, Math.min(g.hits, maxByTime, ABS_CAP));
|
|
|
|
const cleanName = String(name || 'Anonym').trim().slice(0, 20) || 'Anonym';
|
|
if (isBanned(cleanName)) return res.status(403).json({ error: 'Name gesperrt.' });
|
|
|
|
const boards = addScore(cleanName, score);
|
|
// Tracking (nur approximativ; fuer Moderation im Admin-Panel)
|
|
try {
|
|
logGame({
|
|
name: cleanName,
|
|
score,
|
|
ip: clientIp(req),
|
|
ua: req.headers['user-agent'] || '',
|
|
cfCountry: req.headers['cf-ipcountry'] || '',
|
|
});
|
|
} catch {}
|
|
res.json(boards);
|
|
});
|
|
|
|
// ================= Admin-Panel =================
|
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
|
|
const ALLOWED_UPLOAD_EXT = ['.svg', '.png', '.webp', '.jpg', '.jpeg', '.gif'];
|
|
|
|
function parseCookies(req) {
|
|
const out = {};
|
|
const h = req.headers.cookie;
|
|
if (!h) return out;
|
|
for (const part of h.split(';')) {
|
|
const i = part.indexOf('=');
|
|
if (i > -1) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
}
|
|
return out;
|
|
}
|
|
const isAuthed = (req) => validSession(parseCookies(req)[SESSION_COOKIE]);
|
|
function requireAdmin(req, res, next) {
|
|
if (isAuthed(req)) return next();
|
|
res.status(401).json({ error: 'Nicht angemeldet.' });
|
|
}
|
|
|
|
// Login-Rate-Limit pro IP (5 Versuche/Minute)
|
|
const loginHits = new Map();
|
|
function loginAllowed(ip) {
|
|
const now = Date.now();
|
|
const e = loginHits.get(ip);
|
|
if (!e || now > e.resetAt) {
|
|
loginHits.set(ip, { count: 1, resetAt: now + 60000 });
|
|
return true;
|
|
}
|
|
e.count += 1;
|
|
return e.count <= 5;
|
|
}
|
|
|
|
app.post('/api/admin/login', (req, res) => {
|
|
if (!loginAllowed(clientIp(req))) return res.status(429).json({ error: 'Zu viele Versuche. Kurz warten.' });
|
|
if (!verifyPassword(req.body && req.body.password)) return res.status(401).json({ error: 'Falsches Passwort.' });
|
|
const token = createSession();
|
|
const secure = req.secure || req.headers['x-forwarded-proto'] === 'https';
|
|
res.setHeader(
|
|
'Set-Cookie',
|
|
`${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.floor(SESSION_MAX_AGE / 1000)}${secure ? '; Secure' : ''}`
|
|
);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/admin/logout', (req, res) => {
|
|
destroySession(parseCookies(req)[SESSION_COOKIE]);
|
|
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; HttpOnly; Path=/; Max-Age=0`);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.get('/api/admin/me', requireAdmin, (req, res) => res.json({ ok: true }));
|
|
|
|
app.get('/api/admin/players', requireAdmin, (req, res) => {
|
|
res.json({ players: aggregatePlayers(), bans: readBans() });
|
|
});
|
|
|
|
app.post('/api/admin/ban', requireAdmin, (req, res) => {
|
|
const name = req.body && req.body.name;
|
|
if (!name) return res.status(400).json({ error: 'Name fehlt.' });
|
|
banPlayer(name);
|
|
const removed = deletePlayer(name);
|
|
res.json({ ok: true, removed });
|
|
});
|
|
|
|
app.post('/api/admin/unban', requireAdmin, (req, res) => {
|
|
unbanPlayer(req.body && req.body.name);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/admin/reset', requireAdmin, (req, res) => {
|
|
const n = (req.body && req.body.scope) === 'all' ? clearAll() : clearToday();
|
|
res.json({ ok: true, removed: n });
|
|
});
|
|
|
|
app.get('/api/admin/config', requireAdmin, (req, res) => res.json(getConfig()));
|
|
app.post('/api/admin/config', requireAdmin, (req, res) => res.json(setConfig(req.body || {})));
|
|
|
|
app.get('/api/admin/images', requireAdmin, (req, res) => res.json({ feet, props }));
|
|
|
|
app.post('/api/admin/images/:kind', requireAdmin, upload.single('image'), (req, res) => {
|
|
const kind = req.params.kind === 'props' ? 'props' : req.params.kind === 'feet' ? 'feet' : null;
|
|
if (!kind) return res.status(400).json({ error: 'Ungueltige Kategorie.' });
|
|
if (!req.file) return res.status(400).json({ error: 'Keine Datei.' });
|
|
const ext = extname(req.file.originalname).toLowerCase();
|
|
if (!ALLOWED_UPLOAD_EXT.includes(ext)) return res.status(400).json({ error: 'Dateityp nicht erlaubt.' });
|
|
const base =
|
|
basename(req.file.originalname, extname(req.file.originalname))
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_-]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 40) || 'bild';
|
|
const dir = join(LIBRARY_DIR, kind);
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
writeFileSync(join(dir, base + ext), req.file.buffer);
|
|
rescanLibrary();
|
|
res.json({ ok: true, file: base + ext });
|
|
});
|
|
|
|
app.delete('/api/admin/images/:kind/:file', requireAdmin, (req, res) => {
|
|
const kind = req.params.kind === 'props' ? 'props' : req.params.kind === 'feet' ? 'feet' : null;
|
|
if (!kind) return res.status(400).json({ error: 'Ungueltige Kategorie.' });
|
|
const file = basename(req.params.file); // kein Pfad-Ausbruch
|
|
const p = join(LIBRARY_DIR, kind, file);
|
|
if (!existsSync(p)) return res.status(404).json({ error: 'Datei nicht gefunden.' });
|
|
try {
|
|
unlinkSync(p);
|
|
} catch {
|
|
return res.status(500).json({ error: 'Loeschen fehlgeschlagen.' });
|
|
}
|
|
rescanLibrary();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ---- Mode-Gates (Live: getMode() liest pro Request die Datei) ----
|
|
function sendHtml(res, file) {
|
|
noStore(res);
|
|
res.sendFile(join(PUBLIC_DIR, file));
|
|
}
|
|
|
|
// Admin-Seite: immer erreichbar (Auth-Check macht die API); vor dem /:slug-Gate.
|
|
app.get('/admin', (req, res) => sendHtml(res, 'admin.html'));
|
|
|
|
// Startseite: public -> Spiel, private -> "nicht erlaubt"
|
|
app.get('/', (req, res) => {
|
|
sendHtml(res, getMode().mode === 'private' ? 'blocked.html' : 'index.html');
|
|
});
|
|
|
|
// Geheimer Spiel-Pfad: nur im private-Modus aktiv, nur beim passenden Slug
|
|
app.get('/:slug', (req, res, next) => {
|
|
const m = getMode();
|
|
if (m.mode === 'private' && req.params.slug === m.path) return sendHtml(res, 'index.html');
|
|
next();
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
const m = getMode();
|
|
console.log(`[server] fuesse.sexy (Wanted!) laeuft auf http://localhost:${PORT}`);
|
|
console.log(`[server] Modus: ${m.mode}${m.mode === 'private' ? ` (Spiel unter /${m.path})` : ''}`);
|
|
});
|