Author SHA1 Message Date
tim 4f0b706c28 docker-compose pre prod (non final) 2026-07-01 20:05:40 +02:00
timandClaude Sonnet 5 fbe0c3aa18 Add login lockout and encrypt Player.totp_secret/auth_token at rest
Per-username lockout (5 failed TOTP attempts / 5 min) stops account-targeted
brute force regardless of source IP. Player.totp_secret is now Fernet-
encrypted (ENCRYPTION_KEY env, db/crypto.py) instead of stored in plaintext,
and auth_token is stored as a SHA-256 hash rather than the raw session token.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 19:49:56 +02:00
timandClaude Sonnet 5 0845562a21 Add self-hosted usage analytics: pageview tracking + admin stats dashboard
Records pageviews (path, referrer, browser/OS/device, IP, GeoIP country) via
a POST /api/track beacon into a new PageView table, and exposes aggregated
daily/breakdown stats behind a token-gated GET /api/admin/stats endpoint with
brute-force lockout. Frontend gets a /admin dashboard (charts + breakdown
tables) built on recharts, with a switchable per-day pageviews chart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 19:43:10 +02:00
25 changed files with 1611 additions and 33 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.venv
__pycache__
*.pyc
.pytest_cache
.idea
.devcontainer
frontend
*.db
.env
.env.*
docker-compose*.yaml
+25
View File
@@ -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 "".
+4
View File
@@ -6,3 +6,7 @@ __pycache__/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/.vite/ frontend/.vite/
.env
.env.*
!.env.example
geoip/*.mmdb
+2 -1
View File
@@ -51,8 +51,9 @@ Serialization: `Card.JSONEncoder` flattens a `Card` to `{color, value}` name str
Async SQLAlchemy 2.0, independent of Socket.IO (mirrors how the engine is kept clean). Async SQLAlchemy 2.0, independent of Socket.IO (mirrors how the engine is kept clean).
- **`db/db.py`** — async `engine` + `async_sessionmaker`, declarative `Base`, and `init_db()` (`create_all`). Connection string from env **`DATABASE_URL`** (default `sqlite+aiosqlite:///bridzik.db`; Docker sets PostgreSQL via `asyncpg`). There are **no migrations**`create_all` only adds new tables, so a changed column needs a fresh DB. - **`db/db.py`** — async `engine` + `async_sessionmaker`, declarative `Base`, and `init_db()` (`create_all`). Connection string from env **`DATABASE_URL`** (default `sqlite+aiosqlite:///bridzik.db`; Docker sets PostgreSQL via `asyncpg`). There are **no migrations**`create_all` only adds new tables, so a changed column needs a fresh DB.
- **`db/crypto.py`** — encryption/hashing for sensitive `Player` columns. `encrypt`/`decrypt` (Fernet, key from env **`ENCRYPTION_KEY`** — must stay stable, losing it locks out every account) for `totp_secret`; `hash_token` (SHA-256, one-way) for `auth_token`.
- **`db/models.py`** — 3 tables: - **`db/models.py`** — 3 tables:
- **`Player`** — account + auth: `username` (unique login), `totp_secret`, `totp_last_step` (TOTP replay guard), `auth_token` (session token for reconnect). - **`Player`** — account + auth: `username` (unique login), `totp_secret` (Fernet-encrypted at rest via `db/crypto.py`), `totp_last_step` (TOTP replay guard), `auth_token` (session token for reconnect, stored as a SHA-256 hash, not plaintext).
- **`Game`** — one match: `id` (gid), 4 `playerN_id` seats, `name`, `series`/`round` (current position, used for restore), `created_at`, `ended_at`. - **`Game`** — one match: `id` (gid), 4 `playerN_id` seats, `name`, `series`/`round` (current position, used for restore), `created_at`, `ended_at`.
- **`Guess`** — one player's bid+result in a round: `series_number`, `round_number`, `guess`, `points`. `won` is derived (`points > 0`). Unique on (game, series, round, player) → idempotent writes. - **`Guess`** — one player's bid+result in a round: `series_number`, `round_number`, `guess`, `points`. `won` is derived (`points > 0`). Unique on (game, series, round, player) → idempotent writes.
+10 -3
View File
@@ -1,16 +1,23 @@
FROM python:3.14-slim FROM python:3.14-slim
WORKDIR /app WORKDIR /app
# Keeps Python from generating .pyc files and turns off output buffering.
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
COPY requirements.txt requirements.txt COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . . # Backend code only -- the frontend has its own image (frontend/Dockerfile).
COPY bridzik.py app.py ./
COPY api ./api
COPY db ./db
COPY tests ./tests
RUN useradd --create-home --uid 1000 appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 5000 EXPOSE 5000
# Serve the ASGI Socket.IO app with uvicorn. # Serve the ASGI Socket.IO app with uvicorn.
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "5000"] CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "5000"]
+101 -7
View File
@@ -1,6 +1,9 @@
import hmac
import json import json
import os import os
import time
import uuid import uuid
from collections import defaultdict
from json import JSONEncoder from json import JSONEncoder
import socketio import socketio
@@ -8,6 +11,7 @@ import socketio
from bridzik import Bridzik, BridzikException, Card from bridzik import Bridzik, BridzikException, Card
from db.db import init_db from db.db import init_db
from api import auth as auth_module, history from api import auth as auth_module, history
from api import stats as stats_module
from api.auth import AuthError 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): 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": if scope["type"] == "lifespan":
while True: while True:
message = await receive() message = await receive()
@@ -46,12 +137,15 @@ async def _health_app(scope, receive, send):
await send({"type": "lifespan.shutdown.complete"}) await send({"type": "lifespan.shutdown.complete"})
return return
if scope["type"] == "http": if scope["type"] == "http":
ok = scope.get("path", "") in ("/health", "/healthz") path = scope.get("path", "")
status = 200 if ok else 404 method = scope.get("method", "GET")
body = b"ok" if ok else b"not found" if path in ("/health", "/healthz"):
await send({"type": "http.response.start", "status": status, return await _send_text(send, 200, b"ok")
"headers": [(b"content-type", b"text/plain")]}) if path == "/api/track" and method == "POST":
await send({"type": "http.response.body", "body": body}) 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 # Run with: uvicorn api:app --host 0.0.0.0 --port 5000
+41 -7
View File
@@ -6,16 +6,25 @@ Hodnoty sa overuju cez pyotp; replay sa bloku pomocou Player.totp_last_step.
import secrets import secrets
import time import time
from collections import defaultdict
import pyotp import pyotp
from sqlalchemy import select from sqlalchemy import select
from db import crypto
from db.db import async_session from db.db import async_session
from db.models import Player from db.models import Player
ISSUER = "Bridžik" ISSUER = "Bridžik"
TOTP_PERIOD = 30 # sekund -- default pyotp TOTP_PERIOD = 30 # sekund -- default pyotp
# Lockout na neuspesne prihlasovacie pokusy, per username, len v pamati
# procesu (rovnaky pattern ako games/sessions/accounts v api/__init__.py --
# proces je jediny, ziadny Redis).
_LOGIN_ATTEMPT_LIMIT = 5
_LOGIN_ATTEMPT_WINDOW = 300 # sekund
_failed_attempts: dict[str, list[float]] = defaultdict(list)
class AuthError(Exception): class AuthError(Exception):
"""Chyba prihlasenia/registracie (slovenska sprava pre klienta).""" """Chyba prihlasenia/registracie (slovenska sprava pre klienta)."""
@@ -25,6 +34,25 @@ def _new_token() -> str:
return secrets.token_urlsafe(48) return secrets.token_urlsafe(48)
def _check_lockout(username: str) -> None:
cutoff = time.monotonic() - _LOGIN_ATTEMPT_WINDOW
attempts = [t for t in _failed_attempts.get(username, []) if t > cutoff]
if attempts:
_failed_attempts[username] = attempts
else:
_failed_attempts.pop(username, None)
if len(attempts) >= _LOGIN_ATTEMPT_LIMIT:
raise AuthError("Príliš veľa neúspešných pokusov. Skúste to znova o pár minút.")
def _register_failure(username: str) -> None:
_failed_attempts[username].append(time.monotonic())
def _clear_failures(username: str) -> None:
_failed_attempts.pop(username, None)
def _current_step() -> int: def _current_step() -> int:
return int(time.time()) // TOTP_PERIOD return int(time.time()) // TOTP_PERIOD
@@ -34,7 +62,7 @@ def _verify_code(player: Player, code: str) -> None:
Akceptuje +-1 casovy krok (tolerancia hodin) a odmietne uz pouzity krok. Akceptuje +-1 casovy krok (tolerancia hodin) a odmietne uz pouzity krok.
""" """
totp = pyotp.TOTP(player.totp_secret) totp = pyotp.TOTP(crypto.decrypt(player.totp_secret))
current = _current_step() current = _current_step()
for step in (current - 1, current, current + 1): for step in (current - 1, current, current + 1):
if step <= player.totp_last_step: if step <= player.totp_last_step:
@@ -57,7 +85,7 @@ async def register_account(username: str) -> dict:
) )
if existing is not None: if existing is not None:
raise AuthError("Toto meno je už obsadené.") raise AuthError("Toto meno je už obsadené.")
session.add(Player(username=username, totp_secret=secret)) session.add(Player(username=username, totp_secret=crypto.encrypt(secret)))
await session.commit() await session.commit()
otpauth_uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=ISSUER) otpauth_uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=ISSUER)
return {"username": username, "secret": secret, "otpauth_uri": otpauth_uri} return {"username": username, "secret": secret, "otpauth_uri": otpauth_uri}
@@ -74,15 +102,21 @@ async def login(username: str, code: str) -> dict:
async def _verify_and_issue_token(username: str, code: str) -> dict: async def _verify_and_issue_token(username: str, code: str) -> dict:
username = (username or "").strip()
_check_lockout(username)
async with async_session() as session: async with async_session() as session:
player = await session.scalar( player = await session.scalar(select(Player).where(Player.username == username))
select(Player).where(Player.username == (username or "").strip())
)
if player is None: if player is None:
_register_failure(username)
raise AuthError("Účet neexistuje.") raise AuthError("Účet neexistuje.")
try:
_verify_code(player, (code or "").strip()) _verify_code(player, (code or "").strip())
except AuthError:
_register_failure(username)
raise
_clear_failures(username)
token = _new_token() token = _new_token()
player.auth_token = token player.auth_token = crypto.hash_token(token)
await session.commit() await session.commit()
return {"player_id": player.id, "username": player.username, "token": token} return {"player_id": player.id, "username": player.username, "token": token}
@@ -93,7 +127,7 @@ async def player_by_token(token: str) -> dict | None:
return None return None
async with async_session() as session: async with async_session() as session:
player = await session.scalar( player = await session.scalar(
select(Player).where(Player.auth_token == token) select(Player).where(Player.auth_token == crypto.hash_token(token))
) )
if player is None: if player is None:
return None return None
+232
View File
@@ -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},
}
+42
View File
@@ -0,0 +1,42 @@
"""Sifrovanie/hashovanie citlivych stlpcov (Player.totp_secret, Player.auth_token).
totp_secret sa musi dat spatne desifrovat (treba ho na vygenerovanie/overenie
TOTP kodu), preto Fernet -- symetricke sifrovanie s klucom z env ENCRYPTION_KEY.
auth_token sa iba porovnava, nikdy nepotrebujeme povodnu hodnotu spat, preto
staci jednosmerny SHA-256 hash (token ma 384 bitov entropie z
secrets.token_urlsafe(48) v api/auth.py, takze netreba salt/pepper).
"""
import hashlib
import os
from cryptography.fernet import Fernet, InvalidToken
_ENV_VAR = "ENCRYPTION_KEY"
def _fernet() -> Fernet:
key = os.environ.get(_ENV_VAR)
if not key:
raise RuntimeError(
f"{_ENV_VAR} nie je nastaveny. Vygeneruj ho pomocou:\n"
' python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"\n'
"a nastav ako env premennu (drz ho stabilny -- zmena znamena, "
"ze existujuce totp_secret sa uz nedaju desifrovat)."
)
return Fernet(key.encode())
def encrypt(plaintext: str) -> str:
return _fernet().encrypt(plaintext.encode()).decode()
def decrypt(ciphertext: str) -> str:
try:
return _fernet().decrypt(ciphertext.encode()).decode()
except InvalidToken as exc:
raise ValueError("Neplatny alebo poskodeny sifrovany udaj.") from exc
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
+24 -3
View File
@@ -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). (trafeny tip = 10 + tip, inak 0; pozri Round.get_points_summary v bridzik.py).
""" """
@@ -20,10 +20,12 @@ class Player(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(40), unique=True, index=True) username: Mapped[str] = mapped_column(String(40), unique=True, index=True)
totp_secret: Mapped[str] = mapped_column(String(32)) # Sifrovany cez db/crypto.py (Fernet) -- nikdy neuklada plaintext secret.
totp_secret: Mapped[str] = mapped_column(String(255))
# Posledny pouzity TOTP casovy krok -- ochrana proti replay v ramci okna. # Posledny pouzity TOTP casovy krok -- ochrana proti replay v ramci okna.
totp_last_step: Mapped[int] = mapped_column(Integer, default=0) totp_last_step: Mapped[int] = mapped_column(Integer, default=0)
# Session token pre auto-reconnect (poslany v Socket.IO `auth`). # Session token pre auto-reconnect (poslany v Socket.IO `auth`).
# Uklada sa SHA-256 hash (db/crypto.hash_token), nie surovy token.
auth_token: Mapped[str | None] = mapped_column( auth_token: Mapped[str | None] = mapped_column(
String(64), unique=True, nullable=True String(64), unique=True, nullable=True
) )
@@ -83,3 +85,22 @@ class Guess(Base):
round_number: Mapped[int] = mapped_column(Integer) round_number: Mapped[int] = mapped_column(Integer)
guess: Mapped[int] = mapped_column(Integer) guess: Mapped[int] = mapped_column(Integer)
points: 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()
)
+42
View File
@@ -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:
- /home/tim/docker_volumes/bridzik/:/app/geoip:ro
restart: always
depends_on:
- db
frontend:
build: ./frontend
environment:
BACKEND_UPSTREAM: backend:5000
restart: always
depends_on:
- backend
volumes:
pgdata:
+10
View File
@@ -20,6 +20,16 @@ services:
environment: environment:
# Async SQLAlchemy URL -> the Postgres service above (asyncpg driver). # Async SQLAlchemy URL -> the Postgres service above (asyncpg driver).
DATABASE_URL: postgresql+asyncpg://bridzik:bridzik@db:5432/bridzik DATABASE_URL: postgresql+asyncpg://bridzik:bridzik@db:5432/bridzik
# Shared secret for the self-hosted /api/admin/* stats endpoints.
ADMIN_TOKEN: tajneheslo
# Dev-only Fernet key encrypting Player.totp_secret -- fine to hardcode
# here since the dev DB is disposable (docker-compose down -v).
ENCRYPTION_KEY: FAMD5i_Pc-Ursu_Bi49ZYMN2ehhfBkjjehxTOFvNrBU=
# 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: ports:
- "5000:5000" - "5000:5000"
volumes: volumes:
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.git
+20
View File
@@ -0,0 +1,20 @@
# ---- builder: npm ci + vite build -----------------------------------------
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- runtime: static files served by nginx ---------------------------------
FROM nginx:1.27-alpine AS runtime
COPY --from=builder /app/dist /usr/share/nginx/html
# Rendered to /etc/nginx/conf.d/default.conf at container start by the base
# image's docker-entrypoint.sh, substituting ${BACKEND_UPSTREAM} (set via
# compose env, e.g. "backend:5000").
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
EXPOSE 80
+38
View File
@@ -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;
}
}
+355 -3
View File
@@ -12,6 +12,7 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
"recharts": "^2.12.7",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"zustand": "^4.5.4" "zustand": "^4.5.4"
}, },
@@ -1577,7 +1578,6 @@
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -2697,6 +2697,69 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -3208,6 +3271,15 @@
"node": ">= 6" "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": { "node_modules/commander": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -3291,9 +3363,129 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT" "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": { "node_modules/data-view-buffer": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "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": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -3425,6 +3623,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "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" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3707,6 +3921,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fast-glob": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -4211,6 +4434,15 @@
"node": ">= 0.4" "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": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4822,6 +5054,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -4989,7 +5227,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -5344,6 +5581,23 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5409,6 +5663,12 @@
"react": "^18.3.1" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5451,6 +5711,37 @@
"react-dom": ">=16.8" "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": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -5474,6 +5765,39 @@
"node": ">=8.10.0" "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": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6295,6 +6619,12 @@
"node": ">=0.8" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -6622,6 +6952,28 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+2
View File
@@ -2,6 +2,7 @@
"name": "bridzik-frontend", "name": "bridzik-frontend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -12,6 +13,7 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
"recharts": "^2.12.7",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"zustand": "^4.5.4" "zustand": "^4.5.4"
}, },
+30 -2
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react'; 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 { useGameStore } from './store/gameStore';
import { socket, emit } from './lib/socket'; import { socket, emit } from './lib/socket';
import type { MyPlayer } from './types'; import type { MyPlayer } from './types';
@@ -8,10 +8,13 @@ import Lobby from './pages/Lobby';
import GameTable from './pages/GameTable'; import GameTable from './pages/GameTable';
import Auth from './pages/Auth'; import Auth from './pages/Auth';
import History from './pages/History'; import History from './pages/History';
import AdminLayout from './pages/admin/AdminLayout';
import AdminStats from './pages/admin/AdminStats';
function AppInner() { function AppInner() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const navigationType = useNavigationType();
const account = useGameStore((s) => s.account); const account = useGameStore((s) => s.account);
const myPlayer = useGameStore((s) => s.myPlayer); const myPlayer = useGameStore((s) => s.myPlayer);
const gameStatus = useGameStore((s) => s.gameStatus); const gameStatus = useGameStore((s) => s.gameStatus);
@@ -40,7 +43,11 @@ function AppInner() {
location.pathname === '/auth' || location.pathname === '/auth' ||
location.pathname.startsWith('/game') || location.pathname.startsWith('/game') ||
location.pathname.startsWith('/lobby'); location.pathname.startsWith('/lobby');
const targetRoute = !account // /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' ? '/auth'
: myPlayer : myPlayer
? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}` ? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}`
@@ -58,6 +65,23 @@ function AppInner() {
return () => clearTimeout(t); return () => clearTimeout(t);
}, [error, clearError]); }, [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 ( return (
<> <>
{error && ( {error && (
@@ -71,6 +95,10 @@ function AppInner() {
<Route path="/history" element={<History />} /> <Route path="/history" element={<History />} />
<Route path="/lobby/:gid" element={<Lobby />} /> <Route path="/lobby/:gid" element={<Lobby />} />
<Route path="/game/:gid" element={<GameTable />} /> <Route path="/game/:gid" element={<GameTable />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="stats" replace />} />
<Route path="stats" element={<AdminStats />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</> </>
+79
View File
@@ -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<string>('');
/** 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 (
<div className="max-w-sm mx-auto p-4 pt-24 min-h-screen">
<h1 className="font-serif text-2xl text-gold mb-4">Admin</h1>
<form
onSubmit={(e) => {
e.preventDefault();
if (!draft.trim()) return;
sessionStorage.setItem(TOKEN_KEY, draft.trim());
setToken(draft.trim());
}}
className="flex flex-col gap-3"
>
<input
type="password"
value={draft}
onChange={(e) => 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
/>
<button
type="submit"
className="px-4 py-2 rounded-lg font-serif font-semibold bg-gold text-table hover:bg-gold-bright transition-colors"
>
Vstup
</button>
</form>
</div>
);
}
return (
<AdminTokenContext.Provider value={token}>
<div className="max-w-4xl mx-auto p-4 pt-8 min-h-screen">
<div className="flex items-center justify-between mb-6">
<h1 className="font-serif text-2xl text-gold">Admin</h1>
<button
onClick={() => {
sessionStorage.removeItem(TOKEN_KEY);
setToken('');
}}
className="text-sm text-green-dim hover:text-gold"
>
Odhlásiť
</button>
</div>
<nav className="flex gap-4 mb-6 border-b border-gold/[.14] pb-2">
<NavLink
to="stats"
className={({ isActive }) =>
`text-sm ${isActive ? 'text-gold' : 'text-green-dim hover:text-gold'}`
}
>
Štatistiky
</NavLink>
</nav>
<Outlet />
</div>
</AdminTokenContext.Provider>
);
}
+169
View File
@@ -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<string, number>;
players_per_day: Record<string, number>;
completion_rate: number | null;
avg_game_duration_minutes: number | null;
total_players: number;
peak_hours: Record<string, number>;
rounds_per_day: Record<string, number>;
pageviews_per_day: Record<string, number>;
pageviews_per_day_by_device: Record<string, Record<string, number>>;
pageviews_per_day_by_browser: Record<string, Record<string, number>>;
pageviews_per_day_by_os: Record<string, Record<string, number>>;
top_referrers: Record<string, number>;
top_paths: Record<string, number>;
browsers: Record<string, number>;
operating_systems: Record<string, number>;
device_types: Record<string, number>;
countries: Record<string, number>;
}
function toSeries(byDay: Record<string, number>) {
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 (
<div className="bg-header border border-[#142018] rounded-xl px-4 py-3 flex-1 min-w-[140px]">
<p className="text-xs text-green-dim uppercase tracking-wide">{label}</p>
<p className="font-serif text-xl text-gold mt-1">{value}</p>
</div>
);
}
function TimeSeriesChart({ title, data, kind = 'line' }: { title: string; data: Record<string, number>; kind?: 'line' | 'bar' }) {
const series = toSeries(data);
return (
<div className="bg-header border border-[#142018] rounded-xl p-4">
<p className="text-sm text-gold mb-2">{title}</p>
<ResponsiveContainer width="100%" height={200}>
{kind === 'line' ? (
<LineChart data={series}>
<CartesianGrid stroke="#142018" />
<XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} />
<Line type="monotone" dataKey="n" stroke="#c9a84c" strokeWidth={2} dot={false} />
</LineChart>
) : (
<BarChart data={series}>
<CartesianGrid stroke="#142018" />
<XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} />
<Bar dataKey="n" fill="#c9a84c" />
</BarChart>
)}
</ResponsiveContainer>
</div>
);
}
function BreakdownTable({ title, data }: { title: string; data: Record<string, number> }) {
const rows = Object.entries(data).sort((a, b) => b[1] - a[1]);
return (
<div className="bg-header border border-[#142018] rounded-xl p-4">
<p className="text-sm text-gold mb-2">{title}</p>
{rows.length === 0 ? (
<p className="text-xs text-green-dim">Žiadne dáta.</p>
) : (
<table className="w-full text-sm">
<tbody>
{rows.map(([key, n]) => (
<tr key={key} className="border-b border-gold/[.06] last:border-0">
<td className="py-1 text-green-score truncate">{key || '—'}</td>
<td className="py-1 text-right text-gold-dim">{n}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
export default function AdminStats() {
const token = useAdminToken();
const desktop = useIsDesktop();
const [data, setData] = useState<DailyStats | null>(null);
const [error, setError] = useState<string | null>(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 <p className="text-red-400 text-sm">{error}</p>;
if (!data) return <p className="text-green-dim text-sm">Načítavam...</p>;
return (
<div className="flex flex-col gap-4">
<div className={`flex gap-3 ${desktop ? '' : 'flex-wrap'}`}>
<SummaryCard label="Hráči celkom" value={String(data.total_players)} />
<SummaryCard
label="Dokončené hry"
value={data.completion_rate != null ? `${Math.round(data.completion_rate * 100)}%` : '—'}
/>
<SummaryCard
label="Priem. dĺžka hry"
value={data.avg_game_duration_minutes != null ? `${Math.round(data.avg_game_duration_minutes)} min` : '—'}
/>
</div>
<div className={`grid gap-4 ${desktop ? 'grid-cols-2' : 'grid-cols-1'}`}>
<TimeSeriesChart title="Hry za deň" data={data.games_per_day} />
<TimeSeriesChart title="Nové registrácie za deň" data={data.players_per_day} />
<TimeSeriesChart title="Odohrané kolá za deň" data={data.rounds_per_day} />
<TimeSeriesChart title="Hodiny s najvyššou aktivitou" data={data.peak_hours} kind="bar" />
</div>
<PageviewsChart
total={data.pageviews_per_day}
byDevice={data.pageviews_per_day_by_device}
byBrowser={data.pageviews_per_day_by_browser}
byOs={data.pageviews_per_day_by_os}
/>
<div className={`grid gap-4 ${desktop ? 'grid-cols-2' : 'grid-cols-1'}`}>
<BreakdownTable title="Najnavštevovanejšie stránky" data={data.top_paths} />
<BreakdownTable title="Zdroje návštevnosti" data={data.top_referrers} />
<BreakdownTable title="Prehliadače" data={data.browsers} />
<BreakdownTable title="Operačné systémy" data={data.operating_systems} />
<BreakdownTable title="Typ zariadenia" data={data.device_types} />
<BreakdownTable title="Krajiny" data={data.countries} />
</div>
</div>
);
}
@@ -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<Dimension, string> = {
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<string, number>;
byDevice: Record<string, Record<string, number>>;
byBrowser: Record<string, Record<string, number>>;
byOs: Record<string, Record<string, number>>;
}
export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Props) {
const [dimension, setDimension] = useState<Dimension>('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<string, number | string> = { day };
for (const c of categories) row[c] = byDay[day][c] ?? 0;
return row;
});
return { rows, categories };
}, [dimension, total, byDevice, byBrowser, byOs]);
return (
<div className="bg-header border border-[#142018] rounded-xl p-4">
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
<p className="text-sm text-gold">Návštevy za deň</p>
<div className="flex gap-1">
{(Object.keys(DIMENSION_LABELS) as Dimension[]).map((d) => (
<button
key={d}
onClick={() => setDimension(d)}
className={`px-2 py-1 rounded text-xs transition-colors ${
dimension === d ? 'bg-gold text-table' : 'text-green-dim hover:text-gold'
}`}
>
{DIMENSION_LABELS[d]}
</button>
))}
</div>
</div>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={rows}>
<CartesianGrid stroke="#142018" />
<XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} />
{dimension !== 'total' && <Legend wrapperStyle={{ fontSize: 11, color: '#9c906c' }} />}
{categories.map((c, i) => (
<Line
key={c}
type="monotone"
dataKey={c}
name={dimension === 'total' ? 'Návštevy' : c}
stroke={PALETTE[i % PALETTE.length]}
strokeWidth={2}
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
+6
View File
@@ -40,6 +40,12 @@ export default defineConfig({
ws: true, ws: true,
changeOrigin: 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,
},
}, },
}, },
}); });
+3
View File
@@ -8,6 +8,9 @@ SQLAlchemy[asyncio]>=2.0
aiosqlite>=0.20 # dev / default DATABASE_URL aiosqlite>=0.20 # dev / default DATABASE_URL
asyncpg>=0.29 # production (PostgreSQL) asyncpg>=0.29 # production (PostgreSQL)
pyotp>=2.9 # TOTP login pyotp>=2.9 # TOTP login
cryptography>=42 # Fernet encryption for Player.totp_secret at rest
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, # NOTE: the legacy Flask/Jinja HTTP UI (api/routes.py, api/forms.py,
# api/templates/) is dormant and its dependencies (Flask, Flask-WTF, etc.) # api/templates/) is dormant and its dependencies (Flask, Flask-WTF, etc.)
+84 -2
View File
@@ -12,14 +12,21 @@ import unittest
import uuid import uuid
from types import SimpleNamespace from types import SimpleNamespace
# Nastav DB PRED importom db/api modulov -- engine sa vytvara pri importe. # 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") _DB_FILE = os.path.join(tempfile.gettempdir(), f"bridzik_test_{uuid.uuid4().hex}.db")
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///" + _DB_FILE.replace("\\", "/") 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 pyotp # noqa: E402
from sqlalchemy import select # noqa: E402
from api import auth, history # noqa: E402 from api import auth, history # noqa: E402
from db.db import init_db # noqa: E402 from db import crypto # noqa: E402
from db.db import async_session, init_db # noqa: E402
from db.models import Player # noqa: E402
def run(coro): def run(coro):
@@ -76,6 +83,32 @@ class HistoryCase(unittest.TestCase):
with self.assertRaises(auth.AuthError): with self.assertRaises(auth.AuthError):
run(auth.login(username, "000000")) run(auth.login(username, "000000"))
def test_login_lockout_after_repeated_failures(self):
username = "bob_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username))
for _ in range(auth._LOGIN_ATTEMPT_LIMIT):
with self.assertRaises(auth.AuthError):
run(auth.login(username, "000000"))
# Lockout odmietne aj spravny kod, kym neubehne okno
code = pyotp.TOTP(data["secret"]).now()
with self.assertRaises(auth.AuthError):
run(auth.login(username, code))
def test_successful_login_clears_failed_attempts(self):
username = "carol_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username))
for _ in range(auth._LOGIN_ATTEMPT_LIMIT - 1):
with self.assertRaises(auth.AuthError):
run(auth.login(username, "000000"))
code = pyotp.TOTP(data["secret"]).now()
ident = run(auth.login(username, code))
self.assertEqual(ident["username"], username)
self.assertNotIn(username, auth._failed_attempts)
def test_record_rounds_and_idempotency(self): def test_record_rounds_and_idempotency(self):
ids = self._make_players() ids = self._make_players()
gid = str(uuid.uuid4()) gid = str(uuid.uuid4())
@@ -191,6 +224,55 @@ class HistoryCase(unittest.TestCase):
self.assertEqual(mine["my_points"], 12) self.assertEqual(mine["my_points"], 12)
self.assertEqual(len(mine["players"]), 4) self.assertEqual(len(mine["players"]), 4)
def test_totp_secret_stored_encrypted_not_plaintext(self):
username = "dave_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username))
async def _raw_secret():
async with async_session() as session:
return await session.scalar(
select(Player.totp_secret).where(Player.username == username)
)
stored = run(_raw_secret())
self.assertNotEqual(stored, data["secret"])
self.assertEqual(crypto.decrypt(stored), data["secret"])
def test_auth_token_stored_hashed_not_plaintext(self):
username = "erin_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username))
ident = run(auth.login(username, pyotp.TOTP(data["secret"]).now()))
async def _raw_token():
async with async_session() as session:
return await session.scalar(
select(Player.auth_token).where(Player.username == username)
)
stored = run(_raw_token())
self.assertNotEqual(stored, ident["token"])
self.assertEqual(stored, crypto.hash_token(ident["token"]))
class CryptoCase(unittest.TestCase):
def test_encrypt_decrypt_roundtrip(self):
secret = "JBSWY3DPEHPK3PXP"
ciphertext = crypto.encrypt(secret)
self.assertNotEqual(ciphertext, secret)
self.assertEqual(crypto.decrypt(ciphertext), secret)
def test_hash_token_is_deterministic_and_distinct(self):
self.assertEqual(crypto.hash_token("abc"), crypto.hash_token("abc"))
self.assertNotEqual(crypto.hash_token("abc"), crypto.hash_token("abd"))
def test_missing_key_raises(self):
saved = os.environ.pop("ENCRYPTION_KEY")
try:
with self.assertRaises(RuntimeError):
crypto.encrypt("x")
finally:
os.environ["ENCRYPTION_KEY"] = saved
def tearDownModule(): def tearDownModule():
from db.db import engine from db.db import engine
+180
View File
@@ -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()