diff --git a/README.md b/README.md index a197e9c..279cec8 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,10 @@ node server/admin.js bans # gebannte Namen anzeigen node server/admin.js mode # aktuellen Modus anzeigen node server/admin.js mode public # Spiel öffentlich unter "/" node server/admin.js mode private geheim # "/" gesperrt, Spiel nur unter "/geheim" + +# Admin-Panel-Passwort +node server/admin.js password # Passwort anzeigen (Login unter /admin) +node server/admin.js password reset # neues Passwort erzeugen ``` (Kurzform via npm: `npm run lb -- `.) @@ -90,6 +94,24 @@ node server/admin.js mode private geheim # "/" gesperrt, Spiel nur unter "/geh - **private**: `/` zeigt „You are not allowed to play", das Spiel läuft nur unter dem gewählten Pfad (`/`) — praktisch zum Testen im Live-Betrieb. +## Admin-Panel (`/admin`) + +Passwortgeschütztes Web-Panel. Passwort wird beim ersten Start automatisch generiert +(20 Zeichen) und liegt in `data/admin.json` — anzeigen mit `node server/admin.js password`. + +Funktionen: +- **Spieler**: approximative Zuordnung (IP, grober Ort via `geoip-lite` offline, Gerät, Browser) + aus den Spiel-Logs; einzelne Spieler direkt **bannen/entbannen**. +- **Einstellungen**: Timer (Start/Max/Bonus/Strafe) **und** Schwierigkeitskurve (Objekt-Anzahl, + Stufen, Tempo) **live** einstellen — wirkt sofort beim nächsten Spielstart. +- **Bilder**: Füße & Deko **hochladen/löschen** (kein Neustart). Empfehlung: **quadratische** + Bilder, 256×256 oder 512×512 PNG (transparent) bzw. SVG mit quadratischem `viewBox`; + nicht-quadratische werden per `object-fit: contain` eingepasst statt verzerrt. +- **Rangliste**: heute/alles zurücksetzen. + +> Datenschutz: IP + grober Ort + Gerät/Browser sind personenbezogen (nur für Moderation). +> Für den öffentlichen Betrieb einen kurzen Datenschutz-Hinweis ergänzen. + ## Deployment (Ausblick) Normaler Node-Server. Auf einen Node-Host bringen (VPS/LXC/Railway/Render), `./install.sh`, diff --git a/package-lock.json b/package-lock.json index ea523a5..0b6a84d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,9 @@ "version": "1.0.0", "dependencies": { "express": "^4.19.2", - "image-size": "^1.2.1", - "panzoom": "^9.4.3" + "geoip-lite": "^1.4.10", + "multer": "^1.4.5-lts.2", + "ua-parser-js": "^1.0.41" } }, "node_modules/accepts": { @@ -26,25 +27,46 @@ "node": ">= 0.6" } }, - "node_modules/amator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz", - "integrity": "sha512-V5+aH8pe+Z3u/UG3L3pG3BaFQGXAyXHVQDroRwjPHdh08bcUEchAVsU1MCuJSCaU5o60wTK6KaE6te5memzgYw==", + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { - "bezier-easing": "^2.0.3" + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, - "node_modules/bezier-easing": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", - "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==", + "node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, "node_modules/body-parser": { @@ -71,6 +93,42 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -109,6 +167,61 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -145,6 +258,12 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -293,6 +412,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -329,6 +457,12 @@ "node": ">= 0.6" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -338,6 +472,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/geoip-lite": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/geoip-lite/-/geoip-lite-1.4.10.tgz", + "integrity": "sha512-4N69uhpS3KFd97m00wiFEefwa+L+HT5xZbzPhwu+sDawStg6UN/dPwWtUfkQuZkGIY1Cj7wDVp80IsqNtGMi2w==", + "license": "Apache-2.0", + "dependencies": { + "async": "2.1 - 2.6.4", + "chalk": "4.1 - 4.1.2", + "iconv-lite": "0.4.13 - 0.6.3", + "ip-address": "5.8.9 - 5.9.4", + "lazy": "1.0.11", + "rimraf": "2.5.2 - 2.7.1", + "yauzl": "2.9.2 - 2.10.0" + }, + "engines": { + "node": ">=10.3.0" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -375,6 +527,27 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -387,6 +560,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -443,19 +625,15 @@ "node": ">=0.10.0" } }, - "node_modules/image-size": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", - "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", - "license": "MIT", + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", "dependencies": { - "queue": "6.0.2" - }, - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=16.x" + "once": "^1.3.0", + "wrappy": "1" } }, "node_modules/inherits": { @@ -464,6 +642,20 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-5.9.4.tgz", + "integrity": "sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "lodash": "^4.17.15", + "sprintf-js": "1.1.2" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -473,6 +665,33 @@ "node": ">= 0.10" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, + "node_modules/lazy": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz", + "integrity": "sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==", + "license": "MIT", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -542,12 +761,64 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -557,11 +828,14 @@ "node": ">= 0.6" } }, - "node_modules/ngraph.events": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.4.0.tgz", - "integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==", - "license": "BSD-3-Clause" + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/object-inspect": { "version": "1.13.4", @@ -587,15 +861,13 @@ "node": ">= 0.8" } }, - "node_modules/panzoom": { - "version": "9.4.4", - "resolved": "https://registry.npmjs.org/panzoom/-/panzoom-9.4.4.tgz", - "integrity": "sha512-r1KfkNZvsBw59IPq7Yy+GWZnZE1YCG/t7aG6caSkij/TBqdxTzmxNTm/lHf3h6qlVMFisIGIO+lgS2Ym23PIoA==", - "license": "MIT", + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", "dependencies": { - "amator": "^1.1.0", - "ngraph.events": "^1.2.2", - "wheel": "^1.0.0" + "wrappy": "1" } }, "node_modules/parseurl": { @@ -607,12 +879,33 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -642,15 +935,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", - "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", - "license": "MIT", - "dependencies": { - "inherits": "~2.0.3" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -675,6 +959,40 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -824,6 +1142,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sprintf-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", + "license": "BSD-3-Clause" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -833,6 +1157,41 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -855,6 +1214,38 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -864,6 +1255,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -882,11 +1279,30 @@ "node": ">= 0.8" } }, - "node_modules/wheel": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz", - "integrity": "sha512-XiCMHibOiqalCQ+BaNSwRoZ9FDTAvOsXxGHXChBugewDj7HC8VBIER71dEOiRH1fSdLbRCQzngKTSiZ06ZQzeA==", - "license": "MIT" + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } } } } diff --git a/package.json b/package.json index 4692aba..125aff6 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "lb": "node server/admin.js" }, "dependencies": { - "express": "^4.19.2" + "express": "^4.19.2", + "geoip-lite": "^1.4.10", + "multer": "^1.4.5-lts.2", + "ua-parser-js": "^1.0.41" } } diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..2c11465 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,137 @@ + + +füsse.sexy — Admin + + + +
+ + + + + +
+ +
+ diff --git a/public/css/style.css b/public/css/style.css index 4c78b5b..6e6a389 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -101,6 +101,7 @@ button { font-family: inherit; cursor: pointer; } .item { position: absolute; top: 0; left: 0; will-change: transform; cursor: pointer; -webkit-user-drag: none; user-select: none; + object-fit: contain; /* nicht-quadratische Bilder werden NICHT verzerrt */ filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.5)); } .item.shake { animation: shake 0.3s ease; } diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..aa240ec --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,189 @@ +// Admin-Panel-Logik. Auth laeuft ueber Cookie-Session; alle /api/admin/* sind geschuetzt. +const $ = (id) => document.getElementById(id); + +async function api(path, opts = {}) { + const res = await fetch(path, opts); + let data = null; + try { data = await res.json(); } catch {} + return { ok: res.ok, status: res.status, data }; +} +function toast(msg) { + const t = $('toast'); + t.textContent = msg; + t.classList.add('show'); + clearTimeout(toast._t); + toast._t = setTimeout(() => t.classList.remove('show'), 1800); +} +const esc = (s) => String(s == null ? '' : s); +function fmt(iso) { + try { return new Date(iso).toLocaleString('de-DE'); } catch { return iso; } +} + +// ---------- Login ---------- +function showLogin() { + $('login').classList.remove('hidden'); + $('panel').classList.add('hidden'); + $('pw').focus(); +} +function showPanel() { + $('login').classList.add('hidden'); + $('panel').classList.remove('hidden'); + loadPlayers(); +} +async function doLogin() { + const password = $('pw').value; + const { ok, status } = await api('/api/admin/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), + }); + if (ok) { $('pw').value = ''; $('login-err').textContent = ''; showPanel(); } + else $('login-err').textContent = status === 429 ? 'Zu viele Versuche — kurz warten.' : 'Falsches Passwort.'; +} +$('login-btn').addEventListener('click', doLogin); +$('pw').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); +$('logout-btn').addEventListener('click', async () => { await api('/api/admin/logout', { method: 'POST' }); showLogin(); }); + +// ---------- Tabs ---------- +document.querySelectorAll('.tabs button').forEach((btn) => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tabs button').forEach((b) => b.classList.remove('active')); + document.querySelectorAll('section.tab').forEach((s) => s.classList.remove('active')); + btn.classList.add('active'); + const tab = btn.dataset.tab; + $('tab-' + tab).classList.add('active'); + if (tab === 'players') loadPlayers(); + if (tab === 'settings') loadSettings(); + if (tab === 'images') loadImages(); + }); +}); + +// ---------- Spieler ---------- +async function loadPlayers() { + const { ok, data } = await api('/api/admin/players'); + if (!ok) return showLogin(); + const bans = new Set((data.bans || []).map((b) => String(b).toLowerCase())); + const players = data.players || []; + if (!players.length) { $('players-wrap').innerHTML = '

