""" Orchard YouTube Audits System @author: Aleix Cortadellas (acortadellas@theorchard.com) """ import asyncio import os from contextlib import asynccontextmanager import uvicorn from box import Box from fastapi import Depends, FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from src.backend import environment_vars as env_vars from src.backend import logger, responses, sentry from src.backend.connectors.db import Client from src.backend.connectors.youtube_apis import YtCidClient from src.backend.constants import BASE_FRONTEND_PATH from src.backend.routers import app as endpoints_app from src.backend.routers import audits as endpoints_audits from src.backend.routers import auth as endpoints_auth from src.backend.routers import utils as endpoints_utils from src.backend.users.manage import verify_auth0 logger = logger.new_logger(__name__) # Initialize Sentry error tracking sentry.use_sentry(dsn_env_var="SENTRY_DSN") # Global state container. Uses Box instead of dict to allow dot notation. state: Box = Box() @asynccontextmanager async def lifespan(_: FastAPI): """Set up and tear down resources during the lifespan of the app. See: https://fastapi.tiangolo.com/advanced/events/#lifespan-function """ logger.debug("Setting up app state...") state.db = Client() state.youtube_api_cid_client = YtCidClient() # Trigger initialization of YouTube Content ID API client, so that if any API # discovery error takes place it happens early when the app starts try: await state.youtube_api_cid_client.init() except Exception as ex: logger.error(f"Failed to initialize YouTube Content ID API client: {ex}") # Purge archived audit groups older than n days, to reduce the size of the DB. # This is done asynchronously to avoid blocking the app startup. Fire and forget. # Use __name__ == "__main__" to avoid running this when testing or doing # any other operation that doesn't require the app to be running. if __name__ == "__main__": logger.debug("Purging audit groups archived 90 or more days ago...") asyncio.create_task(state.db.AuditGroups.purge(days_ago=90)) yield state.clear() app = FastAPI(lifespan=lifespan) app.add_middleware( CORSMiddleware, # noqa allow_origins=tuple(f"{_}:{env_vars.PORT}" for _ in env_vars.CORS_ALLOW_ORIGINS), allow_credentials=True, allow_methods=env_vars.CORS_ALLOW_METHODS, allow_headers=env_vars.CORS_ALLOW_HEADERS, expose_headers=["X-Custom-File-Name"], ) # Performance boost: automatically use GZip compression for responses larger than 1KB # when the incoming request includes the "Accept-Encoding: gzip" header. app.add_middleware(GZipMiddleware, minimum_size=1000) app.mount( "/static", StaticFiles(directory=os.path.join(BASE_FRONTEND_PATH, "static")), name="static", ) not_http_protected_endpoints = { endpoints_auth, # Auth endpoints are public endpoints_audits.ws_export, # WebSockets are protected by their own middleware } # Load routers and protect them with the Auth0 middleware. for router in ( endpoints_audits.data, endpoints_audits.export, endpoints_audits.query, endpoints_audits.ws_export, endpoints_app, endpoints_utils, endpoints_auth, ): app.include_router( router.create_router(state), # Prefix all API endpoints with /api to prevent them from conflicting with # the frontend routes. This is important for the catch-all endpoint to work # correctly. prefix=f"/api{router.ROUTE}", # Always explicitly require authentication at router level, i.e. for all # endpoints except for the auth endpoints, as they might be used to retrieve # the Auth0 configuration for the frontend and therefore should be # publicly accessible. dependencies=( [] if router in not_http_protected_endpoints else [ Depends(verify_auth0), ] ), ) templates = Jinja2Templates(directory=os.path.join(BASE_FRONTEND_PATH, "templates")) @app.exception_handler(RequestValidationError) async def validation_exception_handler(_, ex) -> JSONResponse: """This is a security measure to prevent clients from seeing the raw JSON schema provided by FastAPI in the event of a validation error. This handler is triggered when a RequestValidationError is raised. The error message is generic to prevent leaking information about the schema. However, the original error is logged. """ for error in ex.args[0]: logger.error("Bad JSON schema. Raw JSON triggering error: {}", str(error)) return responses.json_422() @app.get("/", response_class=HTMLResponse) async def home(request: Request): """Home page.""" return templates.TemplateResponse("home.html", {"request": request}) @app.get("/{catchall:path}") async def catch_all(request: Request): """This endpoint serves the frontend and should NOT be protected by the backend's Auth0 middleware. """ print("Catch-all endpoint triggered for path:", request.url.path) return templates.TemplateResponse("home.html", {"request": request}) if __name__ == "__main__": uvicorn.run( "main:app", host=env_vars.HOST, port=env_vars.PORT, reload=(env_vars.HOST == env_vars.LOCALHOST), proxy_headers=True, forwarded_allow_ips="*", )