217 lines
8.3 KiB
Python
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.",
|
|
)
|