407 lines
16 KiB
Python
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
|