Backend: - models/invitation.py — Invitation model + create/accept/preview schemas - routes_invitations.py — org-scoped POST/GET/DELETE + public preview/accept endpoints Single-use token via find_one_and_update; sha256(token) stored in DB, plaintext in email URL - emailer.py — _send() helper; send_invitation_email, send_welcome_email, send_password_reset_email send_completion_email refactored to use _send() - migration_2026-04-28-000002 — creates invitations collection with TTL index (30d audit trail) - routes_auth.py — new MS SSO users provisioned with zero memberships instead of role=PRODUCTION; they land on "no access" page until an admin invites them - main.py — registers invitations_org_router and invitations_router Frontend: - routes/AcceptInvite.tsx — public page at /accept-invite?token=... Four states: new user (name+password), existing user (confirm), MS user, already-member - App.tsx — /accept-invite route outside RequireAuth - types/api.ts — Invitation, InvitationCreate, InvitationPreview, InvitationAcceptRequest/Response - lib/api.ts — listInvitations, createInvitation, revokeInvitation, previewInvitation, acceptInvitation - hooks/useClients.ts — useInvitations, useCreateInvitation, useRevokeInvitation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
313 lines
12 KiB
Python
313 lines
12 KiB
Python
from contextlib import asynccontextmanager
|
|
|
|
import sentry_sdk
|
|
from fastapi import FastAPI, Request, HTTPException
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
|
from sentry_sdk.integrations.redis import RedisIntegration
|
|
from sentry_sdk.integrations.pymongo import PyMongoIntegration
|
|
from sentry_sdk.integrations.celery import CeleryIntegration
|
|
|
|
from .api.v1.routes_admin import router as admin_router
|
|
from .api.v1.routes_auth import router as auth_router
|
|
from .api.v1.routes_clients import router as clients_router
|
|
from .api.v1.routes_files import router as files_router
|
|
from .api.v1.routes_jobs import router as jobs_router
|
|
from .api.v1.routes_invitations import org_router as invitations_org_router
|
|
from .api.v1.routes_invitations import router as invitations_router
|
|
from .api.v1.routes_organizations import router as organizations_router
|
|
from .api.v1.routes_review_notes import router as review_notes_router
|
|
from .api.v1.routes_tts import router as tts_router
|
|
from .api.v1.routes_websockets import router as websockets_router
|
|
from .services.websocket import connection_manager
|
|
from .core.config import settings
|
|
from .core.secrets_config import initialize_config
|
|
from .core.database import close_mongo_connection, connect_to_mongo, create_indexes, get_database
|
|
from .core.logging import setup_logging
|
|
from .core.redis import close_redis_connection, connect_to_redis, get_redis_client
|
|
from .core.seed import seed_default_admin
|
|
from .middleware import create_rate_limit_middleware, create_validation_middleware
|
|
from .telemetry import (
|
|
app_metrics,
|
|
instrument_dependencies,
|
|
instrument_fastapi_app,
|
|
setup_tracing
|
|
)
|
|
from .services.websocket import connection_manager
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Startup
|
|
setup_logging()
|
|
|
|
# Initialize configuration with secrets
|
|
if settings.app_env == "prod":
|
|
try:
|
|
await initialize_config()
|
|
print("✅ Configuration initialized with Secret Manager")
|
|
except Exception as e:
|
|
print(f"⚠️ Failed to load secrets from Secret Manager: {e}")
|
|
print("⚠️ Falling back to environment variables")
|
|
|
|
# Initialize Sentry error tracking
|
|
if settings.sentry_dsn and settings.sentry_dsn.startswith(('http', 'https')):
|
|
sentry_sdk.init(
|
|
dsn=settings.sentry_dsn,
|
|
integrations=[
|
|
FastApiIntegration(),
|
|
RedisIntegration(),
|
|
PyMongoIntegration(),
|
|
CeleryIntegration(monitor_beat_tasks=True),
|
|
],
|
|
traces_sample_rate=0.1 if settings.app_env == "prod" else 1.0,
|
|
environment=settings.app_env,
|
|
release="1.0.0",
|
|
attach_stacktrace=True,
|
|
send_default_pii=False, # Don't send PII for privacy
|
|
)
|
|
|
|
# Initialize telemetry (disabled for local development)
|
|
# setup_tracing("accessible-video-api", "1.0.0")
|
|
# instrument_dependencies()
|
|
|
|
# Start Prometheus metrics server in production
|
|
if settings.app_env == "prod":
|
|
app_metrics.start_prometheus_server(port=8001)
|
|
|
|
await connect_to_mongo()
|
|
await connect_to_redis()
|
|
|
|
try:
|
|
db = await get_database()
|
|
await seed_default_admin(db)
|
|
except Exception as e:
|
|
print(f"⚠️ Could not seed default admin: {e}")
|
|
# await create_indexes() # Temporarily disabled for debugging
|
|
|
|
# Start WebSocket connection manager
|
|
await connection_manager.start()
|
|
|
|
# Initialize middleware with Redis client
|
|
redis_client = get_redis_client()
|
|
if redis_client:
|
|
rate_limit_middleware = await create_rate_limit_middleware(redis_client)
|
|
validation_middleware = await create_validation_middleware()
|
|
|
|
# Store middleware in app state for access
|
|
app.state.rate_limit_middleware = rate_limit_middleware
|
|
app.state.validation_middleware = validation_middleware
|
|
|
|
yield
|
|
# Shutdown
|
|
await connection_manager.stop()
|
|
await close_mongo_connection()
|
|
await close_redis_connection()
|
|
|
|
|
|
app = FastAPI(
|
|
title="Accessible Video API",
|
|
description="API for accessible video processing platform",
|
|
version="1.0.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# CORS middleware
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.cors_origins_list,
|
|
allow_credentials=True,
|
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Custom CORS error handler middleware to ensure CORS headers are added to all error responses
|
|
# This must be added BEFORE CORSMiddleware (which will be applied after due to reverse order)
|
|
@app.middleware("http")
|
|
async def cors_error_handler(request, call_next):
|
|
"""Ensure CORS headers are added to all responses, including errors."""
|
|
try:
|
|
response = await call_next(request)
|
|
except Exception as e:
|
|
# LOG THE EXCEPTION BEFORE HANDLING IT
|
|
print(f"🚨 EXCEPTION IN CORS MIDDLEWARE: {e}")
|
|
import traceback
|
|
print(f"Traceback:\n{traceback.format_exc()}")
|
|
|
|
# Handle any unhandled exceptions and add CORS headers
|
|
from fastapi.responses import JSONResponse
|
|
response = JSONResponse(
|
|
status_code=500,
|
|
content={"detail": "Internal server error", "error": str(e)}
|
|
)
|
|
|
|
# Always add CORS headers for allowed origins
|
|
origin = request.headers.get("origin")
|
|
if origin and origin in settings.cors_origins_list:
|
|
response.headers["access-control-allow-origin"] = origin
|
|
response.headers["access-control-allow-credentials"] = "true"
|
|
# Add other necessary CORS headers for error responses
|
|
if response.status_code >= 400:
|
|
response.headers["access-control-allow-methods"] = "GET, POST, PUT, PATCH, DELETE"
|
|
response.headers["access-control-allow-headers"] = "*"
|
|
|
|
return response
|
|
|
|
# Global exception handler to ensure CORS headers on all errors
|
|
@app.exception_handler(HTTPException)
|
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
|
"""Handle HTTP exceptions with CORS headers"""
|
|
response = JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={"detail": exc.detail}
|
|
)
|
|
|
|
# Add CORS headers
|
|
origin = request.headers.get("origin")
|
|
if origin and origin in settings.cors_origins_list:
|
|
response.headers["access-control-allow-origin"] = origin
|
|
response.headers["access-control-allow-credentials"] = "true"
|
|
response.headers["access-control-allow-methods"] = "GET, POST, PUT, PATCH, DELETE"
|
|
response.headers["access-control-allow-headers"] = "*"
|
|
|
|
return response
|
|
|
|
# Global exception handler for validation errors
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
|
"""Handle request validation errors with CORS headers"""
|
|
response = JSONResponse(
|
|
status_code=422,
|
|
content={"detail": exc.errors(), "body": exc.body}
|
|
)
|
|
|
|
# Add CORS headers
|
|
origin = request.headers.get("origin")
|
|
if origin and origin in settings.cors_origins_list:
|
|
response.headers["access-control-allow-origin"] = origin
|
|
response.headers["access-control-allow-credentials"] = "true"
|
|
response.headers["access-control-allow-methods"] = "GET, POST, PUT, PATCH, DELETE"
|
|
response.headers["access-control-allow-headers"] = "*"
|
|
|
|
return response
|
|
|
|
# Global exception handler for all other exceptions
|
|
@app.exception_handler(Exception)
|
|
async def general_exception_handler(request: Request, exc: Exception):
|
|
"""Handle all uncaught exceptions with logging"""
|
|
import traceback
|
|
from .core.logging import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
logger.error(f"Unhandled exception in {request.method} {request.url.path}: {exc}")
|
|
logger.error(f"Exception type: {type(exc).__name__}")
|
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
|
|
|
# Also print to stdout for immediate visibility
|
|
print(f"🚨 UNHANDLED EXCEPTION: {request.method} {request.url.path}")
|
|
print(f"Exception: {exc}")
|
|
print(f"Traceback:\n{traceback.format_exc()}")
|
|
|
|
response = JSONResponse(
|
|
status_code=500,
|
|
content={"detail": "Internal server error", "error": str(exc)}
|
|
)
|
|
|
|
# Add CORS headers
|
|
origin = request.headers.get("origin")
|
|
if origin and origin in settings.cors_origins_list:
|
|
response.headers["access-control-allow-origin"] = origin
|
|
response.headers["access-control-allow-credentials"] = "true"
|
|
|
|
return response
|
|
|
|
# Add custom middleware (order matters - applied in reverse order)
|
|
@app.middleware("http")
|
|
async def rate_limiting_middleware(request, call_next):
|
|
"""Apply rate limiting middleware."""
|
|
# Skip middleware for auth endpoints during debugging
|
|
if request.url.path in ["/api/v1/auth/login", "/api/v1/auth/refresh"]:
|
|
return await call_next(request)
|
|
if hasattr(app.state, 'rate_limit_middleware'):
|
|
return await app.state.rate_limit_middleware(request, call_next)
|
|
return await call_next(request)
|
|
|
|
@app.middleware("http")
|
|
async def validation_middleware(request, call_next):
|
|
"""Apply request validation middleware."""
|
|
# TEMPORARILY DISABLED FOR DEBUGGING
|
|
return await call_next(request)
|
|
|
|
# Skip middleware for auth endpoints during debugging
|
|
if request.url.path in ["/api/v1/auth/login", "/api/v1/auth/refresh"]:
|
|
return await call_next(request)
|
|
if hasattr(app.state, 'validation_middleware'):
|
|
return await app.state.validation_middleware(request, call_next)
|
|
return await call_next(request)
|
|
|
|
# Instrument FastAPI app for tracing (disabled for local development)
|
|
# instrument_fastapi_app(app)
|
|
|
|
# Include routers
|
|
app.include_router(auth_router, prefix="/api/v1")
|
|
app.include_router(clients_router, prefix="/api/v1")
|
|
app.include_router(organizations_router, prefix="/api/v1")
|
|
app.include_router(invitations_org_router, prefix="/api/v1")
|
|
app.include_router(invitations_router, prefix="/api/v1")
|
|
app.include_router(files_router, prefix="/api/v1")
|
|
app.include_router(jobs_router, prefix="/api/v1")
|
|
app.include_router(review_notes_router, prefix="/api/v1")
|
|
app.include_router(tts_router, prefix="/api/v1")
|
|
app.include_router(admin_router, prefix="/api/v1")
|
|
app.include_router(websockets_router, prefix="/api/v1")
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup_event():
|
|
"""Initialize services on startup"""
|
|
logger.info("🚀 Starting up FastAPI application...")
|
|
|
|
# Start WebSocket connection manager
|
|
try:
|
|
await connection_manager.start()
|
|
logger.info("✅ WebSocket connection manager started successfully")
|
|
except Exception as e:
|
|
logger.error(f"❌ Failed to start WebSocket connection manager: {e}")
|
|
raise
|
|
|
|
|
|
@app.on_event("shutdown")
|
|
async def shutdown_event():
|
|
"""Cleanup services on shutdown"""
|
|
logger.info("🛑 Shutting down FastAPI application...")
|
|
|
|
# Stop WebSocket connection manager
|
|
try:
|
|
await connection_manager.stop()
|
|
logger.info("✅ WebSocket connection manager stopped successfully")
|
|
except Exception as e:
|
|
logger.error(f"❌ Error stopping WebSocket connection manager: {e}")
|
|
|
|
|
|
@app.get("/health")
|
|
async def health_check():
|
|
return {"status": "healthy", "version": "1.0.0"}
|
|
|
|
@app.get("/debug-test")
|
|
async def debug_test():
|
|
print("🔥🔥🔥 DEBUG TEST ENDPOINT HIT 🔥🔥🔥")
|
|
return {"message": "If you see this, routing works"}
|
|
|
|
|
|
@app.get("/metrics")
|
|
async def metrics():
|
|
"""Prometheus metrics endpoint"""
|
|
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
|
|
from fastapi import Response
|
|
|
|
return Response(
|
|
content=generate_latest(),
|
|
media_type=CONTENT_TYPE_LATEST
|
|
)
|