Noch keine Spieler erfasst.

'; return; } + + const rows = players.map((p) => { + const banned = bans.has(String(p.name).toLowerCase()); + const chips = (arr) => (arr || []).map((x) => ``).join(''); + const tr = document.createElement('tr'); + tr.innerHTML = + `${banned ? ' gebannt' : ''}` + + `${p.bestScore}${p.games}${fmt(p.lastAt)}` + + `` + + ``; + tr.querySelector('.pname').textContent = p.name; // XSS-safe + const fill = (sel, arr) => { const c = tr.querySelector(sel); (arr || []).forEach((x) => { const s = document.createElement('span'); s.className = 'chip'; s.textContent = x; c.appendChild(s); }); }; + fill('.ips', p.ips); fill('.locs', p.locations); fill('.devs', p.devices); fill('.brs', p.browsers); + tr.querySelector('.act').addEventListener('click', () => (banned ? unban(p.name) : ban(p.name))); + return tr; + }); + + const table = document.createElement('table'); + table.innerHTML = 'NameBestSpieleZuletztIP(s)OrtGerätBrowser'; + const tbody = document.createElement('tbody'); + rows.forEach((r) => tbody.appendChild(r)); + table.appendChild(tbody); + $('players-wrap').innerHTML = ''; + $('players-wrap').appendChild(table); +} +async function ban(name) { + if (!confirm(`"${name}" bannen? Bestehende Einträge werden entfernt.`)) return; + await api('/api/admin/ban', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); + toast('Gebannt: ' + name); loadPlayers(); +} +async function unban(name) { + await api('/api/admin/unban', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); + toast('Entbannt: ' + name); loadPlayers(); +} + +// ---------- Einstellungen ---------- +const CFG_LABELS = { + startTime: 'Startzeit (s)', maxTime: 'Max-Zeit (s)', bonus: 'Bonus pro Treffer (s)', penalty: 'Strafe pro Fehlklick (s)', + countBase: 'Objekte: Basis', countPerScore: 'Objekte: pro Score', stepStart: 'Stufen ab Score', stepEvery: 'Stufe alle N Punkte', stepAdd: 'Objekte pro Stufe', countCap: 'Objekte max.', + stageScatter: 'Ab Score: verstreut', stageGroups: 'Ab Score: Gruppen', stageIndividual: 'Ab Score: einzeln', + speedGroupsBase: 'Tempo Gruppen (Basis)', speedGroupsPer: 'Tempo Gruppen (pro Score)', speedGroupsMax: 'Tempo Gruppen (max)', + speedIndivBase: 'Tempo einzeln (Basis)', speedIndivPer: 'Tempo einzeln (pro Score)', speedIndivMax: 'Tempo einzeln (max)', +}; +async function loadSettings() { + const { ok, data } = await api('/api/admin/config'); + if (!ok) return showLogin(); + const grid = $('settings-grid'); + grid.innerHTML = ''; + Object.keys(data).forEach((key) => { + const wrap = document.createElement('div'); + wrap.className = 'field'; + const label = document.createElement('label'); + label.textContent = CFG_LABELS[key] || key; + const input = document.createElement('input'); + input.type = 'number'; input.step = 'any'; input.dataset.key = key; input.value = data[key]; + wrap.append(label, input); + grid.appendChild(wrap); + }); +} +$('cfg-save').addEventListener('click', async () => { + const body = {}; + document.querySelectorAll('#settings-grid input').forEach((i) => (body[i.dataset.key] = Number(i.value))); + const { ok } = await api('/api/admin/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + $('cfg-status').textContent = ok ? '✓ gespeichert' : 'Fehler'; + if (ok) toast('Einstellungen gespeichert'); + setTimeout(() => ($('cfg-status').textContent = ''), 2500); +}); + +// ---------- Bilder ---------- +async function loadImages() { + const { ok, data } = await api('/api/admin/images'); + if (!ok) return showLogin(); + renderGallery('feet-gallery', 'feet', data.feet || []); + renderGallery('props-gallery', 'props', data.props || []); +} +function renderGallery(elId, kind, list) { + const el = $(elId); + el.innerHTML = ''; + if (!list.length) { el.innerHTML = '

