Compare commits

..
9 Commits
48 changed files with 2071 additions and 371 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.3.2" version = "2.7.0"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+5 -5
View File
@@ -13,7 +13,7 @@ from tesm_core.licensing_client import LicenseError
from ..services import transfer from ..services import transfer
bp = Blueprint("backup", __name__, url_prefix="/sicherung") bp = Blueprint("backup", __name__, url_prefix="/administration/backup")
MAX_IMPORT_BYTES = 64 * 1024 * 1024 MAX_IMPORT_BYTES = 64 * 1024 * 1024
PREVIEW_SESSION_KEY = "_import_token" PREVIEW_SESSION_KEY = "_import_token"
@@ -78,7 +78,7 @@ def overview():
@bp.post("/export") @bp.post("/export")
@permission_required("backup.export") @permission_required("backup.export")
@reauth_required # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def export(): def export():
extension = core() extension = core()
conn = db() conn = db()
@@ -129,7 +129,7 @@ def export():
) )
@bp.post("/import/vorschau") @bp.post("/import/preview")
@permission_required("backup.execute") @permission_required("backup.execute")
@reauth_required @reauth_required
def import_preview(): def import_preview():
@@ -161,7 +161,7 @@ def import_preview():
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
@bp.post("/import/uebernehmen") @bp.post("/import/apply")
@permission_required("backup.execute") @permission_required("backup.execute")
@reauth_required @reauth_required
def import_apply(): def import_apply():
@@ -225,7 +225,7 @@ def import_apply():
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
@bp.post("/import/verwerfen") @bp.post("/import/discard")
@permission_required("backup.execute") @permission_required("backup.execute")
def import_discard(): def import_discard():
token = session.pop(PREVIEW_SESSION_KEY, "") token = session.pop(PREVIEW_SESSION_KEY, "")
+6 -6
View File
@@ -6,13 +6,13 @@ from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from tesm_core import audit from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required, reauth_required from tesm_core.auth.models import current_user, permission_required
from tesm_core.auth.service import client_ip from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
from ..services import inventory from ..services import inventory
bp = Blueprint("credentials", __name__, url_prefix="/zugangsdaten") bp = Blueprint("credentials", __name__, url_prefix="/monitoring/credentials")
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
@@ -39,7 +39,7 @@ def overview():
) )
@bp.post("/neu") @bp.post("/new")
@permission_required("credentials.create") @permission_required("credentials.create")
def create(): def create():
try: try:
@@ -91,7 +91,7 @@ def update(credential_id: int):
return redirect(url_for("credentials.overview")) return redirect(url_for("credentials.overview"))
@bp.post("/<int:credential_id>/loeschen") @bp.post("/<int:credential_id>/delete")
@permission_required("credentials.delete") @permission_required("credentials.delete")
def delete(credential_id: int): def delete(credential_id: int):
try: try:
@@ -109,9 +109,9 @@ def delete(credential_id: int):
return redirect(url_for("credentials.overview")) return redirect(url_for("credentials.overview"))
@bp.post("/<int:credential_id>/anzeigen") @bp.post("/<int:credential_id>/reveal")
@permission_required("credentials.secrets") @permission_required("credentials.secrets")
@reauth_required # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def reveal(credential_id: int): def reveal(credential_id: int):
"""Zeigt ein gespeichertes Passwort im Klartext. """Zeigt ein gespeichertes Passwort im Klartext.
+2 -2
View File
@@ -121,7 +121,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
} }
@bp.get("/uebersicht") @bp.get("/overview")
def index(): def index():
from tesm_core.auth.models import current_user from tesm_core.auth.models import current_user
@@ -138,7 +138,7 @@ def index_view():
return index() return index()
@bp.get("/uebersicht/kacheln") @bp.get("/overview/tiles")
@fragment @fragment
def tiles(): def tiles():
"""Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment.""" """Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment."""
+9 -9
View File
@@ -20,7 +20,7 @@ from tesm_core.extension import core, db, transaction
from ..services import inventory, monitor, restart, ssh from ..services import inventory, monitor, restart, ssh
bp = Blueprint("devices", __name__, url_prefix="/clients") bp = Blueprint("devices", __name__, url_prefix="/monitoring/clients")
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
@@ -113,7 +113,7 @@ def _credential_from_form(conn: Any) -> int | None:
) )
@bp.post("/neu") @bp.post("/new")
@permission_required("devices.create") @permission_required("devices.create")
def create(): def create():
try: try:
@@ -161,7 +161,7 @@ def update(device_id: int):
return redirect(url_for("devices.overview")) return redirect(url_for("devices.overview"))
@bp.post("/<int:device_id>/aktiv") @bp.post("/<int:device_id>/active")
@permission_required("devices.edit") @permission_required("devices.edit")
def toggle_active(device_id: int): def toggle_active(device_id: int):
active = request.form.get("active") == "1" active = request.form.get("active") == "1"
@@ -180,7 +180,7 @@ def toggle_active(device_id: int):
return redirect(request.referrer or url_for("devices.overview")) return redirect(request.referrer or url_for("devices.overview"))
@bp.post("/<int:device_id>/loeschen") @bp.post("/<int:device_id>/delete")
@permission_required("devices.delete") @permission_required("devices.delete")
def delete(device_id: int): def delete(device_id: int):
with transaction() as conn: with transaction() as conn:
@@ -193,7 +193,7 @@ def delete(device_id: int):
return redirect(url_for("devices.overview")) return redirect(url_for("devices.overview"))
@bp.post("/<int:device_id>/neustart") @bp.post("/<int:device_id>/restart")
@permission_required("devices.execute") @permission_required("devices.execute")
def restart_device(device_id: int): def restart_device(device_id: int):
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde. """Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
@@ -351,7 +351,7 @@ def detail(device_id: int):
categories=inventory.DEVICE_CATEGORIES, categories=inventory.DEVICE_CATEGORIES,
host_keys=host_keys, host_keys=host_keys,
method_labels=monitor.METHOD_LABELS, method_labels=monitor.METHOD_LABELS,
terminal_enabled=bool(extension.settings.get(conn, "terminal_enabled")), terminal_enabled=bool(extension.settings.get(conn, "terminal_clients_enabled")),
) )
@@ -360,7 +360,7 @@ def detail(device_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/<int:device_id>/hostkey/pruefen") @bp.post("/<int:device_id>/host-key/probe")
@permission_required("devices.edit") @permission_required("devices.edit")
def probe_host_key(device_id: int): def probe_host_key(device_id: int):
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden. """Liest den Host-Schluessel des Clients, ohne sich anzumelden.
@@ -390,7 +390,7 @@ def probe_host_key(device_id: int):
) )
@bp.post("/<int:device_id>/hostkey/freigeben") @bp.post("/<int:device_id>/host-key/trust")
@permission_required("devices.edit") @permission_required("devices.edit")
@reauth_required @reauth_required
def trust_host_key(device_id: int): def trust_host_key(device_id: int):
@@ -424,7 +424,7 @@ def trust_host_key(device_id: int):
return redirect(url_for("devices.detail", device_id=device_id)) return redirect(url_for("devices.detail", device_id=device_id))
@bp.post("/<int:device_id>/hostkey/verwerfen") @bp.post("/<int:device_id>/host-key/forget")
@permission_required("devices.edit") @permission_required("devices.edit")
def forget_host_key(device_id: int): def forget_host_key(device_id: int):
conn = db() conn = db()
+12 -12
View File
@@ -105,7 +105,7 @@ def overview():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/einstellungen") @bp.post("/settings")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def save_settings(): def save_settings():
extension = core() extension = core()
@@ -122,7 +122,7 @@ def save_settings():
return _write_and_redirect() return _write_and_redirect()
@bp.post("/subnetz") @bp.post("/subnet")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def add_subnet(): def add_subnet():
extension = core() extension = core()
@@ -158,7 +158,7 @@ def add_subnet():
return _write_and_redirect() return _write_and_redirect()
@bp.post("/subnetz/<int:subnet_id>") @bp.post("/subnet/<int:subnet_id>")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def update_subnet(subnet_id: int): def update_subnet(subnet_id: int):
extension = core() extension = core()
@@ -194,7 +194,7 @@ def update_subnet(subnet_id: int):
return _write_and_redirect() return _write_and_redirect()
@bp.post("/subnetz/<int:subnet_id>/loeschen") @bp.post("/subnet/<int:subnet_id>/delete")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def delete_subnet(subnet_id: int): def delete_subnet(subnet_id: int):
with transaction() as conn: with transaction() as conn:
@@ -209,7 +209,7 @@ def delete_subnet(subnet_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/option/wert") @bp.post("/option/value")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def set_option_value(): def set_option_value():
conn = db() conn = db()
@@ -248,7 +248,7 @@ def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ign
return gruppen return gruppen
@bp.post("/option/geraet") @bp.post("/option/device")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def set_device_option_value(): def set_device_option_value():
"""Setzt eine Option fuer ein einzelnes Geraet. """Setzt eine Option fuer ein einzelnes Geraet.
@@ -296,7 +296,7 @@ def set_device_option_value():
return _write_and_redirect() return _write_and_redirect()
@bp.post("/option/neu") @bp.post("/option/new")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def add_custom_option(): def add_custom_option():
conn = db() conn = db()
@@ -327,7 +327,7 @@ def add_custom_option():
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@bp.post("/option/<int:option_id>/loeschen") @bp.post("/option/<int:option_id>/delete")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def delete_custom_option(option_id: int): def delete_custom_option(option_id: int):
conn = db() conn = db()
@@ -352,7 +352,7 @@ def delete_custom_option(option_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/reservierung") @bp.post("/reservation")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def add_reservation(): def add_reservation():
conn = db() conn = db()
@@ -412,7 +412,7 @@ def add_reservation():
return _write_and_redirect() return _write_and_redirect()
@bp.post("/reservierung/<int:reservation_id>/loeschen") @bp.post("/reservation/<int:reservation_id>/delete")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def delete_reservation(reservation_id: int): def delete_reservation(reservation_id: int):
with transaction() as conn: with transaction() as conn:
@@ -480,13 +480,13 @@ def _write_and_redirect():
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@bp.post("/schreiben") @bp.post("/write")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def write_config(): def write_config():
return _write_and_redirect() return _write_and_redirect()
@bp.post("/dienst/<action>") @bp.post("/service/<action>")
@permission_required("dhcp.execute") @permission_required("dhcp.execute")
def service_action(action: str): def service_action(action: str):
allowed = {"install", "start", "stop"} allowed = {"install", "start", "stop"}
+174 -42
View File
@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import io import io
import json
from datetime import datetime, timezone
from flask import ( from flask import (
Blueprint, Blueprint,
@@ -23,7 +26,7 @@ from tesm_core.extension import core, db, transaction
from ..services import fileshare as service from ..services import fileshare as service
bp = Blueprint("fileshare", __name__, url_prefix="/dateifreigaben") bp = Blueprint("fileshare", __name__, url_prefix="/fileshares")
@bp.before_request @bp.before_request
@@ -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: def _mount_or_404(token: str) -> service.Mount:
mounts = service.active_mounts(db(), current_user.id) mounts = service.active_mounts(db(), current_user.id)
for mount in mounts: for mount in mounts:
@@ -60,8 +85,8 @@ def _mount_or_404(token: str) -> service.Mount:
def browse(): def browse():
conn = db() conn = db()
mounts = service.active_mounts(conn, current_user.id) mounts = service.active_mounts(conn, current_user.id)
token = request.args.get("freigabe") or (mounts[0].token if mounts else "") token = request.args.get("share") or (mounts[0].token if mounts else "")
relative = request.args.get("pfad") or "" relative = request.args.get("path") or ""
listing = None listing = None
error = "" error = ""
if token: if token:
@@ -82,14 +107,21 @@ def browse():
max_upload_mb=uploads.limit_mb(conn), max_upload_mb=uploads.limit_mb(conn),
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen? # Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
verzeichnisse=(service.all_directories(mount) if listing else []), 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"),
) )
@bp.get("/<token>/herunterladen") @bp.get("/<token>/download")
@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): def download(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.args.get("pfad") or "" relative = request.args.get("path") or ""
try: try:
path = service.resolve_path(mount, relative) path = service.resolve_path(mount, relative)
except service.FileshareError as exc: except service.FileshareError as exc:
@@ -110,11 +142,11 @@ def download(token: str):
) )
@bp.get("/<token>/vorschau") @bp.get("/<token>/preview")
@permission_required("fileshare.view") @permission_required("fileshare.view")
def preview(token: str): def preview(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.args.get("pfad") or "" relative = request.args.get("path") or ""
try: try:
path = service.resolve_path(mount, relative) path = service.resolve_path(mount, relative)
mimetype = service.preview_mime(path.name) mimetype = service.preview_mime(path.name)
@@ -149,15 +181,15 @@ def preview(token: str):
) )
@bp.post("/<token>/hochladen") @bp.post("/<token>/upload")
@permission_required("fileshare.create") @permission_required("fileshare.create")
def upload(token: str): def upload(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.form.get("pfad") or "" relative = request.form.get("path") or ""
files = request.files.getlist("files") files = request.files.getlist("files")
if not files: if not files:
flash("Keine Datei ausgewaehlt.", "danger") flash("Keine Datei ausgewaehlt.", "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
limit = uploads.limit_bytes(db()) limit = uploads.limit_bytes(db())
stored = 0 stored = 0
@@ -175,16 +207,47 @@ def upload(token: str):
stored += 1 stored += 1
except service.FileshareError as exc: except service.FileshareError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
if stored: if stored:
_audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)") _audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)")
flash(f"{stored} Datei(en) hochgeladen.", "success") flash(f"{stored} Datei(en) hochgeladen.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
@bp.post("/<token>/formular") @bp.get("/<token>/signature")
@permission_required("fileshare.create") @permission_required("fileshare.view")
def signature_state(token: str):
"""Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll?
Die Vorschau fragt das beim Oeffnen. Eine unterschriebene Fassung, die
seither veraendert wurde, sieht man einer PDF sonst nicht an -- und genau
das ist die Frage, die man sich vor einer Unterschrift stellt.
"""
mount = _mount_or_404(token)
try:
return service.signaturstand(db(), mount, request.args.get("path") or "")
except service.FileshareError as exc:
return {"error": str(exc)}, 400
@bp.get("/<token>/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("/<token>/form")
@permission_required("fileshare_pdf.create")
def save_form(token: str): def save_form(token: str):
"""Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab. """Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab.
@@ -193,7 +256,13 @@ def save_form(token: str):
die alte Datei danach loeschen -- das ist eine bewusste Handlung. die alte Datei danach loeschen -- das ist eine bewusste Handlung.
""" """
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.form.get("pfad") or "" # 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") upload_file = request.files.get("datei")
if upload_file is None or not upload_file.filename: if upload_file is None or not upload_file.filename:
return {"error": "Es kam keine Datei an."}, 400 return {"error": "Es kam keine Datei an."}, 400
@@ -207,6 +276,17 @@ def save_form(token: str):
# waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen. # waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen.
return {"error": "Das ist keine PDF-Datei."}, 415 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. # Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen.
ordner = (request.form.get("ordner") or "").strip().strip("/") ordner = (request.form.get("ordner") or "").strip().strip("/")
wunschname = (request.form.get("name") or "").strip() wunschname = (request.form.get("name") or "").strip()
@@ -214,15 +294,22 @@ def save_form(token: str):
try: try:
original = service.resolve_path(mount, relative) 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, # Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis,
# so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt # so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt
# es beim Ordner des Originals. # es beim Ordner des Originals.
if wunschname: if wunschname:
zielordner = service.resolve_path(mount, ordner) zielordner = service.resolve_path(ziel_mount, ordner)
else: else:
zielordner = service.resolve_path(mount, ordner) if ordner else original.parent zielordner = service.resolve_path(mount, ordner) if ordner else original.parent
if not zielordner.is_dir(): 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: if wunschname:
# Eingegeben wird der Name ohne Endung; die kommt von hier. Wer # Eingegeben wird der Name ohne Endung; die kommt von hier. Wer
# sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche # sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche
@@ -233,8 +320,8 @@ def save_form(token: str):
ziel = zielordner / name ziel = zielordner / name
if ziel.exists() and not ersetzen: if ziel.exists() and not ersetzen:
return { return {
"error": f"'{name}' gibt es dort schon. Anderen Namen waehlen oder " "error": f"In '{ziel_mount.label}' gibt es '{name}' schon. Anderen "
"'Vorhandene ersetzen' ankreuzen." "Namen waehlen oder 'Vorhandene ersetzen' ankreuzen."
}, 409 }, 409
else: else:
# Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben. # Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben.
@@ -243,49 +330,65 @@ def save_form(token: str):
except service.FileshareError as exc: except service.FileshareError as exc:
return {"error": str(exc)}, 400 return {"error": str(exc)}, 400
wohin = ziel.relative_to(mount.path.resolve()).as_posix() wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix()
_audit("fileshare.form_saved", f"{mount.label}:{relative}", wohin, severity="notice") pruefsumme = hashlib.sha256(daten).hexdigest()
return {"message": f"Gespeichert als {wohin}.", "pfad": wohin} 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("/<token>/ordner") @bp.post("/<token>/folder")
@permission_required("fileshare.create") @permission_required("fileshare.create")
def make_directory(token: str): def make_directory(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.form.get("pfad") or "" relative = request.form.get("path") or ""
try: try:
service.make_directory(mount, relative, request.form.get("name", "")) service.make_directory(mount, relative, request.form.get("name", ""))
except (service.FileshareError, OSError) as exc: except (service.FileshareError, OSError) as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
_audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}") _audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}")
flash("Ordner angelegt.", "success") flash("Ordner angelegt.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
@bp.post("/<token>/umbenennen") @bp.post("/<token>/rename")
@permission_required("fileshare.edit") @permission_required("fileshare.edit")
def rename(token: str): def rename(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.form.get("pfad") or "" relative = request.form.get("path") or ""
parent = request.form.get("parent") or "" parent = request.form.get("parent") or ""
try: try:
service.rename_entry(mount, relative, request.form.get("name", "")) service.rename_entry(mount, relative, request.form.get("name", ""))
except (service.FileshareError, OSError) as exc: except (service.FileshareError, OSError) as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) return redirect(url_for("fileshare.browse", share=token, path=parent))
_audit("fileshare.renamed", f"{mount.label}:{relative}") _audit("fileshare.renamed", f"{mount.label}:{relative}")
flash("Umbenannt.", "success") flash("Umbenannt.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) return redirect(url_for("fileshare.browse", share=token, path=parent))
@bp.post("/<token>/loeschen") @bp.post("/<token>/delete")
@permission_required("fileshare.delete") @permission_required("fileshare.delete")
def delete(token: str): def delete(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
parent = request.form.get("parent") or "" parent = request.form.get("parent") or ""
removed = 0 removed = 0
for relative in request.form.getlist("pfade"): for relative in request.form.getlist("paths"):
try: try:
kind = service.delete_entry(mount, relative) kind = service.delete_entry(mount, relative)
_audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning") _audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning")
@@ -294,10 +397,10 @@ def delete(token: str):
flash(str(exc), "danger") flash(str(exc), "danger")
if removed: if removed:
flash(f"{removed} Eintrag/Eintraege geloescht.", "success") flash(f"{removed} Eintrag/Eintraege geloescht.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) return redirect(url_for("fileshare.browse", share=token, path=parent))
@bp.post("/<token>/freigeben") @bp.post("/<token>/release")
@permission_required("fileshare.view") @permission_required("fileshare.view")
def release(token: str): def release(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
@@ -313,7 +416,7 @@ def release(token: str):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/zuordnungen") @bp.get("/mappings")
@permission_required("fileshare.edit") @permission_required("fileshare.edit")
def mappings(): def mappings():
from tesm_core.auth import directory from tesm_core.auth import directory
@@ -327,7 +430,7 @@ def mappings():
) )
@bp.post("/zuordnungen") @bp.post("/mappings")
@permission_required("fileshare.edit") @permission_required("fileshare.edit")
def mapping_add(): def mapping_add():
try: try:
@@ -339,18 +442,33 @@ def mapping_add():
label=request.form.get("label", ""), label=request.form.get("label", ""),
unc=request.form.get("unc", ""), unc=request.form.get("unc", ""),
) )
_audit("fileshare.mapping_added", request.form.get("label", ""), severity="notice") _audit(
"fileshare.mapping_added",
request.form.get("label", ""),
service.describe_mapping(
{
"unc": request.form.get("unc", ""),
"ad_group_name": request.form.get("ad_group_name", ""),
"ad_group_dn": request.form.get("ad_group_dn", ""),
}
),
severity="notice",
)
flash("Zuordnung gespeichert.", "success") flash("Zuordnung gespeichert.", "success")
except service.FileshareError as exc: except service.FileshareError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.mappings")) return redirect(url_for("fileshare.mappings"))
@bp.post("/zuordnungen/<int:mapping_id>") @bp.post("/mappings/<int:mapping_id>")
@permission_required("fileshare.edit") @permission_required("fileshare.edit")
def mapping_update(mapping_id: int): def mapping_update(mapping_id: int):
try: try:
with transaction() as conn: with transaction() as conn:
# Den Stand vor der Aenderung merken -- danach ist er weg, und
# "geaendert" ohne "was" beantwortet keine einzige Frage, die man
# spaeter an ein Protokoll stellt.
vorher = service.get_mapping(conn, mapping_id)
service.update_mapping( service.update_mapping(
conn, conn,
mapping_id, mapping_id,
@@ -359,18 +477,32 @@ def mapping_update(mapping_id: int):
label=request.form.get("label", ""), label=request.form.get("label", ""),
unc=request.form.get("unc", ""), unc=request.form.get("unc", ""),
) )
_audit("fileshare.mapping_updated", request.form.get("label", ""), severity="notice") nachher = service.get_mapping(conn, mapping_id)
_audit(
"fileshare.mapping_updated",
str((nachher or {}).get("label") or request.form.get("label", "")),
service.mapping_changes(vorher, nachher) or "keine inhaltliche Aenderung",
severity="notice",
)
flash("Zuordnung geaendert.", "success") flash("Zuordnung geaendert.", "success")
except service.FileshareError as exc: except service.FileshareError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.mappings")) return redirect(url_for("fileshare.mappings"))
@bp.post("/zuordnungen/<int:mapping_id>/loeschen") @bp.post("/mappings/<int:mapping_id>/delete")
@permission_required("fileshare.edit") @permission_required("fileshare.edit")
def mapping_delete(mapping_id: int): def mapping_delete(mapping_id: int):
with transaction() as conn: with transaction() as conn:
entfernt = service.get_mapping(conn, mapping_id)
service.delete_mapping(conn, mapping_id) service.delete_mapping(conn, mapping_id)
_audit("fileshare.mapping_deleted", str(mapping_id), severity="notice") # Eine entfernte Zuordnung nimmt allen Mitgliedern der Gruppe den Zugang
# zu dieser Freigabe -- das ist keine Notiz am Rande, sondern eine Warnung.
_audit(
"fileshare.mapping_deleted",
str((entfernt or {}).get("label") or mapping_id),
service.describe_mapping(entfernt),
severity="warning",
)
flash("Zuordnung entfernt.", "success") flash("Zuordnung entfernt.", "success")
return redirect(url_for("fileshare.mappings")) return redirect(url_for("fileshare.mappings"))
+5 -5
View File
@@ -31,7 +31,7 @@ from tesm_core.extension import core, db, transaction
from ..services import inventory, ssh from ..services import inventory, ssh
bp = Blueprint("hostkeys", __name__, url_prefix="/hostschluessel") bp = Blueprint("hostkeys", __name__, url_prefix="/monitoring/host-keys")
KIND_DEVICE = "client" KIND_DEVICE = "client"
KIND_SWITCH = "switch" KIND_SWITCH = "switch"
@@ -142,7 +142,7 @@ def _require(kind: str) -> None:
abort(403) abort(403)
@bp.post("/<kind>/<int:target_id>/pruefen") @bp.post("/<kind>/<int:target_id>/probe")
@permission_required("devices.view", "switches.view", mode="any") @permission_required("devices.view", "switches.view", mode="any")
def probe(kind: str, target_id: int): def probe(kind: str, target_id: int):
conn = db() conn = db()
@@ -169,7 +169,7 @@ def probe(kind: str, target_id: int):
) )
@bp.post("/<kind>/<int:target_id>/freigeben") @bp.post("/<kind>/<int:target_id>/trust")
@permission_required("devices.view", "switches.view", mode="any") @permission_required("devices.view", "switches.view", mode="any")
@reauth_required @reauth_required
def trust(kind: str, target_id: int): def trust(kind: str, target_id: int):
@@ -203,7 +203,7 @@ def trust(kind: str, target_id: int):
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
@bp.post("/<kind>/<int:target_id>/verwerfen") @bp.post("/<kind>/<int:target_id>/forget")
@permission_required("devices.view", "switches.view", mode="any") @permission_required("devices.view", "switches.view", mode="any")
def forget(kind: str, target_id: int): def forget(kind: str, target_id: int):
conn = db() conn = db()
@@ -222,7 +222,7 @@ def forget(kind: str, target_id: int):
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
@bp.post("/verwaist/verwerfen") @bp.post("/orphaned/forget")
@permission_required("devices.edit", "switches.edit", mode="any") @permission_required("devices.edit", "switches.edit", mode="any")
def forget_orphan(): def forget_orphan():
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt.""" """Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
+7 -7
View File
@@ -23,7 +23,7 @@ from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
import tesm_licensing as lic import tesm_licensing as lic
bp = Blueprint("license", __name__, url_prefix="/lizenz") bp = Blueprint("license", __name__, url_prefix="/administration/license")
MAX_UPLOAD_BYTES = 256 * 1024 MAX_UPLOAD_BYTES = 256 * 1024
@@ -69,7 +69,7 @@ def overview():
) )
@bp.post("/einspielen") @bp.post("/install")
@permission_required("settings_license.edit") @permission_required("settings_license.edit")
def upload(): def upload():
extension = core() extension = core()
@@ -110,13 +110,13 @@ def upload():
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
@bp.post("/aktivieren") @bp.post("/activate")
@permission_required("settings_license.edit") @permission_required("settings_license.edit")
def activate(): def activate():
return _handshake("activate") return _handshake("activate")
@bp.post("/deaktivieren") @bp.post("/deactivate")
@permission_required("settings_license.edit") @permission_required("settings_license.edit")
@reauth_required @reauth_required
def deactivate(): def deactivate():
@@ -158,7 +158,7 @@ def _handshake(action: str):
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
@bp.post("/offline-antwort") @bp.post("/offline-response")
@permission_required("settings_license.edit") @permission_required("settings_license.edit")
def offline_response(): def offline_response():
extension = core() extension = core()
@@ -177,7 +177,7 @@ def offline_response():
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
@bp.post("/serverzertifikat") @bp.post("/server-certificate")
@permission_required("settings_license.edit") @permission_required("settings_license.edit")
def server_certificate(): def server_certificate():
"""Hinterlegt das Zertifikat des Lizenzservers. """Hinterlegt das Zertifikat des Lizenzservers.
@@ -222,7 +222,7 @@ def server_certificate():
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
@bp.post("/aussteller/<key_id>/entfernen") @bp.post("/issuer/<key_id>/remove")
@admin_required @admin_required
@reauth_required @reauth_required
def remove_trusted_key(key_id: str): def remove_trusted_key(key_id: str):
+4 -4
View File
@@ -13,12 +13,12 @@ from tesm_core.extension import core, db
from ..services import kea, monitor from ..services import kea, monitor
bp = Blueprint("tesmlogs", __name__, url_prefix="/protokolle") bp = Blueprint("tesmlogs", __name__, url_prefix="/logs")
MAX_TAIL_LINES = 2000 MAX_TAIL_LINES = 2000
@bp.get("/kea") @bp.get("/dhcp")
@permission_required("logs_dhcp.view") @permission_required("logs_dhcp.view")
def dhcp(): def dhcp():
extension = core() extension = core()
@@ -38,7 +38,7 @@ def dhcp():
return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error) return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error)
@bp.get("/neustarts") @bp.get("/restarts")
@permission_required("devices.view") @permission_required("devices.view")
def restarts(): def restarts():
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung. """Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
@@ -48,7 +48,7 @@ def restarts():
der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht, der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht,
der wissen will, warum ein Geraet weg war. der wissen will, warum ein Geraet weg war.
""" """
method = (request.args.get("methode") or "").strip() method = (request.args.get("method") or "").strip()
if method and method not in monitor.METHOD_LABELS: if method and method not in monitor.METHOD_LABELS:
method = "" method = ""
sql = ( sql = (
+7 -7
View File
@@ -25,7 +25,7 @@ from tesm_core.extension import core, db, transaction
from ..services import inventory, maintenance as service, ssh, windowsops, monitor from ..services import inventory, maintenance as service, ssh, windowsops, monitor
bp = Blueprint("maintenance", __name__, url_prefix="/wartung") bp = Blueprint("maintenance", __name__, url_prefix="/maintenance")
@bp.before_request @bp.before_request
@@ -85,7 +85,7 @@ def overview():
category_labels=dict(inventory.CREDENTIAL_CATEGORIES), category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
category_effects=inventory.CATEGORY_EFFECTS, category_effects=inventory.CATEGORY_EFFECTS,
update_category=inventory.MAINTENANCE_CATEGORY, update_category=inventory.MAINTENANCE_CATEGORY,
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
windows_tool_missing=_windows_tool_missing(conn), windows_tool_missing=_windows_tool_missing(conn),
windows_package=windowsops.REQUIRED_PACKAGE, windows_package=windowsops.REQUIRED_PACKAGE,
) )
@@ -105,7 +105,7 @@ def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def]
) )
@bp.post("/paket-nachinstallieren") @bp.post("/install-package")
@permission_required("maintenance.execute") @permission_required("maintenance.execute")
def install_windows_tool(): def install_windows_tool():
"""Installiert das Paket, das der Fehlermeldung nach fehlt. """Installiert das Paket, das der Fehlermeldung nach fehlt.
@@ -165,7 +165,7 @@ def status():
) )
@bp.get("/auftrag/<job_id>") @bp.get("/job/<job_id>")
@permission_required("maintenance.view", "maintenance.execute", mode="any") @permission_required("maintenance.view", "maintenance.execute", mode="any")
def job_detail(job_id: str): def job_detail(job_id: str):
conn = db() conn = db()
@@ -175,7 +175,7 @@ def job_detail(job_id: str):
return render_template("tesm/maintenance_job.html", job=job) return render_template("tesm/maintenance_job.html", job=job)
@bp.get("/auftrag/<job_id>/ausgabe") @bp.get("/job/<job_id>/output")
@permission_required("maintenance.view", "maintenance.execute", mode="any") @permission_required("maintenance.view", "maintenance.execute", mode="any")
@fragment @fragment
def job_output(job_id: str): def job_output(job_id: str):
@@ -276,14 +276,14 @@ def _start(kind: str) -> None:
flash(f"{started} Auftrag/Auftraege gestartet.", "success") flash(f"{started} Auftrag/Auftraege gestartet.", "success")
@bp.post("/aktualisieren") @bp.post("/update")
@permission_required("maintenance.execute") @permission_required("maintenance.execute")
def start_update(): def start_update():
_start(service.JOB_KIND_UPDATE) _start(service.JOB_KIND_UPDATE)
return redirect(url_for("maintenance.overview")) return redirect(url_for("maintenance.overview"))
@bp.post("/neustarten") @bp.post("/restart")
@permission_required("maintenance.execute") @permission_required("maintenance.execute")
def start_reboot(): def start_reboot():
_start(service.JOB_KIND_REBOOT) _start(service.JOB_KIND_REBOOT)
+14 -5
View File
@@ -18,7 +18,7 @@ from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
from tesm_core.licensing_client import LicenseError from tesm_core.licensing_client import LicenseError
bp = Blueprint("settings", __name__, url_prefix="/einstellungen") bp = Blueprint("settings", __name__, url_prefix="/administration")
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None: def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
@@ -79,9 +79,13 @@ def system():
"monitor_failures_before_restart", "monitor_failures_before_restart",
"monitor_auto_restart", "monitor_auto_restart",
"dashboard_public", "dashboard_public",
"terminal_enabled", "terminal_switches_enabled",
"terminal_clients_enabled",
"terminal_idle_timeout", "terminal_idle_timeout",
"signature_witness_enabled",
"trash_retention_days", "trash_retention_days",
"session_idle_minutes",
"session_absolute_hours",
"log_rotate_interval", "log_rotate_interval",
"log_rotate_keep", "log_rotate_keep",
], ],
@@ -92,6 +96,7 @@ def system():
timezones=_timezone_choices(), timezones=_timezone_choices(),
nav_items=extension.nav.items, nav_items=extension.nav.items,
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(), nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
license_hint=_license_hint("monitor_interval"), license_hint=_license_hint("monitor_interval"),
sysops_available=bool(sysops and sysops.available), sysops_available=bool(sysops and sysops.available),
) )
@@ -109,9 +114,13 @@ def system_save():
"monitor_failures_before_restart", "monitor_failures_before_restart",
"monitor_auto_restart", "monitor_auto_restart",
"dashboard_public", "dashboard_public",
"terminal_enabled", "terminal_switches_enabled",
"terminal_clients_enabled",
"terminal_idle_timeout", "terminal_idle_timeout",
"signature_witness_enabled",
"trash_retention_days", "trash_retention_days",
"session_idle_minutes",
"session_absolute_hours",
"log_rotate_interval", "log_rotate_interval",
"log_rotate_keep", "log_rotate_keep",
): ):
@@ -181,7 +190,7 @@ def set_hostname():
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
@bp.post("/system/zeitzone") @bp.post("/system/timezone")
@permission_required("settings_system.execute") @permission_required("settings_system.execute")
def set_timezone(): def set_timezone():
extension = core() extension = core()
@@ -210,7 +219,7 @@ def set_timezone():
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
@bp.post("/system/pruefung-jetzt") @bp.post("/system/check-now")
@permission_required("settings_system.execute") @permission_required("settings_system.execute")
def run_check_now(): def run_check_now():
"""Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund.""" """Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund."""
+9 -9
View File
@@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction
from ..services import inventory, ssh, switchcli from ..services import inventory, ssh, switchcli
bp = Blueprint("switches", __name__, url_prefix="/switche") bp = Blueprint("switches", __name__, url_prefix="/monitoring/switches")
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
@@ -54,11 +54,11 @@ def overview():
"tesm/switches.html", "tesm/switches.html",
switches=inventory.list_switches(conn), switches=inventory.list_switches(conn),
credentials=inventory.list_credentials(conn), credentials=inventory.list_credentials(conn),
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")),
) )
@bp.post("/neu") @bp.post("/new")
@permission_required("switches.create") @permission_required("switches.create")
def create(): def create():
try: try:
@@ -98,7 +98,7 @@ def update(switch_id: int):
return redirect(request.referrer or url_for("switches.overview")) return redirect(request.referrer or url_for("switches.overview"))
@bp.post("/<int:switch_id>/loeschen") @bp.post("/<int:switch_id>/delete")
@permission_required("switches.delete") @permission_required("switches.delete")
def delete(switch_id: int): def delete(switch_id: int):
try: try:
@@ -142,7 +142,7 @@ def detail(switch_id: int):
devices=devices, devices=devices,
credentials=inventory.list_credentials(conn), credentials=inventory.list_credentials(conn),
safe_commands=switchcli.SAFE_COMMANDS, safe_commands=switchcli.SAFE_COMMANDS,
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")),
) )
@@ -151,7 +151,7 @@ def detail(switch_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/<int:switch_id>/hostkey/pruefen") @bp.post("/<int:switch_id>/host-key/probe")
@permission_required("switches.edit") @permission_required("switches.edit")
def probe_host_key(switch_id: int): def probe_host_key(switch_id: int):
conn = db() conn = db()
@@ -176,7 +176,7 @@ def probe_host_key(switch_id: int):
) )
@bp.post("/<int:switch_id>/hostkey/freigeben") @bp.post("/<int:switch_id>/host-key/trust")
@permission_required("switches.edit") @permission_required("switches.edit")
@reauth_required @reauth_required
def trust_host_key(switch_id: int): def trust_host_key(switch_id: int):
@@ -215,7 +215,7 @@ def trust_host_key(switch_id: int):
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
@bp.post("/<int:switch_id>/hostkey/verwerfen") @bp.post("/<int:switch_id>/host-key/forget")
@permission_required("switches.edit") @permission_required("switches.edit")
def forget_host_key(switch_id: int): def forget_host_key(switch_id: int):
conn = db() conn = db()
@@ -234,7 +234,7 @@ def forget_host_key(switch_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/<int:switch_id>/kommando") @bp.post("/<int:switch_id>/command")
@permission_required("switches.execute") @permission_required("switches.execute")
@reauth_required @reauth_required
def run_command(switch_id: int): def run_command(switch_id: int):
+20 -5
View File
@@ -24,7 +24,10 @@ Rechteausweitung, die dieses Projekt an anderer Stelle ausschliesst.
Gemeinsame Sicherheitsmerkmale: Gemeinsame Sicherheitsmerkmale:
* Frische Passwortbestaetigung, zusaetzlich zum Recht. * Frische Passwortbestaetigung, zusaetzlich zum Recht.
* Abschaltbar ueber die Einstellung ``terminal_enabled``. * Abschaltbar -- getrennt fuer Switche (``terminal_switches_enabled``) und
Clients (``terminal_clients_enabled``). Das Freigeben von Host-Schluesseln
haengt an eigenen Routen und bleibt davon unberuehrt: es wird fuer den
PoE-Neustart gebraucht.
* Der Host-Schluessel muss vorher freigegeben sein -- die Konsole akzeptiert * Der Host-Schluessel muss vorher freigegeben sein -- die Konsole akzeptiert
keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier
interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite. interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite.
@@ -128,6 +131,18 @@ def _load_target(conn: Any, kind: str, target_id: int) -> Target | None:
) )
def _konsole_erlaubt(conn: Any, kind: str) -> bool:
"""Ist die Konsole fuer diese Art Ziel eingeschaltet?
Zwei Schalter, weil beides unabhaengig voneinander gebraucht wird: eine
Werkstatt will vielleicht auf Switche, aber nicht auf Clients.
"""
schluessel = (
"terminal_switches_enabled" if kind == KIND_SWITCH else "terminal_clients_enabled"
)
return bool(core().settings.get(conn, schluessel))
def _client_module_active() -> bool: def _client_module_active() -> bool:
manager = core().license manager = core().license
return manager is None or manager.module_active("maintenance") return manager is None or manager.module_active("maintenance")
@@ -136,7 +151,7 @@ def _client_module_active() -> bool:
def _render_console(target: Target): # type: ignore[no-untyped-def] def _render_console(target: Target): # type: ignore[no-untyped-def]
extension = core() extension = core()
conn = db() conn = db()
if not extension.settings.get(conn, "terminal_enabled"): if not _konsole_erlaubt(conn, target.kind):
abort(404) abort(404)
if not target.credential_id: if not target.credential_id:
abort(409, description="Fuer dieses Ziel sind keine Zugangsdaten hinterlegt.") abort(409, description="Fuer dieses Ziel sind keine Zugangsdaten hinterlegt.")
@@ -151,7 +166,7 @@ def _render_console(target: Target): # type: ignore[no-untyped-def]
) )
@bp.get("/switche/<int:switch_id>/konsole") @bp.get("/monitoring/switches/<int:switch_id>/console")
@permission_required("switches.execute") @permission_required("switches.execute")
@reauth_required @reauth_required
def switch_console(switch_id: int): def switch_console(switch_id: int):
@@ -161,7 +176,7 @@ def switch_console(switch_id: int):
return _render_console(target) return _render_console(target)
@bp.get("/clients/<int:device_id>/konsole") @bp.get("/monitoring/clients/<int:device_id>/console")
@permission_required("maintenance.execute") @permission_required("maintenance.execute")
@reauth_required @reauth_required
def device_console(device_id: int): def device_console(device_id: int):
@@ -217,7 +232,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
if not reauth_is_fresh(): if not reauth_is_fresh():
_send(ws, "error", "Die Passwortbestaetigung ist abgelaufen. Bitte Seite neu laden.") _send(ws, "error", "Die Passwortbestaetigung ist abgelaufen. Bitte Seite neu laden.")
return return
if not extension.settings.get(conn, "terminal_enabled"): if not _konsole_erlaubt(conn, kind):
_send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.") _send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.")
return return
if kind == KIND_DEVICE and not _client_module_active(): if kind == KIND_DEVICE and not _client_module_active():
+12 -12
View File
@@ -45,10 +45,10 @@ def _has_fileshare_mounts(user: Any) -> bool:
NAVIGATION = NavTree( NAVIGATION = NavTree(
[ [
NavItem(key="index", label="Statusuebersicht", icon="grid", endpoint="dashboard.index"), NavItem(key="index", label="Statusübersicht", icon="grid", endpoint="dashboard.index"),
NavItem( NavItem(
key="monitoring", key="monitoring",
label="Ueberwachung", label="Überwachung",
icon="cpu", icon="cpu",
children=( children=(
NavItem( NavItem(
@@ -74,21 +74,21 @@ NAVIGATION = NavTree(
), ),
NavItem( NavItem(
key="hostkeys", key="hostkeys",
label="Host-Schluessel", label="Host-Schlüssel",
icon="shield", icon="shield",
endpoint="hostkeys.overview", endpoint="hostkeys.overview",
permissions=("devices.view", "switches.view"), permissions=("devices.view", "switches.view"),
), ),
NavItem(
key="maintenance",
label="Wartung",
icon="wrench",
endpoint="maintenance.overview",
permissions=("maintenance.view", "maintenance.execute"),
condition=_module("maintenance"),
),
), ),
), ),
NavItem(
key="maintenance",
label="Wartung",
icon="wrench",
endpoint="maintenance.overview",
permissions=("maintenance.view", "maintenance.execute"),
condition=_module("maintenance"),
),
NavItem( NavItem(
key="dhcp", key="dhcp",
label="DHCP-Server", label="DHCP-Server",
@@ -147,7 +147,7 @@ NAVIGATION = NavTree(
), ),
NavItem( NavItem(
key="logs_dhcp", key="logs_dhcp",
label="Kea-DHCP", label="DHCP",
icon="network", icon="network",
endpoint="tesmlogs.dhcp", endpoint="tesmlogs.dhcp",
permissions=("logs_dhcp.view",), permissions=("logs_dhcp.view",),
+49 -9
View File
@@ -31,7 +31,7 @@ DEVICES = Resource(
key="devices", key="devices",
label="Clients", label="Clients",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
hint="Ueberwachte PoE-Endgeraete", hint="Überwachte PoE-Endgeräte",
action_hints={ action_hints={
A.EXECUTE: "PoE-Neustart eines Geraets manuell ausloesen. Der automatische " A.EXECUTE: "PoE-Neustart eines Geraets manuell ausloesen. Der automatische "
"Neustart bei Ausfall ist davon unabhaengig und laeuft immer.", "Neustart bei Ausfall ist davon unabhaengig und laeuft immer.",
@@ -65,7 +65,8 @@ CREDENTIALS = Resource(
MAINTENANCE = Resource( MAINTENANCE = Resource(
key="maintenance", key="maintenance",
label="Wartung", # Nicht "Wartung": so heisst der Bereich. Die Zeile sagt, was sie erlaubt.
label="Aktualisierung und Neustart",
actions=(A.VIEW, A.EXECUTE), actions=(A.VIEW, A.EXECUTE),
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)", hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."}, action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."},
@@ -75,7 +76,7 @@ DHCP = Resource(
key="dhcp", key="dhcp",
label="DHCP-Server", label="DHCP-Server",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="Kea-DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)", hint="DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)",
action_hints={ action_hints={
A.EDIT: "Konfiguration aendern und schreiben.", A.EDIT: "Konfiguration aendern und schreiben.",
A.EXECUTE: "Dienst installieren, starten oder stoppen.", A.EXECUTE: "Dienst installieren, starten oder stoppen.",
@@ -85,27 +86,65 @@ DHCP = Resource(
FILESHARE = Resource( FILESHARE = Resource(
key="fileshare", key="fileshare",
label="Dateifreigaben", 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)", hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
action_hints={ action_hints={
A.CREATE: "Ordner anlegen und Dateien hochladen.", A.CREATE: "Ordner anlegen und Dateien hochladen.",
A.EDIT: "Umbenennen und verschieben.", 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.",
},
# Exporte verlangen sonst eine frische Passwortbestaetigung. Hier nicht:
# Herunterladen ist in einer Dateifreigabe der Alltag, nicht der
# Sonderfall. Alle 15 Minuten ein Passwort waere Schikane -- und wuerde
# nur dazu fuehren, dass jemand das Recht pauschal vergibt oder ganz
# weglaesst. Die Grenze zieht hier das Recht selbst, nicht die Abfrage.
ohne_wiederanmeldung=(A.EXPORT,),
)
#: 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.",
}, },
) )
LOGS_DHCP = Resource( LOGS_DHCP = Resource(
key="logs_dhcp", key="logs_dhcp",
label="Kea-DHCP-Protokoll", label="DHCP-Protokoll",
actions=(A.VIEW,), actions=(A.VIEW,),
) )
MONITORING_AREA = Area( MONITORING_AREA = Area(
key="monitoring", key="monitoring",
label="Ueberwachung", label="Überwachung",
icon="cpu", icon="cpu",
hint="Geraete, Switche, Zugangsdaten und Wartung. Ohne diesen Zugang bleibt der " hint="Geraete, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
"gesamte Bereich verborgen.", "gesamte Bereich verborgen.",
resources=(DEVICES, SWITCHES, CREDENTIALS, MAINTENANCE), resources=(DEVICES, SWITCHES, CREDENTIALS),
)
#: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als
#: eigener Punkt im Menue. Der Bereichsschluessel heisst ``servicing``, weil
#: ``maintenance`` schon die Ressource ist und Bereiche und Ressourcen sich
#: einen Namensraum teilen. Nach aussen heisst beides "Wartung".
MAINTENANCE_AREA = Area(
key="servicing",
label="Wartung",
icon="wrench",
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
"verborgen -- auch wenn einzelne Rechte darunter gesetzt sind.",
resources=(MAINTENANCE,),
) )
NETWORK_AREA = Area( NETWORK_AREA = Area(
@@ -119,12 +158,13 @@ FILES_AREA = Area(
key="files", key="files",
label="Dateifreigaben", label="Dateifreigaben",
icon="folder", icon="folder",
resources=(FILESHARE,), resources=(FILESHARE, FILESHARE_PDF),
) )
PERMISSIONS = PermissionTree( PERMISSIONS = PermissionTree(
[ [
MONITORING_AREA, MONITORING_AREA,
MAINTENANCE_AREA,
NETWORK_AREA, NETWORK_AREA,
FILES_AREA, FILES_AREA,
observability_area(LOGS_DHCP), observability_area(LOGS_DHCP),
+61
View File
@@ -250,8 +250,69 @@ 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';
"""
# Aus einem Schalter werden zwei. Wer die Konsole abgeschaltet hatte, soll sie
# nicht durch ein Update zurueckbekommen -- der alte Wert gilt deshalb fuer
# beide weiter. Fehlt er, greift die Vorgabe der Einstellung.
V5 = """
INSERT OR IGNORE INTO settings (key, value, updated_at, updated_by)
SELECT 'terminal_switches_enabled', value, datetime('now'), 'migration'
FROM settings WHERE key = 'terminal_enabled';
INSERT OR IGNORE INTO settings (key, value, updated_at, updated_by)
SELECT 'terminal_clients_enabled', value, datetime('now'), 'migration'
FROM settings WHERE key = 'terminal_enabled';
DELETE FROM settings WHERE key = 'terminal_enabled';
"""
# Wartung ist ein eigener Bereich geworden und hat damit einen eigenen
# Kill-Switch. Ohne diesen Schritt waere sie nach dem Update bei allen weg:
# das neue Bereichsrecht ist bei niemandem gesetzt, und ohne Bereichsrecht
# greift kein Recht darunter.
#
# Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich
# Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update
# darf Zugang erhalten, aber keinen neuen schaffen.
V6 = """
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT DISTINCT group_id, 'servicing.view'
FROM group_permissions
WHERE permission IN ('maintenance.view', 'maintenance.execute')
AND group_id IN (
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.view'
);
"""
TESM_MIGRATIONS: tuple[Migration, ...] = ( TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1), Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
Migration(version=3, name="restart-events", sql=V3), Migration(version=3, name="restart-events", sql=V3),
Migration(version=4, name="fileshare-pdf-rechte", sql=V4),
Migration(version=5, name="terminal-getrennt", sql=V5),
Migration(version=6, name="wartung-eigener-bereich", sql=V6),
) )
+189 -7
View File
@@ -21,6 +21,8 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
from __future__ import annotations from __future__ import annotations
import hashlib
import zlib
import logging import logging
import mimetypes import mimetypes
import os import os
@@ -32,7 +34,7 @@ import sqlite3
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any, Iterable from typing import Any, Iterable, Mapping
from tesm_core.db import Database from tesm_core.db import Database
from tesm_core.extension import core from tesm_core.extension import core
@@ -207,6 +209,50 @@ def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]:
) )
#: Felder einer Zuordnung mit der Beschriftung, unter der sie in der
#: Oberflaeche stehen. Das Protokoll soll dieselben Woerter benutzen wie das
#: Formular -- sonst muss man beim Lesen uebersetzen.
MAPPING_FELDER: tuple[tuple[str, str], ...] = (
("label", "Anzeigename"),
("unc", "Pfad"),
("ad_group_name", "Gruppe"),
("ad_group_dn", "Gruppen-DN"),
)
def get_mapping(conn: sqlite3.Connection, mapping_id: int) -> dict[str, Any] | None:
return Database.one(conn, "SELECT * FROM fileshare_mappings WHERE id=?", (mapping_id,))
def describe_mapping(row: Mapping[str, Any] | None) -> str:
"""Eine Zuordnung in einer Zeile -- fuer das Protokoll.
Ohne sie stand beim Loeschen nur die Nummer der Zuordnung im Protokoll.
Die ist nach dem Loeschen nirgends mehr nachschlagbar; damit war der
Eintrag da, aber wertlos.
"""
if row is None:
return ""
teile = [str(row["unc"] or "")]
gruppe = str(row["ad_group_name"] or row["ad_group_dn"] or "")
if gruppe:
teile.append(f"Gruppe: {gruppe}")
return " -- ".join(teil for teil in teile if teil)
def mapping_changes(vorher: Mapping[str, Any] | None, nachher: Mapping[str, Any] | None) -> str:
"""Was sich geaendert hat, Feld fuer Feld: ``Anzeigename: alt -> neu``."""
if vorher is None or nachher is None:
return ""
geaendert = []
for feld, beschriftung in MAPPING_FELDER:
alt = str(vorher[feld] or "")
neu = str(nachher[feld] or "")
if alt != neu:
geaendert.append(f"{beschriftung}: {alt or '(leer)'} -> {neu or '(leer)'}")
return "; ".join(geaendert)
def add_mapping( def add_mapping(
conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str
) -> int: ) -> int:
@@ -388,7 +434,39 @@ def mount_for_login(
if not password: if not password:
return ["Kein Passwort fuer den Fileserver verfuegbar -- keine Einbindungen."] return ["Kein Passwort fuer den Fileserver verfuegbar -- keine Einbindungen."]
# Was zuerst kommt: das Ende der Sitzung oder die eigene Obergrenze. Die
# Sitzung ist die eigentliche Klammer -- mit ihr endet die Einbindung; die
# Obergrenze sorgt dafuer, dass eine sehr lange Sitzung nicht beliebig
# lange auf fremden Zugangsdaten sitzt.
hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12) hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12)
frist = _now() + timedelta(hours=hours)
# Die Sitzungszeile gibt es hier noch nicht: eingebunden wird waehrend der
# Anmeldung, geschrieben wird sie erst beim Speichern der Antwort. Die
# Dauer steht aber in derselben Einstellung, aus der die Sitzung sie
# gleich bekommt -- und falls die Zeile doch schon steht (erneutes
# Einbinden), gilt deren Ende.
zeile = (
Database.one(
conn, "SELECT absolute_expires_at FROM user_sessions WHERE id=?", (session_id,)
)
if session_id
else None
)
if zeile is not None and zeile["absolute_expires_at"]:
try:
sitzungsende = datetime.fromisoformat(str(zeile["absolute_expires_at"]))
if sitzungsende.tzinfo is None:
sitzungsende = sitzungsende.replace(tzinfo=timezone.utc)
frist = min(frist, sitzungsende)
except ValueError:
pass
else:
try:
dauer = int(extension.settings.get(conn, "session_absolute_hours") or 0)
except (TypeError, ValueError):
dauer = 0
if dauer > 0:
frist = min(frist, _now() + timedelta(hours=dauer))
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares")) root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
messages: list[str] = [] messages: list[str] = []
@@ -418,7 +496,7 @@ def mount_for_login(
str(share["unc"]), str(share["unc"]),
str(target), str(target),
_iso(_now()), _iso(_now()),
_iso(_now() + timedelta(hours=hours)), _iso(frist),
), ),
) )
messages.append(f"{share['label']}: eingebunden") messages.append(f"{share['label']}: eingebunden")
@@ -473,7 +551,10 @@ def sweep_expired(database: Any) -> int:
released = 0 released = 0
with database.session() as conn: with database.session() as conn:
# Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie # Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie
# entstanden ist. Vorher hing sie an einer eigenen Frist von zwoelf # entstanden ist. Ohne Sitzungsangabe gilt allein die eigene Frist:
# "keine Sitzung eingetragen" ist etwas anderes als "Sitzung
# abgelaufen". Diese Verwechslung hat jede frische Einbindung binnen
# Minuten wieder geloest, weil die ID beim Anmelden noch nicht stand. Vorher hing sie an einer eigenen Frist von zwoelf
# Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung # Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung
# bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers # bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers
# weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen; # weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen;
@@ -486,10 +567,11 @@ def sweep_expired(database: Any) -> int:
LEFT JOIN user_sessions s ON s.id = m.session_id LEFT JOIN user_sessions s ON s.id = m.session_id
WHERE m.released_at IS NULL WHERE m.released_at IS NULL
AND (m.expires_at <= ? AND (m.expires_at <= ?
OR s.id IS NULL OR (COALESCE(m.session_id, '') <> ''
OR s.revoked_at IS NOT NULL AND (s.id IS NULL
OR s.idle_expires_at <= ? OR s.revoked_at IS NOT NULL
OR s.absolute_expires_at <= ?) OR s.idle_expires_at <= ?
OR s.absolute_expires_at <= ?)))
""", """,
(_iso(_now()), _iso(_now()), _iso(_now())), (_iso(_now()), _iso(_now()), _iso(_now())),
) )
@@ -523,6 +605,83 @@ def resolve_path(mount: Mount, relative: str) -> Path:
return target return target
#: Wie eine unterschriebene Fassung im Aenderungsprotokoll steht. Die
#: Pruefsumme ist der Anker -- sie steht im selben Eintrag wie der Name, den
#: der Unterschreibende angegeben hat.
SIGNATUR_AKTION = "fileshare.form_signed"
def pruefsumme(pfad: Path) -> str:
"""SHA-256 der Datei, blockweise gelesen."""
kessel = hashlib.sha256()
with pfad.open("rb") as strom:
for block in iter(lambda: strom.read(1024 * 1024), b""):
kessel.update(block)
return kessel.hexdigest()
def signaturstand(conn: sqlite3.Connection, mount: "Mount", relative: str) -> dict[str, Any]:
"""Ist diese Datei die, die unterschrieben wurde?
Drei moegliche Antworten, und die dritte ist die wichtigste:
* ``unveraendert`` -- es gibt einen Protokolleintrag mit genau dieser
Pruefsumme. Die Datei ist Byte fuer Byte die unterschriebene.
* ``veraendert`` -- es gibt einen Eintrag zu genau diesem Ablageort, aber
mit anderer Pruefsumme. Jemand hat nach der Unterschrift etwas an der
Datei geaendert. Das ist der Fall, wegen dem es diese Pruefung gibt.
* ``unbekannt`` -- kein Eintrag. Kein Verdacht: die meisten Dateien in
einer Freigabe sind nie durch TESM gegangen.
Gesucht wird nur in der Datenbank. Ausgelagerte Protokolltage (Archiv)
bleiben aussen vor -- ein Dateidialog darf nicht minutenlang suchen.
"""
ziel = resolve_path(mount, relative)
if not ziel.is_file():
return {"status": "unbekannt"}
hash_ = pruefsumme(ziel)
zeile = Database.one(
conn,
"SELECT ts, actor, detail FROM audit_log WHERE action=? AND detail LIKE ? "
"ORDER BY id DESC LIMIT 1",
(SIGNATUR_AKTION, f"%sha256={hash_}%"),
)
if zeile is not None:
return {
"status": "unveraendert",
"sha256": hash_,
"zeit": str(zeile["ts"]),
"konto": str(zeile["actor"]),
"namen": _namen_aus_detail(str(zeile["detail"])),
}
# Nichts mit dieser Pruefsumme -- aber vielleicht etwas zu diesem Ort.
zeile = Database.one(
conn,
"SELECT ts, actor, detail FROM audit_log WHERE action=? AND detail LIKE ? "
"ORDER BY id DESC LIMIT 1",
(SIGNATUR_AKTION, f"{mount.label}:{relative} sha256=%"),
)
if zeile is not None:
return {
"status": "veraendert",
"sha256": hash_,
"zeit": str(zeile["ts"]),
"konto": str(zeile["actor"]),
"namen": _namen_aus_detail(str(zeile["detail"])),
}
return {"status": "unbekannt", "sha256": hash_}
def _namen_aus_detail(detail: str) -> list[str]:
"""Die angegebenen Namen aus dem Protokolltext -- ohne sie zu deuten."""
marke = "angegeben unterschrieben von: "
if marke not in detail:
return []
return [teil.strip() for teil in detail.split(marke, 1)[1].split(",") if teil.strip()]
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]: def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
path = resolve_path(mount, relative) path = resolve_path(mount, relative)
if not path.is_dir(): if not path.is_dir():
@@ -622,6 +781,29 @@ def all_directories(mount: Mount, *, tiefe: int = 4, grenze: int = 400) -> list[
return gefunden 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: def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path:
"""Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``. """Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``.
+27 -4
View File
@@ -98,14 +98,37 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
minimum=30, minimum=30,
maximum=1800, maximum=1800,
), ),
# -- Terminal ----------------------------------------------------------- # # -- Unterschriften ------------------------------------------------------ #
SettingSpec( SettingSpec(
"terminal_enabled", "signature_witness_enabled",
True, True,
"bool", "bool",
"Browser-Terminal fuer Switche aktiv", "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",
True,
"bool",
"Browser-Konsole fuer Switche zulassen",
hint="Auch wenn aktiv: erfordert das Recht 'Ausfuehren' auf Switche, eine frische " hint="Auch wenn aktiv: erfordert das Recht 'Ausfuehren' auf Switche, eine frische "
"Passwortbestaetigung und wird vollstaendig protokolliert.", "Passwortbestaetigung und wird vollstaendig protokolliert. Das Freigeben von "
"Host-Schluesseln bleibt davon unberuehrt -- es wird fuer den PoE-Neustart "
"gebraucht.",
section="monitoring",
),
SettingSpec(
"terminal_clients_enabled",
True,
"bool",
"Browser-Konsole fuer Clients zulassen",
hint="Braucht zusaetzlich das Modul Wartung und das Recht 'Ausfuehren' auf Wartung. "
"Das Freigeben von Host-Schluesseln bleibt davon unberuehrt.",
section="monitoring", section="monitoring",
), ),
SettingSpec( SettingSpec(
+130 -33
View File
@@ -23,7 +23,7 @@
<div class="tabs"> <div class="tabs">
{% for mount in mounts %} {% for mount in mounts %}
<a class="tab {{ 'is-active' if mount.token == token else '' }}" <a class="tab {{ 'is-active' if mount.token == token else '' }}"
href="{{ url_for('fileshare.browse', freigabe=mount.token) }}">{{ mount.label }}</a> href="{{ url_for('fileshare.browse', share=mount.token) }}">{{ mount.label }}</a>
{% endfor %} {% endfor %}
</div> </div>
@@ -33,9 +33,9 @@
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<nav class="breadcrumb"> <nav class="breadcrumb">
<span><a href="{{ url_for('fileshare.browse', freigabe=token) }}">{{ current.label }}</a></span> <span><a href="{{ url_for('fileshare.browse', share=token) }}">{{ current.label }}</a></span>
{% for crumb in listing.breadcrumb %} {% for crumb in listing.breadcrumb %}
<span><a href="{{ url_for('fileshare.browse', freigabe=token, pfad=crumb.relative) }}">{{ crumb.name }}</a></span> <span><a href="{{ url_for('fileshare.browse', share=token, path=crumb.relative) }}">{{ crumb.name }}</a></span>
{% endfor %} {% endfor %}
</nav> </nav>
<div class="card__sub mono fs-xs">{{ current.unc }}</div> <div class="card__sub mono fs-xs">{{ current.unc }}</div>
@@ -69,8 +69,8 @@
<tr> <tr>
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %} {% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
<td colspan="4"> <td colspan="4">
<a class="link-icon" href="{{ url_for('fileshare.browse', freigabe=token, <a class="link-icon" href="{{ url_for('fileshare.browse', share=token,
pfad=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}"> path=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
{{ icon('chevron-right', size=14) }} eine Ebene höher {{ icon('chevron-right', size=14) }} eine Ebene höher
</a> </a>
</td> </td>
@@ -79,11 +79,11 @@
{% for entry in listing.directories %} {% for entry in listing.directories %}
<tr> <tr>
{% if current_user.has('fileshare.delete') %} {% if current_user.has('fileshare.delete') %}
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td> <td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
{% endif %} {% endif %}
<td> <td>
{{ icon('folder', size=15) }} {{ icon('folder', size=15) }}
<a href="{{ url_for('fileshare.browse', freigabe=token, pfad=entry.relative) }}">{{ entry.name }}</a> <a href="{{ url_for('fileshare.browse', share=token, path=entry.relative) }}">{{ entry.name }}</a>
</td> </td>
<td class="numeric faint">&ndash;</td> <td class="numeric faint">&ndash;</td>
<td class="fs-sm">{{ entry.modified | dt }}</td> <td class="fs-sm">{{ entry.modified | dt }}</td>
@@ -93,7 +93,7 @@
{% for entry in listing.files %} {% for entry in listing.files %}
<tr> <tr>
{% if current_user.has('fileshare.delete') %} {% if current_user.has('fileshare.delete') %}
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td> <td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
{% endif %} {% endif %}
{# Der Name ist die grösste Fläche der Zeile -- er soll auch {# Der Name ist die grösste Fläche der Zeile -- er soll auch
das Naheliegende tun. Was sich anzeigen lässt, öffnet die das Naheliegende tun. Was sich anzeigen lässt, öffnet die
@@ -102,18 +102,27 @@
{% if entry.previewable %} {% if entry.previewable %}
<button class="link-icon file-name" type="button" <button class="link-icon file-name" type="button"
data-dialog-open="file-preview" data-dialog-open="file-preview"
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}" data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
data-fill-Kind="{{ entry.preview_kind }}" data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}" data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}" 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, path=entry.relative) }}"{% endif %}>
{{ icon('file-text', size=15) }} {{ entry.name }} {{ icon('file-text', size=15) }} {{ entry.name }}
</button> </button>
{% else %} {% else %}
<a class="link-icon" {% if current_user.has('fileshare.export') %}
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"> <a class="link-icon"
{{ icon('file-text', size=15) }} {{ entry.name }} href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}">
</a> {{ icon('file-text', size=15) }} {{ entry.name }}
</a>
{% else %}
{# Ohne Exportrecht bleibt der Name Text: es gibt hier
nichts zu holen, und ein toter Link waere Spott. #}
<span class="link-icon faint">
{{ icon('file-text', size=15) }} {{ entry.name }}
</span>
{% endif %}
{% endif %} {% endif %}
</td> </td>
<td class="numeric fs-sm">{{ entry.size | bytes }}</td> <td class="numeric fs-sm">{{ entry.size | bytes }}</td>
@@ -122,11 +131,12 @@
{% if entry.previewable %} {% if entry.previewable %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-preview" data-dialog-open="file-preview"
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}" data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
data-fill-Kind="{{ entry.preview_kind }}" data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}" data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}" 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, path=entry.relative) }}"{% endif %}
aria-label="Vorschau">{{ icon('eye', size=15) }}</button> aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
{% endif %} {% endif %}
{% if current_user.has('fileshare.edit') %} {% if current_user.has('fileshare.edit') %}
@@ -137,9 +147,11 @@
data-fill-Endung="{{ entry.suffix or '' }}" data-fill-Endung="{{ entry.suffix or '' }}"
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button> aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
{% endif %} {% endif %}
<a class="btn btn--sm btn--ghost" {% if current_user.has('fileshare.export') %}
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}" <a class="btn btn--sm btn--ghost"
aria-label="Herunterladen">{{ icon('download', size=15) }}</a> href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -172,7 +184,7 @@
<form method="post" enctype="multipart/form-data" data-no-guard <form method="post" enctype="multipart/form-data" data-no-guard
action="{{ url_for('fileshare.upload', token=token) }}"> action="{{ url_for('fileshare.upload', token=token) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="pfad" value="{{ listing.relative }}"> <input type="hidden" name="path" value="{{ listing.relative }}">
<div class="card__body row"> <div class="card__body row">
<input class="input" type="file" name="files" multiple required style="flex:1"> <input class="input" type="file" name="files" multiple required style="flex:1">
<button class="btn btn--primary" type="submit" data-busy-label="Lädt ...">Hochladen</button> <button class="btn btn--primary" type="submit" data-busy-label="Lädt ...">Hochladen</button>
@@ -189,7 +201,7 @@
{% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %} {% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %}
<form method="post" action="{{ url_for('fileshare.make_directory', token=token) }}"> <form method="post" action="{{ url_for('fileshare.make_directory', token=token) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="pfad" value="{{ listing.relative }}"> <input type="hidden" name="path" value="{{ listing.relative }}">
<div class="modal__body">{{ field('name', 'Name', required=True) }}</div> <div class="modal__body">{{ field('name', 'Name', required=True) }}</div>
<div class="modal__footer"> <div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button> <button class="btn" type="button" data-dialog-close>Abbrechen</button>
@@ -210,8 +222,14 @@
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #} nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
<div class="modal__body modal__body--flush" data-viewer <div class="modal__body modal__body--flush" data-viewer
{# Ohne das Recht zum Ausfuellen zeigt die Vorschau das Dokument so,
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-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 }}">
<div class="viewer"> <div class="viewer">
<div class="viewer__stage" data-viewer-bildbuehne hidden> <div class="viewer__stage" data-viewer-bildbuehne hidden>
<img class="viewer__img" alt="" data-viewer-bild> <img class="viewer__img" alt="" data-viewer-bild>
@@ -227,8 +245,52 @@
<iframe class="file-preview" title="Vorschau" data-viewer-rahmen <iframe class="file-preview" title="Vorschau" data-viewer-rahmen
src="about:blank" referrerpolicy="same-origin" hidden></iframe> src="about:blank" referrerpolicy="same-origin" hidden></iframe>
{# Gefragt wird erst, wenn gespeichert werden soll -- vorher waere es {# Gefragt wird erst, wenn gespeichert werden soll -- vorher waere es
eine Zeile Formular ueber jeder Bildvorschau. #} eine Zeile Formular über jeder Bildvorschau. Und nur, wer Formulare
ablegen darf, sieht sie überhaupt. #}
{% if current_user.has('fileshare_pdf.create') %}
{# Unterschreiben: Name tippen, mit dem Finger zeichnen, ins Dokument
setzen. Der Name daneben ist eine Angabe des Unterschreibenden – wie
auf Papier; wer abgelegt hat, steht im Änderungsprotokoll. #}
<div class="viewer__sign" data-viewer-unterschriftpanel hidden>
<div class="viewer__signfelder">
<label class="viewer__feld">
<span class="field__label">Vorname</span>
<input class="input" data-viewer-vorname autocomplete="off">
</label>
<label class="viewer__feld">
<span class="field__label">Nachname</span>
<input class="input" data-viewer-nachname autocomplete="off">
</label>
</div>
<canvas class="viewer__signpad" data-viewer-signpad width="700" height="200"
aria-label="Unterschriftsfeld"></canvas>
<div class="viewer__signaktionen">
<span class="fs-xs faint" data-viewer-signhinweis></span>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-signleeren>Leeren</button>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-signabbrechen>Abbrechen</button>
<button class="btn btn--sm btn--primary" type="button" data-viewer-signsetzen>
Unterschrift setzen
</button>
</div>
<p class="fs-xs faint">
Nach „Unterschrift setzen“ auf die Stelle im Dokument tippen. Mit Unterschrift
wird immer abgeschlossen gespeichert – die Felder sind danach gesperrt.
</p>
</div>
<div class="viewer__save" data-viewer-speichernzeile hidden> <div class="viewer__save" data-viewer-speichernzeile hidden>
{# Zwei eingebundene Freigaben, ein Formular: die Vorlage liegt in der
einen, die ausgefüllte Fassung gehört in die andere. #}
<label class="viewer__feld">
<span class="field__label">Freigabe</span>
<select class="select" data-viewer-ziel
data-url-vorlage="{{ url_for('fileshare.folders', token='__token__') }}">
{% for eintrag in mounts %}
<option value="{{ eintrag.token }}" {% if eintrag.token == token %}selected{% endif %}>
{{ eintrag.label }}
</option>
{% endfor %}
</select>
</label>
<label class="viewer__feld"> <label class="viewer__feld">
<span class="field__label">Ordner</span> <span class="field__label">Ordner</span>
<input class="input" list="freigabe-ordner" data-viewer-ordner <input class="input" list="freigabe-ordner" data-viewer-ordner
@@ -238,13 +300,43 @@
<span class="field__label">Dateiname (ohne Endung)</span> <span class="field__label">Dateiname (ohne Endung)</span>
<input class="input" data-viewer-name autocomplete="off"> <input class="input" data-viewer-name autocomplete="off">
</label> </label>
<label class="check"> <label class="viewer__feld">
<input type="checkbox" data-viewer-ersetzen> <span class="field__label">Speichern als</span>
<span>Vorhandene ersetzen</span> {# Zwei Wege, beide gewollt: weiter ausfüllbar (das Bild wird das
Aussehen des Knopfes und lässt sich später austauschen) oder
abgeschlossen (alle Felder werden Teil der Seite). #}
{# Nur, was das Recht hergibt: "weiter ausfüllbar" hängt an
PDF-Formulare/Bearbeiten, "abgeschlossen" an Anlegen. Wer nur
anlegen darf, sieht eine Zeile statt einer Auswahl. #}
<select class="select" data-viewer-modus>
{% if current_user.has('fileshare_pdf.edit') %}
<option value="formular">Formular – weiter ausfüllbar</option>
{% endif %}
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
</select>
</label> </label>
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>Speichern</button> {# Haken über den Knöpfen und beides rechts: gelesen wird die Zeile von
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>Abbrechen</button> links nach rechts (wohin, wie es heißt, wie gespeichert), und die
Entscheidung steht am Ende. #}
<div class="viewer__saveaktionen">
<label class="check" data-viewer-ersetzenfeld>
<input type="checkbox" data-viewer-ersetzen>
<span>Vorhandene ersetzen</span>
</label>
<div class="viewer__saveknoepfe">
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>
Abbrechen
</button>
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>
Speichern
</button>
</div>
</div>
</div> </div>
{% endif %}
{# Das Ergebnis der Pruefung steht über der Leiste – wer eine
unterschriebene Fassung öffnet, soll es sehen, bevor er liest. #}
<p class="viewer__pruefung" data-viewer-pruefung hidden></p>
<div class="viewer__bar" data-viewer-leiste hidden> <div class="viewer__bar" data-viewer-leiste hidden>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus" <button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button> aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
@@ -256,10 +348,15 @@
</div> </div>
</div> </div>
<div class="modal__footer"> <div class="modal__footer">
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download> {% if current_user.has('fileshare.export') %}
{{ icon('download', size=15) }} Herunterladen <a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
</a> {{ icon('download', size=15) }} Herunterladen
{% if current_user.has('fileshare.create') %} </a>
{% endif %}
{% if current_user.has('fileshare_pdf.create') %}
<button class="btn btn--sm" type="button" data-viewer-unterschreiben hidden>
{{ icon('pencil', size=15) }} Unterschreiben
</button>
<button class="btn btn--sm" type="button" data-viewer-speichern hidden <button class="btn btn--sm" type="button" data-viewer-speichern hidden
data-url="{{ url_for('fileshare.save_form', token=token) }}"> data-url="{{ url_for('fileshare.save_form', token=token) }}">
{{ icon('save', size=15) }} Ausgefüllt speichern {{ icon('save', size=15) }} Ausgefüllt speichern
@@ -279,7 +376,7 @@
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}"> <form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="parent" value="{{ listing.relative }}"> <input type="hidden" name="parent" value="{{ listing.relative }}">
<input type="hidden" name="pfad" data-fill-target="Pfad" value=""> <input type="hidden" name="path" data-fill-target="Pfad" value="">
<div class="modal__body"> <div class="modal__body">
{{ field('name', 'Neuer Name (ohne Endung)', required=True, {{ field('name', 'Neuer Name (ohne Endung)', required=True,
attrs='data-fill-target="Name"') }} attrs='data-fill-target="Name"') }}
@@ -1,6 +1,6 @@
{% extends "tesm_core/base.html" %} {% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import alert %} {% from "tesm_core/macros.html" import alert %}
{% block page_title %}Kea-DHCP-Protokoll{% endblock %} {% block page_title %}DHCP-Protokoll{% endblock %}
{% block page_sub %}{{ path }}{% endblock %} {% block page_sub %}{{ path }}{% endblock %}
{% block content %} {% block content %}
{% if error %}{{ alert(error, 'warning') }}{% endif %} {% if error %}{{ alert(error, 'warning') }}{% endif %}
@@ -15,7 +15,7 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
<div class="card__sub"> <div class="card__sub">
{% for value, label in methods %} {% for value, label in methods %}
<a class="btn btn--sm {{ 'btn--ghost' if method != value else '' }}" <a class="btn btn--sm {{ 'btn--ghost' if method != value else '' }}"
href="{{ url_for('tesmlogs.restarts', methode=value) }}">{{ label }}</a> href="{{ url_for('tesmlogs.restarts', method=value) }}">{{ label }}</a>
{% endfor %} {% endfor %}
<a class="btn btn--sm {{ 'btn--ghost' if method else '' }}" <a class="btn btn--sm {{ 'btn--ghost' if method else '' }}"
href="{{ url_for('tesmlogs.restarts') }}">Alle</a> href="{{ url_for('tesmlogs.restarts') }}">Alle</a>
@@ -1,5 +1,5 @@
{% extends "tesm_core/base.html" %} {% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %} {% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill, nav_order_card %}
{% block page_title %}Systemeinstellungen{% endblock %} {% block page_title %}Systemeinstellungen{% endblock %}
{% block page_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %} {% block page_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %}
@@ -54,18 +54,23 @@
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Switch-Konsole</div> <div class="card__title">{{ icon('terminal', size=17) }} Terminal</div>
<div class="card__sub"> <div class="card__sub">
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung, Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung,
vollständige Mitschrift vollständige Mitschrift. Abgeschaltet bleibt das Freigeben von
SSH-Host-Schlüsseln möglich – es wird für den PoE-Neustart gebraucht.
</div> </div>
</div> </div>
</div> </div>
<form method="post" action="{{ url_for('settings.system_save') }}"> <form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack"> <div class="card__body stack">
{{ checkbox('terminal_enabled', 'Browser-Konsole für Switche zulassen', {{ checkbox('terminal_switches_enabled', 'Browser-Konsole für Switche zulassen',
checked=values.terminal_enabled) }} checked=values.terminal_switches_enabled,
hint_text=specs['terminal_switches_enabled'].hint) }}
{{ checkbox('terminal_clients_enabled', 'Browser-Konsole für Clients zulassen',
checked=values.terminal_clients_enabled,
hint_text=specs['terminal_clients_enabled'].hint) }}
{{ field('terminal_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)', {{ field('terminal_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
value=values.terminal_idle_timeout, type='number') }} value=values.terminal_idle_timeout, type='number') }}
</div> </div>
@@ -73,6 +78,58 @@
</form> </form>
</section> </section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('pencil', size=17) }} Unterschriften</div>
<div class="card__sub">
Der Name in der Unterschrift ist eine Angabe des Unterschreibenden – wie auf
Papier. Wer das Dokument in TESM abgelegt hat, ist dagegen belegt.
</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ checkbox('signature_witness_enabled', 'Unterschriften mit dem ablegenden Konto bezeugen',
checked=values.signature_witness_enabled,
hint_text=specs['signature_witness_enabled'].hint) }}
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
{# Eigene Karte: die Abmeldung stand zwischen Protokollrotation und
Papierkorb -- drei Dinge ohne Zusammenhang. Wer die Sitzungsdauer sucht,
sucht sie nicht unter "Protokolle". #}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('clock', size=17) }} Sitzungen</div>
<div class="card__sub">
Wie lange eine Anmeldung gilt. Beides bestimmt auch, wie lange eine
eingebundene Dateifreigabe bestehen bleibt -- sie endet mit der Sitzung.
</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ field('session_idle_minutes', 'Automatische Abmeldung nach Leerlauf (Minuten)',
value=values.session_idle_minutes, type='number',
attrs='min="5" max="1440" step="1"',
hint_text=specs['session_idle_minutes'].hint) }}
{{ field('session_absolute_hours', 'Längstmögliche Sitzungsdauer (Stunden)',
value=values.session_absolute_hours, type='number',
attrs='min="1" max="168" step="1"',
hint_text=specs['session_absolute_hours'].hint) }}
</div>
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
@@ -87,9 +144,6 @@
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }} specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep, {{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
type='number') }} type='number') }}
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
value=values.session_idle_minutes, type='number',
hint_text=specs['session_idle_minutes'].hint) }}
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)', {{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
value=values.trash_retention_days, type='number', value=values.trash_retention_days, type='number',
hint_text=specs['trash_retention_days'].hint) }} hint_text=specs['trash_retention_days'].hint) }}
@@ -133,39 +187,8 @@
</section> </section>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<section class="card"> {{ nav_order_card(nav_items, nav_order, nav_child_order,
<div class="card__header"> url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
<div class="card__sub">Gilt für alle Benutzer; jeder sieht weiter nur, was er darf</div>
</div>
</div>
<form method="post" action="{{ url_for('admin.nav_order') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<ol class="stack stack--tight">
{% for key in nav_order %}
{% set item = nav_items | selectattr('key', 'equalto', key) | list | first %}
{% if item %}
<li class="row row--tight">
<input class="input" type="number" name="position_{{ item.key }}" value="{{ loop.index }}"
min="1" style="width:70px" disabled>
<input type="hidden" name="order" value="{{ item.key }}">
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
</li>
{% endif %}
{% endfor %}
</ol>
<p class="fs-xs faint mt-3">
Die Reihenfolge folgt der Liste. Zum Umsortieren die Einträge in der gewünschten
Reihenfolge speichern (Drag-and-drop folgt in einer späteren Version).
</p>
</div>
<div class="card__footer">
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
</div>
</form>
</section>
{% endif %} {% endif %}
</div> </div>
</div> </div>
+1 -1
View File
@@ -620,7 +620,7 @@ for attempt in $(seq 1 20); do
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete # pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
# eine andere Instanz, und ein toter Dienst galt als gesund. # eine andere Instanz, und ein toter Dienst galt als gesund.
if systemctl is-active --quiet "$UNIT" && if systemctl is-active --quiet "$UNIT" &&
curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
HEALTHY=1 HEALTHY=1
info "Antwort nach ${attempt}x" info "Antwort nach ${attempt}x"
break break
+1 -1
View File
@@ -446,7 +446,7 @@ Unverzichtbar:
**Die Anwendung antwortet nicht.** **Die Anwendung antwortet nicht.**
```bash ```bash
journalctl -u tesm -n 200 --no-pager journalctl -u tesm -n 200 --no-pager
curl -s http://127.0.0.1:5000/gesundheit curl -s http://127.0.0.1:5000/health
``` ```
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx. Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.3.2" version = "2.7.0"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -13,10 +13,12 @@ import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Sequence from typing import Any, Sequence
from flask import Blueprint, Flask, g, render_template, request from urllib.parse import urlencode
from flask import Blueprint, Flask, g, redirect, render_template, request
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from . import audit, logging_setup, ui from . import audit, legacy_urls, logging_setup, ui
from .config import CoreConfig from .config import CoreConfig
from .core_schema import CORE_MIGRATIONS from .core_schema import CORE_MIGRATIONS
from .db import Database from .db import Database
@@ -62,6 +64,17 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
minimum=1, minimum=1,
maximum=3650, maximum=3650,
), ),
SettingSpec(
"session_absolute_hours",
12,
"int",
"Laengstmoegliche Sitzungsdauer (Stunden)",
hint="Danach ist Schluss, auch bei staendiger Arbeit -- eine Sitzung soll nicht "
"ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.",
section="system",
minimum=1,
maximum=168,
),
SettingSpec( SettingSpec(
"session_idle_minutes", "session_idle_minutes",
30, 30,
@@ -135,7 +148,7 @@ def create_base_app(
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Anwendungen. #: Anwendungen.
auth_tagline: str = "", auth_tagline: str = "",
app_version: str = "2.3.2", app_version: str = "2.7.0",
built_in_trusted_keys: tuple[str, ...] = (), built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True, enable_license: bool = True,
template_folder: str | None = None, template_folder: str | None = None,
@@ -230,12 +243,21 @@ def create_base_app(
wert = 0 wert = 0
return wert if wert > 0 else config.security.session_idle_minutes return wert if wert > 0 else config.security.session_idle_minutes
def _gesamtdauer(conn: Any) -> int:
"""Gesamtdauer aus den Einstellungen -- ueber dieselbe offene Verbindung."""
try:
wert = int(settings.get(conn, "session_absolute_hours") or 0)
except (TypeError, ValueError):
wert = 0
return wert if wert > 0 else config.security.session_absolute_hours
app.session_interface = SqliteSessionInterface( app.session_interface = SqliteSessionInterface(
database, database,
cookie_name=app.config["SESSION_COOKIE_NAME"], cookie_name=app.config["SESSION_COOKIE_NAME"],
idle_minutes=config.security.session_idle_minutes, idle_minutes=config.security.session_idle_minutes,
absolute_hours=config.security.session_absolute_hours, absolute_hours=config.security.session_absolute_hours,
idle_lookup=_leerlauf, idle_lookup=_leerlauf,
absolute_lookup=_gesamtdauer,
) )
# -- Schema und Startzustand ------------------------------------------ # # -- Schema und Startzustand ------------------------------------------ #
@@ -336,6 +358,16 @@ def create_base_app(
# -- Fehlerseiten ------------------------------------------------------ # # -- Fehlerseiten ------------------------------------------------------ #
@app.errorhandler(HTTPException) @app.errorhandler(HTTPException)
def _http_error(error: HTTPException): # type: ignore[no-untyped-def] def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
if error.code == 404:
# Vor der Fehlerseite: war das eine Adresse aus der Zeit vor 2.5.0?
# Erst hier gefragt, damit die Tabelle nie eine bestehende Route
# verdeckt -- auf dem Lizenzserver ist /lizenz/<ticket> echt.
ziel = legacy_urls.neue_adresse(request.path)
if ziel is not None:
parameter = legacy_urls.neue_parameter(request.args.to_dict(flat=True))
if parameter:
ziel = ziel + "?" + urlencode(parameter)
return redirect(ziel)
if error.code == 413: if error.code == 413:
# "Request Entity Too Large" allein hilft niemandem weiter: die # "Request Entity Too Large" allein hilft niemandem weiter: die
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie # Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
@@ -399,7 +431,7 @@ def create_base_app(
with app.app_context(): with app.app_context():
trash_views.register_core_providers() trash_views.register_core_providers()
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"]) app.add_url_rule("/health", "health", _health, methods=["GET"])
return app return app
@@ -220,6 +220,37 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
return wrapper return wrapper
def _verlangt_wiederanmeldung(permission: str) -> bool:
"""Fragt das Rechtemodell -- ausserhalb einer Anwendung nie."""
try:
from ..extension import core
return bool(core().permissions.verlangt_wiederanmeldung(permission))
except Exception: # noqa: BLE001 - ohne Modell keine zusaetzliche Huerde
return False
def _wiederanmeldung_frisch() -> bool:
from .service import reauth_is_fresh # zyklischen Import vermeiden
return reauth_is_fresh()
def _bitte_bestaetigen(permission: str) -> Any:
"""Zur Passwortbestaetigung schicken -- oder es einem Skript sagen.
Ein Aufruf per fetch bekaeme sonst die Anmeldeseite als Antwort und wuesste
nichts damit anzufangen.
"""
if is_automatic(request) or (
request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html
):
abort(403, description="Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.")
session["_after_reauth"] = request.full_path if request.query_string else request.path
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info")
return redirect(url_for("auth.reauth"))
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]: def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
"""Erzwingt Rechte serverseitig. """Erzwingt Rechte serverseitig.
@@ -238,6 +269,14 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
) )
if not ok: if not ok:
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.") return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
# Geheimnisse und Exporte verlangen eine frische Bestaetigung --
# das steht im Rechtemodell und wird deshalb hier abgefragt, nicht
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
# Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach,
# sie per Mail zu verschicken nicht.
heikel = [p for p in permissions if _verlangt_wiederanmeldung(p)]
if heikel and not _wiederanmeldung_frisch():
return _bitte_bestaetigen(heikel[0])
return view(*args, **kwargs) return view(*args, **kwargs)
wrapper._required_permissions = permissions # type: ignore[attr-defined] wrapper._required_permissions = permissions # type: ignore[attr-defined]
@@ -66,7 +66,7 @@ def login():
) )
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"]) @bp.route("/login/second-factor", methods=["GET", "POST"])
def second_factor(): def second_factor():
conn = db() conn = db()
user_row = service.pending_totp_user(conn) user_row = service.pending_totp_user(conn)
@@ -116,7 +116,7 @@ def logout_get():
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
@bp.route("/bestaetigen", methods=["GET", "POST"]) @bp.route("/confirm", methods=["GET", "POST"])
@login_required @login_required
def reauth(): def reauth():
target = _safe_next(session.get("_after_reauth")) target = _safe_next(session.get("_after_reauth"))
@@ -257,6 +257,24 @@ def _verify_credentials(
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns) return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
def _ist_erstanmeldung(user_row: dict[str, Any]) -> bool:
"""Meldet sich dieses Konto zum ersten Mal ueberhaupt an?
Das Aenderungsprotokoll soll Aenderungen zeigen. Eine taegliche Anmeldung
ist keine -- sie erzeugte aber jedes Mal eine Zeile und schob damit echte
Eintraege aus dem Blickfeld. Interessant ist genau die erste: mit ihr
entsteht das Konto aus dem Verzeichnisdienst und betritt jemand die
Anwendung, der vorher nicht darin war.
Nicht betroffen: fehlgeschlagene Anmeldungen und Sperren. Die stehen
weiterhin **jedes Mal** im Protokoll -- dort liegt der sicherheitsrelevante
Teil, und Haeufung ist dort das Signal. Wer wann zuletzt angemeldet war,
steht ausserdem unveraendert am Konto und in der Sitzungsuebersicht.
"""
return not str(user_row.get("last_login_at") or "").strip()
def complete_login( def complete_login(
conn: sqlite3.Connection, conn: sqlite3.Connection,
user_row: dict[str, Any], user_row: dict[str, Any],
@@ -275,21 +293,26 @@ def complete_login(
""" """
from ..security import csrf from ..security import csrf
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
erstanmeldung = _ist_erstanmeldung(user_row)
with core().database.transaction(conn): with core().database.transaction(conn):
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type] session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
conn.execute( conn.execute(
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"]) "UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
) )
audit.log( if erstanmeldung:
conn, audit.log(
actor=str(user_row["username"]), conn,
actor_id=int(user_row["id"]), actor=str(user_row["username"]),
action="auth.login", actor_id=int(user_row["id"]),
target=str(user_row["username"]), action="auth.first_login",
detail=f"Quelle: {user_row.get('auth_source') or 'local'}", target=str(user_row["username"]),
ip=ip, detail=f"Erste Anmeldung -- Quelle: {user_row.get('auth_source') or 'local'}",
request_id=getattr(g, "request_id", ""), ip=ip,
) request_id=getattr(g, "request_id", ""),
severity="notice",
)
session.clear() session.clear()
session[SESSION_USER_KEY] = int(user_row["id"]) session[SESSION_USER_KEY] = int(user_row["id"])
@@ -0,0 +1,242 @@
"""Alte, deutsche Adressen auf die neuen englischen umlenken.
Mit 2.5.0 folgen die Adressen der Navigation und sind englisch:
``/monitoring/host-keys`` statt ``/hostschluessel``. Das ist eine gute
Aenderung und eine unangenehme zugleich -- Lesezeichen, offene Browsertabs und
ein Link in einer Mail zeigen weiter auf die alte Adresse. Ein offener Tab ist
dabei der unangenehmste Fall: die Statusuebersicht fragt alle 20 Sekunden ihre
Kacheln ab und bekam nach dem Update jedes Mal einen 404.
Deshalb diese Tabelle. Sie greift **nur, wenn sonst ein 404 herauskaeme** --
also nie vor einer bestehenden Route. Das ist keine Feinheit: auf dem
Lizenzserver ist ``/lizenz/<ticket>`` eine echte, weiterhin gueltige Adresse
(sie steht in verschickten Mails), waehrend dieselbe Wurzel in TESM zur
Lizenzseite fuehrte. Wer zuerst das Routing fragt, muss sich nicht zwischen
beidem entscheiden.
Die Tabelle ist bewusst vollstaendig und nicht nur auf Wurzeln gekuerzt: eine
Umleitung von ``/dateifreigaben/x/vorschau`` auf eine Uebersichtsseite waere
zwar kein 404 mehr, aber auch nicht das, was jemand angeklickt hat.
"""
from __future__ import annotations
from typing import Mapping
#: Alte Wurzel -> (neue Wurzel, Uebersetzung der Segmente darunter).
WURZELN: dict[str, tuple[str, Mapping[str, str]]] = {
"/dateifreigaben": (
"/fileshares",
{
"herunterladen": "download",
"vorschau": "preview",
"hochladen": "upload",
"formular": "form",
"ordner": "folder",
"umbenennen": "rename",
"loeschen": "delete",
"freigeben": "release",
"zuordnungen": "mappings",
},
),
"/hostschluessel": (
"/monitoring/host-keys",
{"pruefen": "probe", "freigeben": "trust", "verwerfen": "forget", "verwaist": "orphaned"},
),
"/zugangsdaten": (
"/monitoring/credentials",
{"neu": "new", "loeschen": "delete", "anzeigen": "reveal"},
),
"/switche": (
"/monitoring/switches",
{
"neu": "new",
"loeschen": "delete",
"hostkey": "host-key",
"pruefen": "probe",
"freigeben": "trust",
"verwerfen": "forget",
"kommando": "command",
"konsole": "console",
},
),
"/clients": (
"/monitoring/clients",
{
"neu": "new",
"aktiv": "active",
"loeschen": "delete",
"neustart": "restart",
"hostkey": "host-key",
"pruefen": "probe",
"freigeben": "trust",
"verwerfen": "forget",
"konsole": "console",
},
),
"/wartung": (
"/maintenance",
{
"paket-nachinstallieren": "install-package",
"auftrag": "job",
"ausgabe": "output",
"aktualisieren": "update",
"neustarten": "restart",
},
),
"/protokolle": (
"/logs",
{
"kea": "dhcp",
"neustarts": "restarts",
"laufend": "live",
"inhalt": "content",
"verlauf": "history",
"datei": "file",
"archiv": "archive",
"archiv-export": "archive-export",
"archiv-loeschen": "archive-delete",
"aenderungen": "audit",
},
),
"/verwaltung": (
"/administration",
{
"benutzer": "users",
"neu": "new",
"loeschen": "delete",
"sitzungen-beenden": "end-sessions",
"verzeichnis-suche": "directory-search",
"verzeichnis-uebernehmen": "directory-import",
"gruppen": "groups",
"vorlage-anwenden": "apply-preset",
"sitzungen": "sessions",
"beenden": "end",
},
),
"/einstellungen": (
"/administration",
{
"zeitzone": "timezone",
"pruefung-jetzt": "check-now",
"netzwerk": "network",
"bestaetigen": "confirm",
"webserver": "web",
"uebernehmen": "apply",
"zertifikat": "certificate",
"selbstsigniert": "self-signed",
"verzeichnisdienst": "directory",
"gruppensuche": "group-search",
"zuordnung": "mapping",
"loeschen": "delete",
"anbieter": "vendor",
"logo-entfernen": "remove-logo",
},
),
"/sicherung": (
"/administration/backup",
{"vorschau": "preview", "uebernehmen": "apply", "verwerfen": "discard"},
),
"/papierkorb": (
"/administration/trash",
{"wiederherstellen": "restore", "endgueltig-loeschen": "purge", "leeren": "empty"},
),
"/diagnose": (
"/administration/diagnostics",
{"aufraeumen": "cleanup", "schluessel-rotieren": "rotate-keys"},
),
"/eigene-lizenz": (
"/own-license",
{
"einspielen": "install",
"aktivieren": "activate",
"deaktivieren": "deactivate",
"offline-antwort": "offline-response",
"signaturschluessel": "signing-key",
},
),
"/lizenzen": (
"/licenses",
{
"aus-ticket": "from-ticket",
"datei": "file",
"neu-signieren": "resign",
"widerrufen": "revoke",
"loeschen": "delete",
"versenden": "send",
"sub-lizenzserver": "sub-servers",
},
),
"/lizenz": (
"/administration/license",
{
"einspielen": "install",
"aktivieren": "activate",
"deaktivieren": "deactivate",
"offline-antwort": "offline-response",
"serverzertifikat": "server-certificate",
"aussteller": "issuer",
"entfernen": "remove",
},
),
"/kunden": ("/customers", {"neu": "new", "loeschen": "delete"}),
"/konto": (
"/account",
{
"stammdaten": "profile",
"profilbild": "avatar",
"sicherheit": "security",
"passwort": "password",
"zwei-faktor": "two-factor",
"starten": "start",
"bestaetigen": "confirm",
"deaktivieren": "disable",
"codes-erneuern": "recovery-codes",
"sitzungen": "sessions",
"beenden": "end",
"alle-beenden": "end-all",
},
),
"/uebersicht": ("/overview", {"kacheln": "tiles"}),
"/gesundheit": ("/health", {}),
"/bestaetigen": ("/confirm", {}),
}
#: Abfrageparameter, die mit umbenannt wurden.
PARAMETER = {
"freigabe": "share",
"pfad": "path",
"aktion": "action",
"benutzer": "actor",
"stufe": "severity",
"seite": "page",
"methode": "method",
"kunde": "customer",
"freischalten": "unlocked",
}
#: Wurzeln, unter denen eine unbekannte Unterseite **nicht** umgelenkt wird.
#: ``/lizenz/<ticket>`` ist auf dem Lizenzserver eine echte Adresse; ein
#: abgelaufenes oder falsches Ticket gehoert dort auf die Fehlerseite und nicht
#: in die Verwaltung.
STRENG = {"/lizenz"}
def neue_adresse(pfad: str) -> str | None:
"""Uebersetzt eine alte Adresse -- oder ``None``, wenn es keine ist."""
for alt, (neu, segmente) in WURZELN.items():
if pfad != alt and not pfad.startswith(alt + "/"):
continue
rest = pfad[len(alt) :]
teile = rest.split("/")
if alt in STRENG:
erstes = next((teil for teil in teile if teil), "")
if erstes and erstes not in segmente:
return None
return neu + "/".join(segmente.get(teil, teil) for teil in teile)
return None
def neue_parameter(args: Mapping[str, str]) -> dict[str, str]:
return {PARAMETER.get(name, name): wert for name, wert in args.items()}
@@ -77,7 +77,7 @@ SETTINGS_NETWORK = Resource(
BACKUP = Resource( BACKUP = Resource(
key="backup", key="backup",
label="Sicherung und Uebertragung", label="Sicherung und Übertragung",
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS), actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
hint="Verschluesselter Export und Import", hint="Verschluesselter Export und Import",
action_hints={ action_hints={
@@ -121,7 +121,7 @@ LOGS_HISTORY = Resource(
LOGS_AUDIT = Resource( LOGS_AUDIT = Resource(
key="logs_audit", key="logs_audit",
label="Aenderungsprotokoll", label="Änderungsprotokoll",
actions=(A.VIEW, A.EXPORT), actions=(A.VIEW, A.EXPORT),
hint="Wer hat wann was geaendert", hint="Wer hat wann was geaendert",
) )
@@ -74,6 +74,11 @@ class Resource:
hint: str = "" hint: str = ""
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint). #: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
action_hints: Mapping[Action, str] = field(default_factory=dict) action_hints: Mapping[Action, str] = field(default_factory=dict)
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
#: stilles Weglassen in der Route: eine Ausnahme von einer
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
ohne_wiederanmeldung: tuple[Action, ...] = ()
def permission(self, action: Action) -> str: def permission(self, action: Action) -> str:
return f"{self.key}.{action.value}" return f"{self.key}.{action.value}"
@@ -159,6 +164,25 @@ class PermissionTree:
def resource(self, key: str) -> tuple[Area, Resource] | None: def resource(self, key: str) -> tuple[Area, Resource] | None:
return self._resources.get(key) return self._resources.get(key)
def verlangt_wiederanmeldung(self, permission: str) -> bool:
"""Braucht dieses Recht eine frische Passwortbestaetigung?
Die Antwort steht im Modell (``SENSITIVE_ACTIONS``) und nicht in der
Route -- sonst vergisst sie die naechste, die dazukommt.
"""
resource_key, _, action_value = permission.rpartition(".")
entry = self._resources.get(resource_key)
if entry is None:
return False
_, resource = entry
try:
action = Action(action_value)
except ValueError:
return False
if action in resource.ohne_wiederanmeldung:
return False
return action in SENSITIVE_ACTIONS
def label_for(self, permission: str) -> str: def label_for(self, permission: str) -> str:
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs.""" """Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
if permission.endswith(".view") and permission[:-5] in self._by_key: if permission.endswith(".view") and permission[:-5] in self._by_key:
@@ -163,6 +163,7 @@ class SqliteSessionInterface(SessionInterface):
idle_minutes: int, idle_minutes: int,
absolute_hours: int, absolute_hours: int,
idle_lookup: Any = None, idle_lookup: Any = None,
absolute_lookup: Any = None,
) -> None: ) -> None:
self.database = database self.database = database
self.cookie_name = cookie_name self.cookie_name = cookie_name
@@ -172,6 +173,16 @@ class SqliteSessionInterface(SessionInterface):
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und #: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene. #: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
self._idle_lookup = idle_lookup self._idle_lookup = idle_lookup
self._absolute_lookup = absolute_lookup
def _absolute(self, conn: Any) -> int:
"""Gesamtdauer in Stunden -- einstellbar wie die Leerlaufgrenze."""
if self._absolute_lookup is None:
return self.absolute_hours
try:
return int(self._absolute_lookup(conn)) or self.absolute_hours
except Exception: # pragma: no cover - Einstellung unlesbar
return self.absolute_hours
def _idle(self, conn: Any) -> int: def _idle(self, conn: Any) -> int:
if self._idle_lookup is None: if self._idle_lookup is None:
@@ -280,7 +291,10 @@ class SqliteSessionInterface(SessionInterface):
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True) payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
with self.database.session() as conn, self.database.transaction(conn): with self.database.session() as conn, self.database.transaction(conn):
if session.new or not session.sid: if session.new or not session.sid:
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES) # Eine vorhandene ID nicht wegwerfen: nach einem Wechsel beim
# Anmelden steht sie schon, und andere haben sie sich gemerkt.
if not session.sid:
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
conn.execute( conn.execute(
"INSERT INTO user_sessions " "INSERT INTO user_sessions "
"(id, user_id, created_at, last_seen_at, idle_expires_at, " "(id, user_id, created_at, last_seen_at, idle_expires_at, "
@@ -292,7 +306,7 @@ class SqliteSessionInterface(SessionInterface):
_iso(now), _iso(now),
_iso(now), _iso(now),
_iso(now + timedelta(minutes=self._idle(conn))), _iso(now + timedelta(minutes=self._idle(conn))),
_iso(now + timedelta(hours=self.absolute_hours)), _iso(now + timedelta(hours=self._absolute(conn))),
session.get("_ip", ""), session.get("_ip", ""),
session.get("_ua", ""), session.get("_ua", ""),
payload, payload,
@@ -371,7 +385,12 @@ def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "p
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?", "UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
(_iso(_now()), reason, old_sid), (_iso(_now()), reason, old_sid),
) )
session.sid = "" # Die neue ID entsteht **jetzt** und nicht erst beim Speichern der Antwort.
# Zwischen beidem laufen die Anmeldehaken, und einer davon bindet die
# Dateifreigaben ein und schreibt sich die Sitzung dazu. Mit einer leeren
# Sitzung hielt der Aufraeumer diese Einbindungen fuer verwaist und loeste
# sie binnen Minuten wieder.
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
session.new = True session.new = True
session.modified = True session.modified = True
return old_sid return old_sid
@@ -847,6 +847,25 @@
flex: 1 1 12rem; flex: 1 1 12rem;
margin: 0; margin: 0;
} }
/* Die Entscheidung steht rechts und der Haken darueber: er gehoert zum
Speichern, nicht zu den Feldern davor. */
.viewer__saveaktionen {
margin-left: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--sp-2);
}
.viewer__saveknoepfe {
display: flex;
gap: var(--sp-2);
}
/* Nach "gibt es schon": der Haken ist die Antwort -- also faellt er auf. */
.viewer__saveaktionen .check.is-gefragt {
outline: 2px solid var(--accent);
outline-offset: 3px;
border-radius: var(--r-xs);
}
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer /* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */ "waagerecht" -- diese Regel haette sie app-weit umgestellt. */
.viewer__feld { .viewer__feld {
@@ -1315,3 +1334,174 @@
.hide-sm { display: none !important; } .hide-sm { display: none !important; }
} }
} }
/* -- Sortierbare Liste (Navigationsreihenfolge) --------------------------- */
/* Symbol und Beschriftung stehen in einer Zeile -- so wie im Menue selbst.
Vorher brach die Beschriftung unter das Symbol, und die Liste sah aus wie
etwas anderes als das, was sie sortiert. */
.reorder {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.reorder__row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--surface-2);
cursor: grab;
}
.reorder__row > .flex-1 {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reorder__row.is-dragging {
opacity: 0.5;
}
.reorder__grip {
color: var(--text-muted);
display: inline-flex;
}
.reorder__pos {
min-width: 1.6em;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: var(--fs-xs);
}
.reorder__moves {
display: inline-flex;
gap: 2px;
}
.reorder__group {
list-style: none;
}
.reorder--child {
margin: 4px 0 4px 28px;
}
.reorder--child .reorder__row {
background: transparent;
border-style: dashed;
}
/* -- Unterschrift --------------------------------------------------------- */
/* Die Zeichenflaeche braucht "touch-action: none": sonst scrollt ein iPad die
Seite, statt einen Strich zu zeichnen -- und es sieht aus, als reagierte das
Feld nicht. */
.viewer__sign {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border);
background: var(--surface-2);
}
.viewer__signfelder {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.viewer__signpad {
width: 100%;
height: 160px;
border: 1px dashed var(--border-strong, var(--border));
border-radius: var(--r-sm);
background: #fff;
touch-action: none;
cursor: crosshair;
}
.viewer__signaktionen {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
}
.viewer__pdfscroll--setzen {
cursor: crosshair;
}
.viewer__pdfscroll--setzen .page {
outline: 2px dashed var(--accent, #c8791f);
outline-offset: -2px;
}
/* Die gesetzte Unterschrift liegt auf der Seite -- in Prozent, damit sie beim
Zoomen an ihrer Stelle bleibt. */
.viewer__unterschrift {
position: absolute;
pointer-events: none;
z-index: 3;
}
/* -- Ergebnis der Signaturpruefung ---------------------------------------- */
/* Der Schleier liegt ueber der Seite und nimmt keine Klicks an: ein
unterschriebenes Dokument ist flach, aber bei einem Irrtum soll die
Kennzeichnung nicht auch noch die Bedienung blockieren. */
.viewer__pdf--unveraendert {
box-shadow: inset 0 0 0 3px var(--success);
}
.viewer__pdf--unveraendert::after,
.viewer__pdf--veraendert::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 4;
}
.viewer__pdf--unveraendert::after {
background: rgba(47, 158, 68, 0.1);
}
.viewer__pdf--veraendert {
box-shadow: inset 0 0 0 3px var(--danger);
}
.viewer__pdf--veraendert::after {
background: rgba(201, 42, 42, 0.08);
}
.viewer__pruefung {
margin: 0;
padding: 6px 12px;
font-size: var(--fs-xs);
border-top: 1px solid var(--border);
}
.viewer__pruefung--gut {
color: var(--success);
background: rgba(47, 158, 68, 0.12);
}
.viewer__pruefung--schlecht {
color: var(--danger);
background: rgba(201, 42, 42, 0.12);
}
@@ -856,6 +856,81 @@ function initTileFilters() {
}); });
} }
/* -- Reihenfolge aendern -------------------------------------------------- */
/**
* Eine Liste, deren Reihenfolge man aendern kann -- ohne Bibliothek.
*
* Die Reihenfolge steckt in der Reihenfolge der versteckten Felder: das
* Formular schickt sie als wiederholtes Feld, und der Server liest sie als
* Liste. Verschiebt man einen Eintrag, verschiebt sich das Feld mit ihm.
*
* Zwei Wege, weil es zwei Arten von Geraeten gibt: Ziehen mit der Maus und
* zwei Knoepfe je Zeile. Die Knoepfe sind nicht die Notloesung -- auf einem
* Tablet sind sie der einzige Weg, der zuverlaessig funktioniert, und mit der
* Tastatur ebenso.
*/
function initReorder() {
$$('[data-behavior="reorder"]').forEach((liste) => {
// Nur die eigenen Zeilen: Gruppen enthalten wiederum eine sortierbare
// Liste, und deren Zeilen gehoeren der inneren Liste, nicht dieser.
const zeilen = () => Array.from(liste.querySelectorAll(":scope > [data-reorder-item]"));
const nummerieren = () => {
zeilen().forEach((zeile, index) => {
const nummer = zeile.querySelector("[data-reorder-position-text]");
if (nummer) nummer.textContent = String(index + 1);
const hoch = zeile.querySelector('[data-reorder-move="up"]');
const runter = zeile.querySelector('[data-reorder-move="down"]');
if (hoch) hoch.disabled = index === 0;
if (runter) runter.disabled = index === zeilen().length - 1;
});
};
liste.addEventListener("click", (event) => {
const knopf = event.target.closest("[data-reorder-move]");
if (!knopf) return;
const zeile = knopf.closest("[data-reorder-item]");
if (!zeile || zeile.parentElement !== liste) return;
event.preventDefault();
if (knopf.dataset.reorderMove === "up") {
const davor = zeile.previousElementSibling;
if (davor) liste.insertBefore(zeile, davor);
} else {
const danach = zeile.nextElementSibling;
if (danach) liste.insertBefore(danach, zeile);
}
nummerieren();
zeile.querySelector(`[data-reorder-move="${knopf.dataset.reorderMove}"]`)?.focus();
});
let gezogen = null;
liste.addEventListener("dragstart", (event) => {
const zeile = event.target.closest("[data-reorder-item]");
if (!zeile || zeile.parentElement !== liste) return;
gezogen = zeile;
gezogen.classList.add("is-dragging");
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
});
liste.addEventListener("dragend", () => {
if (gezogen) gezogen.classList.remove("is-dragging");
gezogen = null;
nummerieren();
});
liste.addEventListener("dragover", (event) => {
if (!gezogen) return;
event.preventDefault();
const ziel = event.target.closest("[data-reorder-item]");
if (!ziel || ziel === gezogen || ziel.parentElement !== liste) return;
const kasten = ziel.getBoundingClientRect();
const obereHaelfte = event.clientY < kasten.top + kasten.height / 2;
liste.insertBefore(gezogen, obereHaelfte ? ziel : ziel.nextElementSibling);
});
nummerieren();
});
}
/* -- Start ---------------------------------------------------------------- */ /* -- Start ---------------------------------------------------------------- */
/** /**
@@ -893,6 +968,7 @@ function boot() {
initPalette(); initPalette();
initSubmitGuards(); initSubmitGuards();
initTileFilters(); initTileFilters();
initReorder();
initLogTails(); initLogTails();
unsavedGuard.init(); unsavedGuard.init();
initViewer(); initViewer();
@@ -215,47 +215,196 @@ async function pdflibLaden(adresse) {
return pdflibGeladen; return pdflibGeladen;
} }
/** Stempelt die gewaehlten Fotos in die Bildfelder. /** Setzt die Fotos ein und schliesst das Formular auf Wunsch ab.
* *
* Eingabe sind die Bytes, die pdf.js nach dem Ausfuellen geschrieben hat -- * Eingabe sind die Bytes, die pdf.js nach dem Ausfuellen geschrieben hat --
* die Formularwerte bleiben also erhalten. * die Feldwerte bleiben also erhalten.
*
* Das Foto wird das **Aussehen des Bildknopfes** und nicht in die Seite
* gestempelt: so bleibt der Knopf erhalten, das Bild ist trotzdem zu sehen,
* und beim naechsten Oeffnen laesst es sich austauschen. Genau das tut
* Acrobat mit "buttonImportIcon".
*
* ``abschliessen`` rechnet danach alle Felder flach in die Seite: heraus kommt
* ein PDF ohne Formular -- nichts mehr zu verstellen.
*/ */
async function fotosEinsetzen(bytes, bilder, adresse) { async function nachbearbeiten(
bytes,
bilder,
adresse,
{ abschliessen = false, unterschriften = [], zeuge = "" } = {}
) {
const PDFLib = await pdflibLaden(adresse); const PDFLib = await pdflibLaden(adresse);
const dokument = await PDFLib.PDFDocument.load(bytes); const dokument = await PDFLib.PDFDocument.load(bytes);
const formular = dokument.getForm(); const formular = dokument.getForm();
for (const eintrag of bilder) { for (const eintrag of bilder) {
const seite = dokument.getPage(eintrag.seite - 1);
const jpeg = await dokument.embedJpg(eintrag.foto.bytes); const jpeg = await dokument.embedJpg(eintrag.foto.bytes);
const knopf = eintrag.feldname ? formular.getFieldMaybe(eintrag.feldname) : null;
if (knopf && typeof knopf.setImage === "function") {
knopf.setImage(jpeg);
continue;
}
// Kein Knopf gefunden: dann eben in die Seite. Damit nichts darueber
// liegt, muss das Feld weichen.
const seite = dokument.getPage(eintrag.seite - 1);
const [x1, y1, x2, y2] = eintrag.rechteck; const [x1, y1, x2, y2] = eintrag.rechteck;
const kastenBreite = Math.abs(x2 - x1); const kastenBreite = Math.abs(x2 - x1);
const kastenHoehe = Math.abs(y2 - y1); const kastenHoehe = Math.abs(y2 - y1);
// Einpassen statt verzerren: das Seitenverhaeltnis des Fotos bleibt. // Einpassen statt verzerren: das Seitenverhaeltnis des Fotos bleibt.
const faktor = Math.min(kastenBreite / jpeg.width, kastenHoehe / jpeg.height); const faktor = Math.min(kastenBreite / jpeg.width, kastenHoehe / jpeg.height);
const breite = jpeg.width * faktor;
const hoehe = jpeg.height * faktor;
seite.drawImage(jpeg, { seite.drawImage(jpeg, {
x: Math.min(x1, x2) + (kastenBreite - breite) / 2, x: Math.min(x1, x2) + (kastenBreite - jpeg.width * faktor) / 2,
y: Math.min(y1, y2) + (kastenHoehe - hoehe) / 2, y: Math.min(y1, y2) + (kastenHoehe - jpeg.height * faktor) / 2,
width: breite, width: jpeg.width * faktor,
height: hoehe, height: jpeg.height * faktor,
}); });
// Den Knopf aus der fertigen Kopie nehmen: seine Flaeche liegt ueber dem if (knopf) {
// Seiteninhalt und wuerde das Foto verdecken. Zu tun haette er dort auch try {
// nichts mehr -- seine Aktion fuehrt kein Browser aus, und das Bild steht formular.removeField(knopf);
// jetzt in der Seite. Die Vorlage bleibt unberuehrt. } catch (fehler) {
try { console.warn("Bildknopf liess sich nicht entfernen", fehler);
const feld = formular.getFieldMaybe(eintrag.feldname); }
if (feld) formular.removeField(feld);
} catch (fehler) {
// Bleibt der Knopf liegen, ist das Foto verdeckt -- aber die Datei ist
// heil. Das ist der bessere von zwei schlechten Ausgaengen.
console.warn("Bildknopf liess sich nicht entfernen", fehler);
} }
} }
// Unterschriften: Bild und darunter der angegebene Name mit Zeitpunkt. Das
// Bild allein waere eine Zeichnung ohne Aussage -- auf Papier steht neben
// der Unterschrift auch der Name in Druckbuchstaben.
if (unterschriften.length) {
const schrift = await dokument.embedFont(PDFLib.StandardFonts.Helvetica);
for (const eintrag of unterschriften) {
const seite = dokument.getPage(eintrag.seite - 1);
const png = await dokument.embedPng(eintrag.bytes);
const breite = eintrag.breite;
const hoehe = (png.height / png.width) * breite;
seite.drawImage(png, { x: eintrag.x, y: eintrag.y, width: breite, height: hoehe });
const zeilen = [`${eintrag.name} -- ${eintrag.zeit}`];
if (zeuge) zeilen.push(`In TESM abgelegt durch ${zeuge}`);
let unten = eintrag.y - 2;
for (const zeile of zeilen) {
unten -= 9;
seite.drawText(zeile, {
x: eintrag.x,
y: unten,
size: 8,
font: schrift,
color: PDFLib.rgb(0.1, 0.1, 0.1),
});
}
// Ein Strich wie auf dem Formular: er trennt Zeichnung und Klartext.
seite.drawLine({
start: { x: eintrag.x, y: eintrag.y - 1 },
end: { x: eintrag.x + breite, y: eintrag.y - 1 },
thickness: 0.5,
color: PDFLib.rgb(0.4, 0.4, 0.4),
});
}
}
if (abschliessen) {
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
// gewollt: so kann niemand mehr etwas verstellen.
formular.flatten();
}
return dokument.save(); return dokument.save();
} }
/** Die gezeichnete Unterschrift als PNG -- auf das Gezeichnete beschnitten.
*
* Ungeschnitten waere jede Unterschrift so gross wie die Zeichenflaeche, mit
* viel Luft ringsum. Im Dokument sieht das aus wie ein verrutschter Stempel.
*/
function unterschriftAlsPng(leinwand, kasten) {
const rand = 6;
const links = Math.max(0, Math.floor(kasten.x1 - rand));
const oben = Math.max(0, Math.floor(kasten.y1 - rand));
const breite = Math.min(leinwand.width - links, Math.ceil(kasten.x2 - kasten.x1 + rand * 2));
const hoehe = Math.min(leinwand.height - oben, Math.ceil(kasten.y2 - kasten.y1 + rand * 2));
const ziel = document.createElement("canvas");
ziel.width = Math.max(1, breite);
ziel.height = Math.max(1, hoehe);
ziel
.getContext("2d")
.drawImage(leinwand, links, oben, ziel.width, ziel.height, 0, 0, ziel.width, ziel.height);
// Ueber eine data:-Adresse und nicht ueber blob: -- die Sicherheitsregeln
// der Anwendung erlauben blob: nicht, und ein stilles "img-src" waere ein
// Loch, das man fuer eine Unterschrift nicht aufmacht.
const adresse = ziel.toDataURL("image/png");
const roh = atob(adresse.slice(adresse.indexOf(",") + 1));
const bytes = new Uint8Array(roh.length);
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i);
return { bytes, vorschau: adresse, breite: ziel.width, hoehe: ziel.height };
}
/** Zeichenflaeche fuer die Unterschrift -- Finger, Stift oder Maus.
*
* ``touch-action: none`` steht im Stylesheet: ohne das scrollt ein iPad die
* Seite, statt einen Strich zu zeichnen.
*/
function unterschriftPad(leinwand) {
const stift = leinwand.getContext("2d");
let kasten = null;
let zeichnet = false;
const punkt = (event) => {
const rechteck = leinwand.getBoundingClientRect();
return {
x: ((event.clientX - rechteck.left) / rechteck.width) * leinwand.width,
y: ((event.clientY - rechteck.top) / rechteck.height) * leinwand.height,
};
};
const merken = ({ x, y }) => {
kasten = kasten
? {
x1: Math.min(kasten.x1, x),
y1: Math.min(kasten.y1, y),
x2: Math.max(kasten.x2, x),
y2: Math.max(kasten.y2, y),
}
: { x1: x, y1: y, x2: x, y2: y };
};
stift.lineWidth = 2.5;
stift.lineCap = "round";
stift.lineJoin = "round";
stift.strokeStyle = "#111";
leinwand.addEventListener("pointerdown", (event) => {
event.preventDefault();
leinwand.setPointerCapture(event.pointerId);
zeichnet = true;
const p = punkt(event);
merken(p);
stift.beginPath();
stift.moveTo(p.x, p.y);
});
leinwand.addEventListener("pointermove", (event) => {
if (!zeichnet) return;
event.preventDefault();
const p = punkt(event);
merken(p);
stift.lineTo(p.x, p.y);
stift.stroke();
});
const beenden = () => {
zeichnet = false;
};
leinwand.addEventListener("pointerup", beenden);
leinwand.addEventListener("pointercancel", beenden);
leinwand.addEventListener("pointerleave", beenden);
return {
leeren: () => {
stift.clearRect(0, 0, leinwand.width, leinwand.height);
kasten = null;
},
leer: () => kasten === null,
alsPng: () => (kasten ? unterschriftAlsPng(leinwand, kasten) : null),
};
}
/* -- PDF ------------------------------------------------------------------- */ /* -- PDF ------------------------------------------------------------------- */
let pdfjsGeladen = null; let pdfjsGeladen = null;
@@ -304,8 +453,13 @@ async function pdfjsLaden(basis) {
return pdfjsGeladen; return pdfjsGeladen;
} }
/** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */ /** Zeigt ein PDF -- mit ausfuellbaren Feldern, sofern das erlaubt ist.
async function pdfBetrachter(behaelter, seiten, url, basis) { *
* Ohne ``formulare`` bleibt es bei der Darstellung aus dem Dokument: pdf.js
* malt dann die Marken, die im PDF stehen (etwa rotes Kreuz und gruener
* Haken), statt Bedienelemente des Browsers einzusetzen.
*/
async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true } = {}) {
const { lib, komponenten } = await pdfjsLaden(basis); const { lib, komponenten } = await pdfjsLaden(basis);
const adressen = pdfjsAdressen(basis); const adressen = pdfjsAdressen(basis);
const eventBus = new komponenten.EventBus(); const eventBus = new komponenten.EventBus();
@@ -315,8 +469,11 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
viewer: seiten, viewer: seiten,
eventBus, eventBus,
linkService, linkService,
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern. // Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern --
annotationMode: lib.AnnotationMode.ENABLE_FORMS, // wer nicht ausfuellen darf, bekommt die Darstellung aus dem Dokument.
annotationMode: formulare
? lib.AnnotationMode.ENABLE_FORMS
: lib.AnnotationMode.ENABLE,
textLayerMode: 1, textLayerMode: 1,
}); });
linkService.setViewer(viewer); linkService.setViewer(viewer);
@@ -364,6 +521,7 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
const bilder = new Map(); const bilder = new Map();
const bildknoepfeAufsetzen = async (nurSeite) => { const bildknoepfeAufsetzen = async (nurSeite) => {
if (!formulare) return;
for (let nummer = 1; nummer <= dokument.numPages; nummer++) { for (let nummer = 1; nummer <= dokument.numPages; nummer++) {
if (nurSeite && nummer !== nurSeite) continue; if (nurSeite && nummer !== nurSeite) continue;
const seite = await dokument.getPage(nummer); const seite = await dokument.getPage(nummer);
@@ -424,6 +582,65 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
}); });
await bildknoepfeAufsetzen(); await bildknoepfeAufsetzen();
/* Unterschriften: gezeichnet wird ausserhalb, gesetzt wird hier. Die Stelle
bestimmt ein Tipp ins Dokument -- ein festes Feld gibt es in den wenigsten
Vorlagen, und "unten rechts" ist bei einem Formular mit drei
Unterschriftszeilen die falsche Annahme. */
const unterschriften = [];
let wartendeUnterschrift = null;
const platzieren = async (event) => {
if (!wartendeUnterschrift) return;
const seitenEl = event.target.closest(".page");
if (!seitenEl) return;
event.preventDefault();
const nummer = Number(seitenEl.dataset.pageNumber || 0);
if (!nummer) return;
const rechteck = seitenEl.getBoundingClientRect();
const anteilX = (event.clientX - rechteck.left) / rechteck.width;
const anteilY = (event.clientY - rechteck.top) / rechteck.height;
const seite = await dokument.getPage(nummer);
const blick = seite.getViewport({ scale: 1 });
// Ein menschliches Mass: rund fuenf Zentimeter breit, hoechstens knapp
// zwei hoch. Ohne die Hoehengrenze wird aus einem hohen Gekritzel ein
// Stempel, der die halbe Seite einnimmt -- das Seitenverhaeltnis kommt ja
// aus dem Zuschnitt, nicht aus der Vorlage.
const MAX_BREITE = 150;
const MAX_HOEHE = 50;
let breite = Math.min(MAX_BREITE, blick.width * 0.3);
let hoehe = (wartendeUnterschrift.hoehe / wartendeUnterschrift.breite) * breite;
if (hoehe > MAX_HOEHE) {
breite *= MAX_HOEHE / hoehe;
hoehe = MAX_HOEHE;
}
const eintrag = {
...wartendeUnterschrift,
seite: nummer,
breite,
// Der Tipp meint die Mitte der Unterschrift, nicht ihre Ecke.
x: Math.max(4, anteilX * blick.width - breite / 2),
y: Math.max(24, (1 - anteilY) * blick.height - hoehe / 2),
};
unterschriften.push(eintrag);
behaelter.classList.remove("viewer__pdfscroll--setzen");
wartendeUnterschrift = null;
// Sichtbar machen, wo sie gelandet ist -- sonst tippt man ins Blaue.
const marke = document.createElement("img");
marke.className = "viewer__unterschrift";
marke.src = eintrag.vorschau;
marke.alt = "";
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
marke.style.width = `${(breite / blick.width) * 100}%`;
seitenEl.append(marke);
behaelter.dispatchEvent(new CustomEvent("unterschrieben", { detail: eintrag }));
};
behaelter.addEventListener("click", (event) => {
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
});
return { return {
hinein: () => { hinein: () => {
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2); viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
@@ -439,11 +656,22 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit * pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen. * pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
*/ */
speichern: async (pdflibAdresse) => { speichern: async (pdflibAdresse, { abschliessen = false, zeuge = "" } = {}) => {
const bytes = await dokument.saveDocument(); const bytes = await dokument.saveDocument();
if (!bilder.size) return bytes; // Ohne Bild, ohne Unterschrift und ohne Abschluss braucht es pdf-lib nicht.
return fotosEinsetzen(bytes, [...bilder.values()], pdflibAdresse); if (!bilder.size && !abschliessen && !unterschriften.length) return bytes;
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, {
abschliessen,
unterschriften,
zeuge,
});
}, },
/** Wartet der Betrachter auf den Tipp, wohin die Unterschrift soll? */
unterschriftAnnehmen: (daten) => {
wartendeUnterschrift = daten;
behaelter.classList.add("viewer__pdfscroll--setzen");
},
unterschriften: () => unterschriften.map(({ name, zeit }) => ({ name, zeit })),
/** Wurde ueberhaupt etwas eingetragen? */ /** Wurde ueberhaupt etwas eingetragen? */
veraendert: () => { veraendert: () => {
const s = dokument.annotationStorage; const s = dokument.annotationStorage;
@@ -480,9 +708,38 @@ export function initFilePreview(wurzel = document) {
const ordnerFeld = stueck("ordner"); const ordnerFeld = stueck("ordner");
const nameFeld = stueck("name"); const nameFeld = stueck("name");
const ersetzenFeld = stueck("ersetzen"); const ersetzenFeld = stueck("ersetzen");
const ersetzenKasten = stueck("ersetzenfeld");
const pruefzeile = stueck("pruefung");
const jetzt = stueck("jetzt"); const jetzt = stueck("jetzt");
const abbrechen = stueck("abbrechen"); const abbrechen = stueck("abbrechen");
const modusFeld = stueck("modus");
const zielFeld = stueck("ziel");
const unterschreibenKnopf = stueck("unterschreiben");
const unterschriftPanel = stueck("unterschriftpanel");
const vornameFeld = stueck("vorname");
const nachnameFeld = stueck("nachname");
const padLeinwand = stueck("signpad");
const padLeeren = stueck("signleeren");
const padSetzen = stueck("signsetzen");
const padAbbrechen = stueck("signabbrechen");
const padHinweis = stueck("signhinweis");
const basis = rumpf.dataset.viewerPdfjs || ""; const basis = rumpf.dataset.viewerPdfjs || "";
const konto = rumpf.dataset.viewerKonto || "";
const bezeugen = rumpf.dataset.viewerZeuge === "1";
// Die Uhr des Servers, nicht die des Tablets: eine verstellte Uhr am Geraet
// soll nicht das Datum unter einer Unterschrift bestimmen.
const serverStart = Date.parse(rumpf.dataset.viewerZeit || "") || Date.now();
const geladenSeit = Date.now();
const jetztText = () => {
const zeitpunkt = new Date(serverStart + (Date.now() - geladenSeit));
return zeitpunkt.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari), // Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
// soll nicht als leere Flaeche enden. // soll nicht als leere Flaeche enden.
@@ -509,9 +766,12 @@ export function initFilePreview(wurzel = document) {
rahmen.hidden = art !== "text"; rahmen.hidden = art !== "text";
leiste.hidden = art === "text"; leiste.hidden = art === "text";
if (speichern) speichern.hidden = art !== "pdf"; if (speichern) speichern.hidden = art !== "pdf";
if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf";
if (unterschriftPanel) unterschriftPanel.hidden = true;
}; };
const aufraeumen = () => { const aufraeumen = () => {
pruefungZuruecksetzen();
bild.removeAttribute("src"); bild.removeAttribute("src");
bilder.zuruecksetzen(); bilder.zuruecksetzen();
rahmen.src = "about:blank"; rahmen.src = "about:blank";
@@ -529,6 +789,52 @@ export function initFilePreview(wurzel = document) {
aktuell = null; aktuell = null;
}; };
/* Unterschrieben und unveraendert? Die Antwort kommt aus dem
Aenderungsprotokoll: dort steht die Pruefsumme der abgelegten Fassung.
Stimmt sie, bekommt die Seite einen gruenen Schleier -- man soll es sehen,
bevor man liest, nicht danach. */
const pruefungZuruecksetzen = () => {
pdfBehaelter.classList.remove("viewer__pdf--unveraendert", "viewer__pdf--veraendert");
if (pruefzeile) {
pruefzeile.hidden = true;
pruefzeile.textContent = "";
pruefzeile.className = "viewer__pruefung";
}
};
const pruefen = async (adresse) => {
pruefungZuruecksetzen();
if (!adresse) return;
try {
const stand = await window.tesm.api(adresse);
if (stand.status === "unveraendert") {
pdfBehaelter.classList.add("viewer__pdf--unveraendert");
if (pruefzeile) {
pruefzeile.hidden = false;
pruefzeile.className = "viewer__pruefung viewer__pruefung--gut";
const namen = (stand.namen || []).join(", ");
pruefzeile.textContent =
`Unveraendert seit der Unterschrift${namen ? ` von ${namen}` : ""}` +
` -- Pruefsumme stimmt mit dem Aenderungsprotokoll ueberein.`;
}
} else if (stand.status === "veraendert") {
pdfBehaelter.classList.add("viewer__pdf--veraendert");
if (pruefzeile) {
pruefzeile.hidden = false;
pruefzeile.className = "viewer__pruefung viewer__pruefung--schlecht";
pruefzeile.textContent =
"Diese Datei wurde nach der Unterschrift veraendert -- die Pruefsumme " +
"weicht vom Aenderungsprotokoll ab.";
}
}
} catch (fehler) {
// Keine Aussage ist besser als eine falsche: ohne Antwort bleibt die
// Seite schlicht ungekennzeichnet.
console.error("Signaturpruefung", fehler);
}
};
const oeffnen = async (ausloeser) => { const oeffnen = async (ausloeser) => {
aufraeumen(); aufraeumen();
const art = ausloeser.dataset.fillKind || "text"; const art = ausloeser.dataset.fillKind || "text";
@@ -544,8 +850,11 @@ export function initFilePreview(wurzel = document) {
return; return;
} }
if (hinweis) hinweis.textContent = "PDF wird geladen ..."; if (hinweis) hinweis.textContent = "PDF wird geladen ...";
pruefen(ausloeser.dataset.fillPruefung || "");
try { try {
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis); pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
formulare: rumpf.dataset.viewerFormulare === "1",
});
if (hinweis) hinweis.textContent = ""; if (hinweis) hinweis.textContent = "";
} catch (fehler) { } catch (fehler) {
// Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige // Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige
@@ -596,14 +905,68 @@ export function initFilePreview(wurzel = document) {
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt" // Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr. // oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert. // Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt"; // Einmal "-ausgefuellt" reicht: wer eine ausgefuellte Fassung noch einmal
// oeffnet und speichert, bekam sonst "...-ausgefuellt-ausgefuellt".
const rumpfname = datei.replace(/\.pdf$/i, "");
nameFeld.value = /-ausgefuellt$/i.test(rumpfname) ? rumpfname : `${rumpfname}-ausgefuellt`;
ersetzenFeld.checked = false; ersetzenFeld.checked = false;
ersetzenKasten?.classList.remove("is-gefragt");
}; };
/* Unterschreiben: Name tippen, zeichnen, ins Dokument setzen. */
const pad = padLeinwand ? unterschriftPad(padLeinwand) : null;
const panelZeigen = (offen) => {
if (!unterschriftPanel) return;
unterschriftPanel.hidden = !offen;
if (offen) {
pad?.leeren();
if (padHinweis) padHinweis.textContent = "";
vornameFeld?.focus();
}
};
unterschreibenKnopf?.addEventListener("click", () => {
if (!pdf) return;
panelZeigen(unterschriftPanel?.hidden !== false);
});
padLeeren?.addEventListener("click", () => pad?.leeren());
padAbbrechen?.addEventListener("click", () => panelZeigen(false));
padSetzen?.addEventListener("click", () => {
if (!pdf || !pad) return;
const name = [vornameFeld?.value, nachnameFeld?.value]
.map((wert) => (wert || "").trim())
.filter(Boolean)
.join(" ");
// Beides ist noetig, und beides wird hier gesagt statt stumm abgelehnt.
if (!name) {
if (padHinweis) padHinweis.textContent = "Bitte Vor- und Nachnamen eintragen.";
vornameFeld?.focus();
return;
}
const zeichnung = pad.alsPng();
if (!zeichnung) {
if (padHinweis) padHinweis.textContent = "Bitte im Feld unterschreiben.";
return;
}
pdf.unterschriftAnnehmen({ ...zeichnung, name, zeit: jetztText() });
panelZeigen(false);
window.tesm.showToast("Jetzt auf die Stelle im Dokument tippen.", "info");
});
speichern?.addEventListener("click", () => { speichern?.addEventListener("click", () => {
if (!pdf || !aktuell) return; if (!pdf || !aktuell) return;
vorschlagen(); vorschlagen();
zeile.hidden = false; zeile.hidden = false;
if (modusFeld && pdf.unterschriften().length) {
// Mit Unterschrift gibt es nur einen Weg -- und das Feld soll nicht
// etwas anderes behaupten.
modusFeld.value = "fertig";
modusFeld.disabled = true;
}
nameFeld.focus(); nameFeld.focus();
nameFeld.select(); nameFeld.select();
}); });
@@ -612,6 +975,31 @@ export function initFilePreview(wurzel = document) {
zeile.hidden = true; zeile.hidden = true;
}); });
/* Zielfreigabe: die Ordnerliste gehoert zur gewaehlten Freigabe, nicht zur
geoeffneten. Geholt wird sie erst bei Bedarf -- alle Listen im
Seitenaufbau mitzuschicken waere Ballast fuer einen seltenen Fall. */
const ordnerListe = ordnerFeld ? document.getElementById(ordnerFeld.getAttribute("list")) : null;
zielFeld?.addEventListener("change", async () => {
if (!ordnerListe) return;
const vorlage = zielFeld.dataset.urlVorlage || "";
if (!vorlage) return;
ordnerFeld.value = "";
try {
const antwort = await window.tesm.api(vorlage.replace("__token__", zielFeld.value));
ordnerListe.replaceChildren(
...(antwort.folders || []).map((eintrag) => {
const option = document.createElement("option");
option.value = eintrag;
return option;
})
);
} catch (fehler) {
// Kein Beinbruch: das Feld nimmt auch getippte Pfade.
console.error("Ordnerliste", fehler);
}
});
jetzt?.addEventListener("click", async () => { jetzt?.addEventListener("click", async () => {
if (!pdf || !aktuell) return; if (!pdf || !aktuell) return;
const ziel = speichern.dataset.url; const ziel = speichern.dataset.url;
@@ -619,11 +1007,20 @@ export function initFilePreview(wurzel = document) {
const vorher = jetzt.textContent; const vorher = jetzt.textContent;
jetzt.textContent = "Speichern ..."; jetzt.textContent = "Speichern ...";
try { try {
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || ""); const gesetzt = pdf.unterschriften();
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
// Unterschrieben heisst abgeschlossen: ein Dokument, in dem nach der
// Unterschrift noch Felder aenderbar waeren, ist keine Unterschrift
// wert.
abschliessen: modusFeld?.value === "fertig" || gesetzt.length > 0,
zeuge: bezeugen ? konto : "",
});
const daten = new FormData(); const daten = new FormData();
daten.append("pfad", aktuell.pfad); daten.append("path", aktuell.pfad);
daten.append("ordner", ordnerFeld.value.trim()); daten.append("ordner", ordnerFeld.value.trim());
daten.append("name", nameFeld.value.trim()); daten.append("name", nameFeld.value.trim());
if (zielFeld?.value) daten.append("ziel", zielFeld.value);
if (gesetzt.length) daten.append("unterschriften", JSON.stringify(gesetzt));
if (ersetzenFeld.checked) daten.append("ersetzen", "1"); if (ersetzenFeld.checked) daten.append("ersetzen", "1");
daten.append( daten.append(
"datei", "datei",
@@ -639,6 +1036,12 @@ export function initFilePreview(wurzel = document) {
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist. // Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
console.error("Formular speichern", fehler); console.error("Formular speichern", fehler);
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger"); window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
// Genau dieser Fall hat eine Antwort auf der Zeile -- dann zeigen wir
// auch darauf, statt es bei einer Meldung zu belassen.
if (fehler.status === 409) {
ersetzenKasten?.classList.add("is-gefragt");
ersetzenFeld?.focus();
}
} finally { } finally {
jetzt.disabled = false; jetzt.disabled = false;
jetzt.textContent = vorher; jetzt.textContent = vorher;
@@ -24,7 +24,7 @@
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt &ndash; <strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt &ndash;
Anpassungen bleiben erhalten. Anpassungen bleiben erhalten.
{% if current_user.has('groups.edit') %} {% if current_user.has('groups.edit') %}
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, freischalten=1) }}"> <a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, unlocked=1) }}">
{{ icon('unlock', size=14) }} Rechte freischalten {{ icon('unlock', size=14) }} Rechte freischalten
</a> </a>
{% endif %} {% endif %}
@@ -26,19 +26,19 @@
{{ icon('search', size=15) }} {{ icon('search', size=15) }}
<input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Ziel, Detail, Aktion ..."> <input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Ziel, Detail, Aktion ...">
</span> </span>
<select class="select" name="aktion" style="width:auto;max-width:240px"> <select class="select" name="action" style="width:auto;max-width:240px">
<option value="">alle Aktionen</option> <option value="">alle Aktionen</option>
{% for action in actions %} {% for action in actions %}
<option value="{{ action }}" {% if action == filters.aktion %}selected{% endif %}>{{ action }}</option> <option value="{{ action }}" {% if action == filters.action %}selected{% endif %}>{{ action }}</option>
{% endfor %} {% endfor %}
</select> </select>
<select class="select" name="stufe" style="width:auto"> <select class="select" name="severity" style="width:auto">
<option value="">alle Stufen</option> <option value="">alle Stufen</option>
{% for level in ['info', 'notice', 'warning', 'critical'] %} {% for level in ['info', 'notice', 'warning', 'critical'] %}
<option value="{{ level }}" {% if level == filters.stufe %}selected{% endif %}>{{ level }}</option> <option value="{{ level }}" {% if level == filters.severity %}selected{% endif %}>{{ level }}</option>
{% endfor %} {% endfor %}
</select> </select>
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}" <input class="input" type="text" name="actor" value="{{ filters.actor }}"
placeholder="Benutzer" style="width:150px"> placeholder="Benutzer" style="width:150px">
<button class="btn btn--sm" type="submit">Filtern</button> <button class="btn btn--sm" type="submit">Filtern</button>
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a> <a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
@@ -88,10 +88,10 @@
<span class="fs-sm muted">Seite {{ page }}</span> <span class="fs-sm muted">Seite {{ page }}</span>
<span class="row row--tight"> <span class="row row--tight">
{% if page > 1 %} {% if page > 1 %}
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page-1, **filters) }}">Zurück</a> <a class="btn btn--sm" href="{{ url_for('logs.audit_view', page=page-1, **filters) }}">Zurück</a>
{% endif %} {% endif %}
{% if rows | length == per_page %} {% if rows | length == per_page %}
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a> <a class="btn btn--sm" href="{{ url_for('logs.audit_view', page=page+1, **filters) }}">Weiter</a>
{% endif %} {% endif %}
</span> </span>
</div> </div>
@@ -235,3 +235,69 @@
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span> die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
</div> </div>
{%- endmacro %} {%- endmacro %}
{# --------------------------------------------------------------------------
Navigationsreihenfolge. Die Reihenfolge der versteckten Felder ist die
Reihenfolge -- verschiebt sich eine Zeile, verschiebt sich das Feld mit ihr.
Ziehen mit der Maus oder die zwei Knoepfe je Zeile; auf einem Tablet sind
die Knoepfe der verlaessliche Weg.
-------------------------------------------------------------------------- #}
{% macro _reorder_row(item, field_name, label) -%}
<li class="reorder__row" data-reorder-item draggable="true">
<span class="reorder__grip" aria-hidden="true">{{ icon('menu', size=14) }}</span>
<span class="reorder__pos" data-reorder-position-text>1</span>
<input type="hidden" name="{{ field_name }}" value="{{ item.key }}">
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
<span class="reorder__moves">
<button class="btn btn--icon btn--sm" type="button" data-reorder-move="up"
aria-label="{{ label }} nach oben">{{ icon('chevron-up', size=14) }}</button>
<button class="btn btn--icon btn--sm" type="button" data-reorder-move="down"
aria-label="{{ label }} nach unten">{{ icon('chevron-down', size=14) }}</button>
</span>
</li>
{%- endmacro %}
{% macro nav_order_card(items, order, child_order, action, csrf_field, csrf_value,
sub='Gilt für alle Benutzer; jeder sieht weiter nur, was er darf') -%}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
<div class="card__sub">{{ sub }}</div>
</div>
</div>
<form method="post" action="{{ action }}">
<input type="hidden" name="{{ csrf_field }}" value="{{ csrf_value }}">
<div class="card__body">
<ol class="reorder" data-behavior="reorder">
{% for key in order %}
{% set item = items | selectattr('key', 'equalto', key) | list | first %}
{% if item %}
{{ _reorder_row(item, 'order', item.label) }}
{% if item.children %}
<li class="reorder__group">
<ol class="reorder reorder--child" data-behavior="reorder">
{% set kinder = child_order.get(item.key) or (item.children | map(attribute='key') | list) %}
{% for child_key in kinder %}
{% set child = item.children | selectattr('key', 'equalto', child_key) | list | first %}
{% if child %}
{{ _reorder_row(child, 'child_order_' ~ item.key, item.label ~ ' / ' ~ child.label) }}
{% endif %}
{% endfor %}
</ol>
</li>
{% endif %}
{% endif %}
{% endfor %}
</ol>
<p class="fs-xs faint mt-3">
Zeilen ziehen oder mit den Pfeilen verschieben, dann speichern. Untergeordnete
Punkte werden innerhalb ihrer Gruppe sortiert.
</p>
</div>
<div class="card__footer">
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
</div>
</form>
</section>
{%- endmacro %}
+1
View File
@@ -38,6 +38,7 @@ ICONS: dict[str, str] = {
"menu": '<path d="M4 6h16M4 12h16M4 18h16"/>', "menu": '<path d="M4 6h16M4 12h16M4 18h16"/>',
"chevron-right": '<path d="m9 6 6 6-6 6"/>', "chevron-right": '<path d="m9 6 6 6-6 6"/>',
"chevron-down": '<path d="m6 9 6 6 6-6"/>', "chevron-down": '<path d="m6 9 6 6 6-6"/>',
"chevron-up": '<path d="m6 15 6-6 6 6"/>',
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"/>', "sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"/>',
"moon": '<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>', "moon": '<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>',
"monitor": '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>', "monitor": '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
@@ -31,7 +31,7 @@ from ..extension import core, db, transaction
from ..rbac import service as rbac from ..rbac import service as rbac
from ..security import passwords, sessions as session_store, totp from ..security import passwords, sessions as session_store, totp
bp = Blueprint("account", __name__, url_prefix="/konto") bp = Blueprint("account", __name__, url_prefix="/account")
ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"} ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
MAX_AVATAR_BYTES = 2 * 1024 * 1024 MAX_AVATAR_BYTES = 2 * 1024 * 1024
@@ -54,6 +54,19 @@ def _avatar_dir() -> Path:
def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None: def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None:
"""Protokolliert eine Aenderung am **eigenen** Konto.
Bewusst sparsam benutzt. Das Aenderungsprotokoll beantwortet die Frage
"wer hat woran etwas geaendert" -- und wer sein eigenes Passwort setzt,
ein Profilbild tauscht oder seine Anschrift korrigiert, aendert nichts,
was jemand anderen betrifft. Solche Zeilen fuellten das Protokoll und
schoben die Eintraege aus dem Blick, um die es geht.
Was hier trotzdem stehen bleibt: alles rund um den zweiten Faktor. Ob ein
Konto mit oder ohne zweiten Faktor geschuetzt ist, aendert die Staerke der
Anmeldung -- das ist die eine Selbstbedienung, nach der man nach einem
Vorfall sucht.
"""
audit.log( audit.log(
conn, conn,
actor=current_user.username, actor=current_user.username,
@@ -80,7 +93,7 @@ def overview():
) )
@bp.post("/stammdaten") @bp.post("/profile")
@login_required @login_required
def update_profile(): def update_profile():
if current_user.is_directory_account: if current_user.is_directory_account:
@@ -101,12 +114,11 @@ def update_profile():
current_user.id, current_user.id,
), ),
) )
_audit(conn, "account.profile_updated")
flash("Stammdaten gespeichert.", "success") flash("Stammdaten gespeichert.", "success")
return redirect(url_for("account.overview")) return redirect(url_for("account.overview"))
@bp.post("/profilbild") @bp.post("/avatar")
@login_required @login_required
def upload_avatar(): def upload_avatar():
upload = request.files.get("avatar") upload = request.files.get("avatar")
@@ -142,14 +154,13 @@ def upload_avatar():
"UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?", "UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?",
(target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id), (target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
) )
_audit(conn, "account.avatar_updated")
if previous: if previous:
(_avatar_dir() / str(previous)).unlink(missing_ok=True) (_avatar_dir() / str(previous)).unlink(missing_ok=True)
flash("Profilbild aktualisiert.", "success") flash("Profilbild aktualisiert.", "success")
return redirect(url_for("account.overview")) return redirect(url_for("account.overview"))
@bp.get("/profilbild/<path:filename>") @bp.get("/avatar/<path:filename>")
@login_required @login_required
def avatar(filename: str): def avatar(filename: str):
safe = secure_filename(filename) safe = secure_filename(filename)
@@ -163,7 +174,7 @@ def avatar(filename: str):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/sicherheit") @bp.get("/security")
@login_required @login_required
def security(): def security():
conn = db() conn = db()
@@ -199,7 +210,7 @@ def security():
) )
@bp.post("/passwort") @bp.post("/password")
@login_required @login_required
def change_password(): def change_password():
conn = db() conn = db()
@@ -229,21 +240,15 @@ def change_password():
revoked = session_store.revoke_all_for_user( revoked = session_store.revoke_all_for_user(
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change" conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change"
) )
_audit(
conn,
"account.password_changed",
f"{revoked} weitere Sitzungen beendet",
severity="notice",
)
except rbac.ValidationError as exc: except rbac.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
flash("Passwort geaendert. Andere Sitzungen wurden beendet.", "success") flash(f"Passwort geaendert. {revoked} weitere Sitzung(en) wurden beendet.", "success")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/starten") @bp.post("/two-factor/start")
@login_required @login_required
@reauth_required @reauth_required
def totp_start(): def totp_start():
@@ -252,7 +257,7 @@ def totp_start():
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/bestaetigen") @bp.post("/two-factor/confirm")
@login_required @login_required
@reauth_required @reauth_required
def totp_confirm(): def totp_confirm():
@@ -295,7 +300,7 @@ def totp_confirm():
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/deaktivieren") @bp.post("/two-factor/disable")
@login_required @login_required
@reauth_required @reauth_required
def totp_disable(): def totp_disable():
@@ -317,7 +322,7 @@ def totp_disable():
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/codes-erneuern") @bp.post("/two-factor/recovery-codes")
@login_required @login_required
@reauth_required @reauth_required
def recovery_codes(): def recovery_codes():
@@ -341,7 +346,7 @@ def recovery_codes():
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
@bp.post("/sitzungen/<sid>/beenden") @bp.post("/sessions/<sid>/end")
@login_required @login_required
def revoke_session(sid: str): def revoke_session(sid: str):
conn = db() conn = db()
@@ -350,18 +355,16 @@ def revoke_session(sid: str):
abort(404) abort(404)
with transaction(): with transaction():
session_store.revoke(conn, sid, reason="self") session_store.revoke(conn, sid, reason="self")
_audit(conn, "account.session_revoked", sid[:12])
flash("Sitzung beendet.", "success") flash("Sitzung beendet.", "success")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
@bp.post("/sitzungen/alle-beenden") @bp.post("/sessions/end-all")
@login_required @login_required
def revoke_other_sessions(): def revoke_other_sessions():
with transaction() as conn: with transaction() as conn:
count = session_store.revoke_all_for_user( count = session_store.revoke_all_for_user(
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self" conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self"
) )
_audit(conn, "account.sessions_revoked", f"{count} Sitzungen")
flash(f"{count} weitere Sitzung(en) beendet.", "success") flash(f"{count} weitere Sitzung(en) beendet.", "success")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
+17 -17
View File
@@ -16,7 +16,7 @@ from ..rbac import service as rbac
from ..rbac.model import ACTION_META, Action from ..rbac.model import ACTION_META, Action
from ..security import sessions as session_store from ..security import sessions as session_store
bp = Blueprint("admin", __name__, url_prefix="/verwaltung") bp = Blueprint("admin", __name__, url_prefix="/administration")
def _audit(conn: Any, action: str, target: str = "", detail: str = "", severity: str = "info") -> None: def _audit(conn: Any, action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
@@ -47,7 +47,7 @@ def _int_list(name: str) -> list[int]:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/benutzer") @bp.get("/users")
@permission_required("users.view") @permission_required("users.view")
def users(): def users():
conn = db() conn = db()
@@ -60,7 +60,7 @@ def users():
) )
@bp.post("/benutzer/neu") @bp.post("/users/new")
@permission_required("users.create") @permission_required("users.create")
def user_create(): def user_create():
conn = db() conn = db()
@@ -94,7 +94,7 @@ def user_create():
return redirect(url_for("admin.users")) return redirect(url_for("admin.users"))
@bp.post("/benutzer/<int:user_id>") @bp.post("/users/<int:user_id>")
@permission_required("users.edit") @permission_required("users.edit")
def user_update(user_id: int): def user_update(user_id: int):
conn = db() conn = db()
@@ -137,7 +137,7 @@ def user_update(user_id: int):
return redirect(url_for("admin.users")) return redirect(url_for("admin.users"))
@bp.post("/benutzer/<int:user_id>/loeschen") @bp.post("/users/<int:user_id>/delete")
@permission_required("users.delete") @permission_required("users.delete")
def user_delete(user_id: int): def user_delete(user_id: int):
conn = db() conn = db()
@@ -163,7 +163,7 @@ def user_delete(user_id: int):
return redirect(url_for("admin.users")) return redirect(url_for("admin.users"))
@bp.post("/benutzer/<int:user_id>/sitzungen-beenden") @bp.post("/users/<int:user_id>/end-sessions")
@permission_required("users.edit") @permission_required("users.edit")
def user_revoke_sessions(user_id: int): def user_revoke_sessions(user_id: int):
conn = db() conn = db()
@@ -184,7 +184,7 @@ def user_revoke_sessions(user_id: int):
return redirect(url_for("admin.users")) return redirect(url_for("admin.users"))
@bp.get("/benutzer/verzeichnis-suche") @bp.get("/users/directory-search")
@permission_required("users.create") @permission_required("users.create")
def directory_search(): def directory_search():
conn = db() conn = db()
@@ -200,7 +200,7 @@ def directory_search():
return jsonify({"results": results}) return jsonify({"results": results})
@bp.post("/benutzer/verzeichnis-uebernehmen") @bp.post("/users/directory-import")
@permission_required("users.create") @permission_required("users.create")
def directory_adopt(): def directory_adopt():
"""Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen.""" """Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen."""
@@ -238,7 +238,7 @@ def directory_adopt():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/gruppen") @bp.get("/groups")
@permission_required("groups.view") @permission_required("groups.view")
def groups(): def groups():
conn = db() conn = db()
@@ -254,7 +254,7 @@ def groups():
) )
@bp.get("/gruppen/<int:group_id>") @bp.get("/groups/<int:group_id>")
@permission_required("groups.view") @permission_required("groups.view")
def group_detail(group_id: int): def group_detail(group_id: int):
conn = db() conn = db()
@@ -275,11 +275,11 @@ def group_detail(group_id: int):
(group_id,), (group_id,),
), ),
can_edit=current_user.has("groups.edit"), can_edit=current_user.has("groups.edit"),
unlocked=request.args.get("freischalten") == "1", unlocked=request.args.get("unlocked") == "1",
) )
@bp.post("/gruppen/neu") @bp.post("/groups/new")
@permission_required("groups.create") @permission_required("groups.create")
def group_create(): def group_create():
conn = db() conn = db()
@@ -301,7 +301,7 @@ def group_create():
return redirect(url_for("admin.groups")) return redirect(url_for("admin.groups"))
@bp.post("/gruppen/<int:group_id>") @bp.post("/groups/<int:group_id>")
@permission_required("groups.edit") @permission_required("groups.edit")
def group_update(group_id: int): def group_update(group_id: int):
conn = db() conn = db()
@@ -334,7 +334,7 @@ def group_update(group_id: int):
return redirect(url_for("admin.group_detail", group_id=group_id)) return redirect(url_for("admin.group_detail", group_id=group_id))
@bp.post("/gruppen/<int:group_id>/loeschen") @bp.post("/groups/<int:group_id>/delete")
@permission_required("groups.delete") @permission_required("groups.delete")
def group_delete(group_id: int): def group_delete(group_id: int):
conn = db() conn = db()
@@ -354,7 +354,7 @@ def group_delete(group_id: int):
return redirect(url_for("admin.groups")) return redirect(url_for("admin.groups"))
@bp.post("/gruppen/<int:group_id>/vorlage-anwenden") @bp.post("/groups/<int:group_id>/apply-preset")
@admin_required @admin_required
def group_apply_preset(group_id: int): def group_apply_preset(group_id: int):
"""Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch.""" """Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch."""
@@ -386,7 +386,7 @@ def group_apply_preset(group_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/sitzungen") @bp.get("/sessions")
@admin_required @admin_required
def sessions(): def sessions():
conn = db() conn = db()
@@ -399,7 +399,7 @@ def sessions():
return render_template("tesm_core/sessions.html", sessions=rows) return render_template("tesm_core/sessions.html", sessions=rows)
@bp.post("/sitzungen/<sid>/beenden") @bp.post("/sessions/<sid>/end")
@admin_required @admin_required
def session_revoke(sid: str): def session_revoke(sid: str):
with transaction() as conn: with transaction() as conn:
@@ -24,7 +24,7 @@ from ..keystore import check_private_file
from ..security import ratelimit from ..security import ratelimit
from ..security import sessions as session_store from ..security import sessions as session_store
bp = Blueprint("diagnostics", __name__, url_prefix="/diagnose") bp = Blueprint("diagnostics", __name__, url_prefix="/administration/diagnostics")
@bp.get("/") @bp.get("/")
@@ -88,7 +88,7 @@ def overview():
) )
@bp.post("/aufraeumen") @bp.post("/cleanup")
@admin_required @admin_required
def housekeeping(): def housekeeping():
"""Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle.""" """Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle."""
@@ -116,7 +116,7 @@ def housekeeping():
return redirect(url_for("diagnostics.overview")) return redirect(url_for("diagnostics.overview"))
@bp.post("/schluessel-rotieren") @bp.post("/rotate-keys")
@admin_required @admin_required
def rotate_keys(): def rotate_keys():
"""Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu. """Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu.
@@ -29,7 +29,7 @@ from ..licensing_client import LicenseError
from ..rbac import service as rbac from ..rbac import service as rbac
from ..system import hostnet, webserver from ..system import hostnet, webserver
bp = Blueprint("hostsettings", __name__, url_prefix="/einstellungen") bp = Blueprint("hostsettings", __name__, url_prefix="/administration")
PENDING_NETWORK_KEY = "pending_network_revert" PENDING_NETWORK_KEY = "pending_network_revert"
@@ -64,7 +64,7 @@ def _license_hint(feature: str) -> str:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/netzwerk") @bp.get("/network")
@permission_required("settings_network.view") @permission_required("settings_network.view")
def network(): def network():
extension = core() extension = core()
@@ -80,7 +80,7 @@ def network():
) )
@bp.post("/netzwerk") @bp.post("/network")
@permission_required("settings_network.edit") @permission_required("settings_network.edit")
def network_apply(): def network_apply():
extension = core() extension = core()
@@ -147,7 +147,7 @@ def network_apply():
return redirect(url_for("hostsettings.network")) return redirect(url_for("hostsettings.network"))
@bp.post("/netzwerk/bestaetigen") @bp.post("/network/confirm")
@permission_required("settings_network.edit") @permission_required("settings_network.edit")
def network_confirm(): def network_confirm():
if core().registry.pop(PENDING_NETWORK_KEY, None) is None: if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
@@ -221,7 +221,7 @@ def _web_config() -> webserver.WebConfig:
) )
@bp.get("/webserver") @bp.get("/web")
@permission_required("settings_web.view") @permission_required("settings_web.view")
def web(): def web():
extension = core() extension = core()
@@ -240,7 +240,7 @@ def web():
) )
@bp.post("/webserver") @bp.post("/web")
@permission_required("settings_web.edit") @permission_required("settings_web.edit")
def web_save(): def web_save():
extension = core() extension = core()
@@ -302,7 +302,7 @@ def web_save():
return redirect(url_for("hostsettings.web")) return redirect(url_for("hostsettings.web"))
@bp.post("/webserver/uebernehmen") @bp.post("/web/apply")
@permission_required("settings_web.execute") @permission_required("settings_web.execute")
def web_apply(): def web_apply():
problem = _license_hint("nginx") problem = _license_hint("nginx")
@@ -327,7 +327,7 @@ def web_apply():
return redirect(url_for("hostsettings.web")) return redirect(url_for("hostsettings.web"))
@bp.post("/webserver/zertifikat") @bp.post("/web/certificate")
@permission_required("settings_web.execute") @permission_required("settings_web.execute")
def web_certificate(): def web_certificate():
problem = _license_hint("nginx") problem = _license_hint("nginx")
@@ -364,7 +364,7 @@ def web_certificate():
return redirect(url_for("hostsettings.web")) return redirect(url_for("hostsettings.web"))
@bp.post("/webserver/selbstsigniert") @bp.post("/web/self-signed")
@permission_required("settings_web.execute") @permission_required("settings_web.execute")
def web_self_signed(): def web_self_signed():
"""Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain. """Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain.
@@ -414,7 +414,7 @@ def web_self_signed():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/verzeichnisdienst") @bp.get("/directory")
@permission_required("settings_directory.view") @permission_required("settings_directory.view")
def directory_settings(): def directory_settings():
extension = core() extension = core()
@@ -435,7 +435,7 @@ def directory_settings():
) )
@bp.post("/verzeichnisdienst") @bp.post("/directory")
@permission_required("settings_directory.edit") @permission_required("settings_directory.edit")
def directory_save(): def directory_save():
problem = _license_hint("directory") problem = _license_hint("directory")
@@ -481,7 +481,7 @@ def directory_save():
return redirect(url_for("hostsettings.directory_settings")) return redirect(url_for("hostsettings.directory_settings"))
@bp.post("/verzeichnisdienst/test") @bp.post("/directory/test")
@permission_required("settings_directory.execute") @permission_required("settings_directory.execute")
def directory_test(): def directory_test():
result = directory.test_connection(db()) result = directory.test_connection(db())
@@ -489,7 +489,7 @@ def directory_test():
return redirect(url_for("hostsettings.directory_settings")) return redirect(url_for("hostsettings.directory_settings"))
@bp.get("/verzeichnisdienst/gruppensuche") @bp.get("/directory/group-search")
@permission_required("settings_directory.edit", "fileshare.edit", mode="any") @permission_required("settings_directory.edit", "fileshare.edit", mode="any")
def directory_group_search(): def directory_group_search():
"""Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder. """Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder.
@@ -504,7 +504,7 @@ def directory_group_search():
) )
@bp.post("/verzeichnisdienst/zuordnung") @bp.post("/directory/mapping")
@permission_required("settings_directory.edit") @permission_required("settings_directory.edit")
def directory_mapping_add(): def directory_mapping_add():
conn = db() conn = db()
@@ -534,7 +534,7 @@ def directory_mapping_add():
return redirect(url_for("hostsettings.directory_settings")) return redirect(url_for("hostsettings.directory_settings"))
@bp.post("/verzeichnisdienst/zuordnung/<int:mapping_id>/loeschen") @bp.post("/directory/mapping/<int:mapping_id>/delete")
@permission_required("settings_directory.edit") @permission_required("settings_directory.edit")
def directory_mapping_delete(mapping_id: int): def directory_mapping_delete(mapping_id: int):
with transaction() as conn: with transaction() as conn:
+17 -17
View File
@@ -26,7 +26,7 @@ from ..auth.service import client_ip
from ..extension import core, db, transaction from ..extension import core, db, transaction
from ..security.sessions import fragment from ..security.sessions import fragment
bp = Blueprint("logs", __name__, url_prefix="/protokolle") bp = Blueprint("logs", __name__, url_prefix="/logs")
MAX_TAIL_LINES = 2000 MAX_TAIL_LINES = 2000
@@ -38,7 +38,7 @@ def _live_log():
return log return log
@bp.get("/laufend") @bp.get("/live")
@permission_required("logs_live.view") @permission_required("logs_live.view")
def live(): def live():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES) lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
@@ -52,7 +52,7 @@ def live():
) )
@bp.get("/laufend/inhalt") @bp.get("/live/content")
@permission_required("logs_live.view") @permission_required("logs_live.view")
@fragment @fragment
def live_fragment(): def live_fragment():
@@ -63,7 +63,7 @@ def live_fragment():
) )
@bp.get("/verlauf") @bp.get("/history")
@permission_required("logs_history.view", "logs_history.export", mode="any") @permission_required("logs_history.view", "logs_history.export", mode="any")
def history(): def history():
extension = core() extension = core()
@@ -82,7 +82,7 @@ def history():
) )
@bp.get("/verlauf/datei/<path:name>") @bp.get("/history/file/<path:name>")
@permission_required("logs_history.view") @permission_required("logs_history.view")
def history_file(name: str): def history_file(name: str):
extension = core() extension = core()
@@ -101,7 +101,7 @@ def history_file(name: str):
) )
@bp.get("/verlauf/archiv/<day>") @bp.get("/history/archive/<day>")
@permission_required("logs_history.view") @permission_required("logs_history.view")
def history_archive(day: str): def history_archive(day: str):
if len(day) != 10 or day.count("-") != 2: if len(day) != 10 or day.count("-") != 2:
@@ -112,7 +112,7 @@ def history_archive(day: str):
return render_template("tesm_core/logs_archive.html", day=day, rows=rows) return render_template("tesm_core/logs_archive.html", day=day, rows=rows)
@bp.post("/verlauf/archiv-export") @bp.post("/history/archive-export")
@permission_required("logs_history.export") @permission_required("logs_history.export")
def history_export(): def history_export():
"""Packt die archivierten Audit-Tage in ein ZIP. """Packt die archivierten Audit-Tage in ein ZIP.
@@ -152,7 +152,7 @@ def history_export():
) )
@bp.post("/verlauf/archiv-loeschen") @bp.post("/history/archive-delete")
@permission_required("logs_history.delete") @permission_required("logs_history.delete")
def history_purge(): def history_purge():
removed = 0 removed = 0
@@ -176,20 +176,20 @@ def history_purge():
return redirect(url_for("logs.history")) return redirect(url_for("logs.history"))
@bp.get("/aenderungen") @bp.get("/audit")
@permission_required("logs_audit.view", "logs_audit.export", mode="any") @permission_required("logs_audit.view", "logs_audit.export", mode="any")
def audit_view(): def audit_view():
conn = db() conn = db()
page = max(1, int(request.args.get("seite") or 1)) page = max(1, int(request.args.get("page") or 1))
per_page = 100 per_page = 100
return render_template( return render_template(
"tesm_core/logs_audit.html", "tesm_core/logs_audit.html",
rows=audit.search( rows=audit.search(
conn, conn,
query=(request.args.get("q") or "").strip(), query=(request.args.get("q") or "").strip(),
action=(request.args.get("aktion") or "").strip(), action=(request.args.get("action") or "").strip(),
actor=(request.args.get("benutzer") or "").strip(), actor=(request.args.get("actor") or "").strip(),
severity=(request.args.get("stufe") or "").strip(), severity=(request.args.get("severity") or "").strip(),
limit=per_page, limit=per_page,
offset=(page - 1) * per_page, offset=(page - 1) * per_page,
), ),
@@ -200,14 +200,14 @@ def audit_view():
per_page=per_page, per_page=per_page,
filters={ filters={
"q": request.args.get("q") or "", "q": request.args.get("q") or "",
"aktion": request.args.get("aktion") or "", "action": request.args.get("action") or "",
"benutzer": request.args.get("benutzer") or "", "actor": request.args.get("actor") or "",
"stufe": request.args.get("stufe") or "", "severity": request.args.get("severity") or "",
}, },
) )
@bp.get("/aenderungen/export") @bp.get("/audit/export")
@permission_required("logs_audit.export") @permission_required("logs_audit.export")
def audit_export(): def audit_export():
conn = db() conn = db()
@@ -28,7 +28,7 @@ from ..db import Database
from ..extension import core, db, transaction from ..extension import core, db, transaction
from ..rbac import service as rbac from ..rbac import service as rbac
bp = Blueprint("trash", __name__, url_prefix="/papierkorb") bp = Blueprint("trash", __name__, url_prefix="/administration/trash")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -191,7 +191,7 @@ def _provider_or_404(key: str) -> TrashProvider:
return provider return provider
@bp.post("/<kind>/<ident>/wiederherstellen") @bp.post("/<kind>/<ident>/restore")
@permission_required("trash.edit") @permission_required("trash.edit")
def restore(kind: str, ident: str): def restore(kind: str, ident: str):
extension = core() extension = core()
@@ -222,7 +222,7 @@ def restore(kind: str, ident: str):
return redirect(url_for("trash.overview")) return redirect(url_for("trash.overview"))
@bp.post("/<kind>/<ident>/endgueltig-loeschen") @bp.post("/<kind>/<ident>/purge")
@permission_required("trash.delete") @permission_required("trash.delete")
def purge(kind: str, ident: str): def purge(kind: str, ident: str):
extension = core() extension = core()
@@ -252,7 +252,7 @@ def purge(kind: str, ident: str):
return redirect(url_for("trash.overview")) return redirect(url_for("trash.overview"))
@bp.post("/leeren") @bp.post("/empty")
@permission_required("trash.delete") @permission_required("trash.delete")
def purge_all(): def purge_all():
extension = core() extension = core()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing" name = "tesm-licensing"
version = "2.3.2" version = "2.7.0"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["cryptography>=42"] dependencies = ["cryptography>=42"]