feat: add app-level authentication with login, session cookies, and admin panel
Replace Nginx basic auth with proper user accounts: - Session cookies via itsdangerous (30-day expiry, httponly, secure) - Password hashing with bcrypt via passlib - Login page with AC-themed UI - Admin page for user management (CRUD) - AuthMiddleware exempts plugin WS and browser WS endpoints - Issues/comments author auto-populated from session - Sidebar shows logged-in username, admin link, and logout - Seed users: erik (admin), alex, lundberg - SECRET_KEY env var for cookie signing
This commit is contained in:
parent
fac5063878
commit
b09169ade2
9 changed files with 878 additions and 60 deletions
155
db_async.py
155
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
|
||||
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}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue