From 754cbea6bb4a54ad37236d37ec079aa24badc275 Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Fri, 11 Sep 2026 20:41:47 +0200 Subject: [PATCH] Version 2.6.0: Unterschreiben mit dem Finger, Ablage in eine andere Freigabe --- apps/tesm/pyproject.toml | 2 +- apps/tesm/src/tesm/blueprints/fileshare.py | 83 ++++- apps/tesm/src/tesm/blueprints/settings.py | 2 + apps/tesm/src/tesm/settings_spec.py | 12 + .../src/tesm/templates/tesm/fileshare.html | 50 ++- .../tesm/templates/tesm/settings_system.html | 21 ++ packages/tesm-core/pyproject.toml | 2 +- .../tesm-core/src/tesm_core/app_factory.py | 2 +- .../static/tesm_core/css/components.css | 55 +++ .../tesm_core/static/tesm_core/js/viewer.js | 316 +++++++++++++++++- packages/tesm-licensing/pyproject.toml | 2 +- 11 files changed, 531 insertions(+), 16 deletions(-) diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index 648c9d7..78afbc9 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.5.2" +version = "2.6.0" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/fileshare.py b/apps/tesm/src/tesm/blueprints/fileshare.py index 51f4762..a267265 100644 --- a/apps/tesm/src/tesm/blueprints/fileshare.py +++ b/apps/tesm/src/tesm/blueprints/fileshare.py @@ -2,7 +2,10 @@ from __future__ import annotations +import hashlib import io +import json +from datetime import datetime, timezone from flask import ( Blueprint, @@ -47,6 +50,28 @@ def _audit(action: str, target: str, detail: str = "", severity: str = "info") - ) +def _unterschriften(roh: str) -> list[str]: + """Die Namen aus dem Unterschriftsfeld -- Text, keine Struktur. + + Was hier ankommt, hat der Browser geschickt und damit der Mensch davor. + Es wird protokolliert, nicht geglaubt: laenge begrenzt, Zeilenumbrueche + raus, mehr passiert damit nicht. + """ + namen: list[str] = [] + try: + eintraege = json.loads(roh) if roh else [] + except ValueError: + return [] + if not isinstance(eintraege, list): + return [] + for eintrag in eintraege[:20]: + name = str(eintrag if isinstance(eintrag, str) else eintrag.get("name", "")) + name = " ".join(name.split())[:120] + if name: + namen.append(name) + return namen + + def _mount_or_404(token: str) -> service.Mount: mounts = service.active_mounts(db(), current_user.id) for mount in mounts: @@ -82,6 +107,10 @@ def browse(): max_upload_mb=uploads.limit_mb(conn), # Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen? verzeichnisse=(service.all_directories(mount) if listing else []), + signature_witness=bool(core().settings.get(conn, "signature_witness_enabled")), + # Die Uhr des Servers. Das Datum unter einer Unterschrift soll nicht + # davon abhaengen, wie das Tablet gestellt ist. + serverzeit=datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), ) @@ -186,6 +215,21 @@ def upload(token: str): return redirect(url_for("fileshare.browse", share=token, path=relative)) +@bp.get("//folders") +@permission_required("fileshare.view") +def folders(token: str): + """Die Ordner **dieser** Freigabe als Vorschlagsliste. + + Gebraucht, seit das Speichern in eine andere Freigabe gehen darf: die + Vorschlaege muessen der gewaehlten Freigabe folgen. Alle Listen beim + Seitenaufbau mitzuschicken waere die Alternative gewesen -- bei vier + eingebundenen Freigaben mit je ein paar hundert Ordnern ist das eine + viertel Megabyte, die fast nie gebraucht wird. + """ + mount = _mount_or_404(token) + return {"folders": service.all_directories(mount)} + + @bp.post("//form") @permission_required("fileshare_pdf.create") def save_form(token: str): @@ -196,6 +240,12 @@ def save_form(token: str): die alte Datei danach loeschen -- das ist eine bewusste Handlung. """ mount = _mount_or_404(token) + # Das Ziel darf eine andere Freigabe sein: die Vorlage liegt oft in einer + # Vorlagenfreigabe, die ausgefuellte Fassung gehoert in die Ablage des + # Bereichs. Geprueft wird es wie jede andere Einbindung -- nur eine, die + # diesem Benutzer gerade gehoert, kommt in Frage. + ziel_token = (request.form.get("ziel") or "").strip() + ziel_mount = _mount_or_404(ziel_token) if ziel_token else mount relative = request.form.get("path") or "" upload_file = request.files.get("datei") if upload_file is None or not upload_file.filename: @@ -228,15 +278,22 @@ def save_form(token: str): try: original = service.resolve_path(mount, relative) + # Ohne Namen landet die Datei neben dem Original -- das geht nur in + # derselben Freigabe. + if ziel_mount.token != mount.token and not wunschname: + return {"error": "Fuer eine andere Freigabe braucht es einen Dateinamen."}, 400 # Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis, # so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt # es beim Ordner des Originals. if wunschname: - zielordner = service.resolve_path(mount, ordner) + zielordner = service.resolve_path(ziel_mount, ordner) else: zielordner = service.resolve_path(mount, ordner) if ordner else original.parent if not zielordner.is_dir(): - return {"error": f"Den Ordner '{ordner}' gibt es in dieser Freigabe nicht."}, 400 + return { + "error": f"Den Ordner '{ordner}' gibt es in der Freigabe " + f"'{ziel_mount.label}' nicht." + }, 400 if wunschname: # Eingegeben wird der Name ohne Endung; die kommt von hier. Wer # sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche @@ -257,9 +314,25 @@ def save_form(token: str): except service.FileshareError as exc: return {"error": str(exc)}, 400 - wohin = ziel.relative_to(mount.path.resolve()).as_posix() - _audit("fileshare.form_saved", f"{mount.label}:{relative}", wohin, severity="notice") - return {"message": f"Gespeichert als {wohin}.", "path": wohin} + wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix() + pruefsumme = hashlib.sha256(daten).hexdigest() + unterschriften = _unterschriften(request.form.get("unterschriften") or "") + + # Zwei Angaben, die man nicht verwechseln darf, und deshalb stehen beide + # da: der Name **in** der Unterschrift ist eine Angabe des + # Unterschreibenden -- wie auf Papier. Wer die Datei abgelegt hat, steht + # ohnehin als Handelnder in der Zeile und ist belegt. + beschreibung = f"{ziel_mount.label}:{wohin} sha256={pruefsumme}" + if unterschriften: + namen = ", ".join(unterschriften) + beschreibung = f"{beschreibung} -- angegeben unterschrieben von: {namen}" + _audit( + "fileshare.form_signed" if unterschriften else "fileshare.form_saved", + f"{mount.label}:{relative}", + beschreibung, + severity="notice", + ) + return {"message": f"Gespeichert als {wohin}.", "path": wohin, "sha256": pruefsumme} @bp.post("//folder") diff --git a/apps/tesm/src/tesm/blueprints/settings.py b/apps/tesm/src/tesm/blueprints/settings.py index 682dc20..5deec0f 100644 --- a/apps/tesm/src/tesm/blueprints/settings.py +++ b/apps/tesm/src/tesm/blueprints/settings.py @@ -82,6 +82,7 @@ def system(): "terminal_switches_enabled", "terminal_clients_enabled", "terminal_idle_timeout", + "signature_witness_enabled", "trash_retention_days", "session_idle_minutes", "session_absolute_hours", @@ -116,6 +117,7 @@ def system_save(): "terminal_switches_enabled", "terminal_clients_enabled", "terminal_idle_timeout", + "signature_witness_enabled", "trash_retention_days", "session_idle_minutes", "session_absolute_hours", diff --git a/apps/tesm/src/tesm/settings_spec.py b/apps/tesm/src/tesm/settings_spec.py index a0776ef..4955361 100644 --- a/apps/tesm/src/tesm/settings_spec.py +++ b/apps/tesm/src/tesm/settings_spec.py @@ -98,6 +98,18 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = ( minimum=30, maximum=1800, ), + # -- Unterschriften ------------------------------------------------------ # + SettingSpec( + "signature_witness_enabled", + True, + "bool", + "Unterschriften mit dem ablegenden Konto bezeugen", + hint="Unter jede Unterschrift kommt eine Zeile, wer das Dokument in TESM abgelegt " + "hat. Der Name in der Unterschrift ist eine Angabe des Unterschreibenden -- wie " + "auf Papier; das Konto daneben ist belegt. Eine Passwortabfrage gibt es dafuer " + "nicht.", + section="fileshare", + ), # -- Terminal ----------------------------------------------------------- # SettingSpec( "terminal_switches_enabled", diff --git a/apps/tesm/src/tesm/templates/tesm/fileshare.html b/apps/tesm/src/tesm/templates/tesm/fileshare.html index 55ee255..621b7c7 100644 --- a/apps/tesm/src/tesm/templates/tesm/fileshare.html +++ b/apps/tesm/src/tesm/templates/tesm/fileshare.html @@ -224,7 +224,10 @@ wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #} data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.view') else '' }}" data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}" - data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"> + data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}" + data-viewer-konto="{{ current_user.username }}" + data-viewer-zeuge="{{ '1' if signature_witness else '0' }}" + data-viewer-zeit="{{ serverzeit }}">