"""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 .config import settings from .database import Base, SessionLocal, engine from .models import User from .routers.admin import router as admin_router from .routers.auth import router as auth_router 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 all tables (PostgreSQL: idempotent, safe to run on every startup) Base.metadata.create_all(bind=engine) # Create or update admin user 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), email="admin@leopost.it", display_name="Admin", auth_provider="local", subscription_plan="pro", is_admin=True, ) db.add(admin) db.commit() else: # Update existing admin to ensure proper flags updated = False if not existing.is_admin: existing.is_admin = True updated = True if existing.subscription_plan != "pro": existing.subscription_plan = "pro" updated = True if not existing.email: existing.email = "admin@leopost.it" updated = True if updated: 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.2.0", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=[ "http://localhost:5173", "https://leopost.it", "https://www.leopost.it", ], 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(admin_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.2.0"} # --------------------------------------------------------------------------- # SPA catch-all mount (MUST be last) # --------------------------------------------------------------------------- _STATIC_DIR = Path(__file__).parent.parent / "static" if _STATIC_DIR.exists(): app.mount("/", SPAStaticFiles(directory=str(_STATIC_DIR), html=True), name="spa")