diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..df9db2d --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Copy to .env and fill in real values before `docker compose -f docker-compose.prod.yaml up`. +# .env is gitignored -- never commit real secrets. + +POSTGRES_USER=bridzik +POSTGRES_PASSWORD=bridzik +POSTGRES_DB=bridzik + +# Comma-separated list of origins allowed to open a Socket.IO connection. +# Must be the public URL(s) the frontend is served from -- never "*" in prod. +CORS_ALLOWED_ORIGINS=https://bridzik.liptim.eu + +# Shared secret for the self-hosted /api/admin/* stats endpoints. +ADMIN_TOKEN=tajneheslo + +# Fernet key encrypting Player.totp_secret at rest. Generate with: +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# Keep it stable across restarts/redeploys -- losing it locks every account +# out (their TOTP secret can no longer be decrypted). +ENCRYPTION_KEY= + +# Optional: IP -> country for /api/track pageviews (see GEOIP_DB_PATH in +# docker-compose.prod.yaml). Place a .mmdb file (GeoLite2-City/Country from a +# MaxMind account, or a DB-IP/IP2Location Lite equivalent) at +# ./geoip/GeoLite2-City.mmdb next to docker-compose.prod.yaml. Left missing -> +# country is just recorded as "". diff --git a/.gitignore b/.gitignore index 8441f08..0f56871 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pycache__/ frontend/node_modules/ frontend/dist/ frontend/.vite/ +geoip/*.mmdb diff --git a/api/__init__.py b/api/__init__.py index a7ea0cb..48934f9 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,6 +1,9 @@ +import hmac import json import os +import time import uuid +from collections import defaultdict from json import JSONEncoder import socketio @@ -8,6 +11,7 @@ import socketio from bridzik import Bridzik, BridzikException, Card from db.db import init_db from api import auth as auth_module, history +from api import stats as stats_module from api.auth import AuthError @@ -33,8 +37,95 @@ sio = socketio.AsyncServer( ) +async def _read_body(receive) -> bytes: + body = b"" + while True: + message = await receive() + body += message.get("body", b"") + if not message.get("more_body"): + break + return body + + +async def _send_text(send, status: int, body: bytes, content_type: bytes = b"text/plain"): + await send({"type": "http.response.start", "status": status, + "headers": [(b"content-type", content_type)]}) + await send({"type": "http.response.body", "body": body}) + + +def _admin_authorized(scope) -> bool: + token = os.environ.get("ADMIN_TOKEN", "") + if not token: + return False + headers = dict(scope.get("headers") or []) + auth_header = headers.get(b"authorization", b"").decode("utf-8", "ignore") + return hmac.compare_digest(auth_header, f"Bearer {token}") + + +def _client_ip(scope) -> str: + # Behind nginx (docker-compose.prod.yaml) this is the real client IP -- + # the backend port is never published, so X-Forwarded-For can't be spoofed + # by an external caller going around the proxy. + headers = dict(scope.get("headers") or []) + xff = headers.get(b"x-forwarded-for", b"").decode("utf-8", "ignore") + if xff: + return xff.split(",")[0].strip() + client = scope.get("client") + return client[0] if client else "unknown" + + +# Lockout na neuspesne pokusy o /api/admin/stats, per client IP -- rovnaky +# in-memory pattern ako login lockout v api/auth.py (jeden proces, ziadny Redis). +_ADMIN_ATTEMPT_LIMIT = 5 +_ADMIN_ATTEMPT_WINDOW = 300 # sekund +_admin_failed_attempts: dict[str, list[float]] = defaultdict(list) + + +def _admin_locked_out(ip: str) -> bool: + cutoff = time.monotonic() - _ADMIN_ATTEMPT_WINDOW + attempts = [t for t in _admin_failed_attempts.get(ip, []) if t > cutoff] + if attempts: + _admin_failed_attempts[ip] = attempts + else: + _admin_failed_attempts.pop(ip, None) + return len(attempts) >= _ADMIN_ATTEMPT_LIMIT + + +def _register_admin_failure(ip: str) -> None: + _admin_failed_attempts[ip].append(time.monotonic()) + + +async def _handle_track(scope, receive, send): + try: + data = json.loads((await _read_body(receive)) or b"{}") + except json.JSONDecodeError: + data = {} + headers = dict(scope.get("headers") or []) + user_agent = headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300] + await stats_module.record_pageview( + path=str(data.get("path", ""))[:200], + referrer=str(data.get("referrer", ""))[:300], + user_agent=user_agent, + ip=_client_ip(scope), + ) + await send({"type": "http.response.start", "status": 204, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + +async def _handle_admin_stats(scope, send): + ip = _client_ip(scope) + if _admin_locked_out(ip): + return await _send_text(send, 429, b"too many attempts") + if not _admin_authorized(scope): + _register_admin_failure(ip) + return await _send_text(send, 403, b"forbidden") + data = await stats_module.get_daily_stats() + await _send_text(send, 200, json.dumps(data).encode(), b"application/json") + + async def _health_app(scope, receive, send): - """Minimal ASGI handler for non-socket.io HTTP routes (liveness checks).""" + """Minimal ASGI handler for non-socket.io HTTP routes (liveness checks, + pageview tracking beacon, admin stats).""" if scope["type"] == "lifespan": while True: message = await receive() @@ -46,12 +137,15 @@ async def _health_app(scope, receive, send): await send({"type": "lifespan.shutdown.complete"}) return if scope["type"] == "http": - ok = scope.get("path", "") in ("/health", "/healthz") - status = 200 if ok else 404 - body = b"ok" if ok else b"not found" - await send({"type": "http.response.start", "status": status, - "headers": [(b"content-type", b"text/plain")]}) - await send({"type": "http.response.body", "body": body}) + path = scope.get("path", "") + method = scope.get("method", "GET") + if path in ("/health", "/healthz"): + return await _send_text(send, 200, b"ok") + if path == "/api/track" and method == "POST": + return await _handle_track(scope, receive, send) + if path == "/api/admin/stats" and method == "GET": + return await _handle_admin_stats(scope, send) + await _send_text(send, 404, b"not found") # Run with: uvicorn api:app --host 0.0.0.0 --port 5000 diff --git a/api/stats.py b/api/stats.py new file mode 100644 index 0000000..eae2a07 --- /dev/null +++ b/api/stats.py @@ -0,0 +1,232 @@ +"""Self-hosted usage analytics: zapis pageview beacon + citanie agregatov pre /admin/stats. + +Oddelene od api/history.py (ktory drzi zivu hru + restore-on-startup logiku), +rovnako ako je api/auth.py samostatny modul. +""" + +import os + +import geoip2.database +import geoip2.errors +from sqlalchemy import extract, func, select +from user_agents import parse as parse_ua + +from db.db import async_session +from db.models import Game, Guess, PageView, Player + +_geoip_reader: "geoip2.database.Reader | None" = None +_geoip_load_attempted = False + + +def _country_for_ip(ip: str) -> str: + """ISO kod krajiny z lokalneho .mmdb (GEOIP_DB_PATH), alebo "" ak nie je + dostupny subor alebo sa IP neda rozlusit (privatna/lokalna adresa a pod.).""" + global _geoip_reader, _geoip_load_attempted + if not ip: + return "" + if _geoip_reader is None: + if _geoip_load_attempted: + return "" + _geoip_load_attempted = True + path = os.environ.get("GEOIP_DB_PATH", "") + if not path or not os.path.exists(path): + return "" + _geoip_reader = geoip2.database.Reader(path) + try: + return _geoip_reader.country(ip).country.iso_code or "" + except (geoip2.errors.AddressNotFoundError, ValueError): + return "" + + +async def record_pageview(path: str, referrer: str, user_agent: str, ip: str = "") -> None: + ua = parse_ua(user_agent) + device_type = ( + "bot" if ua.is_bot else "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "pc" + ) + async with async_session() as session: + session.add( + PageView( + path=path, + referrer=referrer, + user_agent=user_agent, + browser=ua.browser.family[:40], + os=ua.os.family[:40], + device_type=device_type, + ip=ip[:45], + country=_country_for_ip(ip), + ) + ) + await session.commit() + + +_DAYS_WINDOW = 30 # kazdy "za den" graf/rozklad zobrazuje rovnake okno + + +async def _pageviews_by_day_and(session, column) -> dict[str, dict[str, int]]: + """Denne navstevy rozdelene podla danej dimenzie (device_type/browser/os), + napr. {"2026-07-01": {"pc": 3, "mobile": 1}, ...} -- pre prepinatelny graf. + Orezane na _DAYS_WINDOW dni, rovnako ako pageviews_per_day (a ostatne denne + grafy), aby prepnutie medzi dimenziami neroztiahlo graf na celu historiu.""" + vday = func.date(PageView.created_at) + recent_days = ( + select(vday.label("day")) + .distinct() + .order_by(vday.desc()) + .limit(_DAYS_WINDOW) + .subquery() + ) + rows = ( + await session.execute( + select(vday.label("day"), column.label("cat"), func.count().label("n")) + .where(vday.in_(select(recent_days.c.day))) + .group_by(vday, column) + .order_by(vday.desc()) + ) + ).all() + nested: dict[str, dict[str, int]] = {} + for r in rows: + nested.setdefault(str(r.day), {})[r.cat] = r.n + return nested + + +async def get_daily_stats() -> dict: + async with async_session() as session: + # func.date() (not cast(..., Date)) -- the `date()` SQL function is portable + # across SQLite and Postgres and returns a plain string/date value without + # the double-conversion issue cast(..., Date) triggers on SQLite (aiosqlite + # already coerces TIMESTAMP columns to datetime before the Date result + # processor tries to re-parse them as an ISO string). + day = func.date(Game.created_at) + game_rows = ( + await session.execute( + select(day.label("day"), func.count().label("n")) + .group_by(day) + .order_by(day.desc()) + .limit(_DAYS_WINDOW) + ) + ).all() + + pday = func.date(Player.created_at) + player_rows = ( + await session.execute( + select(pday.label("day"), func.count().label("n")) + .group_by(pday) + .order_by(pday.desc()) + .limit(_DAYS_WINDOW) + ) + ).all() + + total, finished = ( + await session.execute(select(func.count(), func.count(Game.ended_at))) + ).one() + + avg_duration = ( + await session.execute( + select(func.avg(func.extract("epoch", Game.ended_at - Game.created_at))).where( + Game.ended_at.is_not(None) + ) + ) + ).scalar() + + total_players = (await session.execute(select(func.count(Player.id)))).scalar() + + peak_hours = ( + await session.execute( + select(extract("hour", Game.created_at).label("h"), func.count().label("n")) + .group_by("h") + .order_by("h") + ) + ).all() + + rday = func.date(Game.created_at) + rounds_rows = ( + await session.execute( + select(rday.label("day"), func.count().label("n")) + .select_from(Guess) + .join(Game, Guess.game_id == Game.id) + .group_by(rday) + .order_by(rday.desc()) + .limit(_DAYS_WINDOW) + ) + ).all() + + vday = func.date(PageView.created_at) + pageview_rows = ( + await session.execute( + select(vday.label("day"), func.count().label("n")) + .group_by(vday) + .order_by(vday.desc()) + .limit(_DAYS_WINDOW) + ) + ).all() + top_referrers = ( + await session.execute( + select(PageView.referrer, func.count().label("n")) + .where(PageView.referrer != "") + .group_by(PageView.referrer) + .order_by(func.count().desc()) + .limit(20) + ) + ).all() + top_paths = ( + await session.execute( + select(PageView.path, func.count().label("n")) + .group_by(PageView.path) + .order_by(func.count().desc()) + .limit(20) + ) + ).all() + + browsers = ( + await session.execute( + select(PageView.browser, func.count().label("n")) + .group_by(PageView.browser) + .order_by(func.count().desc()) + ) + ).all() + os_rows = ( + await session.execute( + select(PageView.os, func.count().label("n")) + .group_by(PageView.os) + .order_by(func.count().desc()) + ) + ).all() + device_rows = ( + await session.execute( + select(PageView.device_type, func.count().label("n")) + .group_by(PageView.device_type) + .order_by(func.count().desc()) + ) + ).all() + country_rows = ( + await session.execute( + select(PageView.country, func.count().label("n")) + .where(PageView.country != "") + .group_by(PageView.country) + .order_by(func.count().desc()) + ) + ).all() + + pageviews_per_day_by_device = await _pageviews_by_day_and(session, PageView.device_type) + pageviews_per_day_by_browser = await _pageviews_by_day_and(session, PageView.browser) + pageviews_per_day_by_os = await _pageviews_by_day_and(session, PageView.os) + + return { + "games_per_day": {str(r.day): r.n for r in game_rows}, + "players_per_day": {str(r.day): r.n for r in player_rows}, + "completion_rate": finished / total if total else None, + "avg_game_duration_minutes": (avg_duration / 60) if avg_duration else None, + "total_players": total_players, + "peak_hours": {int(r.h): r.n for r in peak_hours}, + "rounds_per_day": {str(r.day): r.n for r in rounds_rows}, + "pageviews_per_day": {str(r.day): r.n for r in pageview_rows}, + "pageviews_per_day_by_device": pageviews_per_day_by_device, + "pageviews_per_day_by_browser": pageviews_per_day_by_browser, + "pageviews_per_day_by_os": pageviews_per_day_by_os, + "top_referrers": {r.referrer: r.n for r in top_referrers}, + "top_paths": {r.path: r.n for r in top_paths}, + "browsers": {r.browser: r.n for r in browsers}, + "operating_systems": {r.os: r.n for r in os_rows}, + "device_types": {r.device_type: r.n for r in device_rows}, + "countries": {r.country: r.n for r in country_rows}, + } diff --git a/db/models.py b/db/models.py index 8a313bf..fb96a57 100644 --- a/db/models.py +++ b/db/models.py @@ -1,6 +1,6 @@ -"""ORM modely: Player, Game, Guess. +"""ORM modely: Player, Game, Guess, PageView. -Zamerne minimalne (3 tabulky). `won` sa neuklada -- vyplyva z `points > 0` +`won` sa neuklada -- vyplyva z `points > 0` (trafeny tip = 10 + tip, inak 0; pozri Round.get_points_summary v bridzik.py). """ @@ -83,3 +83,22 @@ class Guess(Base): round_number: Mapped[int] = mapped_column(Integer) guess: Mapped[int] = mapped_column(Integer) points: Mapped[int] = mapped_column(Integer) + + +class PageView(Base): + """Jedna navsteva stranky (pre /track). Bez schema migracii -- create_all only.""" + + __tablename__ = "page_views" + + id: Mapped[int] = mapped_column(primary_key=True) + path: Mapped[str] = mapped_column(String(200)) + referrer: Mapped[str] = mapped_column(String(300), default="") + user_agent: Mapped[str] = mapped_column(String(300), default="") + browser: Mapped[str] = mapped_column(String(40), default="") + os: Mapped[str] = mapped_column(String(40), default="") + device_type: Mapped[str] = mapped_column(String(20), default="") # mobile/tablet/pc/bot + ip: Mapped[str] = mapped_column(String(45), default="") # surova IP (IPv4/IPv6), "" ak nezname + country: Mapped[str] = mapped_column(String(2), default="") # ISO kod z GeoIP, "" ak nerozlusene + created_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now() + ) diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml new file mode 100644 index 0000000..2545df7 --- /dev/null +++ b/docker-compose.prod.yaml @@ -0,0 +1,42 @@ +# Production stack: built images (no bind mounts), nginx serves the built +# frontend + reverse-proxies Socket.IO, Postgres is internal-only. +# +# Usage: +# cp .env.example .env # fill in real secrets +# docker compose -f docker-compose.prod.yaml up -d --build + +services: + db: + image: postgres:18-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - pgdata:/var/lib/postgresql + restart: always + + backend: + build: . + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} + ADMIN_TOKEN: ${ADMIN_TOKEN} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + GEOIP_DB_PATH: /app/geoip/GeoLite2-Country.mmdb + volumes: + - ./geoip:/app/geoip:ro + restart: always + depends_on: + - db + + frontend: + build: ./frontend + environment: + BACKEND_UPSTREAM: backend:5000 + restart: always + depends_on: + - backend + +volumes: + pgdata: diff --git a/docker-compose.yaml b/docker-compose.yaml index 538e1c1..d8780ff 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -20,6 +20,13 @@ services: environment: # Async SQLAlchemy URL -> the Postgres service above (asyncpg driver). DATABASE_URL: postgresql+asyncpg://bridzik:bridzik@db:5432/bridzik + # Shared secret for the self-hosted /api/admin/* stats endpoints. + ADMIN_TOKEN: tajneheslo + # Optional: IP -> country for /api/track. Drop a .mmdb file (GeoLite2 or + # a DB-IP/IP2Location Lite equivalent) at ./geoip/ -- it's already inside + # the ./:/app bind mount below, no extra volume entry needed. Missing + # file -> country is just recorded as "" (see api/stats.py:_country_for_ip). + GEOIP_DB_PATH: /app/geoip/GeoLite2-Country.mmdb ports: - "5000:5000" volumes: diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template new file mode 100644 index 0000000..0f9aee0 --- /dev/null +++ b/frontend/nginx.conf.template @@ -0,0 +1,38 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA: let the router handle unknown paths. + location / { + try_files $uri $uri/ /index.html; + } + + # Socket.IO (websocket + polling) -> backend. + location /socket.io/ { + proxy_pass http://${BACKEND_UPSTREAM}/socket.io/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } + + location /health { + proxy_pass http://${BACKEND_UPSTREAM}/health; + } + + # Pageview beacon + admin stats -> backend. + location /api/ { + proxy_pass http://${BACKEND_UPSTREAM}/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b88ffba..b71db99 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0", + "recharts": "^2.12.7", "socket.io-client": "^4.7.5", "zustand": "^4.5.4" }, @@ -1577,7 +1578,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2697,6 +2697,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3208,6 +3271,15 @@ "node": ">= 6" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -3291,9 +3363,129 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3365,6 +3557,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -3425,6 +3623,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3700,6 +3908,12 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3707,6 +3921,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4211,6 +4434,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4822,6 +5054,12 @@ "dev": true, "license": "MIT" }, + "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/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -4989,7 +5227,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5344,6 +5581,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5409,6 +5663,12 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5451,6 +5711,37 @@ "react-dom": ">=16.8" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -5474,6 +5765,39 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6295,6 +6619,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6622,6 +6952,28 @@ "dev": true, "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index 898bb40..a9e5c6c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,6 +2,7 @@ "name": "bridzik-frontend", "version": "0.1.0", "private": true, + "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", @@ -12,6 +13,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0", + "recharts": "^2.12.7", "socket.io-client": "^4.7.5", "zustand": "^4.5.4" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 81a475e..265aaa6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useNavigationType } from 'react-router-dom'; import { useGameStore } from './store/gameStore'; import { socket, emit } from './lib/socket'; import type { MyPlayer } from './types'; @@ -8,10 +8,13 @@ import Lobby from './pages/Lobby'; import GameTable from './pages/GameTable'; import Auth from './pages/Auth'; import History from './pages/History'; +import AdminLayout from './pages/admin/AdminLayout'; +import AdminStats from './pages/admin/AdminStats'; function AppInner() { const navigate = useNavigate(); const location = useLocation(); + const navigationType = useNavigationType(); const account = useGameStore((s) => s.account); const myPlayer = useGameStore((s) => s.myPlayer); const gameStatus = useGameStore((s) => s.gameStatus); @@ -40,11 +43,15 @@ function AppInner() { location.pathname === '/auth' || location.pathname.startsWith('/game') || location.pathname.startsWith('/lobby'); - const targetRoute = !account - ? '/auth' - : myPlayer - ? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}` - : onGameRoute ? '/' : null; + // /admin is a separate concern gated by its own token, not the player login. + const onAdminRoute = location.pathname.startsWith('/admin'); + const targetRoute = onAdminRoute + ? null + : !account + ? '/auth' + : myPlayer + ? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}` + : onGameRoute ? '/' : null; useEffect(() => { if (!targetRoute) return; @@ -58,6 +65,23 @@ function AppInner() { return () => clearTimeout(t); }, [error, clearError]); + // Fire-and-forget pageview beacon for self-hosted analytics; must never + // affect the app (network errors are swallowed). Skip REPLACE navigations -- + // those are app-internal gate/index redirects (login gate, /admin index -> + // stats), not a page the user actually navigated to, so they'd otherwise + // inflate the count with one extra row per redirect hop. Skip /admin itself + // too -- that's the dashboard viewing its own traffic, not player usage. + useEffect(() => { + if (navigationType === 'REPLACE') return; + if (location.pathname.startsWith('/admin')) return; + fetch('/api/track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: location.pathname, referrer: document.referrer }), + keepalive: true, + }).catch(() => {}); + }, [location.pathname, navigationType]); + return ( <> {error && ( @@ -71,6 +95,10 @@ function AppInner() { } /> } /> } /> + }> + } /> + } /> + } /> diff --git a/frontend/src/pages/admin/AdminLayout.tsx b/frontend/src/pages/admin/AdminLayout.tsx new file mode 100644 index 0000000..562be56 --- /dev/null +++ b/frontend/src/pages/admin/AdminLayout.tsx @@ -0,0 +1,79 @@ +import { createContext, useContext, useState } from 'react'; +import { NavLink, Outlet } from 'react-router-dom'; + +const TOKEN_KEY = 'bridzik_admin_token'; + +const AdminTokenContext = createContext(''); + +/** Admin token, read from the input/sessionStorage owned by AdminLayout. + * Kept separate from the player login -- /admin is gated by ADMIN_TOKEN only. */ +export function useAdminToken(): string { + return useContext(AdminTokenContext); +} + +export default function AdminLayout() { + const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY) ?? ''); + const [draft, setDraft] = useState(''); + + if (!token) { + return ( +
+

