diff --git a/app.py b/app.py index 0a3b88b..af8f8c4 100644 --- a/app.py +++ b/app.py @@ -12,13 +12,14 @@ from contextlib import asynccontextmanager from contextvars import ContextVar log = logging.getLogger("butler") -VERSION = "2.3.5" +VERSION = "2.3.6" API_DIR = os.environ.get("API_KEY_DIR", "/data/api") VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache") BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "") CONFIG_PATH = os.environ.get("BUTLER_CONFIG", "/data/butler.yaml") UI_PATH = os.environ.get("BUTLER_UI_PATH", os.path.join(os.path.dirname(__file__), "ui.html")) +MEDIA_HANDOFF_ALLOWED_NETWORKS = os.environ.get("MEDIA_HANDOFF_ALLOWED_NETWORKS", "10.2.1.119/32") # --- Config loading --- @@ -3395,6 +3396,71 @@ async def tts_health(_=Depends(_verify)): return results +class MediaHandoffPayload(BaseModel): + action: Literal["start", "moved", "status", "fail"] + category: str | None = Field(None, max_length=32) + directory: str | None = Field(None, max_length=1000) + release: str | None = Field(None, max_length=500) + cleanName: str | None = Field(None, max_length=500) + jobId: str | None = Field(None, max_length=80) + reason: str | None = Field(None, max_length=300) + + +def _media_handoff_caller_allowed(request: Request) -> bool: + auth = request.headers.get("authorization", "") + if BUTLER_TOKEN and secrets.compare_digest(auth, f"Bearer {BUTLER_TOKEN}"): + return True + try: + caller = ipaddress.ip_address(request.client.host if request.client else "") + networks = [ + ipaddress.ip_network(value.strip(), strict=False) + for value in MEDIA_HANDOFF_ALLOWED_NETWORKS.split(",") + if value.strip() + ] + except ValueError: + return False + return any(caller in network for network in networks) + + +@app.post("/media/handoff") +async def media_handoff(payload: MediaHandoffPayload, request: Request): + """Narrow SABnzbd-to-n8n bridge; no generic unauthenticated proxy access.""" + if not _media_handoff_caller_allowed(request): + raise HTTPException(403, "Media handoff caller is not allowed") + + allowed_categories = {"serien4k", "serien", "video4k", "video"} + if payload.action == "start": + if payload.category not in allowed_categories: + raise HTTPException(422, "Unsupported media category") + expected_prefix = f"/usenet/complete/{payload.category}/" + if not payload.directory or not payload.directory.startswith(expected_prefix): + raise HTTPException(422, "Invalid media handoff directory") + if not payload.release: + raise HTTPException(422, "Release name is required") + else: + if not payload.jobId or not re.fullmatch(r"[a-z0-9-]{8,80}", payload.jobId): + raise HTTPException(422, "Valid jobId is required") + + cfg = SERVICES.get("n8n") + if not cfg or not cfg.get("url"): + raise HTTPException(503, "n8n service is not configured") + target = f"{cfg['url'].rstrip('/')}/webhook/media-handoff" + body = payload.model_dump(exclude_none=True) if hasattr(payload, "model_dump") else payload.dict(exclude_none=True) + try: + async with httpx.AsyncClient(verify=False, timeout=15) as client: + response = await client.post(target, json=body, headers={"Content-Type": "application/json"}) + except httpx.HTTPError as exc: + _audit("/media/handoff", "POST", 502, f"action={payload.action} error={type(exc).__name__}") + raise HTTPException(502, "n8n media handoff is unavailable") from exc + + try: + result = response.json() + except Exception: + result = {"ok": False, "error": "invalid_n8n_response"} + _audit("/media/handoff", "POST", response.status_code, f"action={payload.action}") + return JSONResponse(content=result, status_code=response.status_code) + + @app.api_route("/{service}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) async def proxy(service: str, path: str, request: Request, _=Depends(_verify)): SKIP_SERVICES = {"vm", "inventory", "ansible", "debug", "tts", "status", "audit", "config"}