"""BAN YARO — Admin / Moderator Backend""" import os import sys import time import platform from datetime import datetime from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional from database import db, DB_PATH from auth import get_current_user router = APIRouter() _TZ = ZoneInfo("Europe/Berlin") _start_time = time.time() # Audit-Tabelle anlegen (einmalig beim Import) with db() as _conn: _conn.executescript(""" CREATE TABLE IF NOT EXISTS admin_audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, admin_id INTEGER NOT NULL, admin_name TEXT, action TEXT NOT NULL, target TEXT, detail TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); """) # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _audit(conn, admin, action: str, target: str = None, detail: str = None): """Schreibt einen Audit-Eintrag in die admin_audit-Tabelle.""" conn.execute( "INSERT INTO admin_audit (admin_id, admin_name, action, target, detail) VALUES (?,?,?,?,?)", (admin["id"], admin.get("name"), action, target, detail), ) # ------------------------------------------------------------------ # Dependency: Moderator oder Admin # ------------------------------------------------------------------ def require_mod(user=Depends(get_current_user)): if user["rolle"] not in ("admin", "moderator") and not user.get("is_moderator"): raise HTTPException(403, "Kein Zugriff.") return user def require_admin(user=Depends(get_current_user)): if user["rolle"] != "admin": raise HTTPException(403, "Nur Admins.") return user # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class UserPatch(BaseModel): rolle: Optional[str] = None # user | moderator | admin is_moderator: Optional[int] = None is_banned: Optional[int] = None ban_reason: Optional[str] = None class ThreadAdminPatch(BaseModel): is_pinned: Optional[int] = None is_locked: Optional[int] = None is_deleted: Optional[int] = None # ------------------------------------------------------------------ # GET /api/admin/stats # ------------------------------------------------------------------ @router.get("/stats") async def stats(user=Depends(require_mod)): with db() as conn: users_total = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] users_today = conn.execute( "SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')" ).fetchone()[0] threads = conn.execute( "SELECT COUNT(*) FROM forum_threads WHERE is_deleted=0" ).fetchone()[0] posts = conn.execute( "SELECT COUNT(*) FROM forum_posts WHERE is_deleted=0" ).fetchone()[0] open_reports = conn.execute( "SELECT COUNT(*) FROM forum_reports WHERE resolved=0" ).fetchone()[0] banned = conn.execute( "SELECT COUNT(*) FROM users WHERE is_banned=1" ).fetchone()[0] dogs_total = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0] poison_total = conn.execute( "SELECT COUNT(*) FROM poison WHERE geloest=0 AND expires_at > datetime('now')" ).fetchone()[0] # Erweiterte Metriken push_subscriptions = conn.execute( "SELECT COUNT(*) FROM push_subscriptions" ).fetchone()[0] active_users_7d = conn.execute( "SELECT COUNT(*) FROM users WHERE last_seen > datetime('now', '-7 days')" ).fetchone()[0] # Media aus diary (media_url) + health (datei_url) media_diary = conn.execute( "SELECT COUNT(*) FROM diary WHERE media_url IS NOT NULL AND media_url != ''" ).fetchone()[0] media_health = conn.execute( "SELECT COUNT(*) FROM health WHERE datei_url IS NOT NULL AND datei_url != ''" ).fetchone()[0] media_count = media_diary + media_health routes_total = conn.execute("SELECT COUNT(*) FROM routes").fetchone()[0] events_total = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0] osm_total = conn.execute("SELECT COUNT(*) FROM osm_pois").fetchone()[0] osm_tiles = conn.execute("SELECT COUNT(*) FROM osm_tiles").fetchone()[0] osm_by_type = { row[0]: row[1] for row in conn.execute( "SELECT type, COUNT(*) FROM osm_pois GROUP BY type ORDER BY 2 DESC" ).fetchall() } return { "users_total": users_total, "users_today": users_today, "threads": threads, "posts": posts, "open_reports": open_reports, "banned": banned, "dogs_total": dogs_total, "poison_active": poison_total, "push_subscriptions": push_subscriptions, "active_users_7d": active_users_7d, "media_count": media_count, "routes_total": routes_total, "events_total": events_total, "osm_total": osm_total, "osm_tiles": osm_tiles, "osm_by_type": osm_by_type, } # ------------------------------------------------------------------ # GET /api/admin/users # ------------------------------------------------------------------ @router.get("/users") async def list_users( q: str = "", rolle: str = "", limit: int = 50, offset: int = 0, user=Depends(require_mod), ): with db() as conn: where = "WHERE 1=1" params = [] if q.strip(): where += " AND (u.name LIKE ? OR u.email LIKE ?)" params.extend([f"%{q.strip()}%", f"%{q.strip()}%"]) if rolle: where += " AND u.rolle = ?" params.append(rolle) rows = conn.execute(f""" SELECT u.id, u.name, u.email, u.rolle, u.is_premium, u.is_moderator, u.is_banned, u.ban_reason, u.created_at, u.last_login, (SELECT COUNT(*) FROM dogs d WHERE d.user_id=u.id) AS dog_count, (SELECT COUNT(*) FROM forum_threads t WHERE t.user_id=u.id AND t.is_deleted=0) AS thread_count, ROUND(COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=u.id), 0), 1) AS total_km, (SELECT COUNT(*) FROM routes r WHERE r.user_id=u.id) AS route_count, (SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=u.id) AS poi_count, (SELECT MAX(r.created_at) FROM routes r WHERE r.user_id=u.id) AS last_route FROM users u {where} ORDER BY u.created_at DESC LIMIT ? OFFSET ? """, [*params, limit, offset]).fetchall() total = conn.execute(f""" SELECT COUNT(*) FROM users u {where} """, params).fetchone()[0] return {"users": [dict(r) for r in rows], "total": total} # ------------------------------------------------------------------ # PATCH /api/admin/users/{id} — Rolle, Sperre # ------------------------------------------------------------------ @router.patch("/users/{uid}") async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)): # Rollenwechsel nur für Admins if data.rolle is not None and user["rolle"] != "admin": raise HTTPException(403, "Rollenwechsel nur für Admins.") if data.rolle and data.rolle not in ("user", "moderator", "admin"): raise HTTPException(400, "Ungültige Rolle.") with db() as conn: target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone() if not target: raise HTTPException(404, "User nicht gefunden.") # Mods dürfen keine Admins sperren if target["rolle"] == "admin" and user["rolle"] != "admin": raise HTTPException(403, "Admins können nur von Admins verwaltet werden.") updates = data.model_dump(exclude_none=True) if not updates: raise HTTPException(400, "Keine Änderungen.") # is_moderator aus rolle ableiten wenn rolle gesetzt wird if "rolle" in updates: updates["is_moderator"] = 1 if updates["rolle"] in ("moderator", "admin") else 0 cols = ", ".join(f"{k}=?" for k in updates) conn.execute(f"UPDATE users SET {cols} WHERE id=?", [*updates.values(), uid]) row = conn.execute( "SELECT id, name, email, rolle, is_moderator, is_banned, ban_reason FROM users WHERE id=?", (uid,) ).fetchone() # Audit detail_parts = [] if "is_banned" in updates: detail_parts.append("gesperrt" if updates["is_banned"] else "entsperrt") if "rolle" in updates: detail_parts.append(f"Rolle→{updates['rolle']}") _audit(conn, user, "user_patch", f"user:{uid} ({target['name']})", ", ".join(detail_parts) or None) return dict(row) # ------------------------------------------------------------------ # DELETE /api/admin/users/{id} — Account löschen (Admin only) # ------------------------------------------------------------------ @router.delete("/users/{uid}", status_code=204) async def delete_user(uid: int, user=Depends(require_admin)): with db() as conn: target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone() if not target: raise HTTPException(404, "User nicht gefunden.") if target["id"] == user["id"]: raise HTTPException(400, "Du kannst deinen eigenen Account nicht löschen.") conn.execute("DELETE FROM users WHERE id=?", (uid,)) _audit(conn, user, "user_delete", f"user:{uid} ({target['name']})") # ------------------------------------------------------------------ # GET /api/admin/forum/threads — alle Threads inkl. gelöschte # ------------------------------------------------------------------ @router.get("/forum/threads") async def admin_threads( q: str = "", deleted: int = 0, limit: int = 50, offset: int = 0, user=Depends(require_mod), ): with db() as conn: where = "WHERE 1=1" params = [] if not deleted: where += " AND t.is_deleted=0" if q.strip(): where += " AND (t.titel LIKE ? OR t.text LIKE ?)" params.extend([f"%{q.strip()}%", f"%{q.strip()}%"]) rows = conn.execute(f""" SELECT t.id, t.kategorie, t.titel, SUBSTR(t.text,1,100) AS text_preview, t.antworten, t.likes, t.views, t.is_pinned, t.is_locked, t.is_deleted, t.created_at, u.id AS user_id, u.name AS autor_name FROM forum_threads t LEFT JOIN users u ON u.id=t.user_id {where} ORDER BY t.created_at DESC LIMIT ? OFFSET ? """, [*params, limit, offset]).fetchall() total = conn.execute(f""" SELECT COUNT(*) FROM forum_threads t {where} """, params).fetchone()[0] return {"threads": [dict(r) for r in rows], "total": total} # ------------------------------------------------------------------ # PATCH /api/admin/forum/threads/{id} # ------------------------------------------------------------------ @router.patch("/forum/threads/{tid}") async def admin_patch_thread(tid: int, data: ThreadAdminPatch, user=Depends(require_mod)): with db() as conn: thread = conn.execute("SELECT id, titel FROM forum_threads WHERE id=?", (tid,)).fetchone() if not thread: raise HTTPException(404, "Thread nicht gefunden.") updates = data.model_dump(exclude_none=True) if not updates: raise HTTPException(400, "Keine Änderungen.") cols = ", ".join(f"{k}=?" for k in updates) conn.execute(f"UPDATE forum_threads SET {cols} WHERE id=?", [*updates.values(), tid]) _audit(conn, user, "thread_patch", f"thread:{tid}", str(updates)) return {"ok": True} # ------------------------------------------------------------------ # DELETE /api/admin/forum/threads/{id} # ------------------------------------------------------------------ @router.delete("/forum/threads/{tid}", status_code=204) async def admin_delete_thread(tid: int, user=Depends(require_mod)): with db() as conn: thread = conn.execute("SELECT id, titel FROM forum_threads WHERE id=?", (tid,)).fetchone() if not thread: raise HTTPException(404, "Thread nicht gefunden.") conn.execute("UPDATE forum_threads SET is_deleted=1 WHERE id=?", (tid,)) _audit(conn, user, "thread_delete", f"thread:{tid} ({thread['titel'][:60]})") # ------------------------------------------------------------------ # DELETE /api/admin/forum/posts/{id} # ------------------------------------------------------------------ @router.delete("/forum/posts/{pid}", status_code=204) async def admin_delete_post(pid: int, user=Depends(require_mod)): with db() as conn: post = conn.execute("SELECT * FROM forum_posts WHERE id=?", (pid,)).fetchone() if not post: raise HTTPException(404, "Beitrag nicht gefunden.") conn.execute("UPDATE forum_posts SET is_deleted=1 WHERE id=?", (pid,)) conn.execute( "UPDATE forum_threads SET antworten=MAX(0,antworten-1) WHERE id=?", (post["thread_id"],) ) _audit(conn, user, "post_delete", f"post:{pid} thread:{post['thread_id']}") # ------------------------------------------------------------------ # GET /api/admin/reports — offene Meldungen # ------------------------------------------------------------------ @router.get("/reports") async def admin_reports(user=Depends(require_mod)): with db() as conn: rows = conn.execute(""" SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, r.created_at, u.name AS melder_name, CASE r.target_type WHEN 'thread' THEN (SELECT t.titel FROM forum_threads t WHERE t.id=r.target_id) WHEN 'post' THEN (SELECT SUBSTR(p.text,1,80) FROM forum_posts p WHERE p.id=r.target_id) END AS content_preview FROM forum_reports r LEFT JOIN users u ON u.id=r.user_id ORDER BY r.resolved ASC, r.created_at DESC LIMIT 100 """).fetchall() return [dict(r) for r in rows] # ------------------------------------------------------------------ # PATCH /api/admin/reports/{id} — erledigen / wiedereröffnen # ------------------------------------------------------------------ @router.patch("/reports/{rid}") async def admin_resolve_report(rid: int, user=Depends(require_mod)): with db() as conn: r = conn.execute("SELECT resolved FROM forum_reports WHERE id=?", (rid,)).fetchone() if not r: raise HTTPException(404, "Meldung nicht gefunden.") new_state = 0 if r["resolved"] else 1 conn.execute( "UPDATE forum_reports SET resolved=? WHERE id=?", (new_state, rid) ) _audit(conn, user, "report_resolve" if new_state else "report_reopen", f"report:{rid}") return {"ok": True} # ------------------------------------------------------------------ # GET /api/admin/scheduler/jobs # ------------------------------------------------------------------ @router.get("/scheduler/jobs") async def scheduler_jobs(user=Depends(require_admin)): from scheduler import _scheduler jobs = [] for job in _scheduler.get_jobs(): next_run = job.next_run_time jobs.append({ "id": job.id, "name": job.name, "next_run_time": next_run.isoformat() if next_run else None, "trigger": str(job.trigger), }) return jobs # ------------------------------------------------------------------ # POST /api/admin/scheduler/trigger/{job_id} # ------------------------------------------------------------------ @router.post("/scheduler/trigger/{job_id}") async def scheduler_trigger(job_id: str, user=Depends(require_admin)): from scheduler import _scheduler, _TZ as SCHED_TZ job = _scheduler.get_job(job_id) if not job: raise HTTPException(404, f"Job '{job_id}' nicht gefunden.") job.modify(next_run_time=datetime.now(tz=SCHED_TZ)) with db() as conn: _audit(conn, user, "scheduler_trigger", f"job:{job_id}") return {"ok": True, "job_id": job_id} # ------------------------------------------------------------------ # GET /api/admin/system # ------------------------------------------------------------------ @router.get("/system") async def system_info(user=Depends(require_admin)): # DB-Größe try: db_size_mb = os.path.getsize(DB_PATH) / 1024 ** 2 except OSError: db_size_mb = 0.0 # Media-Größe (rekursiv) media_dir = os.getenv("MEDIA_DIR", "/data/media") media_size_bytes = 0 try: for dirpath, _dirs, files in os.walk(media_dir): for fname in files: try: media_size_bytes += os.path.getsize(os.path.join(dirpath, fname)) except OSError: pass except OSError: pass media_size_mb = media_size_bytes / 1024 ** 2 # Disk-Info disk_total_gb = 0.0 disk_free_gb = 0.0 try: st = os.statvfs(DB_PATH) disk_total_gb = st.f_blocks * st.f_frsize / 1024 ** 3 disk_free_gb = st.f_bavail * st.f_frsize / 1024 ** 3 except (OSError, AttributeError): pass return { "db_size_mb": round(db_size_mb, 2), "media_size_mb": round(media_size_mb, 2), "uptime_seconds": int(time.time() - _start_time), "python_version": sys.version.split()[0], "disk_total_gb": round(disk_total_gb, 2), "disk_free_gb": round(disk_free_gb, 2), } # ------------------------------------------------------------------ # GET /api/admin/logs # ------------------------------------------------------------------ @router.get("/logs") async def get_logs(lines: int = 200, level: str = "", user=Depends(require_admin)): from main import log_buffer entries = list(log_buffer) if level: entries = [e for e in entries if e['l'] == level.upper()] return entries[-lines:] # ------------------------------------------------------------------ # GET /api/admin/audit # ------------------------------------------------------------------ @router.get("/audit") async def audit_log(limit: int = 50, user=Depends(require_admin)): with db() as conn: rows = conn.execute( """ SELECT id, admin_id, admin_name, action, target, detail, created_at FROM admin_audit ORDER BY id DESC LIMIT ? """, (min(limit, 200),), ).fetchall() return [dict(r) for r in rows]