"""Leopost Full — FastAPI application. IMPORTANT: root_path is intentionally NOT set in the FastAPI() constructor. It is passed only via Uvicorn's --root-path flag at runtime. """ import logging import os from contextlib import asynccontextmanager from pathlib import Path from threading import Thread from time import sleep from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from .auth import hash_password from .auth import router as auth_router from .config import settings from .database import Base, SessionLocal, engine from .models import User from .routers.affiliates import router as affiliates_router from .routers.characters import router as characters_router from .routers.comments import router as comments_router from .routers.content import router as content_router from .routers.editorial import router as editorial_router from .routers.plans import router as plans_router from .routers.settings import router as settings_router from .routers.social import router as social_router logger = logging.getLogger("leopost") # --------------------------------------------------------------------------- # SPA static files with catch-all fallback # --------------------------------------------------------------------------- class SPAStaticFiles(StaticFiles): """Serve a React SPA: return index.html for any 404 so the client-side router handles unknown paths instead of returning a 404 from the server.""" async def get_response(self, path: str, scope): try: return await super().get_response(path, scope) except Exception: return await super().get_response("index.html", scope) # --------------------------------------------------------------------------- # Background scheduler # --------------------------------------------------------------------------- _scheduler_running = False def _scheduler_loop(): """Simple scheduler that checks for posts to publish every 60 seconds.""" from .scheduler import check_and_publish global _scheduler_running while _scheduler_running: try: check_and_publish() except Exception as e: logger.error(f"Scheduler error: {e}") sleep(60) # --------------------------------------------------------------------------- # Lifespan # --------------------------------------------------------------------------- @asynccontextmanager async def lifespan(app: FastAPI): global _scheduler_running # Ensure data directory exists data_dir = Path("./data") data_dir.mkdir(parents=True, exist_ok=True) # Create tables Base.metadata.create_all(bind=engine) # Create admin user if not exists db = SessionLocal() try: existing = db.query(User).filter(User.username == settings.admin_username).first() if not existing: admin = User( username=settings.admin_username, hashed_password=hash_password(settings.admin_password), ) db.add(admin) db.commit() finally: db.close() # Start background scheduler _scheduler_running = True scheduler_thread = Thread(target=_scheduler_loop, daemon=True) scheduler_thread.start() logger.info("Background scheduler started") yield # Shutdown scheduler _scheduler_running = False # --------------------------------------------------------------------------- # Application # --------------------------------------------------------------------------- # CRITICAL: Do NOT pass root_path here — use Uvicorn --root-path instead. app = FastAPI( title="Leopost Full", version="0.1.0", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --------------------------------------------------------------------------- # API routes (must be registered BEFORE the SPA catch-all mount) # --------------------------------------------------------------------------- app.include_router(auth_router) app.include_router(characters_router) app.include_router(content_router) app.include_router(affiliates_router) app.include_router(plans_router) app.include_router(social_router) app.include_router(comments_router) app.include_router(settings_router) app.include_router(editorial_router) @app.get("/api/health") def health(): return {"status": "ok", "version": "0.1.0"} # --------------------------------------------------------------------------- # SPA catch-all mount (MUST be last) # --------------------------------------------------------------------------- _STATIC_DIR = Path(__file__).parent.parent.parent / "static" if _STATIC_DIR.exists(): app.mount("/", SPAStaticFiles(directory=str(_STATIC_DIR), html=True), name="spa")