Admin

+
{ + e.preventDefault(); + if (!draft.trim()) return; + sessionStorage.setItem(TOKEN_KEY, draft.trim()); + setToken(draft.trim()); + }} + className="flex flex-col gap-3" + > + setDraft(e.target.value)} + placeholder="Admin token" + className="bg-header border border-[#142018] rounded-lg px-3 py-2 text-green-score placeholder:text-green-dim outline-none focus:border-gold" + autoFocus + /> + +
+
+ ); + } + + return ( + +
+
+

Admin

+ +
+ + +
+
+ ); +} diff --git a/frontend/src/pages/admin/AdminStats.tsx b/frontend/src/pages/admin/AdminStats.tsx new file mode 100644 index 0000000..12c1a7c --- /dev/null +++ b/frontend/src/pages/admin/AdminStats.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from 'react'; +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { useIsDesktop } from '../../lib/useIsDesktop'; +import { useAdminToken } from './AdminLayout'; +import PageviewsChart from './PageviewsChart'; + +interface DailyStats { + games_per_day: Record; + players_per_day: Record; + completion_rate: number | null; + avg_game_duration_minutes: number | null; + total_players: number; + peak_hours: Record; + rounds_per_day: Record; + pageviews_per_day: Record; + pageviews_per_day_by_device: Record>; + pageviews_per_day_by_browser: Record>; + pageviews_per_day_by_os: Record>; + top_referrers: Record; + top_paths: Record; + browsers: Record; + operating_systems: Record; + device_types: Record; + countries: Record; +} + +function toSeries(byDay: Record) { + return Object.entries(byDay) + .map(([day, n]) => ({ day, n })) + .sort((a, b) => a.day.localeCompare(b.day)); +} + +function SummaryCard({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function TimeSeriesChart({ title, data, kind = 'line' }: { title: string; data: Record; kind?: 'line' | 'bar' }) { + const series = toSeries(data); + return ( +
+

{title}

+ + {kind === 'line' ? ( + + + + + + + + ) : ( + + + + + + + + )} + +
+ ); +} + +function BreakdownTable({ title, data }: { title: string; data: Record }) { + const rows = Object.entries(data).sort((a, b) => b[1] - a[1]); + return ( +
+

{title}

+ {rows.length === 0 ? ( +

Žiadne dáta.

+ ) : ( + + + {rows.map(([key, n]) => ( + + + + + ))} + +
{key || '—'}{n}
+ )} +
+ ); +} + +export default function AdminStats() { + const token = useAdminToken(); + const desktop = useIsDesktop(); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + fetch('/api/admin/stats', { headers: { Authorization: `Bearer ${token}` } }) + .then((res) => { + if (res.status === 403) throw new Error('Neplatný token.'); + if (!res.ok) throw new Error('Chyba pri načítaní štatistík.'); + return res.json(); + }) + .then((json) => { + if (!cancelled) setData(json); + }) + .catch((e) => { + if (!cancelled) setError(e.message); + }); + return () => { + cancelled = true; + }; + }, [token]); + + if (error) return

{error}

; + if (!data) return

Načítavam...

; + + return ( +
+
+ + + +
+ +
+ + + + +
+ + + +
+ + + + + + +
+
+ ); +} diff --git a/frontend/src/pages/admin/PageviewsChart.tsx b/frontend/src/pages/admin/PageviewsChart.tsx new file mode 100644 index 0000000..438cd9b --- /dev/null +++ b/frontend/src/pages/admin/PageviewsChart.tsx @@ -0,0 +1,92 @@ +import { useMemo, useState } from 'react'; +import { + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +type Dimension = 'total' | 'device' | 'browser' | 'os'; + +const DIMENSION_LABELS: Record = { + total: 'Spolu', + device: 'Zariadenie', + browser: 'Prehliadač', + os: 'OS', +}; + +// Cycled per category line -- theme golds/creams, enough spread to stay +// distinguishable across the handful of browsers/OSes/device types we expect. +const PALETTE = ['#c9a84c', '#d8cba6', '#9c906c', '#f0d060', '#c2b58c', '#8a8064', '#7a6e4a']; + +interface Props { + total: Record; + byDevice: Record>; + byBrowser: Record>; + byOs: Record>; +} + +export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Props) { + const [dimension, setDimension] = useState('total'); + + const { rows, categories } = useMemo(() => { + if (dimension === 'total') { + const days = Object.keys(total).sort(); + return { rows: days.map((day) => ({ day, n: total[day] })), categories: ['n'] }; + } + const byDay = dimension === 'device' ? byDevice : dimension === 'browser' ? byBrowser : byOs; + const days = Object.keys(byDay).sort(); + const categories = [...new Set(days.flatMap((d) => Object.keys(byDay[d])))].sort(); + const rows = days.map((day) => { + const row: Record = { day }; + for (const c of categories) row[c] = byDay[day][c] ?? 0; + return row; + }); + return { rows, categories }; + }, [dimension, total, byDevice, byBrowser, byOs]); + + return ( +
+
+

Návštevy za deň

+
+ {(Object.keys(DIMENSION_LABELS) as Dimension[]).map((d) => ( + + ))} +
+
+ + + + + + + {dimension !== 'total' && } + {categories.map((c, i) => ( + + ))} + + +
+ ); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2790480..2776e46 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -40,6 +40,12 @@ export default defineConfig({ ws: true, changeOrigin: true, }, + '/api': { + // Backend HTTP endpoints (/api/track, /api/admin/stats) -- kept under + // /api so they never collide with client-side routes like /admin/stats. + target: process.env.VITE_BACKEND_URL ?? 'http://localhost:5000', + changeOrigin: true, + }, }, }, }); diff --git a/requirements.txt b/requirements.txt index 6226888..cc4d18b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,8 @@ SQLAlchemy[asyncio]>=2.0 aiosqlite>=0.20 # dev / default DATABASE_URL asyncpg>=0.29 # production (PostgreSQL) pyotp>=2.9 # TOTP login +user-agents>=2.2 # parse User-Agent for /track (self-hosted analytics) +geoip2>=4.8 # resolve IP -> country from a local .mmdb file (no external calls) # NOTE: the legacy Flask/Jinja HTTP UI (api/routes.py, api/forms.py, # api/templates/) is dormant and its dependencies (Flask, Flask-WTF, etc.) diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..77ca094 --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,180 @@ +"""Testy self-hosted usage analytics (api/stats.py). + +Bezia na docasnom SQLite subore, rovnaky pattern ako tests/test_history.py. +""" + +import asyncio +import os +import tempfile +import unittest +import uuid +from types import SimpleNamespace + +# Nastav DB/ENCRYPTION_KEY PRED importom db/api modulov -- engine sa vytvara pri importe. +_DB_FILE = os.path.join(tempfile.gettempdir(), f"bridzik_test_{uuid.uuid4().hex}.db") +os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///" + _DB_FILE.replace("\\", "/") + +from cryptography.fernet import Fernet # noqa: E402 + +os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode()) + +import pyotp # noqa: E402 + +import api as api_module # noqa: E402 +from api import auth, history, stats # noqa: E402 +from db.db import init_db # noqa: E402 + +CHROME_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0 Safari/537.36" +) + + +def run(coro): + return asyncio.run(coro) + + +def make_core(completed=True): + """Stub jednej hry s jednym dohratym kolom (seria 0, kolo 0).""" + rnd = SimpleNamespace( + round_number=0, + guesses={0: 2, 1: 1, 2: 0, 3: 1}, + is_completed=lambda: True, + get_points_summary=lambda: [12, 0, 10, 11], + ) + series = SimpleNamespace( + series_number=0, rounds=[rnd], get_last_round=lambda: rnd + ) + return SimpleNamespace(series=[series], is_completed=lambda: completed) + + +class StatsCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + run(init_db()) + + def _make_players(self, n=4): + ids = [] + for _ in range(n): + username = "u_" + uuid.uuid4().hex[:8] + data = run(auth.register_account(username)) + ident = run(auth.login(username, pyotp.TOTP(data["secret"]).now())) + ids.append(ident["player_id"]) + return ids + + def test_record_pageview_parses_user_agent(self): + run(stats.record_pageview( + path="/", referrer="https://example.com", user_agent=CHROME_UA, ip="203.0.113.5", + )) + + data = run(stats.get_daily_stats()) + self.assertGreaterEqual(sum(data["pageviews_per_day"].values()), 1) + self.assertIn("Chrome", data["browsers"]) + self.assertIn("Windows", data["operating_systems"]) + self.assertGreaterEqual(data["device_types"].get("pc", 0), 1) + self.assertGreaterEqual(data["top_referrers"].get("https://example.com", 0), 1) + self.assertGreaterEqual(data["top_paths"].get("/", 0), 1) + + # Bez GEOIP_DB_PATH (v testoch nenastaveny) sa krajina jednoducho nerozlusi, + # ale IP sa uz do page_views ulozi. + async def _last_ip(): + from sqlalchemy import select as sa_select + + from db.db import async_session + from db.models import PageView + + async with async_session() as session: + row = ( + await session.execute( + sa_select(PageView).order_by(PageView.id.desc()).limit(1) + ) + ).scalar_one() + return row.ip, row.country + + ip, country = run(_last_ip()) + self.assertEqual(ip, "203.0.113.5") + self.assertEqual(country, "") + + def test_country_for_ip_without_geoip_db_path_returns_empty(self): + os.environ.pop("GEOIP_DB_PATH", None) + stats._geoip_reader = None + stats._geoip_load_attempted = False + self.assertEqual(stats._country_for_ip("203.0.113.5"), "") + self.assertEqual(stats._country_for_ip(""), "") + + def test_pageviews_per_day_breakdown_by_dimension(self): + run(stats.record_pageview(path="/", referrer="", user_agent=CHROME_UA)) + data = run(stats.get_daily_stats()) + today = next(iter(data["pageviews_per_day_by_device"])) + self.assertGreaterEqual(data["pageviews_per_day_by_device"][today].get("pc", 0), 1) + self.assertIn("Chrome", data["pageviews_per_day_by_browser"][today]) + self.assertIn("Windows", data["pageviews_per_day_by_os"][today]) + + def test_daily_stats_reflect_games_and_players(self): + before = run(stats.get_daily_stats()) + base_total_players = before["total_players"] + + ids = self._make_players() + gid = str(uuid.uuid4()) + run(history.record_game_started(gid, "Test", ids)) + run(history.record_completed_rounds(gid, make_core())) + + data = run(stats.get_daily_stats()) + self.assertEqual(data["total_players"], base_total_players + 4) + self.assertEqual(data["completion_rate"], 1.0) + self.assertGreaterEqual(sum(data["games_per_day"].values()), 1) + self.assertGreaterEqual(sum(data["rounds_per_day"].values()), 4) + + def test_empty_referrer_excluded_from_top_referrers(self): + run(stats.record_pageview(path="/lobby", referrer="", user_agent=CHROME_UA)) + data = run(stats.get_daily_stats()) + self.assertNotIn("", data["top_referrers"]) + + +def _scope(ip="1.2.3.4", token=None): + headers = [(b"x-forwarded-for", ip.encode())] + if token is not None: + headers.append((b"authorization", f"Bearer {token}".encode())) + return {"headers": headers, "client": ("9.9.9.9", 0)} + + +class AdminAuthCase(unittest.TestCase): + def setUp(self): + os.environ["ADMIN_TOKEN"] = "secret-token" + api_module._admin_failed_attempts.clear() + + def tearDown(self): + os.environ.pop("ADMIN_TOKEN", None) + + def test_client_ip_prefers_x_forwarded_for(self): + self.assertEqual(api_module._client_ip(_scope(ip="5.6.7.8")), "5.6.7.8") + self.assertEqual(api_module._client_ip({"headers": [], "client": ("9.9.9.9", 0)}), "9.9.9.9") + + def test_wrong_token_rejected_and_locked_out_after_repeated_failures(self): + ip = "1.1.1.1" + self.assertFalse(api_module._admin_authorized(_scope(ip=ip, token="wrong"))) + + for _ in range(api_module._ADMIN_ATTEMPT_LIMIT): + self.assertFalse(api_module._admin_locked_out(ip)) + api_module._register_admin_failure(ip) + + self.assertTrue(api_module._admin_locked_out(ip)) + # Iny IP nie je zamknuty + self.assertFalse(api_module._admin_locked_out("2.2.2.2")) + + def test_correct_token_authorized(self): + self.assertTrue(api_module._admin_authorized(_scope(token="secret-token"))) + + +def tearDownModule(): + from db.db import engine + + run(engine.dispose()) + try: + os.remove(_DB_FILE) + except OSError: + pass + + +if __name__ == "__main__": + unittest.main()