""" paperless_config.py - Einstellungen und Dokumentzuordnung. Zwei Aufgaben: 1. Verbindungsdaten dauerhaft ablegen (Basisadresse, Token). 2. Merken, welches lokal geoeffnete Dokument zu welcher Paperless-ID gehört. Zu 2. gibt es zwei Ebenen. Vorrangig stehen die Angaben in den benutzerdefinierten Dokumenteigenschaften - die überleben einen Speichervorgang im ODF-Format und wandern sogar mit, wenn die Datei kopiert wird. Bei Formaten, die keine solchen Eigenschaften kennen (PDF, manche Fremdformate), greift eine lokale Zuordnungsdatei über den Datei-URL. """ import json import os import re import sys import tempfile import time APP_DIR_NAME = "paperless-libreoffice" PROP_DOC_ID = "PaperlessDocumentId" PROP_BASE = "PaperlessBaseUrl" def _config_dir(): """ Betriebssystemabhängiger Ort für Einstellungen. Unter Windows gibt es kein XDG_CONFIG_HOME; dort ist %APPDATA% der vorgesehene Ablageort. Unter macOS wäre ~/Library/Application Support üblich, ~/.config funktioniert dort aber ebenso und hält die Installation überschaubar. """ if sys.platform.startswith("win"): base = os.environ.get("APPDATA") or os.path.expanduser("~") else: base = os.environ.get("XDG_CONFIG_HOME") or \ os.path.expanduser("~/.config") path = os.path.join(base, APP_DIR_NAME) os.makedirs(path, exist_ok=True) lock_down(path, 0o700) return path def lock_down(path, mode): """ Rechte einschränken, soweit die Plattform das über chmod zulässt. Unter Windows ist chmod weitgehend wirkungslos - dort greift die Zugriffssteuerung über ACLs, und die Datei liegt im Benutzerprofil, das andere Benutzer standardmäßig nicht lesen können. """ try: os.chmod(path, mode) except OSError: pass def work_dir(*teile): """ Arbeitsverzeichnis für heruntergeladene Dateien. BEWUSST NICHT im allgemeinen Temp-Verzeichnis: Das ist auf Mehrbenutzersystemen für alle beschreibbar. Ein anderer Benutzer könnte dort einen symbolischen Verweis unter dem erwarteten Namen anlegen und das Schreiben in eine fremde Datei umlenken. Stattdessen ein Unterverzeichnis im Benutzerprofil, in dem nur der eigene Benutzer schreiben darf. """ path = os.path.join(_config_dir(), "arbeit", *[safe_filename(t) for t in teile]) os.makedirs(path, exist_ok=True) lock_down(path, 0o700) return path # Unter Windows verbotene Zeichen und reservierte Gerätenamen. Eine Datei # namens "CON.odt" oder "aux" lässt sich dort nicht anlegen, und Namen mit # ":" oder "\" werden als Pfadangaben gedeutet. _UNSICHER = re.compile(r'[<>:"/\\|?*\x00-\x1f]') _RESERVIERT = { "CON", "PRN", "AUX", "NUL", *("COM%d" % i for i in range(1, 10)), *("LPT%d" % i for i in range(1, 10)), } def safe_filename(name, vorgabe="Dokument", maxlen=80): """ Beliebige Zeichenkette in einen unbedenklichen Dateinamen überführen. Deckt drei Gefahren ab: * Pfadanteile. Ein vom Server geliefertes "../../etc/passwd" oder "..\\..\\datei" darf nicht aus dem Arbeitsverzeichnis herausführen. Deshalb werden BEIDE Trennzeichen entfernt, nicht nur das der laufenden Plattform. * Unter Windows verbotene Zeichen und Gerätenamen. * Punkte und Leerzeichen am Ende, die Windows stillschweigend abschneidet - dadurch könnten zwei Namen zusammenfallen. """ name = str(name or "") # Pfadanteile plattformunabhängig entfernen name = name.replace("\\", "/").split("/")[-1] name = _UNSICHER.sub("", name).strip() name = re.sub(r"\s+", " ", name) name = name.strip(". ") if not name: return vorgabe stamm, punkt, endung = name.rpartition(".") pruef = (stamm if punkt else name).upper() if pruef in _RESERVIERT: name = "_" + name if len(name) > maxlen: if punkt and len(endung) <= 8: name = name[:maxlen - len(endung) - 1] + "." + endung else: name = name[:maxlen] return name or vorgabe # --------------------------------------------------------------- Einstellungen def _settings_path(): return os.path.join(_config_dir(), "settings.json") def load_settings(): try: with open(_settings_path(), "r", encoding="utf-8") as fh: data = json.load(fh) except (OSError, ValueError): data = {} data.setdefault("base_url", "") data.setdefault("token", "") data.setdefault("verify_tls", True) data.setdefault("version_label", "LibreOffice {datum}") data.setdefault("share_days", 7) data.setdefault("share_tag", "Sharelink") # Zusatzfelder, deren Name eines dieser Wortteile enthält, werden beim # Anlegen einer Kopie NICHT übernommen. Sie beziehen sich auf die # Herkunft des Ursprungsdokuments - eine Kopie mit derselben # Fremdkennung wäre falsch. Voreingestellt sind gängige Bezeichnungen # aus vorangegangenen Systemen. data.setdefault("herkunftsfelder", ["ecodms", "quell-id", "source-id", "legacy"]) # Anpassbar, falls ein Reverse Proxy oder eine WAF bestimmte # Kennungen abweist. data.setdefault("user_agent", "") # Basisadresse defensiv normalisieren: abschliessender Schraegstrich # und Leerraum sind haeufige Fehlerquellen. data["base_url"] = str(data.get("base_url") or "").strip().rstrip("/") data["token"] = str(data.get("token") or "").strip() return data def save_settings(data): path = _settings_path() tmp = path + ".neu" with open(tmp, "w", encoding="utf-8") as fh: json.dump(data, fh, indent=2, ensure_ascii=False) lock_down(tmp, 0o600) os.replace(tmp, path) def settings_complete(s): return bool(s.get("base_url")) and bool(s.get("token")) # ------------------------------------------------------------- Zuordnung def _map_path(): return os.path.join(_config_dir(), "documents.json") def _load_map(): try: with open(_map_path(), "r", encoding="utf-8") as fh: return json.load(fh) except (OSError, ValueError): return {} def _save_map(data): # Einträge aelter als 90 Tage entfernen, damit die Datei nicht waechst. cutoff = time.time() - 90 * 86400 data = {k: v for k, v in data.items() if isinstance(v, dict) and v.get("ts", 0) > cutoff} path = _map_path() tmp = path + ".neu" with open(tmp, "w", encoding="utf-8") as fh: json.dump(data, fh, indent=2) lock_down(tmp, 0o600) os.replace(tmp, path) return data def remember(url, doc_id, base_url=""): data = _load_map() data[url] = {"id": int(doc_id), "base": base_url, "ts": time.time()} _save_map(data) def recall(url): entry = _load_map().get(url) return int(entry["id"]) if entry else None # ------------------------------------ Dokumenteigenschaften (bevorzugter Weg) def set_document_id(doc, doc_id, base_url=""): """ Schreibt die Kennung in die benutzerdefinierten Dokumenteigenschaften. Diese überleben einen ODF-Speichervorgang. """ try: props = doc.getDocumentProperties().getUserDefinedProperties() container = props.getPropertySetInfo() for name, value in ((PROP_DOC_ID, str(int(doc_id))), (PROP_BASE, base_url)): if container.hasPropertyByName(name): props.setPropertyValue(name, value) else: # 0 = ATTRIBUTE_NONE props.addProperty(name, 0, value) return True except Exception: return False def get_document_id(doc): """Kennung aus den Dokumenteigenschaften, sonst aus der Zuordnungsdatei.""" try: props = doc.getDocumentProperties().getUserDefinedProperties() if props.getPropertySetInfo().hasPropertyByName(PROP_DOC_ID): raw = props.getPropertyValue(PROP_DOC_ID) if raw: return int(str(raw).strip()) except Exception: pass try: url = doc.getURL() except Exception: url = None return recall(url) if url else None