discordbot/api_service/dependencies.py
2025-06-05 21:31:06 -06:00

217 lines
8.3 KiB
Python

import logging
import asyncio
from fastapi import Depends, HTTPException, Request, status
import aiohttp
from functools import lru_cache
import os
# --- Configuration Loading ---
# Need to load settings here as well, or pass http_session/settings around
# Re-using the settings logic from api_server.py for simplicity
dotenv_path = os.path.join(os.path.dirname(__file__), "..", "discordbot", ".env")
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional
class ApiSettings(BaseSettings):
DISCORD_CLIENT_ID: str
DISCORD_CLIENT_SECRET: str
DISCORD_REDIRECT_URI: str
DISCORD_BOT_TOKEN: Optional[str] = None
DASHBOARD_SECRET_KEY: str = "a_default_secret_key_for_development_only"
POSTGRES_USER: str
POSTGRES_PASSWORD: str
POSTGRES_HOST: str
POSTGRES_SETTINGS_DB: str
REDIS_HOST: str
REDIS_PORT: int = 6379
REDIS_PASSWORD: Optional[str] = None
MOD_LOG_API_SECRET: Optional[str] = None
API_HOST: str = "0.0.0.0"
API_PORT: int = 8001
SSL_CERT_FILE: Optional[str] = None
SSL_KEY_FILE: Optional[str] = None
GURT_STATS_PUSH_SECRET: Optional[str] = None
model_config = SettingsConfigDict(
env_file=dotenv_path, env_file_encoding="utf-8", extra="ignore"
)
@lru_cache()
def get_api_settings() -> ApiSettings:
if not os.path.exists(dotenv_path):
print(
f"Warning: .env file not found at {dotenv_path}. Using defaults or environment variables."
)
return ApiSettings()
settings = get_api_settings()
# --- Constants ---
DISCORD_API_BASE_URL = "https://discord.com/api/v10"
DISCORD_USER_URL = f"{DISCORD_API_BASE_URL}/users/@me"
DISCORD_USER_GUILDS_URL = f"{DISCORD_API_BASE_URL}/users/@me/guilds"
# --- Logging ---
log = logging.getLogger(__name__) # Use specific logger
# --- Global aiohttp Session (managed by api_server lifespan) ---
# We need access to the session created in api_server.py
# A simple way is to have api_server.py set it after creation.
http_session: Optional[aiohttp.ClientSession] = None
def set_http_session(session: aiohttp.ClientSession):
"""Sets the global aiohttp session for dependencies."""
global http_session
http_session = session
# --- Authentication Dependency (Dashboard Specific) ---
async def get_dashboard_user(request: Request) -> dict:
"""Dependency to check if user is authenticated via dashboard session and return user data."""
user_id = request.session.get("user_id")
username = request.session.get("username")
access_token = request.session.get(
"access_token"
) # Needed for subsequent Discord API calls
if not user_id or not username or not access_token:
log.warning("Dashboard: Attempted access by unauthenticated user.")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated for dashboard",
headers={"WWW-Authenticate": "Bearer"}, # Standard header for 401
)
# Return essential user info and token for potential use in endpoints
return {
"user_id": user_id,
"username": username,
"avatar": request.session.get("avatar"),
"access_token": access_token,
}
# --- Guild Admin Verification Dependency (Dashboard Specific) ---
async def verify_dashboard_guild_admin(
guild_id: int, current_user: dict = Depends(get_dashboard_user)
) -> bool:
"""Dependency to verify the dashboard session user is an admin of the specified guild."""
global http_session # Use the global aiohttp session
if not http_session:
log.error("verify_dashboard_guild_admin: HTTP session not ready.")
raise HTTPException(
status_code=500, detail="Internal server error: HTTP session not ready."
)
user_headers = {"Authorization": f'Bearer {current_user["access_token"]}'}
try:
log.debug(
f"Dashboard: Verifying admin status for user {current_user['user_id']} in guild {guild_id}"
)
# Add rate limit handling
max_retries = 3
retry_count = 0
retry_after = 0
while retry_count < max_retries:
if retry_after > 0:
log.warning(
f"Dashboard: Rate limited by Discord API, waiting {retry_after} seconds before retry"
)
await asyncio.sleep(retry_after)
async with http_session.get(
DISCORD_USER_GUILDS_URL, headers=user_headers
) as resp:
if resp.status == 429: # Rate limited
retry_count += 1
try:
retry_after = float(
resp.headers.get(
"X-RateLimit-Reset-After",
resp.headers.get("Retry-After", 1),
)
)
except (ValueError, TypeError):
retry_after = 1.0 # Default wait time if header is invalid
is_global = resp.headers.get("X-RateLimit-Global") is not None
scope = resp.headers.get("X-RateLimit-Scope", "unknown")
log.warning(
f"Dashboard: Discord API rate limit hit. "
f"Global: {is_global}, Scope: {scope}, "
f"Reset after: {retry_after}s, "
f"Retry: {retry_count}/{max_retries}"
)
if is_global:
retry_after = max(
retry_after, 5
) # Wait longer for global limits
continue # Retry the request
if resp.status == 401:
# Session token might be invalid, but we can't clear session here easily.
# Let the frontend handle re-authentication based on the 401.
raise HTTPException(
status_code=401,
detail="Discord token invalid or expired. Please re-login.",
)
resp.raise_for_status() # Raise for other errors (4xx, 5xx)
user_guilds = await resp.json()
ADMINISTRATOR_PERMISSION = 0x8
is_admin = False
for guild in user_guilds:
if int(guild["id"]) == guild_id:
permissions = int(guild["permissions"])
if (
permissions & ADMINISTRATOR_PERMISSION
) == ADMINISTRATOR_PERMISSION:
is_admin = True
break # Found the guild and user is admin
if not is_admin:
log.warning(
f"Dashboard: User {current_user['user_id']} is not admin or not in guild {guild_id}."
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User is not an administrator of this guild.",
)
log.debug(
f"Dashboard: User {current_user['user_id']} verified as admin for guild {guild_id}."
)
return True # Indicate verification success
# If loop finishes without returning True, it means retries were exhausted
raise HTTPException(
status_code=429,
detail="Rate limited by Discord API. Please try again later.",
)
except aiohttp.ClientResponseError as e:
log.exception(
f"Dashboard: HTTP error verifying guild admin status: {e.status} {e.message}"
)
if e.status == 429: # Should be caught by the loop, but safeguard
raise HTTPException(
status_code=429,
detail="Rate limited by Discord API. Please try again later.",
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Error communicating with Discord API.",
)
except Exception as e:
log.exception(f"Dashboard: Generic error verifying guild admin status: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An internal error occurred during permission verification.",
)