Initial commit
This commit is contained in:
406
pythonpath/paperless_client.py
Normal file
406
pythonpath/paperless_client.py
Normal file
@@ -0,0 +1,406 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user