diff --git a/.env.example b/.env.example index 00989e1..af40f13 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,13 @@ NEXTCLOUD_UPLOAD=1 # Nextclouds Standard-Adressbuch heißt "contacts". NEXTCLOUD_ADRESSBUCH=contacts +# --- Aufbewahrungsfrist (pipe.loeschen, täglich per Cron) --- +# Nach so vielen Tagen wird ein erledigter Anruf (Aufnahme + Transkript) +# gelöscht - lokal und bei Nextcloud. 0 = aus, nichts wird gelöscht. Greift +# nur im letzten Stapel (DECK_STAPEL[-1], Default "Erledigt") - unbearbeitete +# Anrufe bleiben unangetastet, egal wie alt. +LOESCHFRIST_TAGE=0 + # --- Telefonie (SIP-Zugang der Praxis, leer lassen = keine Telefonannahme) --- SIP_USER= SIP_DOMAIN=sip.plusnet.de diff --git a/pipe/audit.py b/pipe/audit.py new file mode 100644 index 0000000..05a0eca --- /dev/null +++ b/pipe/audit.py @@ -0,0 +1,50 @@ +"""Unveraenderliches Ereignisprotokoll - Nachweis fuer den Umgang mit +Anrufdaten (u.a. Loeschungen nach Ablauf der Aufbewahrungsfrist). + +Anders als protokoll.log (das fuers Leitstand-UI auf die letzten 2000 Zeilen +gekuerzt wird) wird diese Datei nie gekuerzt oder ueberschrieben - nur +angehaengt. Zusaetzlich technisch gegen nachtraegliches Aendern/Loeschen +abgesichert: macOS' uappnd-Flag (append-only) laesst nur noch Anhaengen zu, +selbst der eigene Prozess kann die Datei danach nicht mehr kuerzen oder +umschreiben, ohne das Flag vorher explizit aufzuheben (`chflags nouappnd`). +Auf anderen Betriebssystemen (kein chflags) ist das Log weiterhin +append-only per Konvention, nur ohne das zusaetzliche OS-Siegel. +""" +from __future__ import annotations + +import json +import subprocess +from datetime import datetime + +from . import config + +DATEI = config.TELEFON / "audit.log" +_gesichert = False + + +def _absichern() -> None: + """Setzt das append-only-Flag einmal pro Prozesslaufzeit. Wirft nie.""" + global _gesichert + if _gesichert: + return + _gesichert = True + try: + subprocess.run(["chflags", "uappnd", str(DATEI)], + check=False, capture_output=True) + except Exception: + pass + + +def schreibe(art: str, text: str, **felder) -> None: + """Haengt ein Ereignis unveraenderlich an. Wirft nie.""" + try: + DATEI.parent.mkdir(parents=True, exist_ok=True) + if not DATEI.exists(): + DATEI.touch() + eintrag = {"zeit": datetime.now().isoformat(timespec="seconds"), + "art": art, "text": text, **felder} + with DATEI.open("a", encoding="utf-8") as f: + f.write(json.dumps(eintrag, ensure_ascii=False) + "\n") + _absichern() + except Exception: + pass diff --git a/pipe/config.py b/pipe/config.py index 77351b9..670df4a 100644 --- a/pipe/config.py +++ b/pipe/config.py @@ -146,3 +146,12 @@ DECK_TEILEN = [n.strip() for n in os.environ.get("DECK_TEILEN", "").split(",") i def nextcloud_aktiv() -> bool: return bool(NEXTCLOUD_URL and NEXTCLOUD_USER and NEXTCLOUD_PASS) + + +# --- Aufbewahrungsfrist (pipe.loeschen) -------------------------------------- +# Nach so vielen Tagen wird ein Anruf-Ordner (Aufnahme + Transkript + Meta) +# geloescht - lokal und, falls hochgeladen, auch bei Nextcloud. 0 = aus (nichts +# wird automatisch geloescht). Greift nur fuer Anrufe im letzten Stapel +# (DECK_STAPEL[-1], per Default "Erledigt") - unbearbeitete Anrufe bleiben +# unangetastet, egal wie alt. +LOESCHFRIST_TAGE = int(os.environ.get("LOESCHFRIST_TAGE", "0") or "0") diff --git a/pipe/loeschen.py b/pipe/loeschen.py new file mode 100644 index 0000000..6c855b1 --- /dev/null +++ b/pipe/loeschen.py @@ -0,0 +1,100 @@ +"""Loescht Anrufe nach Ablauf der Aufbewahrungsfrist (LOESCHFRIST_TAGE). + +Nur Anrufe im letzten Stapel (per Default "Erledigt") werden angefasst - +unbearbeitete Anrufe bleiben unangetastet, egal wie alt: der Eingang ist +tabu. Loescht lokal UND (falls hochgeladen) die Nextcloud-Kopie - sonst +bringt lokales Loeschen fuer die Datenminimierung nichts. Jede Loeschung +landet unveraenderlich in audit.log (ueber protokoll.schreibe). + + python3 -m pipe.loeschen # loescht faellige Anrufe + python3 -m pipe.loeschen --pruefen # zeigt nur, was faellig waere + +Gedacht fuer taeglichen Cron/launchd-Lauf - siehe README fuer die Einrichtung. +""" +from __future__ import annotations + +import argparse +import json +import shutil +from datetime import datetime, timedelta + +from . import config, protokoll, stapel, store + + +def _faellig(zeit: datetime) -> bool: + if not config.LOESCHFRIST_TAGE: + return False + return datetime.now() - zeit > timedelta(days=config.LOESCHFRIST_TAGE) + + +def laeuft(pruefen: bool = False) -> int: + """Loescht (oder zeigt bei pruefen=True nur) faellige Anrufe. Gibt die Anzahl zurück.""" + if not config.LOESCHFRIST_TAGE: + print("LOESCHFRIST_TAGE ist 0 - Loeschung ist aus, nichts zu tun.") + return 0 + if not config.ABLAGE_LOKAL.is_dir(): + return 0 + + letzter_stapel = stapel.erlaubt()[-1] + anzahl = 0 + + for meta_pfad in sorted(config.ABLAGE_LOKAL.glob("*/*/meta.json")): + ordner = meta_pfad.parent + try: + daten = json.loads(meta_pfad.read_text(encoding="utf-8")) + zeit = datetime.fromisoformat(daten["empfangen"]) + except Exception: + continue + + if stapel.lies(ordner) != letzter_stapel: + continue # unbearbeitet - Eingang ist tabu, egal wie alt + if not _faellig(zeit): + continue + + hatte_cloud_kopie = (ordner / store.MARKER).is_file() + wartet_noch_auf_upload = ( + config.nextcloud_aktiv() and config.NEXTCLOUD_UPLOAD and not hatte_cloud_kopie + ) + if wartet_noch_auf_upload: + # Noch nicht gesichert - nicht loeschen, sonst waere die lokale + # Kopie die einzige und faellt komplett weg. Naechster Sync-Lauf + # (pipe.resync) holt das nach, dann greift die Loeschung. + continue + + alter_tage = (datetime.now() - zeit).days + kennung = str(ordner.relative_to(config.ABLAGE_LOKAL)) + + if pruefen: + print(f"wuerde loeschen: {kennung} ({alter_tage} Tage alt)") + anzahl += 1 + continue + + if hatte_cloud_kopie: + store.loeschen_bei_nextcloud(ordner) + shutil.rmtree(ordner) + protokoll.schreibe( + "loeschung", + f"{ordner.name} geloescht (Frist {config.LOESCHFRIST_TAGE}d abgelaufen, " + f"war {alter_tage} Tage alt)", + id=kennung, alter_tage=alter_tage, hatte_nextcloud_kopie=hatte_cloud_kopie, + ) + anzahl += 1 + + return anzahl + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--pruefen", action="store_true", + help="nur anzeigen, was faellig waere - nichts loeschen") + args = ap.parse_args() + + anzahl = laeuft(pruefen=args.pruefen) + if config.LOESCHFRIST_TAGE: + verb = "waeren faellig" if args.pruefen else "geloescht" + print(f"{anzahl} Anruf(e) {verb} (Frist: {config.LOESCHFRIST_TAGE} Tage, " + f"nur Stapel '{stapel.erlaubt()[-1]}').") + + +if __name__ == "__main__": + main() diff --git a/pipe/protokoll.py b/pipe/protokoll.py index 37c321f..2edfd1c 100644 --- a/pipe/protokoll.py +++ b/pipe/protokoll.py @@ -3,20 +3,24 @@ Jede Zeile ist ein JSON-Objekt (JSONL) — maschinenlesbar für das Dashboard und trotzdem mit ``tail -f`` lesbar. Bewusst schlank: anhängen, nie sperren, nie scheitern. Ein kaputtes Protokoll darf keinen Anruf kosten. + +Diese Datei ist reine UI-Historie (siehe MAX_ZEILEN/kuerze) - fuer einen +dauerhaften Nachweis (Compliance, u.a. Loeschungen) schreibt schreibe() +jedes Ereignis zusaetzlich unveraenderlich nach audit.log, siehe pipe.audit. """ from __future__ import annotations import json from datetime import datetime -from . import config +from . import audit, config DATEI = config.TELEFON / "verlauf.log" MAX_ZEILEN = 2000 # darüber wird beim Schreiben gekürzt def schreibe(art: str, text: str, **felder) -> None: - """Hängt ein Ereignis an. Wirft nie.""" + """Hängt ein Ereignis an (UI-Verlauf + dauerhaftes Audit-Log). Wirft nie.""" try: DATEI.parent.mkdir(parents=True, exist_ok=True) eintrag = {"zeit": datetime.now().isoformat(timespec="seconds"), @@ -25,6 +29,7 @@ def schreibe(art: str, text: str, **felder) -> None: f.write(json.dumps(eintrag, ensure_ascii=False) + "\n") except Exception: pass + audit.schreibe(art, text, **felder) def lies(anzahl: int = 200) -> list[dict]: diff --git a/pipe/server.py b/pipe/server.py index 8b5fb5b..3e292e5 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -316,7 +316,16 @@ class Handler(BaseHTTPRequestHandler): self._json({"ok": True}) def _audio(self, rest: str) -> None: - """Liefert eine Aufnahme aus der Ablage - nur von dort.""" + """Liefert eine Aufnahme aus der Ablage - nur von dort. + + Muss HTTP-Range unterstuetzen: der