feat: add backup recovery, setup wizard and user-view guards
This commit is contained in:
+62
-6
@@ -6,6 +6,8 @@ import uuid
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.exception_handlers import request_validation_exception_handler
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -33,6 +35,10 @@ from .routers.insights import router as insights_router
|
||||
from .routers.identities import router as identities_router
|
||||
from .routers.recaps import router as recaps_router
|
||||
from .routers.newsletters import router as newsletters_router
|
||||
from .routers.backups import router as backups_router
|
||||
from .routers.setup import router as setup_router
|
||||
from .services.backups import apply_pending_restore
|
||||
from .services.setup import initialize_setup_state, is_setup_required, setup_token_configured
|
||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||
from .services.issue_resolution import run_issue_confirmation_loop
|
||||
from .services.email_recaps import run_email_recap_loop
|
||||
@@ -52,10 +58,12 @@ from .logging_config import (
|
||||
)
|
||||
from .runtime import get_runtime_settings
|
||||
from .metrics import record_api, start_metrics
|
||||
from .request_limits import InstallationBodyLimitMiddleware
|
||||
from .secret_storage import validate_secret_storage_configuration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_background_tasks: list[asyncio.Task[None]] = []
|
||||
_background_started = False
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
@@ -71,6 +79,23 @@ app.add_middleware(
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(InstallationBodyLimitMiddleware)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def installation_validation_error(request: Request, exc: RequestValidationError):
|
||||
if request.url.path.rstrip("/") == "/setup/bootstrap" or request.url.path.startswith("/admin/backups"):
|
||||
# Pydantic SecretStr masks parsed values, but FastAPI's default 422 body
|
||||
# includes rejected raw input. Never echo tokens/passwords/passphrases.
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"detail": [
|
||||
{key: error[key] for key in ("type", "loc", "msg") if key in error}
|
||||
for error in exc.errors()
|
||||
]},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
return await request_validation_exception_handler(request, exc)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -221,9 +246,9 @@ def _log_security_configuration_warnings() -> None:
|
||||
"security configuration warning: JWT_SECRET is missing, short, or still set to the default value"
|
||||
)
|
||||
admin_password = str(settings.admin_password or "")
|
||||
if not admin_password or admin_password == "adminadmin":
|
||||
if admin_password == "adminadmin":
|
||||
logger.warning(
|
||||
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
|
||||
"security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default"
|
||||
)
|
||||
if bool(settings.api_docs_enabled):
|
||||
logger.warning(
|
||||
@@ -244,8 +269,11 @@ def _enforce_secure_startup_configuration() -> None:
|
||||
_enforce_secret_configuration()
|
||||
admin_password = str(settings.admin_password or "")
|
||||
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
|
||||
if is_setup_required() and setup_token_configured():
|
||||
return
|
||||
raise RuntimeError(
|
||||
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
|
||||
"First startup requires a strong SETUP_TOKEN (at least 32 characters) for the setup wizard, "
|
||||
"or a secure ADMIN_PASSWORD, until an admin account exists."
|
||||
)
|
||||
|
||||
|
||||
@@ -264,6 +292,9 @@ async def startup() -> None:
|
||||
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
||||
_log_security_configuration_warnings()
|
||||
_enforce_secret_configuration()
|
||||
# Restore offline, before any schema migration, database reader or worker.
|
||||
apply_pending_restore()
|
||||
initialize_setup_state()
|
||||
init_db()
|
||||
_enforce_secure_startup_configuration()
|
||||
runtime = get_runtime_settings()
|
||||
@@ -286,9 +317,22 @@ async def startup() -> None:
|
||||
runtime.log_background_sync_level,
|
||||
runtime.requests_data_source,
|
||||
)
|
||||
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
|
||||
logger.info("Background imports and automation paused for initial setup")
|
||||
app.state.on_setup_complete = _start_background_tasks
|
||||
await _start_background_tasks()
|
||||
logger.info("startup complete")
|
||||
|
||||
|
||||
async def _start_background_tasks() -> None:
|
||||
global _background_started
|
||||
if _background_started:
|
||||
return
|
||||
if is_setup_required():
|
||||
logger.info("Background imports and automation paused until setup is complete")
|
||||
return
|
||||
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
|
||||
logger.info("Background imports and automation disabled by configuration")
|
||||
return
|
||||
_background_started = True
|
||||
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
|
||||
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
|
||||
_launch_background_task("request-local-stages", run_local_request_stage_loop)
|
||||
@@ -298,7 +342,17 @@ async def startup() -> None:
|
||||
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
|
||||
_launch_background_task("email-recaps", run_email_recap_loop)
|
||||
_launch_background_task("newsletters", run_newsletter_loop)
|
||||
logger.info("startup complete")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown() -> None:
|
||||
global _background_started
|
||||
for task in _background_tasks:
|
||||
task.cancel()
|
||||
if _background_tasks:
|
||||
await asyncio.gather(*_background_tasks, return_exceptions=True)
|
||||
_background_tasks.clear()
|
||||
_background_started = False
|
||||
|
||||
|
||||
app.include_router(requests_router)
|
||||
@@ -317,3 +371,5 @@ app.include_router(insights_router)
|
||||
app.include_router(identities_router)
|
||||
app.include_router(recaps_router)
|
||||
app.include_router(newsletters_router)
|
||||
app.include_router(backups_router)
|
||||
app.include_router(setup_router)
|
||||
|
||||
Reference in New Issue
Block a user