Merge pull request 'feat: Butler-mediated Paperless PDF import' (#47) from feature/paperless-import-20260901 into main

This commit is contained in:
sascha 2026-09-01 08:40:54 +02:00
commit 9d2b5f1185
2 changed files with 102 additions and 1 deletions

68
app.py
View file

@ -1,7 +1,7 @@
"""Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab. """Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab.
Reads service config from butler.yaml, credentials from Vaultwarden cache with flat-file fallback.""" Reads service config from butler.yaml, credentials from Vaultwarden cache with flat-file fallback."""
import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress, secrets, sqlite3, math import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress, secrets, sqlite3, math, hashlib
from datetime import datetime, timezone from datetime import datetime, timezone
import httpx, yaml import httpx, yaml
from typing import Literal from typing import Literal
@ -2023,6 +2023,72 @@ def _ssh(host, cmd, timeout=600):
return 124, "", f"SSH command timed out after {timeout} seconds" return 124, "", f"SSH command timed out after {timeout} seconds"
PAPERLESS_SSH = "sascha@10.5.1.120"
PAPERLESS_CONSUME_DIR = "/app-config/paperless/consume"
PAPERLESS_MAX_IMPORT_BYTES = 50 * 1024 * 1024
def _ssh_bytes(host: str, cmd: str, payload: bytes, timeout: int = 120):
"""Send a binary payload to a fixed remote command over Butler-managed SSH."""
try:
result = _sp.run(
["ssh", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=accept-new",
"-o", "UserKnownHostsFile=/tmp/butler_known_hosts", host, cmd],
input=payload, capture_output=True, timeout=timeout,
)
return result.returncode, result.stdout.decode(errors="replace"), result.stderr.decode(errors="replace")
except _sp.TimeoutExpired:
return 124, "", f"SSH upload timed out after {timeout} seconds"
def _paperless_import_filename(filename: str, digest: str) -> str:
base_name = os.path.basename(filename or "document.pdf")
stem = re.sub(r"[^A-Za-z0-9._-]+", "_", base_name.rsplit(".", 1)[0]).strip("._-")
if not stem:
stem = "document"
return f"{stem[:100]}-{digest[:12]}.pdf"
@app.post("/paperless/import")
async def paperless_import(request: Request, filename: str = Query(..., min_length=1, max_length=180), _=Depends(_verify)):
"""Queue a PDF in Paperless without exposing Paperless or SSH to the caller."""
payload = await request.body()
if not payload or not payload.startswith(b"%PDF-"):
raise HTTPException(400, "Only valid PDF documents are accepted")
if len(payload) > PAPERLESS_MAX_IMPORT_BYTES:
raise HTTPException(413, "PDF exceeds the 50 MiB import limit")
digest = hashlib.sha256(payload).hexdigest()
import_name = _paperless_import_filename(filename, digest)
remote_path = f"{PAPERLESS_CONSUME_DIR}/{import_name}"
command = (
f"sudo -n install -d -o sascha -g sascha -m 0755 {PAPERLESS_CONSUME_DIR} && "
f"tmp=$(mktemp /tmp/paperless-import.XXXXXX) && "
f"cat > \"$tmp\" && sudo -n install -o sascha -g sascha -m 0644 \"$tmp\" {remote_path} && rm -f \"$tmp\""
)
rc, out, err = await asyncio.to_thread(_ssh_bytes, PAPERLESS_SSH, command, payload, 180)
if rc != 0:
raise HTTPException(502, (err or out).strip()[-500:] or "Paperless import transfer failed")
_audit("/paperless/import", "POST", 202, f"sha256={digest}; bytes={len(payload)}")
return {"status": "queued", "filename": import_name, "sha256": digest, "bytes": len(payload)}
@app.get("/paperless/import/status")
async def paperless_import_status(filename: str = Query(..., min_length=1, max_length=180), _=Depends(_verify)):
if not re.fullmatch(r"[A-Za-z0-9._-]+\.pdf", filename):
raise HTTPException(400, "Invalid import filename")
remote_path = f"{PAPERLESS_CONSUME_DIR}/{filename}"
command = (
f"if sudo -n test -f {remote_path}; then echo QUEUED; else echo CONSUMED; fi; "
f"sudo -n docker logs --since 15m paperless-ngx 2>&1 | grep -F -- {filename} | tail -20 || true"
)
rc, out, err = await asyncio.to_thread(_ssh, PAPERLESS_SSH, command, 45)
if rc != 0:
raise HTTPException(502, (err or out).strip()[-500:] or "Paperless status check failed")
lines = out.splitlines()
state = lines[0].strip().lower() if lines else "unknown"
return {"status": state, "filename": filename, "recent_log": lines[1:]}
def _wireguard_status_command() -> str: def _wireguard_status_command() -> str:
script = '''import json, subprocess script = '''import json, subprocess

View file

@ -46,6 +46,41 @@ def test_health_exposes_current_version():
assert response.json()["version"] == app.VERSION == "2.3.5" assert response.json()["version"] == app.VERSION == "2.3.5"
def test_paperless_import_queues_pdf_through_butler(monkeypatch):
captured = {}
def fake_upload(host, command, payload, timeout):
captured.update(host=host, command=command, payload=payload, timeout=timeout)
return 0, "", ""
monkeypatch.setattr(app, "_ssh_bytes", fake_upload)
pdf = b"%PDF-1.7\nvalid-test-payload"
with TestClient(app.app) as client:
response = client.post(
"/paperless/import?filename=Hausordnung%20Stand%2009.05.2022.pdf",
content=pdf,
headers={"Authorization": "Bearer test-token", "Content-Type": "application/pdf"},
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "queued"
assert body["filename"].startswith("Hausordnung_Stand_09.05.2022-")
assert captured["host"] == app.PAPERLESS_SSH
assert captured["payload"] == pdf
assert app.PAPERLESS_CONSUME_DIR in captured["command"]
def test_paperless_import_rejects_non_pdf(monkeypatch):
monkeypatch.setattr(app, "_ssh_bytes", lambda *args: (_ for _ in ()).throw(AssertionError("must not upload")))
with TestClient(app.app) as client:
response = client.post(
"/paperless/import?filename=bad.pdf",
content=b"not a pdf",
headers={"Authorization": "Bearer test-token"},
)
assert response.status_code == 400
def test_ui_serves_self_contained_operator_console(): def test_ui_serves_self_contained_operator_console():
with TestClient(app.app) as client: with TestClient(app.app) as client:
response = client.get("/ui") response = client.get("/ui")