media-rip/backend/app/main.py
xpltd 2bb97a0b30 Accept plaintext admin password — hash on startup, clear from memory
- New MEDIARIP__ADMIN__PASSWORD env var accepts plaintext password
- Hashed via bcrypt on startup, plaintext cleared from memory immediately
- PASSWORD_HASH still works for backward compatibility (takes precedence)
- Removes the 'docker run python bcrypt' ceremony from setup flow
- Updated README, docker-compose, .env.example to use plaintext
2026-03-21 22:40:34 -05:00

203 lines
7.6 KiB
Python

"""media.rip() — FastAPI application entry point.
The lifespan context manager wires together config, database, SSE broker,
download service, and purge scheduler. All services are stored on
``app.state`` for access from route handlers via ``request.app.state``.
"""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request as StarletteRequest
from starlette.responses import Response
from app.core.config import AppConfig
from app.core.database import close_db, init_db
from app.core.sse_broker import SSEBroker
from app.middleware.session import SessionMiddleware
from app.routers.admin import router as admin_router
from app.routers.cookies import router as cookies_router
from app.routers.downloads import router as downloads_router
from app.routers.files import router as files_router
from app.routers.formats import router as formats_router
from app.routers.health import router as health_router
from app.routers.sse import router as sse_router
from app.routers.system import router as system_router
from app.routers.themes import router as themes_router
from app.services.download import DownloadService
logger = logging.getLogger("mediarip.app")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan — initialise services on startup, tear down on shutdown."""
# --- Config ---
config_path = Path("config.yaml")
if config_path.is_file():
config = AppConfig(yaml_file=str(config_path))
logger.info("Config loaded from YAML: %s", config_path)
else:
config = AppConfig()
logger.info("Config loaded from defaults + env vars (no YAML file)")
# --- Derive password hash from plaintext if provided ---
if config.admin.password and not config.admin.password_hash:
import bcrypt
config.admin.password_hash = bcrypt.hashpw(
config.admin.password.encode("utf-8"),
bcrypt.gensalt(),
).decode("utf-8")
logger.info("Admin password hashed from plaintext config")
# Clear plaintext from memory — only the hash is needed at runtime
config.admin.password = ""
# --- TLS warning ---
if config.admin.enabled and config.admin.password_hash:
logger.warning(
"Admin panel is enabled. Ensure HTTPS is configured via a reverse proxy "
"(Caddy, Traefik, nginx) to protect admin credentials in transit."
)
# --- Database ---
# Ensure data directory exists for DB and session state
data_dir = Path(config.server.data_dir)
data_dir.mkdir(parents=True, exist_ok=True)
db = await init_db(config.server.db_path)
logger.info("Database initialised at %s", config.server.db_path)
# --- Load persisted settings from DB ---
from app.services.settings import apply_persisted_to_config, load_persisted_settings
persisted = await load_persisted_settings(db)
if persisted:
apply_persisted_to_config(config, persisted)
# --- Event loop + SSE broker ---
loop = asyncio.get_event_loop()
broker = SSEBroker(loop)
# --- Download service ---
download_service = DownloadService(config, db, broker, loop)
# --- Recover zombie jobs from unclean shutdown ---
# Jobs stuck in queued/downloading status from a previous crash will never
# complete — mark them as failed so they don't confuse the UI.
try:
recovered = await db.execute(
"UPDATE jobs SET status = 'failed', error_message = 'Interrupted by server restart' "
"WHERE status IN ('queued', 'downloading')"
)
await db.commit()
if recovered.rowcount > 0:
logger.warning("Recovered %d zombie job(s) from previous shutdown", recovered.rowcount)
except Exception as e:
logger.error("Failed to recover zombie jobs: %s", e)
# --- Purge scheduler ---
scheduler = None
if config.purge.enabled:
try:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from app.services.purge import run_purge
scheduler = AsyncIOScheduler()
scheduler.add_job(
run_purge,
CronTrigger.from_crontab(config.purge.cron),
args=[db, config],
id="purge_job",
name="Scheduled purge",
)
scheduler.start()
logger.info("Purge scheduler started: cron=%s", config.purge.cron)
except ImportError:
logger.warning("APScheduler not installed — scheduled purge disabled")
except Exception as e:
logger.error("Failed to start purge scheduler: %s", e)
# --- Store on app.state ---
app.state.config = config
app.state.db = db
app.state.broker = broker
app.state.download_service = download_service
app.state.start_time = datetime.now(timezone.utc)
# Store format overrides from persisted settings
app.state._default_video_format = persisted.get("default_video_format", "auto")
app.state._default_audio_format = persisted.get("default_audio_format", "auto")
yield
# --- Teardown ---
if scheduler is not None:
scheduler.shutdown(wait=False)
download_service.shutdown()
await close_db(db)
logger.info("Application shutdown complete")
app = FastAPI(title="media.rip()", lifespan=lifespan)
app.add_middleware(SessionMiddleware)
# --- Security headers middleware (R020: zero outbound telemetry) ---
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers enforcing no outbound resource loading."""
async def dispatch(self, request: StarletteRequest, call_next): # type: ignore[override]
response: Response = await call_next(request)
# Content-Security-Policy: only allow resources from self
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"font-src 'self'; "
"connect-src 'self'; "
"object-src 'none'; "
"frame-ancestors 'none'"
)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer"
return response
app.add_middleware(SecurityHeadersMiddleware)
app.include_router(admin_router, prefix="/api")
app.include_router(cookies_router, prefix="/api")
app.include_router(downloads_router, prefix="/api")
app.include_router(files_router, prefix="/api")
app.include_router(formats_router, prefix="/api")
app.include_router(health_router, prefix="/api")
app.include_router(sse_router, prefix="/api")
app.include_router(system_router, prefix="/api")
app.include_router(themes_router, prefix="/api")
# --- Static file serving (production: built frontend) ---
_static_dir = Path(__file__).resolve().parent.parent / "static"
if _static_dir.is_dir():
from fastapi.responses import FileResponse
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Serve the Vue SPA. Falls back to index.html for client-side routing."""
file_path = _static_dir / full_path
if file_path.is_file() and file_path.resolve().is_relative_to(_static_dir.resolve()):
return FileResponse(file_path)
return FileResponse(_static_dir / "index.html")
logger.info("Static file serving enabled from %s", _static_dir)