Files
api/base.py
2026-01-25 13:14:00 -05:00

224 lines
6.3 KiB
Python

import importlib
import sys
sys.path.insert(0, ".")
import logging
import asyncio
# Install uvloop for better async performance (2-4x speedup on I/O)
try:
import uvloop
uvloop.install()
logging.info("uvloop installed successfully")
except ImportError:
logging.warning("uvloop not available, using default asyncio event loop")
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from scalar_fastapi import get_scalar_api_reference
from lyric_search.sources import redis_cache
import shared # Shared connection pools
logging.basicConfig(level=logging.INFO)
logging.getLogger("aiosqlite").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("python_multipart.multipart").setLevel(logging.WARNING)
logging.getLogger("streamrip").setLevel(logging.WARNING)
logging.getLogger("utils.sr_wrapper").setLevel(logging.WARNING)
logger = logging.getLogger()
loop = asyncio.get_event_loop()
# Pre-import endpoint modules so we can wire up lifespan
constants = importlib.import_module("constants").Constants()
# Will be set after app creation
_routes: dict = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup/shutdown events."""
# Startup
uvicorn_access_logger = logging.getLogger("uvicorn.access")
uvicorn_access_logger.disabled = True
# Initialize shared infrastructure (Redis pool, aiohttp session, SQLite pool)
await shared.startup()
# Start Radio playlists
if "radio" in _routes and hasattr(_routes["radio"], "on_start"):
await _routes["radio"].on_start()
# Start endpoint background tasks
if "trip" in _routes and hasattr(_routes["trip"], "startup"):
await _routes["trip"].startup()
if "lighting" in _routes and hasattr(_routes["lighting"], "startup"):
await _routes["lighting"].startup()
logger.info("Application startup complete")
yield
# Shutdown
if "lighting" in _routes and hasattr(_routes["lighting"], "shutdown"):
await _routes["lighting"].shutdown()
if "trip" in _routes and hasattr(_routes["trip"], "shutdown"):
await _routes["trip"].shutdown()
# Clean up shared infrastructure
await shared.shutdown()
logger.info("Application shutdown complete")
app = FastAPI(
title="codey.lol API",
version="1.0",
contact={"name": "codey"},
redirect_slashes=False,
loop=loop,
docs_url=None, # Disabled - using Scalar at /docs instead
redoc_url="/redoc",
lifespan=lifespan,
)
util = importlib.import_module("util").Utilities(app, constants)
origins = [
"https://codey.lol",
"https://old.codey.lol",
"https://api.codey.lol",
"https://status.boatson.boats",
"https://_new.codey.lol",
"http://localhost:4321",
"https://local.codey.lol:4321",
]
app.add_middleware(
CORSMiddleware, # type: ignore
allow_origins=origins,
allow_credentials=True,
allow_methods=["POST", "GET", "HEAD", "OPTIONS"],
allow_headers=["*"],
) # type: ignore
# Scalar API documentation at /docs (replaces default Swagger UI)
@app.get("/docs", include_in_schema=False)
def scalar_docs():
# Replace default FastAPI favicon with site favicon
html_response = get_scalar_api_reference(
openapi_url="/openapi.json", title="codey.lol API"
)
try:
body = (
html_response.body.decode("utf-8")
if isinstance(html_response.body, (bytes, bytearray))
else str(html_response.body)
)
body = body.replace(
"https://fastapi.tiangolo.com/img/favicon.png",
"https://codey.lol/images/favicon.png",
)
# Build fresh response so Content-Length matches modified body
return HTMLResponse(content=body, status_code=html_response.status_code)
except Exception:
# Fallback to original if anything goes wrong
return html_response
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
"""Redirect favicon requests to the site icon."""
return RedirectResponse("https://codey.lol/images/favicon.png")
"""
Blacklisted routes
"""
@app.get("/", include_in_schema=False)
def disallow_get():
return util.get_blocked_response()
@app.head("/", include_in_schema=False)
def base_head():
return
@app.get("/{path}", include_in_schema=False)
def disallow_get_any(request: Request, var: Any = None):
path = request.path_params["path"]
allowed_paths = ["widget", "misc/no", "docs", "redoc", "openapi.json"]
logging.info(
f"Checking path: {path}, allowed: {path in allowed_paths or path.split('/', maxsplit=1)[0] in allowed_paths}"
)
if not (
isinstance(path, str)
and (path.split("/", maxsplit=1)[0] in allowed_paths or path in allowed_paths)
):
logging.error(f"BLOCKED path: {path}")
return util.get_blocked_response()
else:
logging.info("OK, %s", path)
@app.post("/", include_in_schema=False)
def disallow_base_post():
return util.get_blocked_response()
"""
End Blacklisted Routes
"""
"""
Actionable Routes
"""
_routes.update(
{
"randmsg": importlib.import_module("endpoints.rand_msg").RandMsg(
app, util, constants
),
"lyrics": importlib.import_module("endpoints.lyric_search").LyricSearch(
app, util, constants
),
"yt": importlib.import_module("endpoints.yt").YT(app, util, constants),
"radio": importlib.import_module("endpoints.radio").Radio(
app, util, constants, loop
),
"meme": importlib.import_module("endpoints.meme").Meme(app, util, constants),
"trip": importlib.import_module("endpoints.rip").RIP(app, util, constants),
"auth": importlib.import_module("endpoints.auth").Auth(app),
"lighting": importlib.import_module("endpoints.lighting").Lighting(
app, util, constants
),
}
)
# Misc endpoint depends on radio endpoint instance
radio_endpoint = _routes.get("radio")
if radio_endpoint:
_routes["misc"] = importlib.import_module("endpoints.misc").Misc(
app, util, constants, radio_endpoint
)
"""
End Actionable Routes
"""
"""
Startup
"""
redis = redis_cache.RedisCache()
loop.create_task(redis.create_index())