|
from fastapi import FastAPI, Request, HTTPException, Security, Depends |
|
from fastapi.middleware.cors import CORSMiddleware |
|
from app.routes import health, wagons, chat, players, generate |
|
from app.core.logging import get_logger, setup_logging |
|
from dotenv import load_dotenv |
|
from datetime import datetime |
|
import time |
|
from pathlib import Path |
|
from fastapi.security.api_key import APIKeyHeader |
|
import os |
|
import secrets |
|
|
|
|
|
load_dotenv() |
|
|
|
|
|
logger = get_logger("main") |
|
|
|
API_KEY = os.getenv("API_KEY") |
|
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=True) |
|
|
|
|
|
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000") |
|
|
|
|
|
async def get_api_key(api_key_header: str = Security(api_key_header)): |
|
if not API_KEY: |
|
logger.error("API key not configured on server") |
|
raise HTTPException( |
|
status_code=500, |
|
detail="Server configuration error" |
|
) |
|
|
|
if not api_key_header: |
|
raise HTTPException( |
|
status_code=401, |
|
detail="Missing API key", |
|
headers={"WWW-Authenticate": "ApiKey"}, |
|
) |
|
|
|
if not secrets.compare_digest(api_key_header, API_KEY): |
|
logger.warning(f"Invalid API key attempt from {request.client.host}") |
|
raise HTTPException( |
|
status_code=403, |
|
detail="Invalid authentication credentials", |
|
headers={"WWW-Authenticate": "ApiKey"}, |
|
) |
|
|
|
return api_key_header |
|
|
|
|
|
app = FastAPI( |
|
title="Game Jam API", |
|
description="API for Game Jam Hackathon", |
|
version="1.0.0", |
|
dependencies=[Depends(get_api_key)], |
|
) |
|
|
|
app.add_middleware( |
|
CORSMiddleware, |
|
allow_origins=[FRONTEND_URL], |
|
allow_credentials=True, |
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"], |
|
allow_headers=["*"], |
|
expose_headers=[ |
|
"Location", |
|
"Access-Control-Allow-Origin", |
|
"Access-Control-Allow-Methods", |
|
"Access-Control-Allow-Headers", |
|
"Access-Control-Allow-Credentials", |
|
"Access-Control-Expose-Headers", |
|
], |
|
max_age=3600, |
|
) |
|
|
|
|
|
@app.middleware("http") |
|
async def handle_redirects(request: Request, call_next): |
|
"""Ensure CORS headers are in redirect responses and force https in the 'Location' header.""" |
|
response = await call_next(request) |
|
|
|
response.headers["Access-Control-Allow-Origin"] = FRONTEND_URL |
|
response.headers["Access-Control-Allow-Methods"] = ( |
|
"GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH" |
|
) |
|
response.headers["Access-Control-Allow-Headers"] = "*" |
|
response.headers["Access-Control-Max-Age"] = "3600" |
|
|
|
if response.status_code in [301, 302, 307, 308]: |
|
response.headers["Access-Control-Expose-Headers"] = "Location" |
|
if "Location" in response.headers: |
|
location = response.headers["Location"] |
|
if location.startswith("http://"): |
|
response.headers["Location"] = location.replace( |
|
"http://", "https://", 1 |
|
) |
|
|
|
return response |
|
|
|
|
|
@app.middleware("http") |
|
async def security_headers(request: Request, call_next): |
|
"""Add security-related headers to all responses.""" |
|
response = await call_next(request) |
|
|
|
response.headers["X-Content-Type-Options"] = "nosniff" |
|
response.headers["X-Frame-Options"] = "DENY" |
|
response.headers["X-XSS-Protection"] = "1; mode=block" |
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload" |
|
response.headers["Content-Security-Policy"] = ( |
|
"default-src 'self'; " |
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " |
|
"style-src 'self' 'unsafe-inline'; " |
|
"img-src 'self' data: https:; " |
|
"connect-src 'self' " |
|
) |
|
response.headers["Permissions-Policy"] = ( |
|
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), " |
|
"magnetometer=(), microphone=(), payment=(), usb=()" |
|
) |
|
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate" |
|
response.headers["Pragma"] = "no-cache" |
|
response.headers["Expires"] = "0" |
|
|
|
return response |
|
|
|
|
|
@app.middleware("http") |
|
async def log_requests(request: Request, call_next): |
|
"""Middleware to log all requests and responses.""" |
|
start_time = time.time() |
|
|
|
logger.info( |
|
"Incoming request", |
|
extra={ |
|
"method": request.method, |
|
"url": str(request.url), |
|
"client_host": request.client.host if request.client else None, |
|
"timestamp": datetime.utcnow().isoformat(), |
|
}, |
|
) |
|
|
|
try: |
|
response = await call_next(request) |
|
process_time = time.time() - start_time |
|
|
|
logger.info( |
|
"Request completed", |
|
extra={ |
|
"method": request.method, |
|
"url": str(request.url), |
|
"status_code": response.status_code, |
|
"process_time_ms": round(process_time * 1000, 2), |
|
}, |
|
) |
|
return response |
|
|
|
except Exception as e: |
|
logger.error( |
|
"Request failed", |
|
extra={"method": request.method, "url": str(request.url), "error": str(e)}, |
|
) |
|
raise |
|
|
|
|
|
app.include_router(health.router) |
|
app.include_router(wagons.router) |
|
app.include_router(chat.router) |
|
app.include_router(players.router) |
|
app.include_router(generate.router) |
|
|
|
|
|
@app.get("/") |
|
async def root(): |
|
logger.info("Root endpoint accessed") |
|
return { |
|
"message": "Welcome to Game Jam API", |
|
"docs_url": "/docs", |
|
"health_check": "/health", |
|
"wagons_endpoint": "/api/wagons", |
|
"chat_endpoint": "/api/chat", |
|
"players_endpoint": "/api/players", |
|
} |
|
|
|
|
|
@app.on_event("startup") |
|
async def startup_event(): |
|
logs_dir = Path("logs") |
|
logs_dir.mkdir(exist_ok=True) |
|
|
|
setup_logging() |
|
|