// 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})` : ''}`); });