diff --git a/Dockerfile b/Dockerfile index 9df47396..e8a80c27 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,9 @@ RUN python -m pip install --upgrade pip && \ sqlalchemy \ alembic \ psycopg2-binary \ - httpx + httpx \ + passlib[bcrypt] \ + itsdangerous ## Copy application source code and migration scripts into container COPY static/ /app/static/ diff --git a/db_async.py b/db_async.py index 998d6707..d265036b 100644 --- a/db_async.py +++ b/db_async.py @@ -3,6 +3,7 @@ Defines table schemas via SQLAlchemy Core and provides an initialization function to set up TimescaleDB hypertable. """ + import os import sqlalchemy from datetime import datetime, timedelta, timezone @@ -10,9 +11,12 @@ from databases import Database from sqlalchemy import MetaData, Table, Column, Integer, String, Float, DateTime, text from sqlalchemy import Index, BigInteger, JSON, Boolean, UniqueConstraint from sqlalchemy.sql import func +from passlib.hash import bcrypt # Environment: Postgres/TimescaleDB connection URL -DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth") +DATABASE_URL = os.getenv( + "DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth" +) # Async database client with explicit connection pool configuration and query timeout database = Database(DATABASE_URL, min_size=5, max_size=100, command_timeout=120) # Metadata for SQLAlchemy Core @@ -48,9 +52,9 @@ telemetry_events = Table( ) # Composite index to accelerate Grafana queries filtering by character_name then ordering by timestamp Index( - 'ix_telemetry_events_char_ts', + "ix_telemetry_events_char_ts", telemetry_events.c.character_name, - telemetry_events.c.timestamp + telemetry_events.c.timestamp, ) # Table for persistent total kills per character @@ -149,7 +153,12 @@ server_health_checks = Table( Column("id", Integer, primary_key=True), Column("server_name", String, nullable=False, index=True), Column("server_address", String, nullable=False), - Column("timestamp", DateTime(timezone=True), nullable=False, default=sqlalchemy.func.now()), + Column( + "timestamp", + DateTime(timezone=True), + nullable=False, + default=sqlalchemy.func.now(), + ), Column("status", String(10), nullable=False), # 'up' or 'down' Column("latency_ms", Float, nullable=True), Column("player_count", Integer, nullable=True), @@ -171,16 +180,18 @@ server_status = Table( # Index for efficient server health check queries Index( - 'ix_server_health_checks_name_ts', + "ix_server_health_checks_name_ts", server_health_checks.c.server_name, - server_health_checks.c.timestamp.desc() + server_health_checks.c.timestamp.desc(), ) character_stats = Table( "character_stats", metadata, Column("character_name", String, primary_key=True, nullable=False), - Column("timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()), + Column( + "timestamp", DateTime(timezone=True), nullable=False, server_default=func.now() + ), Column("level", Integer, nullable=True), Column("total_xp", BigInteger, nullable=True), Column("unassigned_xp", BigInteger, nullable=True), @@ -190,6 +201,20 @@ character_stats = Table( Column("stats_data", JSON, nullable=False), ) +# User accounts for app-level authentication +users = Table( + "users", + metadata, + Column("id", Integer, primary_key=True), + Column("username", String, nullable=False, unique=True), + Column("password_hash", String, nullable=False), + Column("is_admin", Boolean, nullable=False, default=False), + Column( + "created_at", DateTime(timezone=True), nullable=False, server_default=func.now() + ), +) + + async def init_db_async(): """Initialize PostgreSQL/TimescaleDB schema and hypertable. @@ -212,10 +237,12 @@ async def init_db_async(): print(f"Warning: failed to create extension timescaledb: {e}") # Convert to hypertable, migrating existing data and skipping default index creation try: - conn.execute(text( - "SELECT create_hypertable('telemetry_events', 'timestamp', " - "if_not_exists => true, migrate_data => true, create_default_indexes => false)" - )) + conn.execute( + text( + "SELECT create_hypertable('telemetry_events', 'timestamp', " + "if_not_exists => true, migrate_data => true, create_default_indexes => false)" + ) + ) except Exception as e: print(f"Warning: failed to create hypertable telemetry_events: {e}") except Exception as e: @@ -223,44 +250,56 @@ async def init_db_async(): # Ensure composite index exists for efficient time-series queries by character try: with engine.connect() as conn: - conn.execute(text( - "CREATE INDEX IF NOT EXISTS ix_telemetry_events_char_ts " - "ON telemetry_events (character_name, timestamp)" - )) + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_telemetry_events_char_ts " + "ON telemetry_events (character_name, timestamp)" + ) + ) except Exception as e: - print(f"Warning: failed to create composite index ix_telemetry_events_char_ts: {e}") + print( + f"Warning: failed to create composite index ix_telemetry_events_char_ts: {e}" + ) # Add retention and compression policies on the hypertable try: with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: # Retain only recent data (default 7 days or override via DB_RETENTION_DAYS) - days = int(os.getenv('DB_RETENTION_DAYS', '7')) - conn.execute(text( - f"SELECT add_retention_policy('telemetry_events', INTERVAL '{days} days')" - )) + days = int(os.getenv("DB_RETENTION_DAYS", "7")) + conn.execute( + text( + f"SELECT add_retention_policy('telemetry_events', INTERVAL '{days} days')" + ) + ) # Compress chunks older than 1 day - conn.execute(text( - "SELECT add_compression_policy('telemetry_events', INTERVAL '1 day')" - )) + conn.execute( + text( + "SELECT add_compression_policy('telemetry_events', INTERVAL '1 day')" + ) + ) except Exception as e: print(f"Warning: failed to set retention/compression policies: {e}") - + # Create unique constraint on rounded portal coordinates try: with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: # Drop old portal_discoveries table if it exists conn.execute(text("DROP TABLE IF EXISTS portal_discoveries CASCADE")) - + # Create unique constraint on rounded coordinates for the new portals table - conn.execute(text( - """CREATE UNIQUE INDEX IF NOT EXISTS unique_portal_coords + conn.execute( + text( + """CREATE UNIQUE INDEX IF NOT EXISTS unique_portal_coords ON portals (ROUND(ns::numeric, 2), ROUND(ew::numeric, 2))""" - )) - + ) + ) + # Create index on coordinates for efficient lookups - conn.execute(text( - "CREATE INDEX IF NOT EXISTS idx_portals_coords ON portals (ns, ew)" - )) - + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_portals_coords ON portals (ns, ew)" + ) + ) + print("Portal table indexes and constraints created successfully") except Exception as e: print(f"Warning: failed to create portal table constraints: {e}") @@ -268,7 +307,8 @@ async def init_db_async(): # Ensure character_stats table exists with JSONB column type try: with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: - conn.execute(text(""" + conn.execute( + text(""" CREATE TABLE IF NOT EXISTS character_stats ( character_name VARCHAR(255) PRIMARY KEY, timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -280,25 +320,60 @@ async def init_db_async(): deaths INTEGER, stats_data JSONB NOT NULL ) - """)) + """) + ) print("character_stats table created/verified successfully") except Exception as e: print(f"Warning: failed to create character_stats table: {e}") + async def cleanup_old_portals(): """Clean up portals older than 1 hour.""" try: cutoff_time = datetime.now(timezone.utc) - timedelta(hours=1) - + # Delete old portals result = await database.execute( "DELETE FROM portals WHERE discovered_at < :cutoff_time", - {"cutoff_time": cutoff_time} + {"cutoff_time": cutoff_time}, ) - + print(f"Cleaned up {result} portals older than 1 hour") return result - + except Exception as e: print(f"Warning: failed to cleanup old portals: {e}") - return 0 \ No newline at end of file + return 0 + + +async def seed_users(): + """Seed default users if the users table is empty.""" + try: + count = await database.fetch_val("SELECT COUNT(*) FROM users") + if count > 0: + print(f"Users table already has {count} users, skipping seed") + return + + default_users = [ + {"username": "erik", "password": "erik123", "is_admin": True}, + {"username": "alex", "password": "AlexGillar100Killar", "is_admin": False}, + { + "username": "lundberg", + "password": "JohanGillar100Kvinnor", + "is_admin": False, + }, + ] + for u in default_users: + pw_hash = bcrypt.hash(u["password"]) + await database.execute( + "INSERT INTO users (username, password_hash, is_admin) VALUES (:username, :password_hash, :is_admin)", + { + "username": u["username"], + "password_hash": pw_hash, + "is_admin": u["is_admin"], + }, + ) + role = "admin" if u["is_admin"] else "user" + print(f"Seeded {role} user: {u['username']}") + except Exception as e: + print(f"Warning: failed to seed users: {e}") diff --git a/docker-compose.yml b/docker-compose.yml index 1f04d940..49ac3a15 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: DB_MAX_SQL_VARIABLES: "${DB_MAX_SQL_VARIABLES}" DB_WAL_AUTOCHECKPOINT_PAGES: "${DB_WAL_AUTOCHECKPOINT_PAGES}" SHARED_SECRET: "${SHARED_SECRET}" + SECRET_KEY: "${SECRET_KEY}" LOG_LEVEL: "DEBUG" INVENTORY_SERVICE_URL: "http://inventory-service:8000" restart: unless-stopped diff --git a/main.py b/main.py index 17494121..5f451015 100644 --- a/main.py +++ b/main.py @@ -28,13 +28,22 @@ from fastapi import ( WebSocketDisconnect, Request, ) -from fastapi.responses import JSONResponse, Response, StreamingResponse +from fastapi.responses import ( + JSONResponse, + Response, + StreamingResponse, + HTMLResponse, + RedirectResponse, +) from fastapi.routing import APIRoute from fastapi.staticfiles import StaticFiles from fastapi.encoders import jsonable_encoder from pydantic import BaseModel from typing import Optional +from starlette.middleware.base import BaseHTTPMiddleware import httpx +from passlib.hash import bcrypt +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired # Async database support from sqlalchemy.dialects.postgresql import insert as pg_insert @@ -51,8 +60,10 @@ from db_async import ( portals, server_health_checks, server_status, + users, init_db_async, cleanup_old_portals, + seed_users, ) import asyncio @@ -987,11 +998,72 @@ dungeon_map_cache: Dict[str, dict] = {} # landblock hex string -> dungeon map d # Shared secret used to authenticate plugin WebSocket connections (override for production) SHARED_SECRET = "your_shared_secret" +# Secret key for signing session cookies (override via SECRET_KEY env var) +SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production-please") +SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days in seconds +_serializer = URLSafeTimedSerializer(SECRET_KEY) + # LOG_FILE = "telemetry_log.jsonl" # ------------------------------------------------------------------ ACTIVE_WINDOW = timedelta( seconds=30 -) # Time window defining “online” players (last 30 seconds) +) # Time window defining "online" players (last 30 seconds) + + +# ─── Session helpers ───────────────────────────────────────────── +def create_session_cookie(username: str, is_admin: bool) -> str: + """Create a signed session token.""" + return _serializer.dumps({"u": username, "a": is_admin}) + + +def verify_session_cookie(token: str) -> dict | None: + """Verify and decode a session token. Returns None if invalid/expired.""" + try: + data = _serializer.loads(token, max_age=SESSION_MAX_AGE) + return {"username": data["u"], "is_admin": data["a"]} + except (BadSignature, SignatureExpired, KeyError): + return None + + +# Paths that don't require authentication +_PUBLIC_PATHS = {"/login", "/logout"} +_PUBLIC_PREFIXES = ("/ws/position",) # Plugin WS uses X-Plugin-Secret + + +class AuthMiddleware(BaseHTTPMiddleware): + """Redirect unauthenticated requests to /login.""" + + async def dispatch(self, request: Request, call_next): + path = request.url.path + + # Always allow public paths + if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PREFIXES): + return await call_next(request) + + # Allow login page static assets + if path == "/login.html" or path == "/login-style.css": + return await call_next(request) + + # WebSocket upgrade for /ws/live — allow (browser WS, read-only) + if path.startswith("/ws/live"): + return await call_next(request) + + # Check session cookie + token = request.cookies.get("session") + if token: + user = verify_session_cookie(token) + if user: + request.state.user = user + return await call_next(request) + + # Not authenticated — redirect browser, reject API + if "text/html" in request.headers.get("accept", ""): + return RedirectResponse("/login", status_code=302) + return JSONResponse({"detail": "Not authenticated"}, status_code=401) + + +app.add_middleware(AuthMiddleware) + """ Data models for plugin events: @@ -1159,6 +1231,8 @@ async def on_startup(): logger.info( "Background cache refresh, server monitoring, and connection cleanup tasks started" ) + # Seed default users on first run + await seed_users() @app.on_event("shutdown") @@ -1212,6 +1286,175 @@ async def on_shutdown(): await database.disconnect() +# ─── Authentication endpoints ──────────────────────────────────── + + +@app.get("/login") +async def login_page(): + """Serve the login page.""" + login_html = Path("static/login.html") + if login_html.exists(): + return HTMLResponse(login_html.read_text()) + return HTMLResponse("

