Compare commits
3
Commits
c59dca754f
...
4f0b706c28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f0b706c28 | ||
|
|
fbe0c3aa18 | ||
|
|
0845562a21 |
@@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
.idea
|
||||||
|
.devcontainer
|
||||||
|
frontend
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
docker-compose*.yaml
|
||||||
@@ -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 "".
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+42
-8
@@ -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.")
|
||||||
_verify_code(player, (code or "").strip())
|
try:
|
||||||
|
_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
@@ -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},
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
||||||
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+355
-3
@@ -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,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"
|
||||||
},
|
},
|
||||||
|
|||||||
+34
-6
@@ -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,11 +43,15 @@ 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.
|
||||||
? '/auth'
|
const onAdminRoute = location.pathname.startsWith('/admin');
|
||||||
: myPlayer
|
const targetRoute = onAdminRoute
|
||||||
? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}`
|
? null
|
||||||
: onGameRoute ? '/' : null;
|
: !account
|
||||||
|
? '/auth'
|
||||||
|
: myPlayer
|
||||||
|
? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}`
|
||||||
|
: onGameRoute ? '/' : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!targetRoute) return;
|
if (!targetRoute) return;
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user