Update configuration and README for improved setup and security; disable default API and emoji sync, and enhance Discord permission checks in API routes.

This commit is contained in:
TheOnlyMace
2026-07-21 16:00:52 +02:00
parent 1fdaf4dd6c
commit 127f8a032f
25 changed files with 780 additions and 434 deletions

130
bot/api/discord_auth.py Normal file
View File

@@ -0,0 +1,130 @@
# ╔══════════════════════════════════════════════════════════════════╗
# ║ Discord OAuth permission checks for dashboard API requests ║
# ╚══════════════════════════════════════════════════════════════════╝
from __future__ import annotations
import re
from typing import Any
import aiohttp
from fastapi import HTTPException, Request
from utils.config import OWNER_IDS_STR
MANAGE_GUILD = 0x20
ADMINISTRATOR = 0x8
GUILD_PATH_RE = re.compile(r"^/api/v1/guilds/(\d+)(?:/|$)")
def _parse_admin_ids() -> list[str]:
import os
raw = os.getenv("DASHBOARD_ADMIN_IDS", "").strip()
if raw:
return [p.strip() for p in raw.split(",") if p.strip().isdigit()]
return list(OWNER_IDS_STR)
def can_manage_guild(guild: dict[str, Any]) -> bool:
if guild.get("owner"):
return True
try:
perms = int(guild.get("permissions", "0"))
except (TypeError, ValueError):
return False
return bool(perms & ADMINISTRATOR) or bool(perms & MANAGE_GUILD)
async def fetch_user_guilds(access_token: str) -> list[dict[str, Any]]:
async with aiohttp.ClientSession() as session:
async with session.get(
"https://discord.com/api/users/@me/guilds",
headers={"Authorization": f"Bearer {access_token}"},
) as resp:
if resp.status != 200:
raise HTTPException(
status_code=403,
detail="Invalid or expired Discord access token.",
)
data = await resp.json()
return data if isinstance(data, list) else []
async def verify_discord_token(access_token: str) -> dict[str, Any]:
async with aiohttp.ClientSession() as session:
async with session.get(
"https://discord.com/api/users/@me",
headers={"Authorization": f"Bearer {access_token}"},
) as resp:
if resp.status != 200:
raise HTTPException(
status_code=403,
detail="Invalid or expired Discord access token.",
)
return await resp.json()
async def user_can_manage_guild(access_token: str, guild_id: int) -> bool:
guilds = await fetch_user_guilds(access_token)
for guild in guilds:
if str(guild.get("id")) == str(guild_id):
return can_manage_guild(guild)
return False
async def get_manageable_guild_ids(access_token: str) -> set[str]:
guilds = await fetch_user_guilds(access_token)
return {str(g["id"]) for g in guilds if can_manage_guild(g)}
def get_discord_headers(request: Request) -> tuple[str | None, str | None]:
token = request.headers.get("X-Discord-Access-Token")
user_id = request.headers.get("X-Discord-User-Id")
return token, user_id
async def require_discord_session(request: Request) -> tuple[str, str]:
token, user_id = get_discord_headers(request)
if not token:
raise HTTPException(
status_code=403,
detail="X-Discord-Access-Token header is required.",
)
user = await verify_discord_token(token)
resolved_id = str(user.get("id", ""))
if not resolved_id:
raise HTTPException(status_code=403, detail="Could not resolve Discord user.")
if user_id and user_id != resolved_id:
raise HTTPException(status_code=403, detail="Discord user ID mismatch.")
return token, resolved_id
async def require_guild_access(request: Request, guild_id: int) -> None:
token, _ = await require_discord_session(request)
if not await user_can_manage_guild(token, guild_id):
raise HTTPException(
status_code=403,
detail="You do not have permission to manage this guild.",
)
async def require_dashboard_admin(request: Request) -> None:
token, user_id = await require_discord_session(request)
admin_ids = _parse_admin_ids()
if user_id not in admin_ids:
raise HTTPException(
status_code=403,
detail="Dashboard admin access required.",
)
def extract_guild_id_from_path(path: str) -> int | None:
match = GUILD_PATH_RE.match(path)
if not match:
return None
return int(match.group(1))

View File

@@ -14,6 +14,7 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from api.dependencies import get_bot, limiter
from api.discord_auth import require_discord_session, get_manageable_guild_ids
from api.db_manager import db_manager
from api.schemas import (
GuildSummary, GuildDetails, PrefixConfig, AutomodConfig,
@@ -41,13 +42,18 @@ if TYPE_CHECKING:
router = APIRouter()
@router.get("/", response_model=List[GuildSummary], summary="List all guilds", description="Returns a summary of all guilds the bot is currently in.")
async def list_guilds(bot: "zyrox" = Depends(get_bot)):
@router.get("/", response_model=List[GuildSummary], summary="List all guilds", description="Returns guilds the authenticated user can manage that the bot is in.")
async def list_guilds(request: Request, bot: "zyrox" = Depends(get_bot)):
"""
Lists detailed information about all guilds the bot is currently in.
Lists guilds the bot is in, filtered to those the caller can manage on Discord.
"""
token, _ = await require_discord_session(request)
manageable_ids = await get_manageable_guild_ids(token)
guilds_list = []
for guild in bot.guilds:
if str(guild.id) not in manageable_ids:
continue
guilds_list.append(GuildSummary(
id=str(guild.id),
name=guild.name,

View File

@@ -12,7 +12,7 @@
# ║ ║
# ╚══════════════════════════════════════════════════════════════════╝
from fastapi import FastAPI, Depends, Request
from fastapi import FastAPI, Depends, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import os
@@ -28,6 +28,13 @@ from utils.config import *
from api.routes import bot, guilds, admin
from api.dependencies import verify_api_key, limiter
from api.db_manager import db_manager
from api.discord_auth import (
require_dashboard_admin,
require_discord_session,
require_guild_access,
extract_guild_id_from_path,
)
from fastapi.responses import JSONResponse
# Configure logging
logger = logging.getLogger("api_request_logs")
@@ -57,6 +64,29 @@ def create_app() -> FastAPI:
lifespan=lifespan
)
# Discord permission checks for dashboard API routes
@app.middleware("http")
async def discord_permission_middleware(request: Request, call_next):
if request.method == "OPTIONS":
return await call_next(request)
path = request.url.path
try:
if path.startswith("/api/v1/admin"):
await require_dashboard_admin(request)
elif path.startswith("/api/v1/bot"):
await require_discord_session(request)
elif path.startswith("/api/v1/guilds"):
guild_id = extract_guild_id_from_path(path)
if guild_id is not None:
await require_guild_access(request, guild_id)
else:
await require_discord_session(request)
except HTTPException as exc:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
return await call_next(request)
# Structured Logging Middleware
@app.middleware("http")
async def log_requests(request: Request, call_next):