// 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 { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync, readdirSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { readLeaderboard, buildBoards, addScore } from './leaderboardStore.js'; import { getMode } from './modeStore.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}` })); } const feet = listImages(join(LIBRARY_DIR, 'feet'), '/library/feet'); const props = listImages(join(LIBRARY_DIR, 'props'), '/library/props'); 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.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 Spiel-Sessions ---- // Der Server vergibt zu Spielbeginn eine Session und zaehlt die Treffer SELBST. Beim // Eintragen zaehlt nur diese Server-Zahl (nicht, was der Client behauptet). Zusaetzlich: // Mindestabstand zwischen Treffern (gegen Auto-Klicker) + Deckelung nach echter Spielzeit. const games = new Map(); // sessionId -> { startedAt, lastHitAt, hits } const MIN_HIT_INTERVAL_MS = 55; // schneller = ignoriert (unmenschlich) const MAX_SESSION_MS = 15 * 60 * 1000; // Session-Lebensdauer const MIN_AVG_SEC_PER_HIT = 0.18; // max. Schnitt-Tempo -> Score-Deckel nach Zeit const ABS_CAP = 100000; 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(); games.set(sessionId, { startedAt: Date.now(), lastHitAt: 0, hits: 0 }); res.json({ sessionId }); }); app.post('/api/game/hit', (req, res) => { const g = games.get(req.body && req.body.sessionId); if (!g) return res.status(404).json({ error: 'Keine Session.' }); const now = Date.now(); if (now - g.startedAt > MAX_SESSION_MS) { games.delete(req.body.sessionId); return res.status(410).json({ error: 'Session abgelaufen.' }); } // Treffer zu schnell nach dem letzten -> ignorieren (Auto-Klicker), aber kein Fehler if (now - g.lastHitAt < MIN_HIT_INTERVAL_MS) return res.json({ counted: false }); g.lastHitAt = now; g.hits += 1; res.json({ counted: true }); }); // Ranglisten: Allzeit-Top-5 + Heute-Top-10 app.get('/api/leaderboard', (req, res) => { res.json(buildBoards(readLeaderboard())); }); // 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'; res.json(addScore(cleanName, score)); }); // ---- Mode-Gates (Live: getMode() liest pro Request die Datei) ---- function sendHtml(res, file) { noStore(res); res.sendFile(join(PUBLIC_DIR, file)); } // 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})` : ''}`); });