Keine Bilder.

'; return; } + list.forEach((img) => { + const file = img.url.split('/').pop(); + const div = document.createElement('div'); + div.className = 'thumb'; + const im = document.createElement('img'); im.src = img.url; im.alt = img.name; + const fn = document.createElement('div'); fn.className = 'fn'; fn.textContent = file; + const del = document.createElement('button'); del.className = 'danger'; del.textContent = 'Löschen'; del.style.width = '100%'; + del.addEventListener('click', () => deleteImage(kind, file)); + div.append(im, fn, del); + el.appendChild(div); + }); +} +async function deleteImage(kind, file) { + if (!confirm(`"${file}" löschen?`)) return; + const { ok } = await api(`/api/admin/images/${kind}/${encodeURIComponent(file)}`, { method: 'DELETE' }); + if (ok) { toast('Gelöscht'); loadImages(); } else toast('Löschen fehlgeschlagen'); +} +document.querySelectorAll('button[data-up]').forEach((btn) => { + btn.addEventListener('click', async () => { + const kind = btn.dataset.up; + const input = $(kind + '-file'); + if (!input.files || !input.files[0]) return toast('Keine Datei gewählt'); + const fd = new FormData(); + fd.append('image', input.files[0]); + const res = await fetch(`/api/admin/images/${kind}`, { method: 'POST', body: fd }); + if (res.ok) { toast('Hochgeladen'); input.value = ''; loadImages(); } + else { const d = await res.json().catch(() => ({})); toast(d.error || 'Upload fehlgeschlagen'); } + }); +}); + +// ---------- Rangliste ---------- +document.querySelectorAll('button[data-reset]').forEach((btn) => { + btn.addEventListener('click', async () => { + const scope = btn.dataset.reset; + if (!confirm(scope === 'all' ? 'WIRKLICH die komplette Rangliste leeren?' : 'Heutige Rangliste leeren?')) return; + const { ok, data } = await api('/api/admin/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope }) }); + toast(ok ? `Geleert (${data.removed} Einträge)` : 'Fehler'); + }); +}); + +// ---------- Init ---------- +(async () => { + const { ok } = await api('/api/admin/me'); + if (ok) showPanel(); else showLogin(); +})(); diff --git a/public/js/app.js b/public/js/app.js index 207d8ef..2caf1b7 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -4,11 +4,21 @@ const $ = (id) => document.getElementById(id); -// ---- Konstanten (leicht justierbar) ---- -const START_TIME = 15; // Sekunden zu Beginn -const MAX_TIME = 30; // Deckel (Timerleiste = timeLeft / MAX_TIME) -const BONUS = 1.2; // Zeit pro Treffer -const PENALTY = 2.0; // Zeitabzug pro Fehlklick +// ---- Konfiguration: kommt LIVE vom Server (/api/config); hier nur Fallback-Defaults ---- +const DEFAULT_CFG = { + startTime: 15, maxTime: 30, bonus: 1.2, penalty: 2.0, + countBase: 10, countPerScore: 1.5, stepStart: 25, stepEvery: 3, stepAdd: 2, countCap: 100, + stageScatter: 3, stageGroups: 6, stageIndividual: 11, + speedGroupsBase: 45, speedGroupsPer: 10, speedGroupsMax: 150, + speedIndivBase: 70, speedIndivPer: 12, speedIndivMax: 320, +}; +let CFG = { ...DEFAULT_CFG }; +async function loadConfig() { + try { + const r = await fetch('/api/config'); + if (r.ok) CFG = { ...DEFAULT_CFG, ...(await r.json()) }; + } catch {} +} const NAME_KEY = 'fuesse_name'; // ---- Zustand ---- @@ -32,28 +42,24 @@ function shuffle(a) { return a; } -// Objekt-Anzahl: erst linear, ab STEP_START zusaetzlich SCHRITTWEISE mehr (mit Deckel). -// Alle Werte hier leicht justierbar. -const COUNT_BASE = 10; // Startanzahl -const COUNT_PER_SCORE = 1.5; // frühes lineares Wachstum -const STEP_START = 25; // ab diesem Score kommen Stufen dazu -const STEP_EVERY = 3; // alle N Punkte eine Stufe -const STEP_ADD = 2; // +M Objekte pro Stufe -const COUNT_CAP = 100; // harte Obergrenze (Performance/Spielbarkeit) - +// Objekt-Anzahl aus der Config (identisch zur Server-Formel) function objectCount(score) { - let count = COUNT_BASE + Math.floor(score * COUNT_PER_SCORE); - if (score >= STEP_START) count += Math.floor((score - STEP_START) / STEP_EVERY) * STEP_ADD; - return Math.min(count, COUNT_CAP); + 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); } -// Schwierigkeit aus dem Score ableiten +// Schwierigkeit aus dem Score ableiten (Grenzen/Tempo aus der Config) function difficulty(score) { const count = objectCount(score); - if (score < 3) return { count, stage: 'grid', groups: 0, speed: 0 }; - if (score < 6) return { count, stage: 'scatter', groups: 0, speed: 0 }; - if (score < 11) return { count, stage: 'groups', groups: score < 9 ? 2 : 3, speed: Math.min(45 + (score - 6) * 10, 150) }; - return { count, stage: 'individual', groups: 0, speed: Math.min(70 + (score - 11) * 12, 320) }; + if (score < CFG.stageScatter) return { count, stage: 'grid', groups: 0, speed: 0 }; + if (score < CFG.stageGroups) return { count, stage: 'scatter', groups: 0, speed: 0 }; + if (score < CFG.stageIndividual) { + const speed = Math.min(CFG.speedGroupsBase + (score - CFG.stageGroups) * CFG.speedGroupsPer, CFG.speedGroupsMax); + return { count, stage: 'groups', groups: score < CFG.stageGroups + 3 ? 2 : 3, speed }; + } + const speed = Math.min(CFG.speedIndivBase + (score - CFG.stageIndividual) * CFG.speedIndivPer, CFG.speedIndivMax); + return { count, stage: 'individual', groups: 0, speed }; } // ---- Rangliste (zwei Boards: Allzeit-Top-5 + Heute-Top-10) ---- @@ -105,7 +111,8 @@ async function startGame() { $('gameover').classList.add('hidden'); $('game').classList.remove('hidden'); - state = { score: 0, timeLeft: START_TIME, items: [], groups: [], stage: 'grid', running: true, sessionId: null, nonce: null, awaiting: false }; + await loadConfig(); // aktuelle Werte aus dem Admin-Panel holen + state = { score: 0, timeLeft: CFG.startTime, items: [], groups: [], stage: 'grid', running: true, sessionId: null, nonce: null, awaiting: false }; $('score').textContent = '0'; const start = await startSession(); @@ -342,7 +349,7 @@ function loop(now) { } function updateTimerBar() { - const pct = clamp(state.timeLeft / MAX_TIME, 0, 1) * 100; + const pct = clamp(state.timeLeft / CFG.maxTime, 0, 1) * 100; const bar = $('timer-bar'); bar.style.width = pct + '%'; bar.classList.toggle('low', state.timeLeft <= 6); @@ -393,7 +400,7 @@ async function hitTarget(target) { }); const data = res.ok ? await res.json() : null; if (data && data.correct && data.round) { - state.timeLeft = Math.min(state.timeLeft + BONUS, MAX_TIME); + state.timeLeft = Math.min(state.timeLeft + CFG.bonus, CFG.maxTime); $('score').parentElement.classList.remove('bump'); void $('score').parentElement.offsetWidth; $('score').parentElement.classList.add('bump'); @@ -408,7 +415,7 @@ async function hitTarget(target) { } function missDecoy(el) { - state.timeLeft = Math.max(state.timeLeft - PENALTY, 0); + state.timeLeft = Math.max(state.timeLeft - CFG.penalty, 0); if (el) { el.classList.remove('shake'); void el.offsetWidth; diff --git a/server/admin.js b/server/admin.js index cf80ed7..22d37b1 100644 --- a/server/admin.js +++ b/server/admin.js @@ -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 "" 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 "/" diff --git a/server/adminAuth.js b/server/adminAuth.js new file mode 100644 index 0000000..3e80ff3 --- /dev/null +++ b/server/adminAuth.js @@ -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; diff --git a/server/configStore.js b/server/configStore.js new file mode 100644 index 0000000..142cb07 --- /dev/null +++ b/server/configStore.js @@ -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); +} diff --git a/server/index.js b/server/index.js index 877eccc..1e25bae 100644 --- a/server/index.js +++ b/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'); diff --git a/server/playerLog.js b/server/playerLog.js new file mode 100644 index 0000000..f2cf5f7 --- /dev/null +++ b/server/playerLog.js @@ -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)); +}