Files
2026-09-04 19:49:43 +02:00

407 lines
16 KiB
Python

"""
paperless_client.py - REST-Client für Paperless-ngx.
Bewusst OHNE requests: Die in LibreOffice mitgelieferte Python-Laufzeit
bringt nur die Standardbibliothek mit. Multipart-Uploads werden deshalb
von Hand kodiert.
"""
import json
import mimetypes
import os
import ssl
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
TASK_TIMEOUT = 900
TASK_POLL = 1.5
# Obergrenze für Antwortkörper. Ohne Begrenzung könnte eine fehlerhafte
# oder böswillige Gegenstelle den Arbeitsspeicher füllen, indem sie eine
# endlose Antwort liefert.
MAX_JSON = 32 * 1024 * 1024 # 32 MB für Metadaten
MAX_DOWNLOAD = 512 * 1024 * 1024 # 512 MB für Dokumente
# Eigene Kennung statt der Vorgabe von urllib.
#
# urllib sendet standardmäßig "Python-urllib/3.x". Diese Kennung steht in
# den Sperrlisten gängiger Schutzsysteme (CrowdSec http-bad-user-agent,
# fail2ban, WAF-Regelwerke), weil sie typisch für Scanner ist. Folge: Der
# Zugriff über einen Reverse Proxy scheitert mit HTTP 403, während
# derselbe Aufruf direkt an die Anwendung funktioniert - ein schwer zu
# findendes Fehlerbild, zumal die Meldung von einem Dienst stammen kann,
# der mit dem Ziel nichts zu tun hat.
#
# Über den Parameter user_agent bei PaperlessClient anpassbar, falls eine
# Regel auch diesen Namen abweist.
USER_AGENT = "PaperlessLibreOffice/0.1 (+LibreOffice extension)"
class PaperlessError(Exception):
pass
class _SicherRedirect(urllib.request.HTTPRedirectHandler):
"""
Weiterleitungen nur nach http und https zulassen.
Der Vorgabe-Handler von urllib erlaubt auch ftp. Eine böswillige oder
übernommene Gegenstelle könnte damit auf ein anderes Protokoll
umlenken. Für eine REST-Schnittstelle gibt es dafür keinen Grund.
"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
if urllib.parse.urlparse(newurl).scheme not in ("http", "https"):
raise PaperlessError(
"Weiterleitung auf ein nicht zugelassenes Protokoll "
"abgewiesen: %s" % newurl[:120])
return super().redirect_request(req, fp, code, msg, headers, newurl)
class PaperlessClient:
def __init__(self, base_url, token, verify_tls=True, timeout=60,
user_agent=None):
# Nur http und https zulassen. Ohne Prüfung liesse sich hier
# file:// oder ftp:// hinterlegen, und urllib würde das bedienen -
# aus einer Adresseingabe würde ein Dateizugriff.
parsed = urllib.parse.urlparse((base_url or "").strip())
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise PaperlessError(
"Ungültige Basisadresse. Erwartet wird http:// oder "
"https:// mit Rechnernamen, z. B. https://dms.example.org")
self.base = base_url.strip().rstrip("/")
self.token = (token or "").strip()
self.timeout = timeout
self.user_agent = user_agent or USER_AGENT
self._opener = urllib.request.build_opener(_SicherRedirect())
self._ctx = None
if not verify_tls:
# Nur für Testinstanzen mit selbstsigniertem Zertifikat.
self._ctx = ssl.create_default_context()
self._ctx.check_hostname = False
self._ctx.verify_mode = ssl.CERT_NONE
# ------------------------------------------------------------ intern
def _open(self, req):
req.add_header("Authorization", "Token %s" % self.token)
req.add_header("User-Agent", self.user_agent)
req.add_header("Accept", "application/json, */*")
try:
return self._opener.open(req, timeout=self.timeout) \
if self._ctx is None else \
urllib.request.urlopen(req, timeout=self.timeout,
context=self._ctx)
except urllib.error.HTTPError as exc:
body = ""
try:
body = exc.read().decode("utf-8", "replace")[:400]
except Exception:
pass
# Die Adresse kann Suchbegriffe enthalten, aber niemals den
# Token - der steht im Kopfzeilenfeld. Trotzdem vorsichtshalber
# kürzen, damit lange Abfragen die Meldung nicht sprengen.
raise PaperlessError("HTTP %s bei %s: %s"
% (exc.code, req.full_url[:160],
body)) from exc
except urllib.error.URLError as exc:
raise PaperlessError("Verbindung fehlgeschlagen: %s" % exc.reason) from exc
def _json(self, path, params=None, method="GET", payload=None):
url = "%s/api/%s" % (self.base, path.lstrip("/"))
if params:
url += "?" + urllib.parse.urlencode(params, doseq=True)
data = None
req = urllib.request.Request(url, method=method)
if payload is not None:
data = json.dumps(payload).encode("utf-8")
req.add_header("Content-Type", "application/json")
req.data = data
with self._open(req) as r:
raw = r.read(MAX_JSON + 1)
if len(raw) > MAX_JSON:
raise PaperlessError("Antwort überschreitet %d MB"
% (MAX_JSON // 1024 // 1024))
return json.loads(raw.decode("utf-8")) if raw else None
def _multipart(self, path, fields, filename, filedata):
"""Multipart/form-data von Hand, weil requests nicht verfügbar ist."""
boundary = "----paperless%s" % uuid.uuid4().hex
out = []
for key, value in fields.items():
if value is None:
continue
out.append(("--%s\r\n"
"Content-Disposition: form-data; name=\"%s\"\r\n\r\n"
"%s\r\n" % (boundary, key, value)).encode("utf-8"))
ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
out.append(("--%s\r\n"
"Content-Disposition: form-data; name=\"document\"; "
"filename=\"%s\"\r\n"
"Content-Type: %s\r\n\r\n"
% (boundary, os.path.basename(filename), ctype)).encode("utf-8"))
out.append(filedata)
out.append(("\r\n--%s--\r\n" % boundary).encode("utf-8"))
body = b"".join(out)
url = "%s/api/%s" % (self.base, path.lstrip("/"))
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Content-Type",
"multipart/form-data; boundary=%s" % boundary)
with self._open(req) as r:
raw = r.read().decode("utf-8")
try:
return json.loads(raw)
except ValueError:
return raw.strip().strip('"')
# ------------------------------------------------------------ Aufgaben
def wait_for_task(self, task_id, progress=None):
"""
Wartet auf einen Konsumvorgang und liefert die Dokument-ID.
Die Antwortstruktur hat sich zwischen Versionen mehrfach geändert:
Status kommt klein- oder grossgeschrieben, die Dokument-ID steht mal
verschachtelt in result_data, mal als Liste in related_document_ids.
"""
deadline = time.time() + TASK_TIMEOUT
waited = 0
while time.time() < deadline:
task = self._find_task(task_id)
if task:
status = str(task.get("status") or "").upper()
if status in ("SUCCESS", "SUCCEEDED"):
doc = self._doc_id(task)
if doc:
return doc
raise PaperlessError("Aufgabe erfolgreich, aber ohne "
"Dokument-ID: %s" % task)
if status in ("FAILURE", "FAILED", "REVOKED"):
raise PaperlessError(self._error_text(task, status))
time.sleep(TASK_POLL)
waited += TASK_POLL
if progress:
progress(waited)
raise PaperlessError("Zeitüberschreitung nach %ds (Aufgabe %s)"
% (TASK_TIMEOUT, task_id))
def _find_task(self, task_id):
for params in ({"task_id": task_id}, None):
try:
data = self._json("tasks/", params)
except PaperlessError:
continue
rows = data if isinstance(data, list) else (data or {}).get("results", [])
for t in rows:
if str(t.get("task_id")) == str(task_id):
return t
return None
@staticmethod
def _doc_id(task):
def as_int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
for key in ("related_document_ids", "document_ids"):
val = task.get(key)
if isinstance(val, (list, tuple)) and val:
got = as_int(val[0])
if got:
return got
nested = task.get("result_data")
if isinstance(nested, dict):
for key in ("document_id", "related_document", "id"):
got = as_int(nested.get(key))
if got:
return got
for key in ("related_document", "document_id", "related_document_id"):
got = as_int(task.get(key))
if got:
return got
return None
@staticmethod
def _error_text(task, status):
rd = task.get("result_data") or {}
for src in (task.get("result"), rd.get("error"), rd.get("exc_message")):
if src:
txt = " ".join(str(src).split())
if txt and txt.lower() not in ("failure", "failed"):
return txt[:400]
return "Aufgabe %s ohne Meldung" % status
# ------------------------------------------------------------ Dokumente
def ping(self):
self._json("documents/", {"page_size": 1})
return True
def search(self, query, limit=50):
params = {"page_size": limit,
"ordering": "-added",
"fields": "id,title,created,added,archive_serial_number,"
"correspondent,document_type,tags,original_file_name"}
if query:
params["query"] = query
return (self._json("documents/", params) or {}).get("results", [])
def document(self, doc_id, full_perms=False):
params = {"full_perms": "true"} if full_perms else None
return self._json("documents/%d/" % int(doc_id), params)
def version_details(self, doc):
"""
Reichert die versions-Liste eines Dokuments um Format und Dateiname an.
Die eingebettete Liste enthält nur id, added, version_label,
checksum und is_root - nicht aber den Dateityp. Jede Version ist
aber selbst ein Dokumentdatensatz, also je Version ein Abruf.
Vertretbar, weil das nur beim bewussten Öffnen eines Dokuments
passiert.
Rückgabe absteigend nach Versionsnummer, also neueste zuerst -
so wie Paperless die Liste selbst liefert.
"""
versions = doc.get("versions") or []
if not versions:
# Dokument ohne Versionskette: es selbst ist die einzige Fassung.
return [{
"id": doc["id"], "added": doc.get("added"),
"version_label": None, "is_root": True, "no": 1,
"mime_type": doc.get("mime_type"),
"filename": doc.get("original_file_name") or "",
}]
# Paperless liefert absteigend. Die Versionsnummer ergibt sich aus
# der Position von unten gezählt.
total = len(versions)
out = []
for idx, v in enumerate(versions):
entry = dict(v)
entry["no"] = total - idx
try:
full = self.document(v["id"])
entry["mime_type"] = full.get("mime_type")
entry["filename"] = full.get("original_file_name") or ""
except PaperlessError:
entry["mime_type"] = None
entry["filename"] = ""
out.append(entry)
return out
def download(self, doc_id, original=True):
"""Laedt die Datei. original=True liefert das unveraenderte Original."""
url = "%s/api/documents/%d/download/" % (self.base, int(doc_id))
if original:
url += "?original=true"
req = urllib.request.Request(req_url := url)
with self._open(req) as r:
# Der Dateiname stammt aus einem Kopfzeilenfeld der Gegenstelle
# und ist damit nicht vertrauenswürdig. Er wird hier nur roh
# weitergereicht; die Bereinigung passiert beim Ablegen.
disp = r.headers.get("Content-Disposition", "")
name = None
if "filename=" in disp:
name = disp.split("filename=", 1)[1].strip().strip('";')
name = urllib.parse.unquote(name)
data = r.read(MAX_DOWNLOAD + 1)
if len(data) > MAX_DOWNLOAD:
raise PaperlessError(
"Die Datei überschreitet die Obergrenze von %d MB."
% (MAX_DOWNLOAD // 1024 // 1024))
return data, name
def update_version(self, doc_id, filename, filedata, version_label=None):
"""Haengt eine neue Version an. Liefert die Aufgaben-Kennung."""
return self._multipart("documents/%d/update_version/" % int(doc_id),
{"version_label": version_label},
filename, filedata)
def post_document(self, filename, filedata, fields=None):
"""
Legt ein NEUES Dokument an (eigene Versionskette).
Anders als update_version, das eine weitere Fassung an ein
bestehendes Dokument hängt. Liefert die Aufgaben-Kennung.
"""
data = {}
for key, value in (fields or {}).items():
if value in (None, "", []):
continue
data[key] = value
return self._multipart("documents/post_document/", data,
filename, filedata)
def patch_document(self, doc_id, payload):
return self._json("documents/%d/" % int(doc_id),
method="PATCH", payload=payload)
# ------------------------------------------------------------ Stammdaten
def _all(self, path, fields="id,name"):
out, page = [], 1
while True:
data = self._json(path, {"page_size": 200, "page": page,
"fields": fields})
if not data:
break
out.extend(data.get("results", []))
if not data.get("next"):
break
page += 1
return out
def tags(self):
return self._all("tags/")
def correspondents(self):
return self._all("correspondents/")
def document_types(self):
return self._all("document_types/")
def storage_paths(self):
return self._all("storage_paths/")
def custom_fields(self):
return self._all("custom_fields/", fields="id,name,data_type")
def create(self, path, name):
return self._json(path, method="POST", payload={"name": name})
# ------------------------------------------------------------ Freigabe
def share_link(self, doc_id, days=7, file_version="original"):
"""
Erzeugt einen zeitlich begrenzten, unauthentifizierten Link.
Der Endpunkt heißt je nach Version share_links/ oder sharelinks/.
Beide werden probiert.
"""
from datetime import datetime, timedelta, timezone
expiration = None
if days:
expiration = (datetime.now(timezone.utc)
+ timedelta(days=int(days))).isoformat()
payload = {"document": int(doc_id), "file_version": file_version}
if expiration:
payload["expiration"] = expiration
last = None
for path in ("share_links/", "sharelinks/"):
try:
res = self._json(path, method="POST", payload=payload)
slug = res.get("slug") if isinstance(res, dict) else None
if slug:
return "%s/share/%s" % (self.base, slug), res
return None, res
except PaperlessError as exc:
last = exc
raise last