diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index 8a8c725..a508457 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.3.3" +version = "2.4.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 b706e78..0b44f53 100644 --- a/apps/tesm/src/tesm/blueprints/fileshare.py +++ b/apps/tesm/src/tesm/blueprints/fileshare.py @@ -86,7 +86,10 @@ def browse(): @bp.get("//herunterladen") -@permission_required("fileshare.view") +# Herunterladen traegt Daten aus dem System heraus -- in der Rechtematrix ist +# das "Exportieren", nicht "Ansehen". Eine Vorschau bleibt im Haus, eine +# heruntergeladene Datei nicht. +@permission_required("fileshare.export") def download(token: str): mount = _mount_or_404(token) relative = request.args.get("pfad") or "" @@ -184,7 +187,7 @@ def upload(token: str): @bp.post("//formular") -@permission_required("fileshare.create") +@permission_required("fileshare_pdf.create") def save_form(token: str): """Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab. @@ -207,6 +210,17 @@ def save_form(token: str): # waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen. return {"error": "Das ist keine PDF-Datei."}, 415 + # Geprueft wird die **Datei**, nicht ein mitgeschicktes Auswahlfeld: wer nur + # anlegen darf, legt endgueltige Fassungen ab. Traegt die Datei noch + # Formularfelder, ist sie weiter veraenderbar -- dafuer braucht es + # "PDF-Formulare: Bearbeiten". Ein umgebogenes Feld im Browser hilft damit + # niemandem weiter. + if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.edit"): + return { + "error": "Sie duerfen ausgefuellte Formulare nur abgeschlossen speichern " + "(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' waehlen." + }, 403 + # Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen. ordner = (request.form.get("ordner") or "").strip().strip("/") wunschname = (request.form.get("name") or "").strip() diff --git a/apps/tesm/src/tesm/permissions.py b/apps/tesm/src/tesm/permissions.py index c9a1cc7..0d609d1 100644 --- a/apps/tesm/src/tesm/permissions.py +++ b/apps/tesm/src/tesm/permissions.py @@ -85,11 +85,30 @@ DHCP = Resource( FILESHARE = Resource( key="fileshare", label="Dateifreigaben", - actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), + actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXPORT), hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)", action_hints={ A.CREATE: "Ordner anlegen und Dateien hochladen.", A.EDIT: "Umbenennen und verschieben.", + # Genau die Bedeutung aus der Legende: Daten aus dem System + # heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau + # bleibt im Haus, eine heruntergeladene Datei nicht. + A.EXPORT: "Dateien herunterladen.", + }, +) + +#: Eigene Zeile statt Anhaengsel von "Dateifreigaben": ein Formular auszufuellen +#: ist ein anderer Vorgang als eine Datei hochzuladen, und wer umbenennen darf, +#: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen. +FILESHARE_PDF = Resource( + key="fileshare_pdf", + label="PDF-Formulare", + actions=(A.VIEW, A.CREATE, A.EDIT), + hint="Formulare in der Vorschau ausfuellen und in der Freigabe ablegen", + action_hints={ + A.VIEW: "In der Vorschau ausfuellen (ohne Speichern).", + A.CREATE: "Ausgefuellt speichern -- abgeschlossen, Felder gesperrt.", + A.EDIT: "Ausgefuellt speichern -- weiter ausfuellbar, inklusive Bild.", }, ) @@ -119,7 +138,7 @@ FILES_AREA = Area( key="files", label="Dateifreigaben", icon="folder", - resources=(FILESHARE,), + resources=(FILESHARE, FILESHARE_PDF), ) PERMISSIONS = PermissionTree( diff --git a/apps/tesm/src/tesm/schema.py b/apps/tesm/src/tesm/schema.py index 24c0c32..3383899 100644 --- a/apps/tesm/src/tesm/schema.py +++ b/apps/tesm/src/tesm/schema.py @@ -250,8 +250,32 @@ CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, t """ +# Neue Rechte sind nach einem Update bei niemandem gesetzt -- ohne diesen +# Schritt koennte niemand mehr herunterladen oder ein Formular speichern, +# obwohl sich an den Gruppen nichts geaendert hat. Die Zuordnung bildet ab, was +# vorher galt. +V4 = """ +INSERT OR IGNORE INTO group_permissions (group_id, permission) + SELECT group_id, 'fileshare.export' FROM group_permissions + WHERE permission = 'fileshare.view'; + +INSERT OR IGNORE INTO group_permissions (group_id, permission) + SELECT group_id, 'fileshare_pdf.view' FROM group_permissions + WHERE permission = 'fileshare.view'; + +INSERT OR IGNORE INTO group_permissions (group_id, permission) + SELECT group_id, 'fileshare_pdf.create' FROM group_permissions + WHERE permission = 'fileshare.create'; + +INSERT OR IGNORE INTO group_permissions (group_id, permission) + SELECT group_id, 'fileshare_pdf.edit' FROM group_permissions + WHERE permission = 'fileshare.edit'; +""" + + TESM_MIGRATIONS: tuple[Migration, ...] = ( Migration(version=1, name="baseline", sql=V1), Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), Migration(version=3, name="restart-events", sql=V3), + Migration(version=4, name="fileshare-pdf-rechte", sql=V4), ) diff --git a/apps/tesm/src/tesm/services/fileshare.py b/apps/tesm/src/tesm/services/fileshare.py index b1de9dd..9667ab2 100644 --- a/apps/tesm/src/tesm/services/fileshare.py +++ b/apps/tesm/src/tesm/services/fileshare.py @@ -21,6 +21,7 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger: from __future__ import annotations +import zlib import logging import mimetypes import os @@ -622,6 +623,29 @@ def all_directories(mount: Mount, *, tiefe: int = 4, grenze: int = 400) -> list[ return gefunden +def hat_formularfelder(daten: bytes) -> bool: + """Steckt in diesen PDF-Bytes noch ein ausfuellbares Formular? + + Gesucht wird nach Formularfeldern (``/Subtype /Widget``) -- auch in + gepackten Objektstroemen, denn moderne Schreiber legen fast alles dort ab. + Ein flach gerechnetes Formular hat keine mehr. + + Bewusst ohne Zusatzpaket: die Frage ist einfach genug, und eine + PDF-Bibliothek auf jedem Zielsystem waere ein hoher Preis dafuer. + """ + text = bytearray(daten) + for treffer in re.finditer(rb"stream\r?\n", daten): + start = treffer.end() + ende = daten.find(b"endstream", start) + if ende == -1: + continue + try: + text += b"\n" + zlib.decompress(daten[start:ende]) + except zlib.error: + continue # kein zlib-Strom -- Bilder, Schriften, alles moegliche + return re.search(rb"/Subtype\s*/Widget", bytes(text)) is not None + + def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path: """Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``. diff --git a/apps/tesm/src/tesm/templates/tesm/fileshare.html b/apps/tesm/src/tesm/templates/tesm/fileshare.html index b3f9bfd..018263e 100644 --- a/apps/tesm/src/tesm/templates/tesm/fileshare.html +++ b/apps/tesm/src/tesm/templates/tesm/fileshare.html @@ -106,14 +106,22 @@ data-fill-Kind="{{ entry.preview_kind }}" data-fill-Pfad="{{ entry.relative }}" data-fill-Name="{{ entry.name }}" - data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"> + {% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"{% endif %}> {{ icon('file-text', size=15) }} {{ entry.name }} {% else %} - - {{ icon('file-text', size=15) }} {{ entry.name }} - + {% if current_user.has('fileshare.export') %} + + {{ icon('file-text', size=15) }} {{ entry.name }} + + {% else %} + {# Ohne Exportrecht bleibt der Name Text: es gibt hier + nichts zu holen, und ein toter Link waere Spott. #} + + {{ icon('file-text', size=15) }} {{ entry.name }} + + {% endif %} {% endif %} {{ entry.size | bytes }} @@ -126,7 +134,7 @@ data-fill-Kind="{{ entry.preview_kind }}" data-fill-Pfad="{{ entry.relative }}" data-fill-Name="{{ entry.name }}" - data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}" + {% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"{% endif %} aria-label="Vorschau">{{ icon('eye', size=15) }} {% endif %} {% if current_user.has('fileshare.edit') %} @@ -137,9 +145,11 @@ data-fill-Endung="{{ entry.suffix or '' }}" aria-label="Umbenennen">{{ icon('pencil', size=15) }} {% endif %} - {{ icon('download', size=15) }} + {% if current_user.has('fileshare.export') %} + {{ icon('download', size=15) }} + {% endif %} {% endfor %} @@ -210,6 +220,9 @@ iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}