Spaces:
Running
Running
File size: 11,917 Bytes
6c24b50 bd469c1 6c24b50 bd469c1 6c24b50 5a01a63 6c24b50 08919be 89157f5 6c24b50 5a01a63 2ebf97a 3379d24 947ea10 c2bb116 bd469c1 6c24b50 b7dddbe 548dfc9 b7dddbe 548dfc9 b7dddbe 3379d24 c2bb116 947ea10 bd469c1 3379d24 6c24b50 c25809b 6c24b50 bd469c1 6c24b50 c28ae12 6c24b50 5d6260a 5a01a63 5d6260a 5a01a63 5d6260a 5a01a63 5d6260a 5a01a63 c2bb116 4b54fab 3379d24 4b54fab c2bb116 3379d24 2ebf97a 6c24b50 947ea10 8f9855d 947ea10 6c24b50 2ebf97a 947ea10 2ebf97a c2bb116 89157f5 2170658 0e4591f fe8cd16 55ecbeb 8ec8b4a 8f9855d 722c296 bd469c1 5d6260a 6c24b50 3de9972 6c24b50 3379d24 ff6b176 c2bb116 f87115f 8f9855d 6c24b50 bd469c1 d02a73b 8ee0135 d02a73b 5a01a63 d02a73b b7dddbe 548dfc9 b7dddbe bd469c1 6c24b50 62ec94a 8604693 62ec94a 6c24b50 c2bb116 6c24b50 89157f5 6c24b50 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 | from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from app.api.v1.router import api_v1_router
from app.api.v1.system import is_maintenance
from app.config import get_settings
from app.core.auth.deps import init_auth_db
from app.core.database import pool_manager
from app.core.logger import get_logger
from app.core.redis_client import close_redis, create_redis_client
from app.core.scripts import load_scripts
from app.services.embeddings_service import EmbeddingService
from app.services.scheduler_service import SchedulerService
from app.services.vector_store_service import VectorStoreService
from app.utils.http_utils import SharedAsyncClient
_logger = get_logger(__name__)
_settings = get_settings()
# Public /api/v1/ paths that do not require authentication
_PUBLIC_API_PREFIXES = (
"/api/v1/auth/register",
"/api/v1/auth/login",
"/api/v1/auth/refresh",
"/api/v1/auth/forgot-password",
"/api/v1/auth/reset-password",
"/api/v1/url-shortener/",
)
# GET-only paths that must not require authentication: Google redirects the
# user's browser here after authorization, and a redirect cannot attach the API key.
_PUBLIC_GET_PATHS = frozenset({"/api/v1/google/oauth/callback"})
def _is_public_path(path: str, method: str = "GET") -> bool:
if path in _PUBLIC_GET_PATHS and method == "GET":
return True
return path.startswith(_PUBLIC_API_PREFIXES)
_embedding_service: EmbeddingService = EmbeddingService()
_vector_store_service: VectorStoreService = VectorStoreService(_embedding_service)
_scheduler_service: SchedulerService = SchedulerService()
_ping_client = SharedAsyncClient(timeout=30.0)
# Global per-client-IP rate limit applied to every /api/v1 route.
# Configure via RATE_LIMIT_PER_MINUTE (0 or empty disables).
limiter = Limiter(key_func=get_remote_address, storage_uri="memory://", headers_enabled=False)
async def _api_rate_limit(request: Request) -> None:
"""Apply the global per-client-IP rate limit to all /api/v1 routes."""
return None
_RATE_LIMIT_PER_MINUTE = _settings.rate_limit_per_minute
if _RATE_LIMIT_PER_MINUTE and _RATE_LIMIT_PER_MINUTE > 0:
_api_rate_limit = limiter.limit(f"{_RATE_LIMIT_PER_MINUTE}/minute")(_api_rate_limit)
async def _self_ping():
health_url = _settings.self_ping_url
while True:
try:
client = await _ping_client.get()
response = await client.get(health_url)
if response.status_code == 200:
_logger.info("Self-ping successful: %s", health_url)
else:
_logger.warning("Self-ping returned: %s - %s", health_url, response.status_code)
except Exception as exc:
_logger.error("Self-ping error: %s", exc)
await asyncio.sleep(900)
@asynccontextmanager
async def lifespan(app: FastAPI):
if not _settings.supabase_url or not _settings.supabase_service_role_key:
_logger.error(
"Supabase not configured! Set SUPABASE_URL and "
"SUPABASE_SERVICE_ROLE_KEY environment variables."
)
else:
_logger.info("Initializing Supabase databases...")
await init_auth_db()
_logger.info("Authentication database initialized via Supabase")
_logger.info("Initializing vector store database...")
from app.core.vector_store.deps import init_vector_store_db
await init_vector_store_db()
await _vector_store_service.init_db()
_logger.info(
"Vector store database initialized with %d stores",
len(_vector_store_service.list_stores()),
)
_logger.info("Initializing embedding service (loading 384-dim model)...")
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _embedding_service.load_model, 384)
_logger.info("Embedding service initialized with dims: %s", _embedding_service.loaded_dimensions)
_logger.info("Vector store service initialized with %d existing stores", len(_vector_store_service.list_stores()))
redis = create_redis_client(_settings.redis_url) if _settings.redis_url else None
scripts = await load_scripts(redis) if redis else {}
app.state.redis = redis
app.state.scripts = scripts
if redis:
_logger.info("Redis and Lua scripts initialized")
else:
_logger.warning("Redis not configured, running in degraded mode")
asyncio.create_task(_self_ping())
if _settings.supabase_upload_enabled:
try:
from app.services.media_storage_service import get_storage_service
storage = await get_storage_service()
bucket = await storage.ensure_bucket(_settings.supabase_storage_bucket)
_logger.info("Supabase Storage bucket ensured: %s", bucket)
except Exception as exc:
_logger.error("Failed to ensure Supabase Storage bucket at startup: %s", exc)
await _scheduler_service.start()
_logger.info("Scheduler service started")
yield
_logger.info("Shutting down...")
await _scheduler_service.shutdown()
await close_redis(redis)
await _vector_store_service.close_all()
await pool_manager.close_all()
from app.api.v1.google_oauth import close_oauth_service
await close_oauth_service()
from app.api.v1.google_maps import close_maps_service
await close_maps_service()
from app.api.v1.gcs import close_gcs_service
await close_gcs_service()
from app.api.v1.gmail import close_gmail_service
await close_gmail_service()
from app.api.v1.sheets import close_sheets_service
await close_sheets_service()
from app.services.media_storage_service import close_storage_service
await close_storage_service()
from app.utils.http_utils import close_shared_aiohttp_sessions
await close_shared_aiohttp_sessions()
await _ping_client.close()
from app.services.supabase import get_supabase_client
client = get_supabase_client()
if client:
await client.close()
_logger.info("Supabase client closed")
def create_application() -> FastAPI:
app = FastAPI(
title=_settings.app_name,
description="AgentDeck-Backend",
version=_settings.app_version,
docs_url="/docs",
redoc_url="/redoc",
openapi_tags=[
{"name": "Convert", "description": "Single-file and single-URL conversion"},
{"name": "Batch", "description": "Bulk conversion of files and URLs"},
{"name": "System", "description": "Health, info, and supported formats"},
{"name": "Embeddings", "description": "Text embedding generation using transformer models"},
{"name": "Verify", "description": "Phone number and identity verification"},
{"name": "Vector Stores", "description": "Create, manage, and search vector stores for RAG"},
{"name": "URL Shortener", "description": "Create and manage short URLs with analytics"},
{"name": "Media-to-Media Conversion", "description": "PDF-to-image and image-to-image conversion with local or Supabase Storage output"},
],
lifespan=lifespan,
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.state.limiter = limiter
@app.exception_handler(RateLimitExceeded)
async def _rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
retry_after = getattr(exc, "retry_after", 60) or 60
return JSONResponse(
status_code=429,
content={"success": False, "detail": "Rate limit exceeded. Please retry later."},
headers={"Retry-After": str(int(retry_after))},
)
@app.middleware("http")
async def maintenance_middleware(request: Request, call_next):
if is_maintenance():
method = request.method
path = request.url.path
if method not in ("GET", "HEAD", "OPTIONS"):
if not path.startswith("/api/v1/maintenance"):
if method == "POST" and path.startswith("/api/v1/backup"):
pass
else:
from starlette.responses import JSONResponse
return JSONResponse(
status_code=503,
content={
"success": False,
"detail": "Service is under maintenance. No write operations allowed.",
},
)
return await call_next(request)
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
path = request.url.path
if path.startswith("/api/v1/") and not _is_public_path(path, request.method):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
from starlette.responses import JSONResponse
return JSONResponse(
status_code=401,
content={"success": False, "detail": "Missing Authorization header"},
)
token = auth_header.removeprefix("Bearer ")
if token != _settings.api_key:
from starlette.responses import JSONResponse
return JSONResponse(
status_code=401,
content={"success": False, "detail": "Invalid API key"},
)
return await call_next(request)
app.include_router(
api_v1_router,
prefix="/api/v1",
dependencies=[Depends(_api_rate_limit)],
)
@app.get("/", include_in_schema=False)
async def root(request: Request):
from collections import defaultdict
routes_by_tag: dict[str, list[dict]] = defaultdict(list)
for route in app.routes:
if not hasattr(route, "methods") or not hasattr(route, "path"):
continue
if route.path in ("/", "/health", "/ping", "/openapi.json", "/docs", "/redoc", "/docs/oauth2-redirect"):
continue
tags = getattr(route, "tags", None) or ["default"]
for tag in tags:
routes_by_tag[tag].append({
"method": list(route.methods - {"HEAD", "OPTIONS"}),
"path": route.path,
"summary": getattr(route, "summary", ""),
})
return {
"name": _settings.app_name,
"version": _settings.app_version,
"docs": {
"swagger": str(request.base_url) + "docs",
"redoc": str(request.base_url) + "redoc",
},
}
@app.get("/health", include_in_schema=False)
async def root_health():
store_count = len(_vector_store_service.list_stores())
doc_count = await _vector_store_service.get_total_document_count()
return {
"success": True,
"app_name": _settings.app_name,
"version": _settings.app_version,
"embedding_dimension": _settings.embedding_dimension,
"vector_store_count": store_count,
"total_documents": doc_count,
"model_loaded": _embedding_service.is_loaded(384),
}
@app.get("/ping", include_in_schema=False)
async def ping():
return {"name": f"{_settings.app_name}", "version": _settings.app_version}
return app
app = create_application()
|