From b70a9a65ec60a0d09e0ac4310618203031bf40f1 Mon Sep 17 00:00:00 2001 From: sascha Date: Tue, 1 Sep 2026 08:40:52 +0200 Subject: [PATCH] feat: add Butler-mediated Paperless PDF import --- app.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index 4b0b3c5..c50cede 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,7 @@ """Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab. 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 import httpx, yaml from typing import Literal @@ -2023,6 +2023,72 @@ def _ssh(host, cmd, timeout=600): 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: script = '''import json, subprocess