media-rip/backend/app/main.py
xpltd 5a6eb00906 Docker self-hosting: fix persistence, add data_dir config
Critical fix:
- Dockerfile env var was MEDIARIP__DATABASE__PATH (ignored) — now MEDIARIP__SERVER__DB_PATH
  DB was landing at /app/mediarip.db (lost on restart) instead of /data/mediarip.db

Persistence model:
- /downloads → media files (bind mount recommended)
- /data → SQLite DB, session cookies, error logs (named volume)
- /themes → custom CSS themes (read-only bind mount)
- /app/config.yaml → optional YAML config (read-only bind mount)

Other changes:
- Add server.data_dir config field (default: /data) for explicit session storage
- Cookie storage uses data_dir instead of fragile path math from output_dir parent
- Lifespan creates data_dir on startup
- .dockerignore excludes tests, dev DB, egg-info
- docker-compose.yml: inline admin/purge config examples
- docker-compose.example.yml: parameterized with env vars
- .env.example: session mode, clearer docs
- README: Docker volumes table, admin setup docs, full config reference
- PROJECT.md: reflects completed v1.0 state
- REQUIREMENTS.md: all 26 requirements validated
2026-03-19 09:56:10 -05:00

167 lines
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)")
# --- TLS warning ---
if config.admin.enabled:
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)
# --- Event loop + SSE broker ---
loop = asyncio.get_event_loop()
broker = SSEBroker(loop)
# --- Download service ---
download_service = DownloadService(config, db, broker, loop)
# --- 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)
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)