"""Notify Bridge Server — FastAPI application entry point.""" import logging from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware # Ensure app-level loggers are visible logging.basicConfig(level=logging.INFO) from .config import settings as _log_cfg _log_level = logging.DEBUG if _log_cfg.debug else logging.INFO logging.getLogger("notify_bridge_server").setLevel(_log_level) logging.getLogger("notify_bridge_core").setLevel(_log_level) from .database.engine import init_db from .database.models import * # noqa: F401,F403 — ensure all models registered from .auth.routes import router as auth_router from .api.providers import router as providers_router from .api.notification_trackers import router as notification_trackers_router from .api.notification_tracker_targets import router as notification_tracker_targets_router from .api.tracking_configs import router as tracking_configs_router from .api.template_configs import router as template_configs_router from .api.targets import router as targets_router from .api.target_receivers import router as target_receivers_router from .api.telegram_bots import router as telegram_bots_router from .api.email_bots import router as email_bots_router from .api.matrix_bots import router as matrix_bots_router from .api.users import router as users_router from .api.status import router as status_router from .api.template_vars import router as template_vars_router from .api.app_settings import router as app_settings_router from .api.command_configs import router as command_configs_router from .api.command_trackers import router as command_trackers_router from .api.command_template_configs import router as command_template_configs_router from .api.actions import router as actions_router from .api.action_rules import router as action_rules_router from .api.action_types import router as action_types_router from .commands.webhook import router as webhook_router, set_webhook_secret from .api.webhooks import router as webhooks_router from .api.webhook_logs import router as webhook_logs_router from .api.backup import router as backup_router @asynccontextmanager async def lifespan(app: FastAPI): await init_db() # Run data migrations (idempotent) from .database.engine import get_engine from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers, migrate_template_locale, migrate_receivers_from_config, migrate_command_slot_locale, migrate_notification_slot_locale, migrate_user_token_version engine = get_engine() await migrate_schema(engine) await migrate_tracker_targets(engine) await migrate_entity_refactor(engine) await migrate_template_slots(engine) await migrate_target_receivers(engine) await migrate_template_locale(engine) await migrate_receivers_from_config(engine) await migrate_command_slot_locale(engine) await migrate_notification_slot_locale(engine) await migrate_user_token_version(engine) from .database.seeds import seed_all await seed_all() # Apply any pending restore staged via /api/backup/prepare-restore from .services.pending_restore import apply_pending_restore_if_any await apply_pending_restore_if_any() # Configure webhook secret from DB setting (falls back to env var) from sqlmodel.ext.asyncio.session import AsyncSession as _AS from .api.app_settings import get_setting as _get_setting async with _AS(engine) as _session: _secret = await _get_setting(_session, "telegram_webhook_secret") set_webhook_secret(_secret or None) from .services.scheduler import start_scheduler, get_scheduler await start_scheduler() yield # Graceful shutdown from .services.http_session import close_http_session await close_http_session() scheduler = get_scheduler() if scheduler.running: scheduler.shutdown() app = FastAPI(title="Notify Bridge", version="0.1.0", lifespan=lifespan) # --- Security headers --- from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request as StarletteRequest from starlette.responses import Response as StarletteResponse class SecurityHeadersMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: StarletteRequest, call_next): response: StarletteResponse = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" return response app.add_middleware(SecurityHeadersMiddleware) # --- Rate limiting --- from .auth.routes import limiter app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_middleware(SlowAPIMiddleware) # --- CORS --- from .config import settings as _cfg _origins = [o.strip() for o in _cfg.cors_allowed_origins.split(",") if o.strip()] app.add_middleware( CORSMiddleware, allow_origins=_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Register routes — static paths before parameterized app.include_router(auth_router) app.include_router(template_vars_router) app.include_router(providers_router) app.include_router(notification_trackers_router) app.include_router(notification_tracker_targets_router) app.include_router(tracking_configs_router) app.include_router(template_configs_router) app.include_router(targets_router) app.include_router(target_receivers_router) app.include_router(telegram_bots_router) app.include_router(email_bots_router) app.include_router(matrix_bots_router) app.include_router(users_router) app.include_router(status_router) app.include_router(app_settings_router) app.include_router(action_types_router) app.include_router(action_rules_router) app.include_router(actions_router) app.include_router(command_configs_router) app.include_router(command_trackers_router) app.include_router(command_template_configs_router) app.include_router(webhook_router) app.include_router(webhooks_router) app.include_router(webhook_logs_router) app.include_router(backup_router) @app.get("/api/health") async def health(): return {"status": "ok"} # --- Serve frontend static files (production) --- # Must come AFTER all API routes so /api/* takes priority from pathlib import Path if _cfg.static_dir and Path(_cfg.static_dir).is_dir(): from fastapi.staticfiles import StaticFiles from starlette.responses import FileResponse from starlette.exceptions import HTTPException as StarletteHTTPException _static_dir = Path(_cfg.static_dir) class SPAStaticFiles(StaticFiles): """StaticFiles that falls back to index.html for SvelteKit client-side routes. Unknown paths return index.html so that deep links like /settings hydrate the SPA, while /api/* and real asset 404s behave normally. """ async def get_response(self, path: str, scope): try: return await super().get_response(path, scope) except StarletteHTTPException as exc: if exc.status_code == 404 and not path.startswith("api/"): return FileResponse(_static_dir / "index.html") raise app.mount("/", SPAStaticFiles(directory=_cfg.static_dir, html=True), name="frontend") def run(): import uvicorn uvicorn.run(app, host=_cfg.host, port=_cfg.port)