very minor bugfixes, really nothing too important

This commit is contained in:
Micha R. Albert 2025-07-03 11:52:02 -04:00
parent 62da10b69c
commit f2fd4c8d7c
Signed by: mra
SSH key fingerprint: SHA256:2JB0fGfy7m2HQXAzvSXXKm7wPTj9Z60MOjFOQGM2Y/E
20 changed files with 2099 additions and 170 deletions

View file

@ -1,187 +1,135 @@
from logging import Logger
import os
import secrets
from contextlib import asynccontextmanager
from urllib.parse import urlencode
from typing import Annotated, Any
"""FastAPI application main entry point."""
import asyncio
import logging
from collections import namedtuple
from contextlib import asynccontextmanager
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi import FastAPI, Request, Response, HTTPException
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler
from slack_bolt.async_app import AsyncAck, AsyncApp, AsyncRespond, AsyncSay
from slack_bolt.response import BoltResponse
from tortoise import Tortoise
from .models import User, UserCreate, UserResponse, UserUpdate
from random_access.database import get_airtable_base, get_table, airtable_write_worker
from random_access.routes.auth import create_auth_router
from random_access.routes.items import create_items_router, create_user_items_router
from random_access.routes.system import create_system_router
from random_access.routes.users import create_users_router
from random_access.security import get_client_ip, SecurityHeaders
from random_access.settings import settings
from random_access.slack_integration import create_slack_app, setup_slack_handlers
Result = namedtuple("Result", "content, status")
logger = logging.getLogger("uvicorn.error")
if not load_dotenv():
raise FileNotFoundError("Environment secrets not found!")
# Initialize rate limiter
limiter = Limiter(key_func=get_client_ip)
# Initialize Airtable
at_base = get_airtable_base()
SUBMISSIONS = get_table(at_base, "submissions")
USERS = get_table(at_base, "users")
SESSIONS = get_table(at_base, "sessions")
ITEMS = get_table(at_base, "items")
ITEM_ADDONS = get_table(at_base, "item_addons")
@asynccontextmanager
async def lifespan(_: FastAPI):
await Tortoise.init(
db_url="sqlite://random_access.db", modules={"models": ["random_access.models"]}
)
await Tortoise.generate_schemas()
yield
await Tortoise.close_connections()
slack = AsyncApp(
signing_secret=os.getenv("SLACK_SIGNING_SECRET")
)
slack = create_slack_app()
setup_slack_handlers(slack)
slack_handler = AsyncSlackRequestHandler(slack)
@slack.event("app_mention") # pyright:ignore[reportUnknownMemberType]
async def handle_app_mentions(body: BoltResponse, say: AsyncSay, logger: Logger):
logger.info(body)
_ = await say("What's up?")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager."""
asyncio.create_task(airtable_write_worker())
yield
@slack.event("message") # pyright:ignore[reportUnknownMemberType]
async def handle_message():
pass
app = FastAPI(
title="Random Access API",
version="0.1.0",
lifespan=lifespan,
description="Secure API for Random Access game integration"
)
@slack.command("/random-access") # pyright:ignore[reportUnknownMemberType]
async def handle_command(ack: AsyncAck, body: BoltResponse, respond: AsyncRespond):
await ack()
subcommand = dict(body).get('text') # type: ignore
print(subcommand)
# Security middleware
app.state.limiter = limiter
# Custom rate limit exception handler
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
"""Custom rate limit exceeded handler."""
return JSONResponse(
status_code=429,
content={"detail": f"Rate limit exceeded: {exc.detail}"},
headers={
"X-RateLimit-Limit": str(getattr(exc, 'limit', settings.rate_limit_requests)),
"Retry-After": "60" # Default retry after 60 seconds
}
)
app.add_middleware(SlowAPIMiddleware)
# CORS middleware - allows all origins for game compatibility but with secure settings
app.add_middleware(
CORSMiddleware,
allow_origins=settings.origins_list, # ["*"] for development, specific domains for production
allow_credentials=False, # Don't allow credentials with wildcards for security
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Requested-With"],
expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"],
max_age=3600, # Cache preflight requests for 1 hour
)
# Security headers middleware
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
"""Add security headers to all responses."""
response = await call_next(request)
await respond("hewowo")
# Add security headers
security_headers = SecurityHeaders.get_security_headers()
for header, value in security_headers.items():
response.headers[header] = value
# Add rate limit headers
response.headers["X-Content-Security-Policy"] = "default-src 'self'"
return response
app = FastAPI(title="Random Access API", version="0.1.0", lifespan=lifespan)
# Request size limit middleware
@app.middleware("http")
async def limit_request_size(request: Request, call_next):
"""Limit request body size to prevent large payload attacks."""
content_length = request.headers.get("content-length")
if content_length:
content_length = int(content_length)
if content_length > settings.max_request_size:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Request too large. Maximum size: {settings.max_request_size} bytes"
)
response = await call_next(request)
return response
# Include routers
routers = [
create_auth_router(SESSIONS, USERS, SUBMISSIONS, slack),
create_users_router(SESSIONS, USERS),
create_items_router(SESSIONS, USERS, ITEMS, ITEM_ADDONS),
create_user_items_router(SESSIONS, USERS, ITEMS, ITEM_ADDONS),
create_system_router(slack_handler)
]
@app.post("/slack/events")
async def endpoint(req: Request):
return await slack_handler.handle(req)
# Authentication dependency
async def get_current_user(x_api_key: Annotated[str, Header()]) -> User:
if not x_api_key:
raise HTTPException(status_code=401, detail="API key required")
user = await User.get_or_none(api_key=x_api_key)
if not user:
raise HTTPException(status_code=401, detail="Invalid API key")
return user
# Public endpoints (no auth required)
@app.get("/")
async def root():
return {"message": "Random Access API is running"}
@app.get("/auth/start")
async def auth_start(game_id: str):
url = "https://slack.com/openid/connect/authorize/?"
params = {"response_type": "code", "scope": "openid profile email", "client_id": os.environ.get("SLACK_CLIENT_ID"), "state": game_id, "redirect_uri": "https://random-access.prox.mra.sh/auth/callback"}
return RedirectResponse(url + urlencode(params))
@app.get("/auth/callback")
async def auth_callback(code: str, state: str):
print(code, state)
return "yay!"
@app.post("/register", response_model=UserResponse)
async def register_user(user_data: UserCreate):
# Check if username or email already exists
existing_user = await User.get_or_none(slack_id=user_data.slack_id)
if existing_user:
raise HTTPException(status_code=400, detail="Username already exists")
existing_email = await User.get_or_none(email=user_data.email)
if existing_email:
raise HTTPException(status_code=400, detail="Email already exists")
# Generate API key
api_key = secrets.token_urlsafe(32)
# Create user
user = await User.create(
slack_id=user_data.slack_id,
email=user_data.email,
display_name=user_data.display_name,
api_key=api_key,
)
return UserResponse(
id=user.id,
slack_id=user.slack_id,
email=user.email,
display_name=user.display_name,
api_key=user.api_key,
created_at=user.created_at.isoformat(),
)
# Protected endpoints (auth required)
@app.get("/profile", response_model=UserResponse)
async def get_profile(current_user: User = Depends(get_current_user)): # pyright:ignore[reportCallInDefaultInitializer]
return UserResponse(
id=current_user.id,
slack_id=current_user.slack_id,
email=current_user.email,
display_name=current_user.display_name,
api_key=current_user.api_key,
created_at=current_user.created_at.isoformat(),
)
@app.put("/profile", response_model=UserResponse)
async def update_profile(
user_update: UserUpdate, current_user: User = Depends(get_current_user) # pyright:ignore[reportCallInDefaultInitializer]
):
# Update fields if provided
if user_update.email is not None:
# Check if email is already taken by another user
existing_email = await User.get_or_none(email=user_update.email)
if existing_email and existing_email.id != current_user.id:
raise HTTPException(status_code=400, detail="Email already exists")
current_user.email = user_update.email
if user_update.display_name is not None:
current_user.display_name = user_update.display_name
await current_user.save()
return UserResponse(
id=current_user.id,
slack_id=current_user.slack_id,
email=current_user.email,
display_name=current_user.display_name,
api_key=current_user.api_key,
created_at=current_user.created_at.isoformat(),
)
@app.get("/profile/data")
async def get_profile_data(current_user: User = Depends(get_current_user)) -> dict[str, int | str | bool | float | dict[str, Any]]: # pyright:ignore[reportCallInDefaultInitializer,reportExplicitAny]
"""Get flexible profile data (for future passport features)"""
return {"profile_data": current_user.profile_data} # pyright:ignore[reportUnknownMemberType]
@app.put("/profile/data")
async def update_profile_data(
data: dict[str, Any], current_user: User = Depends(get_current_user) # pyright:ignore[reportCallInDefaultInitializer,reportExplicitAny]
):
"""Update flexible profile data (for future passport features)"""
current_user.profile_data = data
await current_user.save()
return {
"message": "Profile data updated",
"profile_data": current_user.profile_data,
}
for router in routers:
app.include_router(router)