import logging import os from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.interval import IntervalTrigger from sqlalchemy.orm import Session from app import models # noqa: F401 from app.api.routes.api.config import router as api_config_router from app.api.routes.api.data import router as api_data_router from app.api.routes.api.session import router as api_session_router from app.api.routes import status from app.db import get_session_local from app.api.routes.homeassistant import router as homeassistant_router from app.api.routes.location import router as location_router from app.api.routes.poo import router as poo_router from app.api.routes.public_ip import router as public_ip_router from app.api.routes.ticktick import router as ticktick_router from app.config import get_settings from app.services.auth import AuthBootstrapError, initialize_auth_schema from app.services.config_page import seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap from app.services.public_ip import check_public_ipv4_and_notify from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db logger = logging.getLogger(__name__) _REPO_ROOT = Path(__file__).resolve().parents[1] def _get_spa_dist_dir() -> Path: env_val = os.environ.get("SPA_DIST_DIR") if env_val: return Path(env_val) return _REPO_ROOT / "frontend" / "dist" def _run_scheduled_public_ip_check() -> None: session_local = get_session_local() session: Session = session_local() try: check_public_ipv4_and_notify(session, bootstrap_settings=get_settings()) finally: session.close() def ensure_auth_db_ready() -> None: session_local = get_session_local() session: Session = session_local() try: validate_app_runtime_db(get_settings().app_database_url) initialize_auth_schema(session, get_settings()) seed_missing_config_from_bootstrap(session, get_settings()) sync_app_hostname_from_bootstrap(session, get_settings()) except AppDatabaseAdoptionError as exc: raise RuntimeError(str(exc)) from exc except AuthBootstrapError as exc: raise RuntimeError(str(exc)) from exc finally: session.close() def ensure_runtime_dirs() -> None: settings = get_settings() if settings.app_sqlite_path is not None: settings.app_sqlite_path.parent.mkdir(parents=True, exist_ok=True) @asynccontextmanager async def lifespan(_: FastAPI): ensure_runtime_dirs() ensure_auth_db_ready() scheduler = BackgroundScheduler(timezone="UTC") scheduler.add_job( _run_scheduled_public_ip_check, trigger=IntervalTrigger(hours=4), id="public-ip-check", replace_existing=True, max_instances=1, coalesce=True, ) scheduler.start() yield scheduler.shutdown(wait=False) def create_app() -> FastAPI: settings = get_settings() app = FastAPI( title=settings.app_name, debug=settings.app_debug, version="0.1.0", lifespan=lifespan, description=( "Home automation backend with auth, runtime config, Home Assistant " "integrations, TickTick integration, and SQLite-backed recorders." ), ) static_dir = Path(__file__).parent / "static" app.mount("/static", StaticFiles(directory=static_dir), name="static") app.include_router(status.router) app.include_router(api_config_router) app.include_router(api_data_router) app.include_router(api_session_router) app.include_router(homeassistant_router) app.include_router(location_router) app.include_router(poo_router) app.include_router(public_ip_router) app.include_router(ticktick_router) # SPA hosting: mount frontend/dist if it exists and has index.html. # If the SPA dist is absent (e.g. backend-only CI), skip SPA serving entirely # so that pytest stays green with only the API routes registered. spa_dist = _get_spa_dist_dir() spa_index = spa_dist / "index.html" if spa_dist.is_dir() and spa_index.is_file(): spa_assets = spa_dist / "assets" if spa_assets.is_dir(): app.mount("/assets", StaticFiles(directory=spa_assets), name="spa-assets") # Resolve the dist root once so the containment check is fast and consistent. _spa_root = spa_dist.resolve() @app.get("/{full_path:path}", include_in_schema=False) async def spa_fallback(full_path: str, request: Request) -> FileResponse: # noqa: RUF029 # Explicit 404 for unmatched /api/* — never return index.html for API paths. if full_path.startswith("api/"): raise HTTPException(status_code=404, detail="not found") # Resolve candidate to an absolute path and verify it stays within the SPA # dist root. Without this check, URL-encoded ".." sequences (e.g. "..%2f") # bypass Starlette's path parameter handling and allow arbitrary file reads. candidate = (spa_dist / full_path).resolve() if candidate.is_file() and candidate.is_relative_to(_spa_root): return FileResponse(candidate) # For any path outside the dist root, or for SPA client routes that don't # correspond to a real file, return index.html so the SPA router handles it. return FileResponse(spa_index) else: logger.warning( "SPA dist not found at %s — SPA hosting disabled (API-only mode).", spa_dist ) return app app = create_app()