import logging import sys from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, HTTPException # When started via uvicorn --log-config the "subfeed" logger is already configured # (see log_config.json). This block is a timestamped fallback for other entrypoints # (tests, scripts) so our logs are never silently dropped. _subfeed_logger = logging.getLogger("subfeed") if not _subfeed_logger.handlers: _handler = logging.StreamHandler(sys.stdout) _handler.setFormatter( logging.Formatter("%(asctime)s %(levelname)-5s [%(name)s] %(message)s") ) _subfeed_logger.addHandler(_handler) _subfeed_logger.setLevel(logging.INFO) _subfeed_logger.propagate = False log = logging.getLogger("subfeed.app") from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from app import auth from app.config import settings from app.routes import ( admin, channels, config as config_routes, feed, health, me, notifications, playlists, quota, scheduler as scheduler_routes, sync, tags, version, ) from app.scheduler import shutdown_scheduler, start_scheduler @asynccontextmanager async def lifespan(app: FastAPI): log.info("Siftlode starting up") start_scheduler() try: yield finally: log.info("Siftlode shutting down") shutdown_scheduler() app = FastAPI(title=settings.app_name, lifespan=lifespan) app.add_middleware( SessionMiddleware, secret_key=settings.secret_key, same_site="lax", # required so the cookie rides the OAuth redirect back from Google https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod) ) if settings.frontend_origin: app.add_middleware( CORSMiddleware, allow_origins=[settings.frontend_origin], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(health.router) app.include_router(auth.router) app.include_router(sync.router) app.include_router(tags.router) app.include_router(feed.router) app.include_router(me.router) app.include_router(notifications.router) app.include_router(channels.router) app.include_router(playlists.router) app.include_router(admin.router) app.include_router(config_routes.router) app.include_router(scheduler_routes.router) app.include_router(quota.router) app.include_router(version.router) # The built SPA (populated by the Docker frontend build stage). STATIC_DIR = Path(__file__).parent / "static_spa" app.mount( "/assets", StaticFiles(directory=STATIC_DIR / "assets", check_dir=False), name="assets", ) @app.get("/") async def index() -> FileResponse: return FileResponse(STATIC_DIR / "index.html") @app.get("/{full_path:path}") async def spa_fallback(full_path: str) -> FileResponse: # Client-side routes fall back to index.html; real API/asset paths are matched above. if full_path.startswith(("api/", "auth/", "healthz", "assets/")): raise HTTPException(status_code=404) # Serve real files that live at the SPA root (Vite copies public/ there — e.g. the landing # screenshots under /welcome/, favicon). /assets is already mounted above; everything else # that isn't a real file is a client-side route → index.html. Guard against path traversal. if full_path: candidate = (STATIC_DIR / full_path).resolve() if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents: return FileResponse(candidate) return FileResponse(STATIC_DIR / "index.html")