Compare commits
No commits in common. "7ddb88b54ee34a53af003a9f57e695827dd689d4" and "22d258143195f2a01e17f6ae851b5465567d5b3f" have entirely different histories.
7ddb88b54e
...
22d2581431
2 changed files with 2 additions and 137 deletions
68
app.py
68
app.py
|
|
@ -12,14 +12,13 @@ from contextlib import asynccontextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
|
|
||||||
log = logging.getLogger("butler")
|
log = logging.getLogger("butler")
|
||||||
VERSION = "2.3.6"
|
VERSION = "2.3.5"
|
||||||
|
|
||||||
API_DIR = os.environ.get("API_KEY_DIR", "/data/api")
|
API_DIR = os.environ.get("API_KEY_DIR", "/data/api")
|
||||||
VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache")
|
VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache")
|
||||||
BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "")
|
BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "")
|
||||||
CONFIG_PATH = os.environ.get("BUTLER_CONFIG", "/data/butler.yaml")
|
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"))
|
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 ---
|
# --- Config loading ---
|
||||||
|
|
||||||
|
|
@ -3396,71 +3395,6 @@ async def tts_health(_=Depends(_verify)):
|
||||||
return results
|
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"])
|
@app.api_route("/{service}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
|
async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
|
||||||
SKIP_SERVICES = {"vm", "inventory", "ansible", "debug", "tts", "status", "audit", "config"}
|
SKIP_SERVICES = {"vm", "inventory", "ansible", "debug", "tts", "status", "audit", "config"}
|
||||||
|
|
|
||||||
|
|
@ -43,76 +43,7 @@ def test_health_exposes_current_version():
|
||||||
with TestClient(app.app) as client:
|
with TestClient(app.app) as client:
|
||||||
response = client.get("/health")
|
response = client.get("/health")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["version"] == app.VERSION == "2.3.6"
|
assert response.json()["version"] == app.VERSION == "2.3.5"
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_paperless_import_queues_pdf_through_butler(monkeypatch):
|
def test_paperless_import_queues_pdf_through_butler(monkeypatch):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue