408 lines
14 KiB
Python
408 lines
14 KiB
Python
# Load environment variables
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
|
|
import asyncio
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from loguru import logger
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
from api.auth import PasswordAuthMiddleware
|
|
from api.middleware import MaxBodySizeMiddleware, get_max_upload_size_bytes
|
|
from api.routers import (
|
|
auth,
|
|
chat,
|
|
config,
|
|
credentials,
|
|
embedding,
|
|
embedding_rebuild,
|
|
episode_profiles,
|
|
insights,
|
|
languages,
|
|
models,
|
|
notebooks,
|
|
notes,
|
|
podcasts,
|
|
providers,
|
|
search,
|
|
settings,
|
|
source_chat,
|
|
sources,
|
|
speaker_profiles,
|
|
transformations,
|
|
)
|
|
from api.routers import commands as commands_router
|
|
from open_notebook.database.async_migrate import AsyncMigrationManager
|
|
from open_notebook.exceptions import (
|
|
AuthenticationError,
|
|
ConfigurationError,
|
|
ExternalServiceError,
|
|
InvalidInputError,
|
|
NetworkError,
|
|
NotFoundError,
|
|
OpenNotebookError,
|
|
RateLimitError,
|
|
UnsupportedTypeException,
|
|
)
|
|
from open_notebook.utils.encryption import get_secret_from_env
|
|
|
|
|
|
def _parse_cors_origins(raw: str) -> list[str]:
|
|
"""Parse CORS_ORIGINS env value into a list of origins."""
|
|
value = raw.strip()
|
|
if value == "*":
|
|
return ["*"]
|
|
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
|
|
|
|
|
# Parsed once at module load; CORS_ORIGINS changes require a restart.
|
|
_cors_origins_raw = os.getenv("CORS_ORIGINS")
|
|
CORS_ALLOWED_ORIGINS = _parse_cors_origins(_cors_origins_raw or "*")
|
|
CORS_IS_DEFAULT_WILDCARD = _cors_origins_raw is None
|
|
# Keyed on the parsed list, not on whether the env var was set: an operator
|
|
# who explicitly sets CORS_ORIGINS=* must get the same wildcard treatment as
|
|
# the default, or credentials would combine with a wildcard origin - the
|
|
# exact reflect-any-Origin behavior this flag exists to prevent.
|
|
CORS_ALLOW_CREDENTIALS = "*" not in CORS_ALLOWED_ORIGINS
|
|
|
|
# Parsed once at module load; OPEN_NOTEBOOK_MAX_UPLOAD_SIZE_MB changes require a restart.
|
|
MAX_UPLOAD_SIZE_BYTES = get_max_upload_size_bytes()
|
|
|
|
DATABASE_STARTUP_RETRY_ATTEMPTS = 12
|
|
DATABASE_STARTUP_RETRY_INITIAL_DELAY_SECONDS = 1
|
|
DATABASE_STARTUP_RETRY_MAX_DELAY_SECONDS = 5
|
|
# Per-probe ceiling so a hung connection cannot exceed the retry budget or
|
|
# block startup indefinitely. A probe that exceeds this is treated as a
|
|
# transient failure and retried like any other unreachable-database attempt.
|
|
DATABASE_STARTUP_RETRY_PROBE_TIMEOUT_SECONDS = 5
|
|
|
|
|
|
def _cors_headers(request: Request) -> dict[str, str]:
|
|
"""
|
|
Build CORS headers for error responses.
|
|
|
|
Mirrors Starlette CORSMiddleware behavior: reflects the request Origin
|
|
when the origin is allowed (or when wildcard is configured, since
|
|
browsers reject `Access-Control-Allow-Origin: *` combined with
|
|
credentials). Omits `Access-Control-Allow-Origin` for disallowed
|
|
origins so the browser blocks the error body from leaking cross-origin.
|
|
Only claims Access-Control-Allow-Credentials when the real CORSMiddleware
|
|
would (see its allow_credentials comment above) - otherwise error
|
|
responses would grant credentialed access the success path doesn't.
|
|
"""
|
|
origin = request.headers.get("origin")
|
|
headers: dict[str, str] = {
|
|
"Access-Control-Allow-Methods": "*",
|
|
"Access-Control-Allow-Headers": "*",
|
|
}
|
|
if CORS_ALLOW_CREDENTIALS:
|
|
headers["Access-Control-Allow-Credentials"] = "true"
|
|
|
|
if origin and ("*" in CORS_ALLOWED_ORIGINS or origin in CORS_ALLOWED_ORIGINS):
|
|
headers["Access-Control-Allow-Origin"] = origin
|
|
headers["Vary"] = "Origin"
|
|
|
|
return headers
|
|
|
|
|
|
# Import commands to register them in the API process
|
|
try:
|
|
logger.info("Commands imported in API process")
|
|
except Exception as e:
|
|
logger.error(f"Failed to import commands in API process: {e}")
|
|
|
|
|
|
async def _wait_for_database(migration_manager: AsyncMigrationManager) -> None:
|
|
"""
|
|
Wait for SurrealDB to accept connections before running migrations.
|
|
|
|
Docker Compose can start the API before the database name is resolvable. Keep
|
|
migration errors fail-fast by only retrying this lightweight readiness probe.
|
|
"""
|
|
attempts = max(1, DATABASE_STARTUP_RETRY_ATTEMPTS)
|
|
delay = DATABASE_STARTUP_RETRY_INITIAL_DELAY_SECONDS
|
|
|
|
for attempt in range(1, attempts + 1):
|
|
try:
|
|
await asyncio.wait_for(
|
|
migration_manager.ping(),
|
|
timeout=DATABASE_STARTUP_RETRY_PROBE_TIMEOUT_SECONDS,
|
|
)
|
|
if attempt > 1:
|
|
logger.info(f"Database became reachable on attempt {attempt}")
|
|
return
|
|
except Exception as e:
|
|
if attempt == attempts:
|
|
logger.error(
|
|
f"Database did not become reachable after {attempts} attempts"
|
|
)
|
|
raise
|
|
|
|
logger.warning(
|
|
"Database is not reachable yet "
|
|
f"(attempt {attempt}/{attempts}): {str(e)}. "
|
|
f"Retrying in {delay:g} seconds..."
|
|
)
|
|
await asyncio.sleep(delay)
|
|
delay = min(delay * 2, DATABASE_STARTUP_RETRY_MAX_DELAY_SECONDS)
|
|
|
|
|
|
async def _run_database_migrations() -> None:
|
|
"""Run startup database migrations after SurrealDB is reachable."""
|
|
migration_manager = AsyncMigrationManager()
|
|
await _wait_for_database(migration_manager)
|
|
|
|
current_version = await migration_manager.get_current_version()
|
|
logger.info(f"Current database version: {current_version}")
|
|
|
|
if await migration_manager.needs_migration():
|
|
logger.warning("Database migrations are pending. Running migrations...")
|
|
await migration_manager.run_migration_up()
|
|
new_version = await migration_manager.get_current_version()
|
|
logger.success(
|
|
f"Migrations completed successfully. Database is now at version {new_version}"
|
|
)
|
|
else:
|
|
logger.info("Database is already at the latest version. No migrations needed.")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""
|
|
Lifespan event handler for the FastAPI application.
|
|
Runs database migrations automatically on startup.
|
|
"""
|
|
# Startup: Security checks
|
|
logger.info("Starting API initialization...")
|
|
|
|
# Security check: Encryption key
|
|
if not get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"):
|
|
logger.warning(
|
|
"OPEN_NOTEBOOK_ENCRYPTION_KEY not set. "
|
|
"API key encryption will fail until this is configured. "
|
|
"Set OPEN_NOTEBOOK_ENCRYPTION_KEY to any secret string."
|
|
)
|
|
|
|
# Run database migrations
|
|
|
|
try:
|
|
await _run_database_migrations()
|
|
except Exception as e:
|
|
logger.error(f"CRITICAL: Database migration failed: {str(e)}")
|
|
logger.exception(e)
|
|
# Fail fast - don't start the API with an outdated database schema
|
|
raise RuntimeError(f"Failed to run database migrations: {str(e)}") from e
|
|
|
|
logger.success("API initialization completed successfully")
|
|
|
|
# Yield control to the application
|
|
yield
|
|
|
|
# Shutdown: cleanup if needed
|
|
logger.info("API shutdown complete")
|
|
|
|
|
|
app = FastAPI(
|
|
title="Open Notebook API",
|
|
description="API for Open Notebook - Research Assistant",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
if CORS_IS_DEFAULT_WILDCARD:
|
|
logger.warning(
|
|
"CORS_ORIGINS is not set — API accepts cross-origin requests from any "
|
|
"origin (default: '*'). For production deployments, set CORS_ORIGINS to "
|
|
"your frontend origin(s), e.g. "
|
|
"CORS_ORIGINS=https://notebook.example.com"
|
|
)
|
|
else:
|
|
logger.info(f"CORS allowed origins: {CORS_ALLOWED_ORIGINS}")
|
|
|
|
# Add password authentication middleware first
|
|
# Exclude /api/auth/status and /api/config from authentication
|
|
app.add_middleware(
|
|
PasswordAuthMiddleware,
|
|
excluded_paths=[
|
|
"/",
|
|
"/health",
|
|
"/docs",
|
|
"/openapi.json",
|
|
"/redoc",
|
|
"/api/auth/status",
|
|
"/api/config",
|
|
],
|
|
)
|
|
|
|
# Reject oversized request bodies before they reach auth or routing - added
|
|
# after PasswordAuthMiddleware (so it wraps around it) so a too-large request
|
|
# is rejected before spending any work checking credentials.
|
|
logger.info(
|
|
f"Max request body size: {MAX_UPLOAD_SIZE_BYTES / (1024 * 1024):g}MB "
|
|
"(set OPEN_NOTEBOOK_MAX_UPLOAD_SIZE_MB to change)"
|
|
)
|
|
app.add_middleware(MaxBodySizeMiddleware, max_body_size=MAX_UPLOAD_SIZE_BYTES)
|
|
|
|
# Add CORS middleware last (so it processes first, and so it can attach
|
|
# CORS headers to a 413 raised by MaxBodySizeMiddleware)
|
|
#
|
|
# allow_credentials is tied to whether CORS_ORIGINS resolves to specific
|
|
# origins: combining allow_origins=["*"] with allow_credentials=True makes
|
|
# Starlette reflect the request's Origin header verbatim (browsers reject a
|
|
# literal "*" alongside credentials), which defeats the origin allowlist.
|
|
# The frontend never sends credentialed requests (withCredentials: false)
|
|
# and auth is a Bearer header, not a cookie, so this isn't independently
|
|
# exploitable today - but there's no reason to allow it for any wildcard
|
|
# case. Once an operator explicitly scopes CORS_ORIGINS to real origins,
|
|
# credentialed cross-origin requests to those origins are safe to allow.
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=CORS_ALLOWED_ORIGINS,
|
|
allow_credentials=CORS_ALLOW_CREDENTIALS,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# Custom exception handler to ensure CORS headers are included in error responses
|
|
# This helps when errors occur before the CORS middleware can process them
|
|
@app.exception_handler(StarletteHTTPException)
|
|
async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):
|
|
"""
|
|
Custom exception handler that ensures CORS headers are included in error responses.
|
|
This is particularly important for 413 (Payload Too Large) errors during file uploads.
|
|
|
|
Note: If a reverse proxy (nginx, traefik) returns 413 before the request reaches
|
|
FastAPI, this handler won't be called. In that case, configure your reverse proxy
|
|
to add CORS headers to error responses.
|
|
"""
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={"detail": exc.detail},
|
|
headers={**(exc.headers or {}), **_cors_headers(request)},
|
|
)
|
|
|
|
|
|
@app.exception_handler(NotFoundError)
|
|
async def not_found_error_handler(request: Request, exc: NotFoundError):
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(InvalidInputError)
|
|
async def invalid_input_error_handler(request: Request, exc: InvalidInputError):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(AuthenticationError)
|
|
async def authentication_error_handler(request: Request, exc: AuthenticationError):
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(RateLimitError)
|
|
async def rate_limit_error_handler(request: Request, exc: RateLimitError):
|
|
return JSONResponse(
|
|
status_code=429,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(ConfigurationError)
|
|
async def configuration_error_handler(request: Request, exc: ConfigurationError):
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(NetworkError)
|
|
async def network_error_handler(request: Request, exc: NetworkError):
|
|
return JSONResponse(
|
|
status_code=502,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(ExternalServiceError)
|
|
async def external_service_error_handler(request: Request, exc: ExternalServiceError):
|
|
return JSONResponse(
|
|
status_code=502,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(UnsupportedTypeException)
|
|
async def unsupported_type_error_handler(
|
|
request: Request, exc: UnsupportedTypeException
|
|
):
|
|
return JSONResponse(
|
|
status_code=415,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(OpenNotebookError)
|
|
async def open_notebook_error_handler(request: Request, exc: OpenNotebookError):
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
# Include routers
|
|
app.include_router(auth.router, prefix="/api", tags=["auth"])
|
|
app.include_router(config.router, prefix="/api", tags=["config"])
|
|
app.include_router(notebooks.router, prefix="/api", tags=["notebooks"])
|
|
app.include_router(search.router, prefix="/api", tags=["search"])
|
|
app.include_router(models.router, prefix="/api", tags=["models"])
|
|
app.include_router(transformations.router, prefix="/api", tags=["transformations"])
|
|
app.include_router(notes.router, prefix="/api", tags=["notes"])
|
|
app.include_router(embedding.router, prefix="/api", tags=["embedding"])
|
|
app.include_router(
|
|
embedding_rebuild.router, prefix="/api/embeddings", tags=["embeddings"]
|
|
)
|
|
app.include_router(settings.router, prefix="/api", tags=["settings"])
|
|
app.include_router(sources.router, prefix="/api", tags=["sources"])
|
|
app.include_router(insights.router, prefix="/api", tags=["insights"])
|
|
app.include_router(commands_router.router, prefix="/api", tags=["commands"])
|
|
app.include_router(podcasts.router, prefix="/api", tags=["podcasts"])
|
|
app.include_router(episode_profiles.router, prefix="/api", tags=["episode-profiles"])
|
|
app.include_router(speaker_profiles.router, prefix="/api", tags=["speaker-profiles"])
|
|
app.include_router(chat.router, prefix="/api", tags=["chat"])
|
|
app.include_router(source_chat.router, prefix="/api", tags=["source-chat"])
|
|
app.include_router(credentials.router, prefix="/api", tags=["credentials"])
|
|
app.include_router(providers.router, prefix="/api", tags=["providers"])
|
|
app.include_router(languages.router, prefix="/api", tags=["languages"])
|
|
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
return {"message": "Open Notebook API is running"}
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "healthy"}
|