Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07ae69e3b7 | ||
|
|
d3c700283a | ||
|
|
61a47debdc | ||
|
|
bec74e6936 | ||
|
|
d2679361bc | ||
|
|
20c7fd8813 | ||
|
|
eaca6f7990 | ||
|
|
4eb1c0c2d8 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.3.0"
|
version = "2.5.2"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -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,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.
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -23,7 +23,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
|
||||||
@@ -60,8 +60,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:
|
||||||
@@ -85,11 +85,14 @@ def browse():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@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 +113,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 +152,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 +178,16 @@ 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.post("/<token>/form")
|
||||||
@permission_required("fileshare.create")
|
@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 +196,7 @@ 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 ""
|
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 +210,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,10 +228,19 @@ def save_form(token: str):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
original = service.resolve_path(mount, relative)
|
original = service.resolve_path(mount, relative)
|
||||||
|
# Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis,
|
||||||
|
# so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt
|
||||||
|
# es beim Ordner des Originals.
|
||||||
|
if wunschname:
|
||||||
|
zielordner = service.resolve_path(mount, ordner)
|
||||||
|
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 dieser Freigabe nicht."}, 400
|
||||||
if wunschname:
|
if wunschname:
|
||||||
|
# Eingegeben wird der Name ohne Endung; die kommt von hier. Wer
|
||||||
|
# sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche
|
||||||
|
# tippt, bekommt trotzdem eine Datei, die sich oeffnen laesst.
|
||||||
name = service.safe_filename(wunschname)
|
name = service.safe_filename(wunschname)
|
||||||
if not name.lower().endswith(".pdf"):
|
if not name.lower().endswith(".pdf"):
|
||||||
name += ".pdf"
|
name += ".pdf"
|
||||||
@@ -236,47 +259,47 @@ def save_form(token: str):
|
|||||||
|
|
||||||
wohin = ziel.relative_to(mount.path.resolve()).as_posix()
|
wohin = ziel.relative_to(mount.path.resolve()).as_posix()
|
||||||
_audit("fileshare.form_saved", f"{mount.label}:{relative}", wohin, severity="notice")
|
_audit("fileshare.form_saved", f"{mount.label}:{relative}", wohin, severity="notice")
|
||||||
return {"message": f"Gespeichert als {wohin}.", "pfad": wohin}
|
return {"message": f"Gespeichert als {wohin}.", "path": wohin}
|
||||||
|
|
||||||
|
|
||||||
@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")
|
||||||
@@ -285,10 +308,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)
|
||||||
@@ -304,7 +327,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
|
||||||
@@ -318,7 +341,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:
|
||||||
@@ -330,18 +353,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,
|
||||||
@@ -350,18 +388,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"))
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,12 @@ 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",
|
||||||
"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 +95,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 +113,12 @@ 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",
|
||||||
"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 +188,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 +217,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."""
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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,11 +74,13 @@ 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(
|
NavItem(
|
||||||
key="maintenance",
|
key="maintenance",
|
||||||
label="Wartung",
|
label="Wartung",
|
||||||
@@ -87,8 +89,6 @@ NAVIGATION = NavTree(
|
|||||||
permissions=("maintenance.view", "maintenance.execute"),
|
permissions=("maintenance.view", "maintenance.execute"),
|
||||||
condition=_module("maintenance"),
|
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",),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
@@ -21,6 +21,7 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import zlib
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
@@ -32,7 +33,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 +208,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 +433,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 +495,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 +550,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 +566,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, '') <> ''
|
||||||
|
AND (s.id IS NULL
|
||||||
OR s.revoked_at IS NOT NULL
|
OR s.revoked_at IS NOT NULL
|
||||||
OR s.idle_expires_at <= ?
|
OR s.idle_expires_at <= ?
|
||||||
OR s.absolute_expires_at <= ?)
|
OR s.absolute_expires_at <= ?)))
|
||||||
""",
|
""",
|
||||||
(_iso(_now()), _iso(_now()), _iso(_now())),
|
(_iso(_now()), _iso(_now()), _iso(_now())),
|
||||||
)
|
)
|
||||||
@@ -548,6 +629,10 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
|||||||
else:
|
else:
|
||||||
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
||||||
record["preview_kind"] = preview_kind(entry.name)
|
record["preview_kind"] = preview_kind(entry.name)
|
||||||
|
# Getrennt, damit die Oberflaeche nur den Namen zum Bearbeiten
|
||||||
|
# gibt und die Endung danebenstellt.
|
||||||
|
record["stem"] = entry.stem
|
||||||
|
record["suffix"] = entry.suffix
|
||||||
files.append(record)
|
files.append(record)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
|
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
|
||||||
@@ -618,6 +703,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``.
|
||||||
|
|
||||||
@@ -656,8 +764,26 @@ def make_directory(mount: Mount, relative: str, name: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def rename_entry(mount: Mount, relative: str, new_name: str) -> Path:
|
def rename_entry(mount: Mount, relative: str, new_name: str) -> Path:
|
||||||
|
"""Benennt um und behaelt die Endung der Datei.
|
||||||
|
|
||||||
|
Eingegeben wird nur der Name ohne Endung. Eine Datei, die danach auf
|
||||||
|
``.pdf.txt`` oder auf gar nichts endet, waere auf einer Windows-Freigabe
|
||||||
|
praktisch kaputt -- sie oeffnet sich nicht mehr mit dem richtigen
|
||||||
|
Programm. Verzeichnisse haben keine Endung; dort bleibt der Name, wie er
|
||||||
|
eingegeben wurde.
|
||||||
|
|
||||||
|
Wer die Endung trotzdem mittippt, bekommt sie nicht doppelt.
|
||||||
|
"""
|
||||||
path = resolve_path(mount, relative)
|
path = resolve_path(mount, relative)
|
||||||
target = path.parent / safe_filename(new_name)
|
endung = path.suffix if path.is_file() else ""
|
||||||
|
gewuenscht = (new_name or "").strip()
|
||||||
|
if endung and gewuenscht.lower().endswith(endung.lower()):
|
||||||
|
gewuenscht = gewuenscht[: -len(endung)]
|
||||||
|
if not gewuenscht:
|
||||||
|
raise FileshareError("Der Name darf nicht leer sein.")
|
||||||
|
target = path.parent / safe_filename(gewuenscht + endung)
|
||||||
|
if target == path:
|
||||||
|
return path
|
||||||
if target.exists():
|
if target.exists():
|
||||||
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
|
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
|
||||||
path.rename(target)
|
path.rename(target)
|
||||||
|
|||||||
@@ -100,12 +100,23 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
),
|
),
|
||||||
# -- Terminal ----------------------------------------------------------- #
|
# -- Terminal ----------------------------------------------------------- #
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"terminal_enabled",
|
"terminal_switches_enabled",
|
||||||
True,
|
True,
|
||||||
"bool",
|
"bool",
|
||||||
"Browser-Terminal fuer Switche aktiv",
|
"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(
|
||||||
|
|||||||
@@ -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">–</td>
|
<td class="numeric faint">–</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,26 @@
|
|||||||
{% 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-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 %}
|
||||||
|
{% if current_user.has('fileshare.export') %}
|
||||||
<a class="link-icon"
|
<a class="link-icon"
|
||||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
|
href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}">
|
||||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||||
</a>
|
</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,23 +130,26 @@
|
|||||||
{% 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-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') %}
|
||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="file-rename"
|
data-dialog-open="file-rename"
|
||||||
data-fill-Pfad="{{ entry.relative }}"
|
data-fill-Pfad="{{ entry.relative }}"
|
||||||
data-fill-Name="{{ entry.name }}"
|
data-fill-Name="{{ entry.stem }}"
|
||||||
|
data-fill-Endung="{{ entry.suffix or '' }}"
|
||||||
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
|
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if current_user.has('fileshare.export') %}
|
||||||
<a class="btn btn--sm btn--ghost"
|
<a class="btn btn--sm btn--ghost"
|
||||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"
|
||||||
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
|
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -171,7 +182,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>
|
||||||
@@ -188,7 +199,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>
|
||||||
@@ -209,7 +220,11 @@
|
|||||||
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
|
||||||
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}">
|
{# 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-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}">
|
||||||
<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>
|
||||||
@@ -225,17 +240,34 @@
|
|||||||
<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') %}
|
||||||
<div class="viewer__save" data-viewer-speichernzeile hidden>
|
<div class="viewer__save" data-viewer-speichernzeile hidden>
|
||||||
<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
|
||||||
placeholder="Wurzelverzeichnis" autocomplete="off">
|
placeholder="Wurzelverzeichnis der Freigabe" autocomplete="off">
|
||||||
</label>
|
</label>
|
||||||
<label class="viewer__feld">
|
<label class="viewer__feld">
|
||||||
<span class="field__label">Dateiname</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="viewer__feld">
|
||||||
|
<span class="field__label">Speichern als</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 class="check">
|
<label class="check">
|
||||||
<input type="checkbox" data-viewer-ersetzen>
|
<input type="checkbox" data-viewer-ersetzen>
|
||||||
<span>Vorhandene ersetzen</span>
|
<span>Vorhandene ersetzen</span>
|
||||||
@@ -243,6 +275,7 @@
|
|||||||
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>Speichern</button>
|
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>Speichern</button>
|
||||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>Abbrechen</button>
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<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>
|
||||||
@@ -254,10 +287,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal__footer">
|
<div class="modal__footer">
|
||||||
|
{% if current_user.has('fileshare.export') %}
|
||||||
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
|
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
|
||||||
{{ icon('download', size=15) }} Herunterladen
|
{{ icon('download', size=15) }} Herunterladen
|
||||||
</a>
|
</a>
|
||||||
{% if current_user.has('fileshare.create') %}
|
{% endif %}
|
||||||
|
{% if current_user.has('fileshare_pdf.create') %}
|
||||||
<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
|
||||||
@@ -277,11 +312,14 @@
|
|||||||
<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', required=True, attrs='data-fill-target="Name"') }}
|
{{ field('name', 'Neuer Name (ohne Endung)', required=True,
|
||||||
|
attrs='data-fill-target="Name"') }}
|
||||||
<p class="fs-xs faint">
|
<p class="fs-xs faint">
|
||||||
Umbenannt wird auf der Freigabe selbst -- es gibt dort keinen Papierkorb.
|
Die Endung <code data-fill-target="Endung"></code> bleibt, wie sie ist --
|
||||||
|
eine Datei ohne passende Endung öffnet auf der Freigabe nichts mehr.
|
||||||
|
Umbenannt wird dort direkt; einen Papierkorb gibt es nicht.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal__footer">
|
<div class="modal__footer">
|
||||||
|
|||||||
@@ -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,37 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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 +123,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 +166,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
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.3.0"
|
version = "2.5.2"
|
||||||
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.0",
|
app_version: str = "2.5.2",
|
||||||
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,20 +293,25 @@ 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"])
|
||||||
)
|
)
|
||||||
|
if erstanmeldung:
|
||||||
audit.log(
|
audit.log(
|
||||||
conn,
|
conn,
|
||||||
actor=str(user_row["username"]),
|
actor=str(user_row["username"]),
|
||||||
actor_id=int(user_row["id"]),
|
actor_id=int(user_row["id"]),
|
||||||
action="auth.login",
|
action="auth.first_login",
|
||||||
target=str(user_row["username"]),
|
target=str(user_row["username"]),
|
||||||
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
|
detail=f"Erste Anmeldung -- Quelle: {user_row.get('auth_source') or 'local'}",
|
||||||
ip=ip,
|
ip=ip,
|
||||||
request_id=getattr(g, "request_id", ""),
|
request_id=getattr(g, "request_id", ""),
|
||||||
|
severity="notice",
|
||||||
)
|
)
|
||||||
|
|
||||||
session.clear()
|
session.clear()
|
||||||
|
|||||||
@@ -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,6 +291,9 @@ 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:
|
||||||
|
# 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)
|
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO user_sessions "
|
"INSERT INTO user_sessions "
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -720,6 +720,16 @@
|
|||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
background: var(--surface-1);
|
background: var(--surface-1);
|
||||||
}
|
}
|
||||||
|
/* Hochkant -- Tablet oder Monitor im Hochformat -- gibt es unter dem Dialog
|
||||||
|
viel Platz, den die Obergrenze verschenkt. Beim Ausfuellen eines
|
||||||
|
Formulars zaehlt jede sichtbare Zeile, also nehmen wir dort die Hoehe,
|
||||||
|
die da ist. Der Rest (Kopf, Dateiname, Fusszeile) braucht rund 200 Pixel;
|
||||||
|
die 240 lassen etwas Luft. */
|
||||||
|
@media (orientation: portrait) {
|
||||||
|
.viewer {
|
||||||
|
height: calc(100dvh - 240px);
|
||||||
|
}
|
||||||
|
}
|
||||||
.viewer > .file-preview {
|
.viewer > .file-preview {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -763,6 +773,19 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
/* Ein PDF ist weisses Papier. pdf.js malt Formularfelder mit Systemfarben
|
||||||
|
und ueberlaesst Ankreuzfelder dem Browser -- im dunklen Design heisst
|
||||||
|
CanvasText weiss, und die Haken waren auf dem Papier unsichtbar. Fuer
|
||||||
|
diesen Ausschnitt gilt deshalb immer das helle Schema. */
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
/* pdf.js rechnet fuer die Seite mit dem Standard "content-box": ihr
|
||||||
|
9-Pixel-Rahmen liegt *ausserhalb* der angegebenen Breite. Unser globales
|
||||||
|
"border-box" rechnete ihn hinein -- die Leinwand wurde 18 Pixel schmaler
|
||||||
|
als die Anmerkungsebene, und die Formularfelder wanderten nach rechts
|
||||||
|
unten, immer weiter je weiter unten sie lagen. */
|
||||||
|
.viewer__pdf .pdfViewer .page {
|
||||||
|
box-sizing: content-box;
|
||||||
}
|
}
|
||||||
.viewer__pdfscroll {
|
.viewer__pdfscroll {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -781,6 +804,36 @@
|
|||||||
.viewer__hint {
|
.viewer__hint {
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
}
|
}
|
||||||
|
/* Bildfeld eines Formulars: der Kasten zeigt das gewaehlte Foto, die
|
||||||
|
Schaltflaeche liegt darin. Acrobats eigener Knopf tut im Browser nichts --
|
||||||
|
das hier ist der Ersatz. */
|
||||||
|
.viewer__bildfeld [data-viewer-bildvorschau] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.viewer__bildwahl {
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 0 4px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.viewer__bildwahl > span {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--r-sm, 6px);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.3));
|
||||||
|
}
|
||||||
|
.viewer__bildfeld--belegt .viewer__bildwahl > span {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
.viewer__save {
|
.viewer__save {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1262,3 +1315,72 @@
|
|||||||
.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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -142,6 +142,135 @@ function bildBetrachter(buehne, bild) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Bildfelder in Formularen ---------------------------------------------- */
|
||||||
|
|
||||||
|
//: Groesser muss ein Foto in einem Formular nicht sein.
|
||||||
|
const FOTO_KANTE = 1600;
|
||||||
|
|
||||||
|
/** Macht aus einer gewaehlten Datei ein JPEG in vertretbarer Groesse.
|
||||||
|
*
|
||||||
|
* Ueber eine Leinwand, nicht roh: damit wird aus allem, was der Browser
|
||||||
|
* anzeigen kann, ein JPEG -- auf einem iPad also auch aus HEIC, das pdf-lib
|
||||||
|
* nicht kennt. Und ein 12-Megapixel-Foto blaeht das Formular nicht auf.
|
||||||
|
*/
|
||||||
|
async function fotoAlsJpeg(datei) {
|
||||||
|
// Keine Objektadresse: die CSP erlaubt Bilder nur von "self" und als data:.
|
||||||
|
// createImageBitmap nimmt die Datei unmittelbar; wo es fehlt, tut es eine
|
||||||
|
// data:-Adresse.
|
||||||
|
let bild;
|
||||||
|
if (typeof createImageBitmap === "function") {
|
||||||
|
try {
|
||||||
|
bild = await createImageBitmap(datei);
|
||||||
|
} catch {
|
||||||
|
bild = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!bild) {
|
||||||
|
const daten = await new Promise((fertig, schiefgegangen) => {
|
||||||
|
const leser = new FileReader();
|
||||||
|
leser.onload = () => fertig(String(leser.result));
|
||||||
|
leser.onerror = () => schiefgegangen(new Error("Die Datei liess sich nicht lesen."));
|
||||||
|
leser.readAsDataURL(datei);
|
||||||
|
});
|
||||||
|
bild = await new Promise((fertig, schiefgegangen) => {
|
||||||
|
const el = new Image();
|
||||||
|
el.onload = () => fertig(el);
|
||||||
|
el.onerror = () => schiefgegangen(new Error("Dieses Bild kann der Browser nicht anzeigen."));
|
||||||
|
el.src = daten;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const breiteRoh = bild.width || bild.naturalWidth;
|
||||||
|
const hoeheRoh = bild.height || bild.naturalHeight;
|
||||||
|
const faktor = Math.min(1, FOTO_KANTE / Math.max(breiteRoh, hoeheRoh));
|
||||||
|
const leinwand = document.createElement("canvas");
|
||||||
|
leinwand.width = Math.max(1, Math.round(breiteRoh * faktor));
|
||||||
|
leinwand.height = Math.max(1, Math.round(hoeheRoh * faktor));
|
||||||
|
leinwand.getContext("2d").drawImage(bild, 0, 0, leinwand.width, leinwand.height);
|
||||||
|
bild.close?.();
|
||||||
|
const klecks = await new Promise((fertig) => leinwand.toBlob(fertig, "image/jpeg", 0.85));
|
||||||
|
return {
|
||||||
|
bytes: new Uint8Array(await klecks.arrayBuffer()),
|
||||||
|
breite: leinwand.width,
|
||||||
|
hoehe: leinwand.height,
|
||||||
|
vorschau: leinwand.toDataURL("image/jpeg", 0.6),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let pdflibGeladen = null;
|
||||||
|
|
||||||
|
/** pdf-lib nachladen -- nur wenn wirklich ein Bild eingesetzt wird. */
|
||||||
|
async function pdflibLaden(adresse) {
|
||||||
|
if (!pdflibGeladen) {
|
||||||
|
pdflibGeladen = new Promise((fertig, schiefgegangen) => {
|
||||||
|
const skript = document.createElement("script");
|
||||||
|
skript.src = adresse;
|
||||||
|
skript.onload = () => fertig(window.PDFLib);
|
||||||
|
skript.onerror = () => {
|
||||||
|
pdflibGeladen = null;
|
||||||
|
schiefgegangen(new Error("pdf-lib liess sich nicht laden."));
|
||||||
|
};
|
||||||
|
document.head.append(skript);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pdflibGeladen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setzt die Fotos ein und schliesst das Formular auf Wunsch ab.
|
||||||
|
*
|
||||||
|
* Eingabe sind die Bytes, die pdf.js nach dem Ausfuellen geschrieben hat --
|
||||||
|
* 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 nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } = {}) {
|
||||||
|
const PDFLib = await pdflibLaden(adresse);
|
||||||
|
const dokument = await PDFLib.PDFDocument.load(bytes);
|
||||||
|
const formular = dokument.getForm();
|
||||||
|
|
||||||
|
for (const eintrag of bilder) {
|
||||||
|
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 kastenBreite = Math.abs(x2 - x1);
|
||||||
|
const kastenHoehe = Math.abs(y2 - y1);
|
||||||
|
// Einpassen statt verzerren: das Seitenverhaeltnis des Fotos bleibt.
|
||||||
|
const faktor = Math.min(kastenBreite / jpeg.width, kastenHoehe / jpeg.height);
|
||||||
|
seite.drawImage(jpeg, {
|
||||||
|
x: Math.min(x1, x2) + (kastenBreite - jpeg.width * faktor) / 2,
|
||||||
|
y: Math.min(y1, y2) + (kastenHoehe - jpeg.height * faktor) / 2,
|
||||||
|
width: jpeg.width * faktor,
|
||||||
|
height: jpeg.height * faktor,
|
||||||
|
});
|
||||||
|
if (knopf) {
|
||||||
|
try {
|
||||||
|
formular.removeField(knopf);
|
||||||
|
} catch (fehler) {
|
||||||
|
console.warn("Bildknopf liess sich nicht entfernen", fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
/* -- PDF ------------------------------------------------------------------- */
|
/* -- PDF ------------------------------------------------------------------- */
|
||||||
|
|
||||||
let pdfjsGeladen = null;
|
let pdfjsGeladen = null;
|
||||||
@@ -190,8 +319,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();
|
||||||
@@ -201,8 +335,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);
|
||||||
@@ -244,6 +381,73 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
|
|||||||
start = null;
|
start = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Bildknoepfe: eigene Schaltflaeche darueber, denn Acrobats Aktion
|
||||||
|
("buttonImportIcon") fuehrt kein Browser aus. Gesammelt wird, was beim
|
||||||
|
Speichern in die Datei gestempelt werden soll. */
|
||||||
|
const bilder = new Map();
|
||||||
|
|
||||||
|
const bildknoepfeAufsetzen = async (nurSeite) => {
|
||||||
|
if (!formulare) return;
|
||||||
|
for (let nummer = 1; nummer <= dokument.numPages; nummer++) {
|
||||||
|
if (nurSeite && nummer !== nurSeite) continue;
|
||||||
|
const seite = await dokument.getPage(nummer);
|
||||||
|
const annots = await seite.getAnnotations({ intent: "display" });
|
||||||
|
for (const annot of annots) {
|
||||||
|
const quelltext = (annot.actions?.Action || []).join(" ");
|
||||||
|
if (!quelltext.includes("buttonImportIcon")) continue;
|
||||||
|
const kasten = behaelter.querySelector(
|
||||||
|
`section[data-annotation-id="${annot.id}"]`
|
||||||
|
);
|
||||||
|
if (!kasten || kasten.querySelector("[data-viewer-bildwahl]")) continue;
|
||||||
|
kasten.classList.add("viewer__bildfeld");
|
||||||
|
const knopf = document.createElement("label");
|
||||||
|
knopf.className = "viewer__bildwahl";
|
||||||
|
knopf.setAttribute("data-viewer-bildwahl", "");
|
||||||
|
knopf.innerHTML =
|
||||||
|
'<input type="file" accept="image/*" hidden>' +
|
||||||
|
"<span>Bild einsetzen</span>";
|
||||||
|
const feld = knopf.querySelector("input");
|
||||||
|
feld.addEventListener("change", async () => {
|
||||||
|
const datei = feld.files?.[0];
|
||||||
|
if (!datei) return;
|
||||||
|
try {
|
||||||
|
const foto = await fotoAlsJpeg(datei);
|
||||||
|
bilder.set(annot.id, {
|
||||||
|
seite: nummer,
|
||||||
|
rechteck: annot.rect,
|
||||||
|
feldname: annot.fieldName,
|
||||||
|
foto,
|
||||||
|
});
|
||||||
|
// Als eigenes Bild und nicht als Hintergrund: der Kasten traegt
|
||||||
|
// eine Leinwand von pdf.js (das Aussehen des Knopfes aus dem PDF),
|
||||||
|
// und die liegt ueber jedem Hintergrund.
|
||||||
|
let vorschau = kasten.querySelector("[data-viewer-bildvorschau]");
|
||||||
|
if (!vorschau) {
|
||||||
|
vorschau = document.createElement("img");
|
||||||
|
vorschau.setAttribute("data-viewer-bildvorschau", "");
|
||||||
|
vorschau.alt = "";
|
||||||
|
kasten.append(vorschau);
|
||||||
|
}
|
||||||
|
vorschau.src = foto.vorschau;
|
||||||
|
kasten.classList.add("viewer__bildfeld--belegt");
|
||||||
|
knopf.querySelector("span").textContent = "Anderes Bild";
|
||||||
|
} catch (fehler) {
|
||||||
|
window.tesm.showToast(fehler.message || "Bild nicht lesbar.", "danger");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
kasten.append(knopf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Die Knoepfe koennen erst auf eine gerenderte Anmerkungsebene -- die
|
||||||
|
// entsteht je Seite, wenn sie sichtbar wird.
|
||||||
|
eventBus.on("annotationlayerrendered", (ereignis) => {
|
||||||
|
bildknoepfeAufsetzen(ereignis.pageNumber).catch((fehler) =>
|
||||||
|
console.error("Bildfelder", fehler)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await bildknoepfeAufsetzen();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hinein: () => {
|
hinein: () => {
|
||||||
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
||||||
@@ -254,8 +458,17 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
|
|||||||
anpassen: () => {
|
anpassen: () => {
|
||||||
viewer.currentScaleValue = "page-width";
|
viewer.currentScaleValue = "page-width";
|
||||||
},
|
},
|
||||||
/** Das ausgefuellte Formular als Bytes -- pdf.js schreibt die Werte hinein. */
|
/** Das ausgefuellte Formular als Bytes.
|
||||||
speichern: () => dokument.saveDocument(),
|
*
|
||||||
|
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
|
||||||
|
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
|
||||||
|
*/
|
||||||
|
speichern: async (pdflibAdresse, { abschliessen = false } = {}) => {
|
||||||
|
const bytes = await dokument.saveDocument();
|
||||||
|
// Ohne Bild und ohne Abschluss braucht es pdf-lib gar nicht.
|
||||||
|
if (!bilder.size && !abschliessen) return bytes;
|
||||||
|
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, { abschliessen });
|
||||||
|
},
|
||||||
/** Wurde ueberhaupt etwas eingetragen? */
|
/** Wurde ueberhaupt etwas eingetragen? */
|
||||||
veraendert: () => {
|
veraendert: () => {
|
||||||
const s = dokument.annotationStorage;
|
const s = dokument.annotationStorage;
|
||||||
@@ -294,6 +507,7 @@ export function initFilePreview(wurzel = document) {
|
|||||||
const ersetzenFeld = stueck("ersetzen");
|
const ersetzenFeld = stueck("ersetzen");
|
||||||
const jetzt = stueck("jetzt");
|
const jetzt = stueck("jetzt");
|
||||||
const abbrechen = stueck("abbrechen");
|
const abbrechen = stueck("abbrechen");
|
||||||
|
const modusFeld = stueck("modus");
|
||||||
const basis = rumpf.dataset.viewerPdfjs || "";
|
const basis = rumpf.dataset.viewerPdfjs || "";
|
||||||
|
|
||||||
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
|
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
|
||||||
@@ -357,7 +571,9 @@ export function initFilePreview(wurzel = document) {
|
|||||||
}
|
}
|
||||||
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
|
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
|
||||||
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
|
||||||
@@ -405,9 +621,10 @@ export function initFilePreview(wurzel = document) {
|
|||||||
const teile = aktuell.pfad.split("/");
|
const teile = aktuell.pfad.split("/");
|
||||||
const datei = teile.pop() || "formular.pdf";
|
const datei = teile.pop() || "formular.pdf";
|
||||||
ordnerFeld.value = teile.join("/");
|
ordnerFeld.value = teile.join("/");
|
||||||
// Der Name der Vorlage mit Zusatz -- die Vorlage selbst bleibt so heil,
|
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
|
||||||
// auch wenn jemand blind auf Speichern drueckt.
|
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
|
||||||
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt.pdf";
|
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
|
||||||
|
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt";
|
||||||
ersetzenFeld.checked = false;
|
ersetzenFeld.checked = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -430,16 +647,18 @@ 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();
|
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
|
||||||
|
abschliessen: modusFeld?.value === "fertig",
|
||||||
|
});
|
||||||
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 (ersetzenFeld.checked) daten.append("ersetzen", "1");
|
if (ersetzenFeld.checked) daten.append("ersetzen", "1");
|
||||||
daten.append(
|
daten.append(
|
||||||
"datei",
|
"datei",
|
||||||
new Blob([bytes], { type: "application/pdf" }),
|
new Blob([bytes], { type: "application/pdf" }),
|
||||||
nameFeld.value.trim() || "formular.pdf"
|
`${nameFeld.value.trim() || "formular"}.pdf`
|
||||||
);
|
);
|
||||||
const antwort = await window.tesm.api(ziel, { method: "POST", body: daten });
|
const antwort = await window.tesm.api(ziel, { method: "POST", body: daten });
|
||||||
window.tesm.showToast(antwort.message || "Gespeichert.", "success");
|
window.tesm.showToast(antwort.message || "Gespeichert.", "success");
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
pdf-lib 1.17.1 -- https://github.com/Hopding/pdf-lib (MIT, siehe LICENSE)
|
||||||
|
|
||||||
|
Aus dem CDN-Bau "pdf-lib.min.js" (UMD, setzt window.PDFLib).
|
||||||
|
|
||||||
|
Wofuer: pdf.js kann Formulare ausfuellen, aber nichts in ein PDF zeichnen. Fuer
|
||||||
|
den Bildknopf in Formularen (Acrobats "buttonImportIcon", das kein Browser
|
||||||
|
ausfuehren kann) wird das gewaehlte Foto damit in das Feld gestempelt.
|
||||||
|
|
||||||
|
Geladen wird die Datei erst, wenn wirklich ein Bild eingesetzt wird.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2019 Andrew Dillon
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1.17.1
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).PDFLib={})}(this,(function(t){"use strict";
|
||||||
|
/*! *****************************************************************************
|
||||||
|
Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||||
|
this file except in compliance with the License. You may obtain a copy of the
|
||||||
|
License at http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
|
||||||
|
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
|
||||||
|
MERCHANTABLITY OR NON-INFRINGEMENT.
|
||||||
|
|
||||||
|
See the Apache Version 2.0 License for specific language governing permissions
|
||||||
|
and limitations under the License.
|
||||||
|
***************************************************************************** */var e=function(t,n){return(e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n])})(t,n)};function n(t,n){function r(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(r.prototype=n.prototype,new r)}var r=function(){return(r=Object.assign||function(t){for(var e,n=1,r=arguments.length;n<r;n++)for(var i in e=arguments[n])Object.prototype.hasOwnProperty.call(e,i)&&(t[i]=e[i]);return t}).apply(this,arguments)};function i(t,e,n,r){return new(n||(n=Promise))((function(i,o){function a(t){try{l(r.next(t))}catch(t){o(t)}}function s(t){try{l(r.throw(t))}catch(t){o(t)}}function l(t){var e;t.done?i(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(a,s)}l((r=r.apply(t,e||[])).next())}))}function o(t,e){var n,r,i,o,a={label:0,sent:function(){if(1&i[0])throw i[1];return i[1]},trys:[],ops:[]};return o={next:s(0),throw:s(1),return:s(2)},"function"==typeof Symbol&&(o[Symbol.iterator]=function(){return this}),o;function s(o){return function(s){return function(o){if(n)throw new TypeError("Generator is already executing.");for(;a;)try{if(n=1,r&&(i=2&o[0]?r.return:o[0]?r.throw||((i=r.return)&&i.call(r),0):r.next)&&!(i=i.call(r,o[1])).done)return i;switch(r=0,i&&(o=[2&o[0],i.value]),o[0]){case 0:case 1:i=o;break;case 4:return a.label++,{value:o[1],done:!1};case 5:a.label++,r=o[1],o=[0];continue;case 7:o=a.ops.pop(),a.trys.pop();continue;default:if(!(i=a.trys,(i=i.length>0&&i[i.length-1])||6!==o[0]&&2!==o[0])){a=0;continue}if(3===o[0]&&(!i||o[1]>i[0]&&o[1]<i[3])){a.label=o[1];break}if(6===o[0]&&a.label<i[1]){a.label=i[1],i=o;break}if(i&&a.label<i[2]){a.label=i[2],a.ops.push(o);break}i[2]&&a.ops.pop(),a.trys.pop();continue}o=e.call(t,a)}catch(t){o=[6,t],r=0}finally{n=i=0}if(5&o[0])throw o[1];return{value:o[0]?o[1]:void 0,done:!0}}([o,s])}}}function a(){for(var t=0,e=0,n=arguments.length;e<n;e++)t+=arguments[e].length;var r=Array(t),i=0;for(e=0;e<n;e++)for(var o=arguments[e],a=0,s=o.length;a<s;a++,i++)r[i]=o[a];return r}for(var s="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",l=new Uint8Array(256),h=0;h<s.length;h++)l[s.charCodeAt(h)]=h;var u,c=function(t){for(var e="",n=t.length,r=0;r<n;r+=3)e+=s[t[r]>>2],e+=s[(3&t[r])<<4|t[r+1]>>4],e+=s[(15&t[r+1])<<2|t[r+2]>>6],e+=s[63&t[r+2]];return n%3==2?e=e.substring(0,e.length-1)+"=":n%3==1&&(e=e.substring(0,e.length-2)+"=="),e},d=function(t){var e,n,r,i,o,a=.75*t.length,s=t.length,h=0;"="===t[t.length-1]&&(a--,"="===t[t.length-2]&&a--);var u=new Uint8Array(a);for(e=0;e<s;e+=4)n=l[t.charCodeAt(e)],r=l[t.charCodeAt(e+1)],i=l[t.charCodeAt(e+2)],o=l[t.charCodeAt(e+3)],u[h++]=n<<2|r>>4,u[h++]=(15&r)<<4|i>>2,u[h++]=(3&i)<<6|63&o;return u},f=/^(data)?:?([\w\/\+]+)?;?(charset=[\w-]+|base64)?.*,/i,p=function(t){var e=t.trim(),n=e.substring(0,100).match(f);if(!n)return d(e);var r=n[0],i=e.substring(r.length);return d(i)},g=function(t){return t.charCodeAt(0)},v=function(t){return t.codePointAt(0)},y=function(t,e){return x(t.toString(16),e,"0").toUpperCase()},m=function(t){return y(t,2)},b=function(t){return String.fromCharCode(t)},w=function(t){return b(parseInt(t,16))},x=function(t,e,n){for(var r="",i=0,o=e-t.length;i<o;i++)r+=n;return r+t},k=function(t,e,n){for(var r=t.length,i=0;i<r;i++)e[n++]=t.charCodeAt(i);return r},F=function(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")},S=function(t){return t.replace(/\t|\u0085|\u2028|\u2029/g," ").replace(/[\b\v]/g,"")},C=["\\n","\\f","\\r","\\u000B"],A=function(t){return/^[\n\f\r\u000B]$/.test(t)},T=function(t){return t.split(/[\n\f\r\u000B]/)},P=function(t){return t.replace(/[\n\f\r\u000B]/g," ")},z=function(t,e){var n,r=t.charCodeAt(e),i=e+1,o=1;return r>=55296&&r<=56319&&t.length>i&&(n=t.charCodeAt(i))>=56320&&n<=57343&&(o=2),[t.slice(e,e+o),o]},R=function(t){for(var e=[],n=0,r=t.length;n<r;){var i=z(t,n),o=i[0],a=i[1];e.push(o),n+=a}return e},O=function(t,e,n,r){for(var i=function(t){for(var e=C.join("|"),n=["$"],r=0,i=t.length;r<i;r++){var o=t[r];if(A(o))throw new TypeError("`wordBreak` must not include "+e);n.push(""===o?".":F(o))}var a=n.join("|");return new RegExp("("+e+")|((.*?)("+a+"))","gm")}(e),o=S(t).match(i),a="",s=0,l=[],h=function(){""!==a&&l.push(a),a="",s=0},u=0,c=o.length;u<c;u++){var d=o[u];if(A(d))h();else{var f=r(d);s+f>n&&h(),a+=d,s+=f}}return h(),l},D=/^D:(\d\d\d\d)(\d\d)?(\d\d)?(\d\d)?(\d\d)?(\d\d)?([+\-Z])?(\d\d)?'?(\d\d)?'?$/,B=function(t){var e=t.match(D);if(e){var n=e[1],r=e[2],i=void 0===r?"01":r,o=e[3],a=void 0===o?"01":o,s=e[4],l=void 0===s?"00":s,h=e[5],u=void 0===h?"00":h,c=e[6],d=void 0===c?"00":c,f=e[7],p=void 0===f?"Z":f,g=e[8],v=void 0===g?"00":g,y=e[9];return new Date(n+"-"+i+"-"+a+"T"+l+":"+u+":"+d+("Z"===p?"Z":""+p+v+":"+(void 0===y?"00":y)))}},N=function(t,e){for(var n,r,i=0;i<t.length;){var o=t.substring(i).match(e);if(!o)return{match:r,pos:i};r=o,i+=(null!==(n=o.index)&&void 0Line truncated
|
||||||
|
//# sourceMappingURL=pdf-lib.min.js.map
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt –
|
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt –
|
||||||
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 %}
|
||||||
@@ -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"))
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.3.0"
|
version = "2.5.2"
|
||||||
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"]
|
||||||
|
|||||||
Reference in new issue
Block a user