Files

153 lines
5.7 KiB
Python
Raw Permalink Normal View History

import logging
import os
2026-04-19 20:19:58 +02:00
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
2026-04-19 20:19:58 +02:00
from fastapi.staticfiles import StaticFiles
2026-04-29 11:45:49 +02:00
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
2026-04-20 15:16:47 +02:00
from sqlalchemy.orm import Session
2026-04-19 20:19:58 +02:00
from app import models # noqa: F401
from app.api.routes.api.config import router as api_config_router
2026-06-12 23:24:17 +02:00
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
2026-04-20 10:42:35 +02:00
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
2026-04-29 11:45:49 +02:00
from app.api.routes.public_ip import router as public_ip_router
2026-04-20 17:06:03 +02:00
from app.api.routes.ticktick import router as ticktick_router
2026-04-19 20:19:58 +02:00
from app.config import get_settings
2026-04-20 15:16:47 +02:00
from app.services.auth import AuthBootstrapError, initialize_auth_schema
2026-04-20 20:40:04 +02:00
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
2026-04-20 15:16:47 +02:00
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
2026-04-19 23:02:43 +02:00
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"
2026-04-19 23:02:43 +02:00
2026-04-29 11:45:49 +02:00
def _run_scheduled_public_ip_check() -> None:
session_local = get_session_local()
2026-04-29 11:45:49 +02:00
session: Session = session_local()
try:
check_public_ipv4_and_notify(session, bootstrap_settings=get_settings())
2026-04-29 11:45:49 +02:00
finally:
session.close()
2026-04-20 15:16:47 +02:00
def ensure_auth_db_ready() -> None:
session_local = get_session_local()
2026-04-20 15:16:47 +02:00
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())
2026-04-20 20:40:04 +02:00
sync_app_hostname_from_bootstrap(session, get_settings())
2026-04-20 15:16:47 +02:00
except AppDatabaseAdoptionError as exc:
raise RuntimeError(str(exc)) from exc
except AuthBootstrapError as exc:
raise RuntimeError(str(exc)) from exc
finally:
session.close()
2026-04-19 20:19:58 +02:00
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)
2026-04-19 20:19:58 +02:00
@asynccontextmanager
async def lifespan(_: FastAPI):
ensure_runtime_dirs()
2026-04-20 15:16:47 +02:00
ensure_auth_db_ready()
2026-04-29 11:45:49 +02:00
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()
2026-04-19 20:19:58 +02:00
yield
2026-04-29 11:45:49 +02:00
scheduler.shutdown(wait=False)
2026-04-19 20:19:58 +02:00
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title=settings.app_name,
debug=settings.app_debug,
version="0.1.0",
lifespan=lifespan,
description=(
2026-04-20 20:40:04 +02:00
"Home automation backend with auth, runtime config, Home Assistant "
"integrations, TickTick integration, and SQLite-backed recorders."
2026-04-19 20:19:58 +02:00
),
)
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)
2026-06-12 23:24:17 +02:00
app.include_router(api_data_router)
app.include_router(api_session_router)
2026-04-20 10:42:35 +02:00
app.include_router(homeassistant_router)
app.include_router(location_router)
app.include_router(poo_router)
2026-04-29 11:45:49 +02:00
app.include_router(public_ip_router)
2026-04-20 17:06:03 +02:00
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
)
2026-04-19 20:19:58 +02:00
return app
app = create_app()