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.")