Add self-hosted usage analytics: pageview tracking + admin stats dashboard

Records pageviews (path, referrer, browser/OS/device, IP, GeoIP country) via
a POST /api/track beacon into a new PageView table, and exposes aggregated
daily/breakdown stats behind a token-gated GET /api/admin/stats endpoint with
brute-force lockout. Frontend gets a /admin dashboard (charts + breakdown
tables) built on recharts, with a switchable per-day pageviews chart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tim
2026-07-01 19:43:10 +02:00
co-authored by Claude Sonnet 5
parent c59dca754f
commit 0845562a21
17 changed files with 1386 additions and 18 deletions
+101 -7
View File
@@ -1,6 +1,9 @@
import hmac
import json
import os
import time
import uuid
from collections import defaultdict
from json import JSONEncoder
import socketio
@@ -8,6 +11,7 @@ import socketio
from bridzik import Bridzik, BridzikException, Card
from db.db import init_db
from api import auth as auth_module, history
from api import stats as stats_module
from api.auth import AuthError
@@ -33,8 +37,95 @@ sio = socketio.AsyncServer(
)
async def _read_body(receive) -> bytes:
body = b""
while True:
message = await receive()
body += message.get("body", b"")
if not message.get("more_body"):
break
return body
async def _send_text(send, status: int, body: bytes, content_type: bytes = b"text/plain"):
await send({"type": "http.response.start", "status": status,
"headers": [(b"content-type", content_type)]})
await send({"type": "http.response.body", "body": body})
def _admin_authorized(scope) -> bool:
token = os.environ.get("ADMIN_TOKEN", "")
if not token:
return False
headers = dict(scope.get("headers") or [])
auth_header = headers.get(b"authorization", b"").decode("utf-8", "ignore")
return hmac.compare_digest(auth_header, f"Bearer {token}")
def _client_ip(scope) -> str:
# Behind nginx (docker-compose.prod.yaml) this is the real client IP --
# the backend port is never published, so X-Forwarded-For can't be spoofed
# by an external caller going around the proxy.
headers = dict(scope.get("headers") or [])
xff = headers.get(b"x-forwarded-for", b"").decode("utf-8", "ignore")
if xff:
return xff.split(",")[0].strip()
client = scope.get("client")
return client[0] if client else "unknown"
# Lockout na neuspesne pokusy o /api/admin/stats, per client IP -- rovnaky
# in-memory pattern ako login lockout v api/auth.py (jeden proces, ziadny Redis).
_ADMIN_ATTEMPT_LIMIT = 5
_ADMIN_ATTEMPT_WINDOW = 300 # sekund
_admin_failed_attempts: dict[str, list[float]] = defaultdict(list)
def _admin_locked_out(ip: str) -> bool:
cutoff = time.monotonic() - _ADMIN_ATTEMPT_WINDOW
attempts = [t for t in _admin_failed_attempts.get(ip, []) if t > cutoff]
if attempts:
_admin_failed_attempts[ip] = attempts
else:
_admin_failed_attempts.pop(ip, None)
return len(attempts) >= _ADMIN_ATTEMPT_LIMIT
def _register_admin_failure(ip: str) -> None:
_admin_failed_attempts[ip].append(time.monotonic())
async def _handle_track(scope, receive, send):
try:
data = json.loads((await _read_body(receive)) or b"{}")
except json.JSONDecodeError:
data = {}
headers = dict(scope.get("headers") or [])
user_agent = headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300]
await stats_module.record_pageview(
path=str(data.get("path", ""))[:200],
referrer=str(data.get("referrer", ""))[:300],
user_agent=user_agent,
ip=_client_ip(scope),
)
await send({"type": "http.response.start", "status": 204, "headers": []})
await send({"type": "http.response.body", "body": b""})
async def _handle_admin_stats(scope, send):
ip = _client_ip(scope)
if _admin_locked_out(ip):
return await _send_text(send, 429, b"too many attempts")
if not _admin_authorized(scope):
_register_admin_failure(ip)
return await _send_text(send, 403, b"forbidden")
data = await stats_module.get_daily_stats()
await _send_text(send, 200, json.dumps(data).encode(), b"application/json")
async def _health_app(scope, receive, send):
"""Minimal ASGI handler for non-socket.io HTTP routes (liveness checks)."""
"""Minimal ASGI handler for non-socket.io HTTP routes (liveness checks,
pageview tracking beacon, admin stats)."""
if scope["type"] == "lifespan":
while True:
message = await receive()
@@ -46,12 +137,15 @@ async def _health_app(scope, receive, send):
await send({"type": "lifespan.shutdown.complete"})
return
if scope["type"] == "http":
ok = scope.get("path", "") in ("/health", "/healthz")
status = 200 if ok else 404
body = b"ok" if ok else b"not found"
await send({"type": "http.response.start", "status": status,
"headers": [(b"content-type", b"text/plain")]})
await send({"type": "http.response.body", "body": body})
path = scope.get("path", "")
method = scope.get("method", "GET")
if path in ("/health", "/healthz"):
return await _send_text(send, 200, b"ok")
if path == "/api/track" and method == "POST":
return await _handle_track(scope, receive, send)
if path == "/api/admin/stats" and method == "GET":
return await _handle_admin_stats(scope, send)
await _send_text(send, 404, b"not found")
# Run with: uvicorn api:app --host 0.0.0.0 --port 5000