Copy main, config, middleware/auth, leftover routers, pipeline_worker, job_runner, event_bridge, projects, and admin_settings without deleting dependency copies. Native auth and impedance routers stay as-is.
178 lines
7.1 KiB
Python
178 lines
7.1 KiB
Python
# Native Periscope overlay: leftover app module still imported from src.
|
|
# PinScope original remains in dependency/.
|
|
|
|
"""PeriscopeX backend — FastAPI application."""
|
|
|
|
import logging
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
|
from backend.config import settings
|
|
from backend.routers import admin, auth, contact, feedback, impedance, pipeline, projects, reports, survey
|
|
from backend.services.projects import ProjectNotFound
|
|
from backend.services.storage import LocalStorageBackend
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Default user ID for unauthenticated local dev
|
|
LOCAL_DEV_USER = "local"
|
|
|
|
|
|
def _create_storage():
|
|
"""Create the appropriate storage backend based on config."""
|
|
if settings.use_gcs:
|
|
from backend.services.storage_gcs import GCSStorageBackend
|
|
|
|
return GCSStorageBackend(settings.gcs_bucket)
|
|
return LocalStorageBackend(settings.data_dir)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Guard: refuse to start in production without authentication
|
|
env = os.getenv("ENVIRONMENT", "").lower()
|
|
if env == "production" and not settings.use_auth:
|
|
raise RuntimeError(
|
|
"Production requires authentication: set AUTH_JWT_SECRET "
|
|
"(local Periscope accounts) or CLERK_JWKS_URL + CLERK_SECRET_KEY."
|
|
)
|
|
if not settings.use_auth:
|
|
logger.warning(
|
|
"Authentication is DISABLED — all users have full access. "
|
|
"This is only safe for local development."
|
|
)
|
|
elif settings.use_local_auth:
|
|
logger.info("Local Periscope authentication enabled (AUTH_JWT_SECRET)")
|
|
elif settings.use_clerk:
|
|
logger.info("Clerk authentication enabled")
|
|
if not settings.billing_enabled:
|
|
logger.warning(
|
|
"Billing is DISABLED — pipelines run free and the billing/credits "
|
|
"routes are not mounted."
|
|
)
|
|
|
|
app.state.storage = _create_storage()
|
|
|
|
# For local backend, ensure base directories exist
|
|
if isinstance(app.state.storage, LocalStorageBackend):
|
|
base = settings.data_dir
|
|
(base / "users").mkdir(parents=True, exist_ok=True)
|
|
(base / "auth" / "users").mkdir(parents=True, exist_ok=True)
|
|
(base / "auth" / "by_email").mkdir(parents=True, exist_ok=True)
|
|
(base / "library" / "extracted").mkdir(parents=True, exist_ok=True)
|
|
(base / "library" / "patterns").mkdir(parents=True, exist_ok=True)
|
|
(base / "library" / "models").mkdir(parents=True, exist_ok=True)
|
|
(base / "library" / "passives").mkdir(parents=True, exist_ok=True)
|
|
(base / "library" / "datasheets" / "refs").mkdir(parents=True, exist_ok=True)
|
|
(base / "library" / "datasheets" / "blobs").mkdir(parents=True, exist_ok=True)
|
|
yield
|
|
# Pipelines run in a separate Cloud Run Job worker (or local
|
|
# subprocess in dev), so the API process has nothing to clean up
|
|
# on shutdown.
|
|
|
|
|
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
"""Add standard security headers to all responses."""
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
response = await call_next(request)
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
if settings.use_auth:
|
|
# Only set HSTS when running behind TLS in production
|
|
response.headers["Strict-Transport-Security"] = (
|
|
"max-age=31536000; includeSubDomains"
|
|
)
|
|
return response
|
|
|
|
|
|
class AuthMiddleware(BaseHTTPMiddleware):
|
|
"""Extract user_id from JWT (Clerk or local) or default to local dev user."""
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
# Let CORS preflight through — browsers send OPTIONS without credentials
|
|
if request.method == "OPTIONS":
|
|
return await call_next(request)
|
|
# Public endpoints that don't require authentication
|
|
if request.url.path in {
|
|
"/api/contact",
|
|
"/api/auth/mode",
|
|
"/api/auth/register",
|
|
"/api/auth/login",
|
|
}:
|
|
request.state.user_id = LOCAL_DEV_USER
|
|
return await call_next(request)
|
|
if settings.use_auth:
|
|
from backend.middleware.auth import verify_request_user
|
|
|
|
user_id = await verify_request_user(request)
|
|
if user_id is None:
|
|
is_production = os.getenv("ENVIRONMENT", "").lower() == "production"
|
|
# Local auth (and production) require a valid token for API routes.
|
|
if is_production or settings.use_local_auth:
|
|
from fastapi.responses import JSONResponse
|
|
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={"detail": "Authentication required"},
|
|
)
|
|
# Non-production Clerk: fall back so missing token doesn't block
|
|
# local development when Clerk is configured but unused.
|
|
user_id = LOCAL_DEV_USER
|
|
request.state.user_id = user_id
|
|
else:
|
|
request.state.user_id = LOCAL_DEV_USER
|
|
response = await call_next(request)
|
|
return response
|
|
|
|
|
|
app = FastAPI(
|
|
title="PeriscopeX",
|
|
description="Agentic schematic validation API",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# Middleware order matters: Starlette applies in LIFO order (last added =
|
|
# outermost). CORSMiddleware MUST be outermost so that CORS headers are
|
|
# present on every response — including 401s from AuthMiddleware.
|
|
app.add_middleware(AuthMiddleware)
|
|
app.add_middleware(SecurityHeadersMiddleware)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.cors_origins,
|
|
allow_credentials=True,
|
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
allow_headers=["content-type", "authorization"],
|
|
expose_headers=["X-Datasheet-Url", "X-Datasheet-Source"],
|
|
)
|
|
|
|
@app.exception_handler(ProjectNotFound)
|
|
async def _project_not_found_handler(request: Request, exc: ProjectNotFound):
|
|
# A mutation raced a project deletion (or hit never-fully-created metadata).
|
|
# Return a clean 404 — CORSMiddleware is outermost, so headers still land.
|
|
return JSONResponse(status_code=404, content={"detail": str(exc)})
|
|
|
|
|
|
app.include_router(projects.router, prefix="/api")
|
|
app.include_router(pipeline.router, prefix="/api")
|
|
app.include_router(reports.router, prefix="/api")
|
|
app.include_router(impedance.router, prefix="/api")
|
|
app.include_router(admin.router, prefix="/api")
|
|
app.include_router(auth.router, prefix="/api")
|
|
if settings.billing_enabled:
|
|
# Import guarded too: with billing disabled the core never loads the
|
|
# billing/credits routers (or, transitively, the Stripe SDK).
|
|
from backend.routers import billing, credits
|
|
|
|
app.include_router(billing.router, prefix="/api")
|
|
app.include_router(credits.router, prefix="/api")
|
|
app.include_router(contact.router, prefix="/api")
|
|
app.include_router(feedback.router, prefix="/api")
|
|
app.include_router(survey.router, prefix="/api")
|