very minor bugfixes, really nothing too important
This commit is contained in:
parent
62da10b69c
commit
f2fd4c8d7c
20 changed files with 2099 additions and 170 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue