diff --git a/app.py b/app.py index af8f8c4..0a3b88b 100644 --- a/app.py +++ b/app.py @@ -12,14 +12,13 @@ from contextlib import asynccontextmanager from contextvars import ContextVar log = logging.getLogger("butler") -VERSION = "2.3.6" +VERSION = "2.3.5" 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 --- @@ -3396,71 +3395,6 @@ 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"} diff --git a/tests/test_app.py b/tests/test_app.py index ef136db..161ded6 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -43,76 +43,7 @@ def test_health_exposes_current_version(): with TestClient(app.app) as client: response = client.get("/health") assert response.status_code == 200 - assert response.json()["version"] == app.VERSION == "2.3.6" - - -def test_media_handoff_proxies_strict_category_contract(monkeypatch): - captured = {} - - class FakeResponse: - status_code = 200 - - def json(self): - return {"ok": True, "jobId": "test-job-1234", "state": "registered"} - - class FakeClient: - def __init__(self, **kwargs): - captured["client"] = kwargs - - async def __aenter__(self): - return self - - async def __aexit__(self, *_args): - return None - - async def post(self, url, json, headers): - captured.update(url=url, json=json, headers=headers) - return FakeResponse() - - monkeypatch.setattr(app.httpx, "AsyncClient", FakeClient) - with TestClient(app.app) as client: - monkeypatch.setattr(app, "SERVICES", {"n8n": {"url": "http://n8n:5678", "auth": "n8n"}}) - response = client.post( - "/media/handoff", - headers={"Authorization": "Bearer test-token"}, - json={ - "action": "start", - "category": "serien4k", - "directory": "/usenet/complete/serien4k/Show.S01E01", - "release": "Show.S01E01-GRP", - "cleanName": "Show S01E01", - }, - ) - assert response.status_code == 200 - assert response.json()["state"] == "registered" - assert captured["url"] == "http://n8n:5678/webhook/media-handoff" - assert captured["json"]["category"] == "serien4k" - - -def test_media_handoff_rejects_untrusted_caller_before_proxy(monkeypatch): - monkeypatch.setattr(app.httpx, "AsyncClient", lambda **_kwargs: (_ for _ in ()).throw(AssertionError("must not proxy"))) - with TestClient(app.app) as client: - response = client.post( - "/media/handoff", - json={"action": "status", "jobId": "test-job-1234"}, - ) - assert response.status_code == 403 - - -def test_media_handoff_rejects_wrong_category_path(monkeypatch): - monkeypatch.setattr(app.httpx, "AsyncClient", lambda **_kwargs: (_ for _ in ()).throw(AssertionError("must not proxy"))) - with TestClient(app.app) as client: - response = client.post( - "/media/handoff", - headers={"Authorization": "Bearer test-token"}, - json={ - "action": "start", - "category": "video4k", - "directory": "/usenet/complete/serien4k/Wrong", - "release": "Wrong", - }, - ) - assert response.status_code == 422 + assert response.json()["version"] == app.VERSION == "2.3.5" def test_paperless_import_queues_pdf_through_butler(monkeypatch):