Login page not found

", status_code=500) + + +@app.post("/login") +async def login(request: Request): + """Authenticate user and set session cookie.""" + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid request body") + username = body.get("username", "").strip().lower() + password = body.get("password", "") + if not username or not password: + raise HTTPException(status_code=400, detail="Username and password required") + + row = await database.fetch_one( + "SELECT id, username, password_hash, is_admin FROM users WHERE LOWER(username) = :username", + {"username": username}, + ) + if not row or not bcrypt.verify(password, row["password_hash"]): + raise HTTPException(status_code=401, detail="Invalid username or password") + + token = create_session_cookie(row["username"], row["is_admin"]) + response = JSONResponse( + {"ok": True, "username": row["username"], "is_admin": row["is_admin"]} + ) + response.set_cookie( + "session", + token, + max_age=SESSION_MAX_AGE, + httponly=True, + samesite="lax", + secure=True, + ) + return response + + +@app.get("/logout") +async def logout(): + """Clear session cookie and redirect to login.""" + response = RedirectResponse("/login", status_code=302) + response.delete_cookie("session") + return response + + +@app.get("/me") +async def me(request: Request): + """Return current user info from session.""" + user = getattr(request.state, "user", None) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + return {"username": user["username"], "is_admin": user["is_admin"]} + + +# ─── Admin user management ─────────────────────────────────────── + + +def _require_admin(request: Request): + """Raise 403 if current user is not admin.""" + user = getattr(request.state, "user", None) + if not user or not user.get("is_admin"): + raise HTTPException(status_code=403, detail="Admin access required") + + +@app.get("/admin/users") +async def admin_page(request: Request): + """Serve the admin user management page.""" + _require_admin(request) + admin_html = Path("static/admin.html") + if admin_html.exists(): + return HTMLResponse(admin_html.read_text()) + return HTMLResponse("

Admin page not found

", status_code=500) + + +@app.get("/api-admin/users") +async def list_users(request: Request): + """List all users (admin only).""" + _require_admin(request) + rows = await database.fetch_all( + "SELECT id, username, is_admin, created_at FROM users ORDER BY id" + ) + return {"users": [dict(r) for r in rows]} + + +@app.post("/api-admin/users") +async def create_user(request: Request): + """Create a new user (admin only).""" + _require_admin(request) + body = await request.json() + username = body.get("username", "").strip() + password = body.get("password", "") + is_admin = bool(body.get("is_admin", False)) + if not username or not password: + raise HTTPException(status_code=400, detail="Username and password required") + if len(password) < 4: + raise HTTPException( + status_code=400, detail="Password must be at least 4 characters" + ) + + existing = await database.fetch_one( + "SELECT id FROM users WHERE LOWER(username) = :username", + {"username": username.lower()}, + ) + if existing: + raise HTTPException(status_code=409, detail="Username already exists") + + pw_hash = bcrypt.hash(password) + await database.execute( + "INSERT INTO users (username, password_hash, is_admin) VALUES (:username, :password_hash, :is_admin)", + {"username": username, "password_hash": pw_hash, "is_admin": is_admin}, + ) + return {"ok": True, "username": username} + + +@app.delete("/api-admin/users/{user_id}") +async def delete_user(user_id: int, request: Request): + """Delete a user (admin only). Cannot delete yourself.""" + _require_admin(request) + current_user = request.state.user["username"] + row = await database.fetch_one( + "SELECT username FROM users WHERE id = :id", {"id": user_id} + ) + if not row: + raise HTTPException(status_code=404, detail="User not found") + if row["username"].lower() == current_user.lower(): + raise HTTPException(status_code=400, detail="Cannot delete yourself") + await database.execute("DELETE FROM users WHERE id = :id", {"id": user_id}) + return {"ok": True} + + +@app.patch("/api-admin/users/{user_id}") +async def update_user(user_id: int, request: Request): + """Update user (admin only). Supports password reset and admin toggle.""" + _require_admin(request) + body = await request.json() + row = await database.fetch_one( + "SELECT id, username FROM users WHERE id = :id", {"id": user_id} + ) + if not row: + raise HTTPException(status_code=404, detail="User not found") + + if "password" in body: + password = body["password"] + if len(password) < 4: + raise HTTPException( + status_code=400, detail="Password must be at least 4 characters" + ) + pw_hash = bcrypt.hash(password) + await database.execute( + "UPDATE users SET password_hash = :pw WHERE id = :id", + {"pw": pw_hash, "id": user_id}, + ) + if "is_admin" in body: + await database.execute( + "UPDATE users SET is_admin = :admin WHERE id = :id", + {"admin": bool(body["is_admin"]), "id": user_id}, + ) + return {"ok": True} + + # ------------------------ GET ----------------------------------- @app.get("/debug") def debug(): @@ -1358,15 +1601,16 @@ async def get_issues(): @app.post("/issues") -async def add_issue(issue: dict): - """Add a new issue.""" +async def add_issue(request: Request, issue: dict): + """Add a new issue. Author from session.""" + user = getattr(request.state, "user", {}) issues = _load_issues() new_issue = { "id": uuid.uuid4().hex[:8], "title": issue.get("title", "").strip(), "description": issue.get("description", "").strip(), "category": issue.get("category", "other"), - "author": issue.get("author", "Anonymous").strip(), + "author": user.get("username", "Anonymous"), "created": datetime.utcnow().isoformat(), "resolved": False, "comments": [], @@ -1405,8 +1649,9 @@ async def update_issue(issue_id: str, update: dict): @app.post("/issues/{issue_id}/comments") -async def add_comment(issue_id: str, comment: dict): - """Add a comment to an issue.""" +async def add_comment(issue_id: str, request: Request, comment: dict): + """Add a comment to an issue. Author from session.""" + user = getattr(request.state, "user", {}) issues = _load_issues() found = None for i in issues: @@ -1420,7 +1665,7 @@ async def add_comment(issue_id: str, comment: dict): raise HTTPException(status_code=400, detail="Comment text is required") new_comment = { "id": uuid.uuid4().hex[:8], - "author": comment.get("author", "Anonymous").strip(), + "author": user.get("username", "Anonymous"), "text": text, "created": datetime.utcnow().isoformat(), } diff --git a/static/admin.html b/static/admin.html new file mode 100644 index 00000000..ca09fa9c --- /dev/null +++ b/static/admin.html @@ -0,0 +1,268 @@ + + + + + Dereth Tracker - Admin + + + +
+ ← Back to Tracker +

User Management

+ + + +
UsernameRoleCreatedActions
Loading...
+ +
+

Add New User

+
+ + + + +
+
+
+
+
+ + + + diff --git a/static/index.html b/static/index.html index 1615513d..365e2776 100644 --- a/static/index.html +++ b/static/index.html @@ -113,6 +113,13 @@ + + + diff --git a/static/login.html b/static/login.html new file mode 100644 index 00000000..5930e54f --- /dev/null +++ b/static/login.html @@ -0,0 +1,169 @@ + + + + + Dereth Tracker - Login + + + +
+

Dereth Tracker

+

Mosswart Enjoyers Club

+ +
+
+ + +
+
+ + +
+ + +
+ + +
+ + + + diff --git a/static/script.js b/static/script.js index 1f5a2f48..00d2a500 100644 --- a/static/script.js +++ b/static/script.js @@ -4125,6 +4125,26 @@ fetch('/api-version').then(r => r.json()).then(d => { if (el) el.textContent = 'v' + d.version; }).catch(() => {}); +// ─── Current User Info ────────────────────────────────────────── +let _currentUser = null; + +fetch('/me').then(r => { + if (!r.ok) throw new Error('not authenticated'); + return r.json(); +}).then(data => { + _currentUser = data; + const userInfo = document.getElementById('userInfo'); + const nameEl = document.getElementById('currentUsername'); + const adminLink = document.getElementById('adminLink'); + if (userInfo && nameEl) { + nameEl.textContent = data.username; + userInfo.style.display = 'flex'; + } + if (adminLink && data.is_admin) { + adminLink.style.display = 'inline'; + } +}).catch(() => {}); + // ─── Issues Board ─────────────────────────────────────────────── const ISSUE_CATEGORIES = { @@ -4165,7 +4185,6 @@ function showIssuesWindow() { form.className = 'issues-form'; form.innerHTML = `
-