Admin-Panel (/admin): Auth, Spieler-Tracking, Live-Tuning, Bild-Verwaltung
- 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>
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
import { readLeaderboard, buildBoards, deletePlayer, clearAll, clearToday } from './leaderboardStore.js';
|
||||
import { getMode, setMode } from './modeStore.js';
|
||||
import { banPlayer, unbanPlayer, readBans } from './banStore.js';
|
||||
import { getPassword, resetPassword } from './adminAuth.js';
|
||||
|
||||
const [, , cmd, ...rest] = process.argv;
|
||||
const arg = rest.join(' ').trim();
|
||||
@@ -80,6 +81,17 @@ switch (cmd) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'password': {
|
||||
if (arg === 'reset') {
|
||||
console.log('🔑 Neues Admin-Passwort: ' + resetPassword());
|
||||
} else {
|
||||
console.log('🔑 Admin-Passwort: ' + getPassword());
|
||||
console.log(' (neu erzeugen: node server/admin.js password reset)');
|
||||
}
|
||||
console.log(' Login unter: /admin');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'mode': {
|
||||
const sub = (rest[0] || '').toLowerCase();
|
||||
if (sub === 'public') {
|
||||
@@ -112,6 +124,10 @@ Bannen (namensbasiert):
|
||||
node server/admin.js unban "<name>" Sperre aufheben
|
||||
node server/admin.js bans gebannte Namen anzeigen
|
||||
|
||||
Admin-Panel (/admin):
|
||||
node server/admin.js password Admin-Passwort anzeigen
|
||||
node server/admin.js password reset neues Passwort erzeugen
|
||||
|
||||
Betriebsmodus (Spiel oeffentlich / privat):
|
||||
node server/admin.js mode aktuellen Modus anzeigen
|
||||
node server/admin.js mode public Spiel oeffentlich unter "/"
|
||||
|
||||
98
server/adminAuth.js
Normal file
98
server/adminAuth.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// Admin-Authentifizierung: automatisch generiertes Passwort (20 Zeichen), persistent in
|
||||
// data/admin.json (Klartext, chmod 600 -> per CLI abrufbar). Sessions liegen im Speicher.
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = join(__dirname, '..', 'data');
|
||||
const ADMIN_FILE = join(DATA_DIR, 'admin.json');
|
||||
|
||||
const LOWER = 'abcdefghijkmnopqrstuvwxyz';
|
||||
const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
const DIGITS = '23456789';
|
||||
const SPECIAL = '!@#$%&*()-_=+?';
|
||||
const ALL = LOWER + UPPER + DIGITS + SPECIAL;
|
||||
const PW_LEN = 20;
|
||||
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage
|
||||
const sessions = new Map(); // token -> expiresAt
|
||||
|
||||
function generatePassword() {
|
||||
// je 1 Zeichen aus jeder Klasse garantieren, Rest zufaellig, dann mischen
|
||||
const pick = (set) => set[randomInt(set.length)];
|
||||
const chars = [pick(LOWER), pick(UPPER), pick(DIGITS), pick(SPECIAL)];
|
||||
while (chars.length < PW_LEN) chars.push(pick(ALL));
|
||||
for (let i = chars.length - 1; i > 0; i--) {
|
||||
const j = randomInt(i + 1);
|
||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
function write(data) {
|
||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||
writeFileSync(ADMIN_FILE, JSON.stringify(data, null, 2));
|
||||
try {
|
||||
chmodSync(ADMIN_FILE, 0o600);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Liest die Admin-Daten; legt beim ersten Mal Passwort + Secret an.
|
||||
function ensure() {
|
||||
if (existsSync(ADMIN_FILE)) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(ADMIN_FILE, 'utf8'));
|
||||
if (data && data.password) return data;
|
||||
} catch {}
|
||||
}
|
||||
const data = { password: generatePassword(), secret: randomBytes(32).toString('hex') };
|
||||
write(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function getPassword() {
|
||||
return ensure().password;
|
||||
}
|
||||
|
||||
export function resetPassword() {
|
||||
const data = ensure();
|
||||
data.password = generatePassword();
|
||||
write(data);
|
||||
return data.password;
|
||||
}
|
||||
|
||||
export function verifyPassword(input) {
|
||||
const real = ensure().password;
|
||||
const a = Buffer.from(String(input || ''));
|
||||
const b = Buffer.from(real);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
// --- Sessions (In-Memory) ---
|
||||
export function createSession() {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
sessions.set(token, Date.now() + SESSION_TTL_MS);
|
||||
return token;
|
||||
}
|
||||
|
||||
export function validSession(token) {
|
||||
if (!token) return false;
|
||||
const exp = sessions.get(token);
|
||||
if (!exp) return false;
|
||||
if (Date.now() > exp) {
|
||||
sessions.delete(token);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function destroySession(token) {
|
||||
if (token) sessions.delete(token);
|
||||
}
|
||||
|
||||
export const SESSION_COOKIE = 'admin_session';
|
||||
export const SESSION_MAX_AGE = SESSION_TTL_MS;
|
||||
97
server/configStore.js
Normal file
97
server/configStore.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// Spiel-Parameter, live per Admin-Panel einstellbar, persistent in data/config.json.
|
||||
// Wird pro Request frisch gelesen -> Aenderungen wirken SOFORT (Server + Client).
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = join(__dirname, '..', 'data');
|
||||
const CONFIG_FILE = join(DATA_DIR, 'config.json');
|
||||
|
||||
// Defaults = die bisherigen Konstanten aus public/js/app.js + server/index.js
|
||||
export const DEFAULT_CONFIG = {
|
||||
// Timer
|
||||
startTime: 15,
|
||||
maxTime: 30,
|
||||
bonus: 1.2,
|
||||
penalty: 2.0,
|
||||
// Objekt-Anzahl-Kurve
|
||||
countBase: 10,
|
||||
countPerScore: 1.5,
|
||||
stepStart: 25,
|
||||
stepEvery: 3,
|
||||
stepAdd: 2,
|
||||
countCap: 100,
|
||||
// Schwierigkeits-Stufen (Score-Grenzen) + Tempo
|
||||
stageScatter: 3, // ab hier "scatter"
|
||||
stageGroups: 6, // ab hier "groups"
|
||||
stageIndividual: 11, // ab hier "individual"
|
||||
speedGroupsBase: 45,
|
||||
speedGroupsPer: 10,
|
||||
speedGroupsMax: 150,
|
||||
speedIndivBase: 70,
|
||||
speedIndivPer: 12,
|
||||
speedIndivMax: 320,
|
||||
};
|
||||
|
||||
// Nur bekannte Keys uebernehmen, Werte auf sinnvolle Zahlen begrenzen
|
||||
function sanitize(input) {
|
||||
const out = { ...DEFAULT_CONFIG };
|
||||
if (input && typeof input === 'object') {
|
||||
for (const key of Object.keys(DEFAULT_CONFIG)) {
|
||||
const v = Number(input[key]);
|
||||
if (Number.isFinite(v)) out[key] = clampField(key, v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clampField(key, v) {
|
||||
const bounds = {
|
||||
startTime: [3, 120],
|
||||
maxTime: [5, 300],
|
||||
bonus: [0, 30],
|
||||
penalty: [0, 30],
|
||||
countBase: [1, 200],
|
||||
countPerScore: [0, 20],
|
||||
stepStart: [0, 500],
|
||||
stepEvery: [1, 100],
|
||||
stepAdd: [0, 50],
|
||||
countCap: [5, 400],
|
||||
stageScatter: [0, 1000],
|
||||
stageGroups: [0, 1000],
|
||||
stageIndividual: [0, 1000],
|
||||
speedGroupsBase: [0, 2000],
|
||||
speedGroupsPer: [0, 500],
|
||||
speedGroupsMax: [0, 5000],
|
||||
speedIndivBase: [0, 2000],
|
||||
speedIndivPer: [0, 500],
|
||||
speedIndivMax: [0, 5000],
|
||||
};
|
||||
const [min, max] = bounds[key] || [-1e9, 1e9];
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
try {
|
||||
if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
|
||||
return sanitize(JSON.parse(readFileSync(CONFIG_FILE, 'utf8')));
|
||||
} catch {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
export function setConfig(input) {
|
||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||
const next = sanitize(input);
|
||||
writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2));
|
||||
return next;
|
||||
}
|
||||
|
||||
// Objekt-Anzahl aus Config + Score (identisch fuer Server & Client)
|
||||
export function objectCount(cfg, score) {
|
||||
let count = cfg.countBase + Math.floor(score * cfg.countPerScore);
|
||||
if (score >= cfg.stepStart) count += Math.floor((score - cfg.stepStart) / cfg.stepEvery) * cfg.stepAdd;
|
||||
return Math.min(count, cfg.countCap);
|
||||
}
|
||||
187
server/index.js
187
server/index.js
@@ -3,14 +3,25 @@
|
||||
// der verfuegbaren Fuesse/Deko bereit und verwaltet eine persistente Rangliste.
|
||||
|
||||
import express from 'express';
|
||||
import { dirname, join } from 'node:path';
|
||||
import multer from 'multer';
|
||||
import { dirname, join, basename, extname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { existsSync, readdirSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { readLeaderboard, buildBoards, addScore } from './leaderboardStore.js';
|
||||
import { readLeaderboard, buildBoards, addScore, clearAll, clearToday, deletePlayer } from './leaderboardStore.js';
|
||||
import { getMode } from './modeStore.js';
|
||||
import { isBanned } from './banStore.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, '..');
|
||||
@@ -38,8 +49,15 @@ function listImages(dir, urlPrefix) {
|
||||
.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');
|
||||
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.`);
|
||||
@@ -48,6 +66,7 @@ if (feet.length < 2) {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -100,16 +119,10 @@ function shuffleArr(a) {
|
||||
}
|
||||
const pickRand = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
// Objekt-Anzahl (muss zur Client-Formel passen)
|
||||
function objectCount(score) {
|
||||
let count = 10 + Math.floor(score * 1.5);
|
||||
if (score >= 15) count += Math.floor((score - 15) / 3) * 5;
|
||||
return Math.min(count, 100);
|
||||
}
|
||||
|
||||
// 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(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 }];
|
||||
@@ -185,11 +198,26 @@ app.get('/api/status', (req, res) => {
|
||||
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 || {};
|
||||
@@ -203,7 +231,133 @@ app.post('/api/score', (req, res) => {
|
||||
|
||||
const cleanName = String(name || 'Anonym').trim().slice(0, 20) || 'Anonym';
|
||||
if (isBanned(cleanName)) return res.status(403).json({ error: 'Name gesperrt.' });
|
||||
res.json(addScore(cleanName, score));
|
||||
|
||||
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) ----
|
||||
@@ -212,6 +366,9 @@ function sendHtml(res, file) {
|
||||
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');
|
||||
|
||||
122
server/playerLog.js
Normal file
122
server/playerLog.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// Spieler-Log: pro Score-Eintrag ein Datensatz mit IP, grobem Ort, Geraet & Browser.
|
||||
// Persistiert in data/players.json (append, gedeckelt). Nur approximativ (kein Login).
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import UAParser from 'ua-parser-js';
|
||||
import geoip from 'geoip-lite';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = join(__dirname, '..', 'data');
|
||||
const PLAYERS_FILE = join(DATA_DIR, 'players.json');
|
||||
const MAX_RECORDS = 5000;
|
||||
|
||||
function readAll() {
|
||||
try {
|
||||
if (!existsSync(PLAYERS_FILE)) return [];
|
||||
const data = JSON.parse(readFileSync(PLAYERS_FILE, 'utf8'));
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll(list) {
|
||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||
writeFileSync(PLAYERS_FILE, JSON.stringify(list, null, 2));
|
||||
}
|
||||
|
||||
// User-Agent -> { device, browser, os }
|
||||
function parseUA(ua) {
|
||||
try {
|
||||
const r = new UAParser(ua || '').getResult();
|
||||
return {
|
||||
device: r.device && r.device.type ? cap(r.device.type) : 'Desktop',
|
||||
browser: [r.browser.name, r.browser.version].filter(Boolean).join(' ') || 'unbekannt',
|
||||
os: [r.os.name, r.os.version].filter(Boolean).join(' ') || 'unbekannt',
|
||||
};
|
||||
} catch {
|
||||
return { device: '?', browser: '?', os: '?' };
|
||||
}
|
||||
}
|
||||
|
||||
// IP -> { country, city } (offline, grob)
|
||||
function geoLookup(ip, cfCountry) {
|
||||
try {
|
||||
const g = geoip.lookup(cleanIp(ip));
|
||||
if (g) return { country: g.country || cfCountry || '', city: g.city || '' };
|
||||
} catch {}
|
||||
return { country: cfCountry || '', city: '' };
|
||||
}
|
||||
|
||||
function cleanIp(ip) {
|
||||
return String(ip || '').replace(/^::ffff:/, '');
|
||||
}
|
||||
const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
||||
|
||||
// Einen Spiel-Abschluss protokollieren.
|
||||
export function logGame({ name, score, ip, ua, cfCountry }) {
|
||||
const { device, browser, os } = parseUA(ua);
|
||||
const { country, city } = geoLookup(ip, cfCountry);
|
||||
const list = readAll();
|
||||
list.push({
|
||||
name: String(name || 'Anonym'),
|
||||
score: Number(score) || 0,
|
||||
at: new Date().toISOString(),
|
||||
ip: cleanIp(ip),
|
||||
device,
|
||||
browser,
|
||||
os,
|
||||
country,
|
||||
city,
|
||||
});
|
||||
writeAll(list.slice(-MAX_RECORDS));
|
||||
}
|
||||
|
||||
// Aggregiert die Log-Eintraege pro Spielername (case-insensitiv).
|
||||
export function aggregatePlayers() {
|
||||
const byName = new Map();
|
||||
for (const r of readAll()) {
|
||||
const key = String(r.name || '').trim().toLowerCase();
|
||||
if (!byName.has(key)) {
|
||||
byName.set(key, {
|
||||
name: r.name,
|
||||
games: 0,
|
||||
bestScore: 0,
|
||||
firstAt: r.at,
|
||||
lastAt: r.at,
|
||||
ips: new Set(),
|
||||
locations: new Set(),
|
||||
devices: new Set(),
|
||||
browsers: new Set(),
|
||||
oses: new Set(),
|
||||
});
|
||||
}
|
||||
const p = byName.get(key);
|
||||
p.games += 1;
|
||||
p.bestScore = Math.max(p.bestScore, r.score || 0);
|
||||
if (r.at < p.firstAt) p.firstAt = r.at;
|
||||
if (r.at > p.lastAt) p.lastAt = r.at;
|
||||
if (r.ip) p.ips.add(r.ip);
|
||||
const loc = [r.city, r.country].filter(Boolean).join(', ');
|
||||
if (loc) p.locations.add(loc);
|
||||
if (r.device) p.devices.add(r.device);
|
||||
if (r.browser) p.browsers.add(r.browser);
|
||||
if (r.os) p.oses.add(r.os);
|
||||
}
|
||||
return [...byName.values()]
|
||||
.map((p) => ({
|
||||
name: p.name,
|
||||
games: p.games,
|
||||
bestScore: p.bestScore,
|
||||
firstAt: p.firstAt,
|
||||
lastAt: p.lastAt,
|
||||
ips: [...p.ips],
|
||||
locations: [...p.locations],
|
||||
devices: [...p.devices],
|
||||
browsers: [...p.browsers],
|
||||
oses: [...p.oses],
|
||||
}))
|
||||
.sort((a, b) => new Date(b.lastAt) - new Date(a.lastAt));
|
||||
}
|
||||
Reference in New Issue
Block a user