Compare commits

...
9 Commits
42 changed files with 1477 additions and 197 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-license" name = "tesm-license"
version = "2.3.0" version = "2.6.0"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -13,7 +13,7 @@ from tesm_core.extension import core, db, transaction
from ..services import transfer from ..services import transfer
from .common import log_event from .common import log_event
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"
@@ -57,7 +57,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():
conn = db() conn = db()
allowed = {category.key for category in _allowed_categories()} allowed = {category.key for category in _allowed_categories()}
@@ -94,7 +94,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():
@@ -131,7 +131,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():
@@ -190,7 +190,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, "")
@@ -9,7 +9,7 @@ from tesm_core.extension import db, transaction
from ..services import issuance, reporting from ..services import issuance, reporting
from .common import customer_facing_url, log_event from .common import customer_facing_url, log_event
bp = Blueprint("customers", __name__, url_prefix="/kunden") bp = Blueprint("customers", __name__, url_prefix="/customers")
FIELDS = ( FIELDS = (
"name", "name",
@@ -61,7 +61,7 @@ def detail(customer_id: int):
) )
@bp.post("/neu") @bp.post("/new")
@permission_required("customers.create") @permission_required("customers.create")
def create(): def create():
try: try:
@@ -103,7 +103,7 @@ def update(customer_id: int):
return redirect(url_for("customers.detail", customer_id=customer_id)) return redirect(url_for("customers.detail", customer_id=customer_id))
@bp.post("/<int:customer_id>/loeschen") @bp.post("/<int:customer_id>/delete")
@permission_required("customers.delete") @permission_required("customers.delete")
def delete(customer_id: int): def delete(customer_id: int):
conn = db() conn = db()
@@ -16,7 +16,7 @@ from ..services import issuance, reporting
bp = Blueprint("dashboard", __name__) bp = Blueprint("dashboard", __name__)
@bp.get("/uebersicht") @bp.get("/overview")
@login_required @login_required
def index(): def index():
conn = db() conn = db()
@@ -38,7 +38,7 @@ from .common import (
vendor_info, vendor_info,
) )
bp = Blueprint("licenses", __name__, url_prefix="/lizenzen") bp = Blueprint("licenses", __name__, url_prefix="/licenses")
@bp.get("/") @bp.get("/")
@@ -49,7 +49,7 @@ def overview():
{**row, **issuance.display_state(row)} {**row, **issuance.display_state(row)}
for row in issuance.list_licenses( for row in issuance.list_licenses(
conn, conn,
customer_id=request.args.get("kunde", type=int), customer_id=request.args.get("customer", type=int),
ticket_id=request.args.get("ticket"), ticket_id=request.args.get("ticket"),
status=request.args.get("status"), status=request.args.get("status"),
) )
@@ -99,7 +99,7 @@ def detail(license_id: str):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/aus-ticket/<ticket_id>") @bp.post("/from-ticket/<ticket_id>")
@permission_required("licenses.create") @permission_required("licenses.create")
def issue(ticket_id: str): def issue(ticket_id: str):
conn = db() conn = db()
@@ -140,9 +140,9 @@ def issue(ticket_id: str):
return redirect(url_for("licenses.detail", license_id=result.license_id)) return redirect(url_for("licenses.detail", license_id=result.license_id))
@bp.get("/<license_id>/datei") @bp.get("/<license_id>/file")
@permission_required("licenses.export") @permission_required("licenses.export")
@reauth_required # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def download(license_id: str): def download(license_id: str):
"""Laedt das vollstaendige Auslieferungs-Bundle herunter. """Laedt das vollstaendige Auslieferungs-Bundle herunter.
@@ -181,7 +181,7 @@ def download(license_id: str):
) )
@bp.post("/<license_id>/neu-signieren") @bp.post("/<license_id>/resign")
@permission_required("licenses.edit") @permission_required("licenses.edit")
@reauth_required @reauth_required
def resign(license_id: str): def resign(license_id: str):
@@ -240,7 +240,7 @@ def resign(license_id: str):
return redirect(url_for("licenses.detail", license_id=license_id)) return redirect(url_for("licenses.detail", license_id=license_id))
@bp.post("/<license_id>/widerrufen") @bp.post("/<license_id>/revoke")
@permission_required("licenses.edit") @permission_required("licenses.edit")
def revoke(license_id: str): def revoke(license_id: str):
conn = db() conn = db()
@@ -265,7 +265,7 @@ def revoke(license_id: str):
return redirect(url_for("licenses.detail", license_id=license_id)) return redirect(url_for("licenses.detail", license_id=license_id))
@bp.post("/<license_id>/loeschen") @bp.post("/<license_id>/delete")
@permission_required("licenses.delete") @permission_required("licenses.delete")
def delete(license_id: str): def delete(license_id: str):
conn = db() conn = db()
@@ -286,7 +286,7 @@ def delete(license_id: str):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/<license_id>/versenden") @bp.post("/<license_id>/send")
@permission_required("licenses.export") @permission_required("licenses.export")
def send_mail(license_id: str): def send_mail(license_id: str):
conn = db() conn = db()
@@ -428,7 +428,7 @@ def manual_code():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/sub-lizenzserver") @bp.get("/sub-servers")
@permission_required("reports.view") @permission_required("reports.view")
def reports(): def reports():
conn = db() conn = db()
@@ -440,7 +440,7 @@ def reports():
) )
@bp.get("/sub-lizenzserver/<license_id>") @bp.get("/sub-servers/<license_id>")
@permission_required("reports.view") @permission_required("reports.view")
def report_detail(license_id: str): def report_detail(license_id: str):
conn = db() conn = db()
@@ -36,7 +36,7 @@ from .. import is_master_edition
from ..services import master_key from ..services import master_key
from .common import can_issue_license_server_licenses, log_event, signing_key from .common import can_issue_license_server_licenses, log_event, signing_key
bp = Blueprint("own_license", __name__, url_prefix="/eigene-lizenz") bp = Blueprint("own_license", __name__, url_prefix="/own-license")
MAX_UPLOAD_BYTES = 256 * 1024 MAX_UPLOAD_BYTES = 256 * 1024
@@ -68,7 +68,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()
@@ -129,13 +129,13 @@ def _handshake(action: str):
return redirect(url_for("own_license.overview")) return redirect(url_for("own_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():
@@ -148,7 +148,7 @@ def heartbeat():
return _handshake("heartbeat") return _handshake("heartbeat")
@bp.post("/offline-antwort") @bp.post("/offline-response")
@permission_required("settings_license.edit") @permission_required("settings_license.edit")
def offline_response(): def offline_response():
manager = core().license manager = core().license
@@ -166,7 +166,7 @@ def offline_response():
return redirect(url_for("own_license.overview")) return redirect(url_for("own_license.overview"))
@bp.get("/signaturschluessel") @bp.get("/signing-key")
@permission_required("settings_license.view") @permission_required("settings_license.view")
def signing_key_info(): def signing_key_info():
"""Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden. """Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden.
@@ -31,6 +31,8 @@ from .common import (
vendor_info, vendor_info,
) )
# Die Adresse steht in verschickten Mails und in Tickets -- sie bleibt
# deutsch, waehrend der Rest der Anwendung auf englische Pfade wechselt.
bp = Blueprint("selfservice", __name__, url_prefix="/lizenz") bp = Blueprint("selfservice", __name__, url_prefix="/lizenz")
RATE_BUCKET = "selfservice" RATE_BUCKET = "selfservice"
@@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction
from ..services import mail from ..services import mail
from .common import log_event, master_endpoint, vendor_info from .common import log_event, master_endpoint, vendor_info
bp = Blueprint("settings", __name__, url_prefix="/einstellungen") bp = Blueprint("settings", __name__, url_prefix="/administration")
MAX_LOGO_BYTES = 200 * 1024 MAX_LOGO_BYTES = 200 * 1024
LOGO_MIME = { LOGO_MIME = {
@@ -54,6 +54,8 @@ def system():
"trash_retention_days", "trash_retention_days",
"log_rotate_interval", "log_rotate_interval",
"log_rotate_keep", "log_rotate_keep",
"session_idle_minutes",
"session_absolute_hours",
], ],
), ),
specs={spec.key: spec for spec in extension.settings.specs()}, specs={spec.key: spec for spec in extension.settings.specs()},
@@ -68,6 +70,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 {},
sysops_available=bool(sysops and sysops.available), sysops_available=bool(sysops and sysops.available),
) )
@@ -97,6 +100,8 @@ def system_save():
"trash_retention_days", "trash_retention_days",
"log_rotate_interval", "log_rotate_interval",
"log_rotate_keep", "log_rotate_keep",
"session_idle_minutes",
"session_absolute_hours",
): ):
spec = extension.settings.spec(key) spec = extension.settings.spec(key)
if spec.kind == "bool": if spec.kind == "bool":
@@ -142,7 +147,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():
import time import time
@@ -173,7 +178,7 @@ def set_timezone():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/anbieter") @bp.get("/vendor")
@permission_required("settings_vendor.view") @permission_required("settings_vendor.view")
def vendor(): def vendor():
extension = core() extension = core()
@@ -195,7 +200,7 @@ def vendor():
) )
@bp.post("/anbieter") @bp.post("/vendor")
@permission_required("settings_vendor.edit") @permission_required("settings_vendor.edit")
def vendor_save(): def vendor_save():
extension = core() extension = core()
@@ -245,7 +250,7 @@ def vendor_save():
return redirect(url_for("settings.vendor")) return redirect(url_for("settings.vendor"))
@bp.post("/anbieter/logo-entfernen") @bp.post("/vendor/remove-logo")
@permission_required("settings_vendor.edit") @permission_required("settings_vendor.edit")
def vendor_logo_remove(): def vendor_logo_remove():
with transaction() as conn: with transaction() as conn:
@@ -45,7 +45,7 @@ def overview():
) )
@bp.get("/neu") @bp.get("/new")
@permission_required("tickets.create") @permission_required("tickets.create")
def new(): def new():
conn = db() conn = db()
@@ -60,11 +60,11 @@ def new():
type_labels=type_labels(), type_labels=type_labels(),
default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365), default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365),
may_create_license_server=can_issue_license_server_licenses(), may_create_license_server=can_issue_license_server_licenses(),
preselected_customer=request.args.get("kunde", type=int), preselected_customer=request.args.get("customer", type=int),
) )
@bp.post("/neu") @bp.post("/new")
@permission_required("tickets.create") @permission_required("tickets.create")
def create(): def create():
conn = db() conn = db()
@@ -186,7 +186,7 @@ def update(ticket_id: str):
return redirect(url_for("tickets.detail", ticket_id=ticket_id)) return redirect(url_for("tickets.detail", ticket_id=ticket_id))
@bp.post("/<ticket_id>/sperre") @bp.post("/<ticket_id>/lock")
@permission_required("tickets.edit") @permission_required("tickets.edit")
def toggle_block(ticket_id: str): def toggle_block(ticket_id: str):
conn = db() conn = db()
@@ -213,7 +213,7 @@ def toggle_block(ticket_id: str):
return redirect(url_for("tickets.detail", ticket_id=ticket_id)) return redirect(url_for("tickets.detail", ticket_id=ticket_id))
@bp.post("/<ticket_id>/loeschen") @bp.post("/<ticket_id>/delete")
@permission_required("tickets.delete") @permission_required("tickets.delete")
def delete(ticket_id: str): def delete(ticket_id: str):
conn = db() conn = db()
@@ -28,7 +28,7 @@ def _is_master(_user: Any) -> bool:
NAVIGATION = NavTree( NAVIGATION = NavTree(
[ [
NavItem(key="index", label="Uebersicht", icon="grid", endpoint="dashboard.index"), NavItem(key="index", label="Übersicht", icon="grid", endpoint="dashboard.index"),
NavItem( NavItem(
key="customers", key="customers",
label="Kunden", label="Kunden",
@@ -14,7 +14,7 @@
{% block topbar_extra %} {% block topbar_extra %}
{% if current_user.has('tickets.create') %} {% if current_user.has('tickets.create') %}
<a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new', kunde=customer.id) }}"> <a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new', customer=customer.id) }}">
{{ icon('plus', size=15) }} Ticket anlegen {{ icon('plus', size=15) }} Ticket anlegen
</a> </a>
{% endif %} {% endif %}
@@ -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 %} {% from "tesm_core/macros.html" import field, select_field, checkbox, alert, nav_order_card %}
{% block page_title %}Systemeinstellungen{% endblock %} {% block page_title %}Systemeinstellungen{% endblock %}
{% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %} {% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %}
@@ -60,9 +60,6 @@
selected=values.log_rotate_interval) }} 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') }}
</div> </div>
@@ -73,6 +70,34 @@
</div> </div>
<div class="stack"> <div class="stack">
{# 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</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="card__title">{{ icon('server', size=17) }} Host</div></div> <div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
@@ -104,33 +129,9 @@
</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"> sub='Gilt für alle Benutzer') }}
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
<div class="card__sub">Gilt für alle Benutzer</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 type="hidden" name="order" value="{{ item.key }}">
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
</li>
{% endif %}
{% endfor %}
</ol>
</div>
<div class="card__footer">
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
</div>
</form>
</section>
{% endif %} {% endif %}
</div> </div>
</div> </div>
+1 -1
View File
@@ -620,7 +620,7 @@ for attempt in $(seq 1 20); do
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete # pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
# eine andere Instanz, und ein toter Dienst galt als gesund. # eine andere Instanz, und ein toter Dienst galt als gesund.
if systemctl is-active --quiet "$UNIT" && if systemctl is-active --quiet "$UNIT" &&
curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
HEALTHY=1 HEALTHY=1
info "Antwort nach ${attempt}x" info "Antwort nach ${attempt}x"
break break
+1 -1
View File
@@ -446,7 +446,7 @@ Unverzichtbar:
**Die Anwendung antwortet nicht.** **Die Anwendung antwortet nicht.**
```bash ```bash
journalctl -u tesm -n 200 --no-pager journalctl -u tesm -n 200 --no-pager
curl -s http://127.0.0.1:5000/gesundheit curl -s http://127.0.0.1:5000/health
``` ```
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx. Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.3.0" version = "2.6.0"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -13,10 +13,12 @@ import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Sequence from typing import Any, Sequence
from flask import Blueprint, Flask, g, render_template, request from urllib.parse import urlencode
from flask import Blueprint, Flask, g, redirect, render_template, request
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from . import audit, logging_setup, ui from . import audit, legacy_urls, logging_setup, ui
from .config import CoreConfig from .config import CoreConfig
from .core_schema import CORE_MIGRATIONS from .core_schema import CORE_MIGRATIONS
from .db import Database from .db import Database
@@ -62,6 +64,17 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
minimum=1, minimum=1,
maximum=3650, maximum=3650,
), ),
SettingSpec(
"session_absolute_hours",
12,
"int",
"Laengstmoegliche Sitzungsdauer (Stunden)",
hint="Danach ist Schluss, auch bei staendiger Arbeit -- eine Sitzung soll nicht "
"ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.",
section="system",
minimum=1,
maximum=168,
),
SettingSpec( SettingSpec(
"session_idle_minutes", "session_idle_minutes",
30, 30,
@@ -135,7 +148,7 @@ def create_base_app(
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Anwendungen. #: Anwendungen.
auth_tagline: str = "", auth_tagline: str = "",
app_version: str = "2.3.0", app_version: str = "2.6.0",
built_in_trusted_keys: tuple[str, ...] = (), built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True, enable_license: bool = True,
template_folder: str | None = None, template_folder: str | None = None,
@@ -230,12 +243,21 @@ def create_base_app(
wert = 0 wert = 0
return wert if wert > 0 else config.security.session_idle_minutes return wert if wert > 0 else config.security.session_idle_minutes
def _gesamtdauer(conn: Any) -> int:
"""Gesamtdauer aus den Einstellungen -- ueber dieselbe offene Verbindung."""
try:
wert = int(settings.get(conn, "session_absolute_hours") or 0)
except (TypeError, ValueError):
wert = 0
return wert if wert > 0 else config.security.session_absolute_hours
app.session_interface = SqliteSessionInterface( app.session_interface = SqliteSessionInterface(
database, database,
cookie_name=app.config["SESSION_COOKIE_NAME"], cookie_name=app.config["SESSION_COOKIE_NAME"],
idle_minutes=config.security.session_idle_minutes, idle_minutes=config.security.session_idle_minutes,
absolute_hours=config.security.session_absolute_hours, absolute_hours=config.security.session_absolute_hours,
idle_lookup=_leerlauf, idle_lookup=_leerlauf,
absolute_lookup=_gesamtdauer,
) )
# -- Schema und Startzustand ------------------------------------------ # # -- Schema und Startzustand ------------------------------------------ #
@@ -336,6 +358,16 @@ def create_base_app(
# -- Fehlerseiten ------------------------------------------------------ # # -- Fehlerseiten ------------------------------------------------------ #
@app.errorhandler(HTTPException) @app.errorhandler(HTTPException)
def _http_error(error: HTTPException): # type: ignore[no-untyped-def] def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
if error.code == 404:
# Vor der Fehlerseite: war das eine Adresse aus der Zeit vor 2.5.0?
# Erst hier gefragt, damit die Tabelle nie eine bestehende Route
# verdeckt -- auf dem Lizenzserver ist /lizenz/<ticket> echt.
ziel = legacy_urls.neue_adresse(request.path)
if ziel is not None:
parameter = legacy_urls.neue_parameter(request.args.to_dict(flat=True))
if parameter:
ziel = ziel + "?" + urlencode(parameter)
return redirect(ziel)
if error.code == 413: if error.code == 413:
# "Request Entity Too Large" allein hilft niemandem weiter: die # "Request Entity Too Large" allein hilft niemandem weiter: die
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie # Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
@@ -399,7 +431,7 @@ def create_base_app(
with app.app_context(): with app.app_context():
trash_views.register_core_providers() trash_views.register_core_providers()
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"]) app.add_url_rule("/health", "health", _health, methods=["GET"])
return app return app
@@ -220,6 +220,37 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
return wrapper return wrapper
def _verlangt_wiederanmeldung(permission: str) -> bool:
"""Fragt das Rechtemodell -- ausserhalb einer Anwendung nie."""
try:
from ..extension import core
return bool(core().permissions.verlangt_wiederanmeldung(permission))
except Exception: # noqa: BLE001 - ohne Modell keine zusaetzliche Huerde
return False
def _wiederanmeldung_frisch() -> bool:
from .service import reauth_is_fresh # zyklischen Import vermeiden
return reauth_is_fresh()
def _bitte_bestaetigen(permission: str) -> Any:
"""Zur Passwortbestaetigung schicken -- oder es einem Skript sagen.
Ein Aufruf per fetch bekaeme sonst die Anmeldeseite als Antwort und wuesste
nichts damit anzufangen.
"""
if is_automatic(request) or (
request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html
):
abort(403, description="Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.")
session["_after_reauth"] = request.full_path if request.query_string else request.path
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info")
return redirect(url_for("auth.reauth"))
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]: def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
"""Erzwingt Rechte serverseitig. """Erzwingt Rechte serverseitig.
@@ -238,6 +269,14 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
) )
if not ok: if not ok:
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.") return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
# Geheimnisse und Exporte verlangen eine frische Bestaetigung --
# das steht im Rechtemodell und wird deshalb hier abgefragt, nicht
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
# Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach,
# sie per Mail zu verschicken nicht.
heikel = [p for p in permissions if _verlangt_wiederanmeldung(p)]
if heikel and not _wiederanmeldung_frisch():
return _bitte_bestaetigen(heikel[0])
return view(*args, **kwargs) return view(*args, **kwargs)
wrapper._required_permissions = permissions # type: ignore[attr-defined] wrapper._required_permissions = permissions # type: ignore[attr-defined]
@@ -66,7 +66,7 @@ def login():
) )
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"]) @bp.route("/login/second-factor", methods=["GET", "POST"])
def second_factor(): def second_factor():
conn = db() conn = db()
user_row = service.pending_totp_user(conn) user_row = service.pending_totp_user(conn)
@@ -116,7 +116,7 @@ def logout_get():
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
@bp.route("/bestaetigen", methods=["GET", "POST"]) @bp.route("/confirm", methods=["GET", "POST"])
@login_required @login_required
def reauth(): def reauth():
target = _safe_next(session.get("_after_reauth")) target = _safe_next(session.get("_after_reauth"))
@@ -257,6 +257,24 @@ def _verify_credentials(
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns) return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
def _ist_erstanmeldung(user_row: dict[str, Any]) -> bool:
"""Meldet sich dieses Konto zum ersten Mal ueberhaupt an?
Das Aenderungsprotokoll soll Aenderungen zeigen. Eine taegliche Anmeldung
ist keine -- sie erzeugte aber jedes Mal eine Zeile und schob damit echte
Eintraege aus dem Blickfeld. Interessant ist genau die erste: mit ihr
entsteht das Konto aus dem Verzeichnisdienst und betritt jemand die
Anwendung, der vorher nicht darin war.
Nicht betroffen: fehlgeschlagene Anmeldungen und Sperren. Die stehen
weiterhin **jedes Mal** im Protokoll -- dort liegt der sicherheitsrelevante
Teil, und Haeufung ist dort das Signal. Wer wann zuletzt angemeldet war,
steht ausserdem unveraendert am Konto und in der Sitzungsuebersicht.
"""
return not str(user_row.get("last_login_at") or "").strip()
def complete_login( def complete_login(
conn: sqlite3.Connection, conn: sqlite3.Connection,
user_row: dict[str, Any], user_row: dict[str, Any],
@@ -275,21 +293,26 @@ def complete_login(
""" """
from ..security import csrf from ..security import csrf
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
erstanmeldung = _ist_erstanmeldung(user_row)
with core().database.transaction(conn): with core().database.transaction(conn):
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type] session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
conn.execute( conn.execute(
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"]) "UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
) )
audit.log( if erstanmeldung:
conn, audit.log(
actor=str(user_row["username"]), conn,
actor_id=int(user_row["id"]), actor=str(user_row["username"]),
action="auth.login", actor_id=int(user_row["id"]),
target=str(user_row["username"]), action="auth.first_login",
detail=f"Quelle: {user_row.get('auth_source') or 'local'}", target=str(user_row["username"]),
ip=ip, detail=f"Erste Anmeldung -- Quelle: {user_row.get('auth_source') or 'local'}",
request_id=getattr(g, "request_id", ""), ip=ip,
) request_id=getattr(g, "request_id", ""),
severity="notice",
)
session.clear() session.clear()
session[SESSION_USER_KEY] = int(user_row["id"]) session[SESSION_USER_KEY] = int(user_row["id"])
@@ -0,0 +1,242 @@
"""Alte, deutsche Adressen auf die neuen englischen umlenken.
Mit 2.5.0 folgen die Adressen der Navigation und sind englisch:
``/monitoring/host-keys`` statt ``/hostschluessel``. Das ist eine gute
Aenderung und eine unangenehme zugleich -- Lesezeichen, offene Browsertabs und
ein Link in einer Mail zeigen weiter auf die alte Adresse. Ein offener Tab ist
dabei der unangenehmste Fall: die Statusuebersicht fragt alle 20 Sekunden ihre
Kacheln ab und bekam nach dem Update jedes Mal einen 404.
Deshalb diese Tabelle. Sie greift **nur, wenn sonst ein 404 herauskaeme** --
also nie vor einer bestehenden Route. Das ist keine Feinheit: auf dem
Lizenzserver ist ``/lizenz/<ticket>`` eine echte, weiterhin gueltige Adresse
(sie steht in verschickten Mails), waehrend dieselbe Wurzel in TESM zur
Lizenzseite fuehrte. Wer zuerst das Routing fragt, muss sich nicht zwischen
beidem entscheiden.
Die Tabelle ist bewusst vollstaendig und nicht nur auf Wurzeln gekuerzt: eine
Umleitung von ``/dateifreigaben/x/vorschau`` auf eine Uebersichtsseite waere
zwar kein 404 mehr, aber auch nicht das, was jemand angeklickt hat.
"""
from __future__ import annotations
from typing import Mapping
#: Alte Wurzel -> (neue Wurzel, Uebersetzung der Segmente darunter).
WURZELN: dict[str, tuple[str, Mapping[str, str]]] = {
"/dateifreigaben": (
"/fileshares",
{
"herunterladen": "download",
"vorschau": "preview",
"hochladen": "upload",
"formular": "form",
"ordner": "folder",
"umbenennen": "rename",
"loeschen": "delete",
"freigeben": "release",
"zuordnungen": "mappings",
},
),
"/hostschluessel": (
"/monitoring/host-keys",
{"pruefen": "probe", "freigeben": "trust", "verwerfen": "forget", "verwaist": "orphaned"},
),
"/zugangsdaten": (
"/monitoring/credentials",
{"neu": "new", "loeschen": "delete", "anzeigen": "reveal"},
),
"/switche": (
"/monitoring/switches",
{
"neu": "new",
"loeschen": "delete",
"hostkey": "host-key",
"pruefen": "probe",
"freigeben": "trust",
"verwerfen": "forget",
"kommando": "command",
"konsole": "console",
},
),
"/clients": (
"/monitoring/clients",
{
"neu": "new",
"aktiv": "active",
"loeschen": "delete",
"neustart": "restart",
"hostkey": "host-key",
"pruefen": "probe",
"freigeben": "trust",
"verwerfen": "forget",
"konsole": "console",
},
),
"/wartung": (
"/maintenance",
{
"paket-nachinstallieren": "install-package",
"auftrag": "job",
"ausgabe": "output",
"aktualisieren": "update",
"neustarten": "restart",
},
),
"/protokolle": (
"/logs",
{
"kea": "dhcp",
"neustarts": "restarts",
"laufend": "live",
"inhalt": "content",
"verlauf": "history",
"datei": "file",
"archiv": "archive",
"archiv-export": "archive-export",
"archiv-loeschen": "archive-delete",
"aenderungen": "audit",
},
),
"/verwaltung": (
"/administration",
{
"benutzer": "users",
"neu": "new",
"loeschen": "delete",
"sitzungen-beenden": "end-sessions",
"verzeichnis-suche": "directory-search",
"verzeichnis-uebernehmen": "directory-import",
"gruppen": "groups",
"vorlage-anwenden": "apply-preset",
"sitzungen": "sessions",
"beenden": "end",
},
),
"/einstellungen": (
"/administration",
{
"zeitzone": "timezone",
"pruefung-jetzt": "check-now",
"netzwerk": "network",
"bestaetigen": "confirm",
"webserver": "web",
"uebernehmen": "apply",
"zertifikat": "certificate",
"selbstsigniert": "self-signed",
"verzeichnisdienst": "directory",
"gruppensuche": "group-search",
"zuordnung": "mapping",
"loeschen": "delete",
"anbieter": "vendor",
"logo-entfernen": "remove-logo",
},
),
"/sicherung": (
"/administration/backup",
{"vorschau": "preview", "uebernehmen": "apply", "verwerfen": "discard"},
),
"/papierkorb": (
"/administration/trash",
{"wiederherstellen": "restore", "endgueltig-loeschen": "purge", "leeren": "empty"},
),
"/diagnose": (
"/administration/diagnostics",
{"aufraeumen": "cleanup", "schluessel-rotieren": "rotate-keys"},
),
"/eigene-lizenz": (
"/own-license",
{
"einspielen": "install",
"aktivieren": "activate",
"deaktivieren": "deactivate",
"offline-antwort": "offline-response",
"signaturschluessel": "signing-key",
},
),
"/lizenzen": (
"/licenses",
{
"aus-ticket": "from-ticket",
"datei": "file",
"neu-signieren": "resign",
"widerrufen": "revoke",
"loeschen": "delete",
"versenden": "send",
"sub-lizenzserver": "sub-servers",
},
),
"/lizenz": (
"/administration/license",
{
"einspielen": "install",
"aktivieren": "activate",
"deaktivieren": "deactivate",
"offline-antwort": "offline-response",
"serverzertifikat": "server-certificate",
"aussteller": "issuer",
"entfernen": "remove",
},
),
"/kunden": ("/customers", {"neu": "new", "loeschen": "delete"}),
"/konto": (
"/account",
{
"stammdaten": "profile",
"profilbild": "avatar",
"sicherheit": "security",
"passwort": "password",
"zwei-faktor": "two-factor",
"starten": "start",
"bestaetigen": "confirm",
"deaktivieren": "disable",
"codes-erneuern": "recovery-codes",
"sitzungen": "sessions",
"beenden": "end",
"alle-beenden": "end-all",
},
),
"/uebersicht": ("/overview", {"kacheln": "tiles"}),
"/gesundheit": ("/health", {}),
"/bestaetigen": ("/confirm", {}),
}
#: Abfrageparameter, die mit umbenannt wurden.
PARAMETER = {
"freigabe": "share",
"pfad": "path",
"aktion": "action",
"benutzer": "actor",
"stufe": "severity",
"seite": "page",
"methode": "method",
"kunde": "customer",
"freischalten": "unlocked",
}
#: Wurzeln, unter denen eine unbekannte Unterseite **nicht** umgelenkt wird.
#: ``/lizenz/<ticket>`` ist auf dem Lizenzserver eine echte Adresse; ein
#: abgelaufenes oder falsches Ticket gehoert dort auf die Fehlerseite und nicht
#: in die Verwaltung.
STRENG = {"/lizenz"}
def neue_adresse(pfad: str) -> str | None:
"""Uebersetzt eine alte Adresse -- oder ``None``, wenn es keine ist."""
for alt, (neu, segmente) in WURZELN.items():
if pfad != alt and not pfad.startswith(alt + "/"):
continue
rest = pfad[len(alt) :]
teile = rest.split("/")
if alt in STRENG:
erstes = next((teil for teil in teile if teil), "")
if erstes and erstes not in segmente:
return None
return neu + "/".join(segmente.get(teil, teil) for teil in teile)
return None
def neue_parameter(args: Mapping[str, str]) -> dict[str, str]:
return {PARAMETER.get(name, name): wert for name, wert in args.items()}
@@ -77,7 +77,7 @@ SETTINGS_NETWORK = Resource(
BACKUP = Resource( BACKUP = Resource(
key="backup", key="backup",
label="Sicherung und Uebertragung", label="Sicherung und Übertragung",
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS), actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
hint="Verschluesselter Export und Import", hint="Verschluesselter Export und Import",
action_hints={ action_hints={
@@ -121,7 +121,7 @@ LOGS_HISTORY = Resource(
LOGS_AUDIT = Resource( LOGS_AUDIT = Resource(
key="logs_audit", key="logs_audit",
label="Aenderungsprotokoll", label="Änderungsprotokoll",
actions=(A.VIEW, A.EXPORT), actions=(A.VIEW, A.EXPORT),
hint="Wer hat wann was geaendert", hint="Wer hat wann was geaendert",
) )
@@ -74,6 +74,11 @@ class Resource:
hint: str = "" hint: str = ""
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint). #: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
action_hints: Mapping[Action, str] = field(default_factory=dict) action_hints: Mapping[Action, str] = field(default_factory=dict)
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
#: stilles Weglassen in der Route: eine Ausnahme von einer
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
ohne_wiederanmeldung: tuple[Action, ...] = ()
def permission(self, action: Action) -> str: def permission(self, action: Action) -> str:
return f"{self.key}.{action.value}" return f"{self.key}.{action.value}"
@@ -159,6 +164,25 @@ class PermissionTree:
def resource(self, key: str) -> tuple[Area, Resource] | None: def resource(self, key: str) -> tuple[Area, Resource] | None:
return self._resources.get(key) return self._resources.get(key)
def verlangt_wiederanmeldung(self, permission: str) -> bool:
"""Braucht dieses Recht eine frische Passwortbestaetigung?
Die Antwort steht im Modell (``SENSITIVE_ACTIONS``) und nicht in der
Route -- sonst vergisst sie die naechste, die dazukommt.
"""
resource_key, _, action_value = permission.rpartition(".")
entry = self._resources.get(resource_key)
if entry is None:
return False
_, resource = entry
try:
action = Action(action_value)
except ValueError:
return False
if action in resource.ohne_wiederanmeldung:
return False
return action in SENSITIVE_ACTIONS
def label_for(self, permission: str) -> str: def label_for(self, permission: str) -> str:
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs.""" """Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
if permission.endswith(".view") and permission[:-5] in self._by_key: if permission.endswith(".view") and permission[:-5] in self._by_key:
@@ -163,6 +163,7 @@ class SqliteSessionInterface(SessionInterface):
idle_minutes: int, idle_minutes: int,
absolute_hours: int, absolute_hours: int,
idle_lookup: Any = None, idle_lookup: Any = None,
absolute_lookup: Any = None,
) -> None: ) -> None:
self.database = database self.database = database
self.cookie_name = cookie_name self.cookie_name = cookie_name
@@ -172,6 +173,16 @@ class SqliteSessionInterface(SessionInterface):
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und #: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene. #: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
self._idle_lookup = idle_lookup self._idle_lookup = idle_lookup
self._absolute_lookup = absolute_lookup
def _absolute(self, conn: Any) -> int:
"""Gesamtdauer in Stunden -- einstellbar wie die Leerlaufgrenze."""
if self._absolute_lookup is None:
return self.absolute_hours
try:
return int(self._absolute_lookup(conn)) or self.absolute_hours
except Exception: # pragma: no cover - Einstellung unlesbar
return self.absolute_hours
def _idle(self, conn: Any) -> int: def _idle(self, conn: Any) -> int:
if self._idle_lookup is None: if self._idle_lookup is None:
@@ -280,7 +291,10 @@ class SqliteSessionInterface(SessionInterface):
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True) payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
with self.database.session() as conn, self.database.transaction(conn): with self.database.session() as conn, self.database.transaction(conn):
if session.new or not session.sid: if session.new or not session.sid:
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES) # Eine vorhandene ID nicht wegwerfen: nach einem Wechsel beim
# Anmelden steht sie schon, und andere haben sie sich gemerkt.
if not session.sid:
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
conn.execute( conn.execute(
"INSERT INTO user_sessions " "INSERT INTO user_sessions "
"(id, user_id, created_at, last_seen_at, idle_expires_at, " "(id, user_id, created_at, last_seen_at, idle_expires_at, "
@@ -292,7 +306,7 @@ class SqliteSessionInterface(SessionInterface):
_iso(now), _iso(now),
_iso(now), _iso(now),
_iso(now + timedelta(minutes=self._idle(conn))), _iso(now + timedelta(minutes=self._idle(conn))),
_iso(now + timedelta(hours=self.absolute_hours)), _iso(now + timedelta(hours=self._absolute(conn))),
session.get("_ip", ""), session.get("_ip", ""),
session.get("_ua", ""), session.get("_ua", ""),
payload, payload,
@@ -371,7 +385,12 @@ def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "p
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?", "UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
(_iso(_now()), reason, old_sid), (_iso(_now()), reason, old_sid),
) )
session.sid = "" # Die neue ID entsteht **jetzt** und nicht erst beim Speichern der Antwort.
# Zwischen beidem laufen die Anmeldehaken, und einer davon bindet die
# Dateifreigaben ein und schreibt sich die Sitzung dazu. Mit einer leeren
# Sitzung hielt der Aufraeumer diese Einbindungen fuer verwaist und loeste
# sie binnen Minuten wieder.
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
session.new = True session.new = True
session.modified = True session.modified = True
return old_sid return old_sid
@@ -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,127 @@
.hide-sm { display: none !important; } .hide-sm { display: none !important; }
} }
} }
/* -- Sortierbare Liste (Navigationsreihenfolge) --------------------------- */
/* Symbol und Beschriftung stehen in einer Zeile -- so wie im Menue selbst.
Vorher brach die Beschriftung unter das Symbol, und die Liste sah aus wie
etwas anderes als das, was sie sortiert. */
.reorder {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.reorder__row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--surface-2);
cursor: grab;
}
.reorder__row > .flex-1 {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reorder__row.is-dragging {
opacity: 0.5;
}
.reorder__grip {
color: var(--text-muted);
display: inline-flex;
}
.reorder__pos {
min-width: 1.6em;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: var(--fs-xs);
}
.reorder__moves {
display: inline-flex;
gap: 2px;
}
.reorder__group {
list-style: none;
}
.reorder--child {
margin: 4px 0 4px 28px;
}
.reorder--child .reorder__row {
background: transparent;
border-style: dashed;
}
/* -- Unterschrift --------------------------------------------------------- */
/* Die Zeichenflaeche braucht "touch-action: none": sonst scrollt ein iPad die
Seite, statt einen Strich zu zeichnen -- und es sieht aus, als reagierte das
Feld nicht. */
.viewer__sign {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border);
background: var(--surface-2);
}
.viewer__signfelder {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.viewer__signpad {
width: 100%;
height: 160px;
border: 1px dashed var(--border-strong, var(--border));
border-radius: var(--r-sm);
background: #fff;
touch-action: none;
cursor: crosshair;
}
.viewer__signaktionen {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
}
.viewer__pdfscroll--setzen {
cursor: crosshair;
}
.viewer__pdfscroll--setzen .page {
outline: 2px dashed var(--accent, #c8791f);
outline-offset: -2px;
}
/* Die gesetzte Unterschrift liegt auf der Seite -- in Prozent, damit sie beim
Zoomen an ihrer Stelle bleibt. */
.viewer__unterschrift {
position: absolute;
pointer-events: none;
z-index: 3;
}
@@ -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,269 @@ 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, unterschriften = [], zeuge = "" } = {}
) {
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);
}
}
}
// Unterschriften: Bild und darunter der angegebene Name mit Zeitpunkt. Das
// Bild allein waere eine Zeichnung ohne Aussage -- auf Papier steht neben
// der Unterschrift auch der Name in Druckbuchstaben.
if (unterschriften.length) {
const schrift = await dokument.embedFont(PDFLib.StandardFonts.Helvetica);
for (const eintrag of unterschriften) {
const seite = dokument.getPage(eintrag.seite - 1);
const png = await dokument.embedPng(eintrag.bytes);
const breite = eintrag.breite;
const hoehe = (png.height / png.width) * breite;
seite.drawImage(png, { x: eintrag.x, y: eintrag.y, width: breite, height: hoehe });
const zeilen = [`${eintrag.name} -- ${eintrag.zeit}`];
if (zeuge) zeilen.push(`In TESM abgelegt durch ${zeuge}`);
let unten = eintrag.y - 2;
for (const zeile of zeilen) {
unten -= 9;
seite.drawText(zeile, {
x: eintrag.x,
y: unten,
size: 8,
font: schrift,
color: PDFLib.rgb(0.1, 0.1, 0.1),
});
}
// Ein Strich wie auf dem Formular: er trennt Zeichnung und Klartext.
seite.drawLine({
start: { x: eintrag.x, y: eintrag.y - 1 },
end: { x: eintrag.x + breite, y: eintrag.y - 1 },
thickness: 0.5,
color: PDFLib.rgb(0.4, 0.4, 0.4),
});
}
}
if (abschliessen) {
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
// gewollt: so kann niemand mehr etwas verstellen.
formular.flatten();
}
return dokument.save();
}
/** Die gezeichnete Unterschrift als PNG -- auf das Gezeichnete beschnitten.
*
* Ungeschnitten waere jede Unterschrift so gross wie die Zeichenflaeche, mit
* viel Luft ringsum. Im Dokument sieht das aus wie ein verrutschter Stempel.
*/
function unterschriftAlsPng(leinwand, kasten) {
const rand = 6;
const links = Math.max(0, Math.floor(kasten.x1 - rand));
const oben = Math.max(0, Math.floor(kasten.y1 - rand));
const breite = Math.min(leinwand.width - links, Math.ceil(kasten.x2 - kasten.x1 + rand * 2));
const hoehe = Math.min(leinwand.height - oben, Math.ceil(kasten.y2 - kasten.y1 + rand * 2));
const ziel = document.createElement("canvas");
ziel.width = Math.max(1, breite);
ziel.height = Math.max(1, hoehe);
ziel
.getContext("2d")
.drawImage(leinwand, links, oben, ziel.width, ziel.height, 0, 0, ziel.width, ziel.height);
// Ueber eine data:-Adresse und nicht ueber blob: -- die Sicherheitsregeln
// der Anwendung erlauben blob: nicht, und ein stilles "img-src" waere ein
// Loch, das man fuer eine Unterschrift nicht aufmacht.
const adresse = ziel.toDataURL("image/png");
const roh = atob(adresse.slice(adresse.indexOf(",") + 1));
const bytes = new Uint8Array(roh.length);
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i);
return { bytes, vorschau: adresse, breite: ziel.width, hoehe: ziel.height };
}
/** Zeichenflaeche fuer die Unterschrift -- Finger, Stift oder Maus.
*
* ``touch-action: none`` steht im Stylesheet: ohne das scrollt ein iPad die
* Seite, statt einen Strich zu zeichnen.
*/
function unterschriftPad(leinwand) {
const stift = leinwand.getContext("2d");
let kasten = null;
let zeichnet = false;
const punkt = (event) => {
const rechteck = leinwand.getBoundingClientRect();
return {
x: ((event.clientX - rechteck.left) / rechteck.width) * leinwand.width,
y: ((event.clientY - rechteck.top) / rechteck.height) * leinwand.height,
};
};
const merken = ({ x, y }) => {
kasten = kasten
? {
x1: Math.min(kasten.x1, x),
y1: Math.min(kasten.y1, y),
x2: Math.max(kasten.x2, x),
y2: Math.max(kasten.y2, y),
}
: { x1: x, y1: y, x2: x, y2: y };
};
stift.lineWidth = 2.5;
stift.lineCap = "round";
stift.lineJoin = "round";
stift.strokeStyle = "#111";
leinwand.addEventListener("pointerdown", (event) => {
event.preventDefault();
leinwand.setPointerCapture(event.pointerId);
zeichnet = true;
const p = punkt(event);
merken(p);
stift.beginPath();
stift.moveTo(p.x, p.y);
});
leinwand.addEventListener("pointermove", (event) => {
if (!zeichnet) return;
event.preventDefault();
const p = punkt(event);
merken(p);
stift.lineTo(p.x, p.y);
stift.stroke();
});
const beenden = () => {
zeichnet = false;
};
leinwand.addEventListener("pointerup", beenden);
leinwand.addEventListener("pointercancel", beenden);
leinwand.addEventListener("pointerleave", beenden);
return {
leeren: () => {
stift.clearRect(0, 0, leinwand.width, leinwand.height);
kasten = null;
},
leer: () => kasten === null,
alsPng: () => (kasten ? unterschriftAlsPng(leinwand, kasten) : null),
};
}
/* -- PDF ------------------------------------------------------------------- */ /* -- PDF ------------------------------------------------------------------- */
let pdfjsGeladen = null; let pdfjsGeladen = null;
@@ -190,8 +453,13 @@ async function pdfjsLaden(basis) {
return pdfjsGeladen; return pdfjsGeladen;
} }
/** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */ /** Zeigt ein PDF -- mit ausfuellbaren Feldern, sofern das erlaubt ist.
async function pdfBetrachter(behaelter, seiten, url, basis) { *
* Ohne ``formulare`` bleibt es bei der Darstellung aus dem Dokument: pdf.js
* malt dann die Marken, die im PDF stehen (etwa rotes Kreuz und gruener
* Haken), statt Bedienelemente des Browsers einzusetzen.
*/
async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true } = {}) {
const { lib, komponenten } = await pdfjsLaden(basis); const { lib, komponenten } = await pdfjsLaden(basis);
const adressen = pdfjsAdressen(basis); const adressen = pdfjsAdressen(basis);
const eventBus = new komponenten.EventBus(); const eventBus = new komponenten.EventBus();
@@ -201,8 +469,11 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
viewer: seiten, viewer: seiten,
eventBus, eventBus,
linkService, linkService,
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern. // Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern --
annotationMode: lib.AnnotationMode.ENABLE_FORMS, // wer nicht ausfuellen darf, bekommt die Darstellung aus dem Dokument.
annotationMode: formulare
? lib.AnnotationMode.ENABLE_FORMS
: lib.AnnotationMode.ENABLE,
textLayerMode: 1, textLayerMode: 1,
}); });
linkService.setViewer(viewer); linkService.setViewer(viewer);
@@ -244,6 +515,123 @@ 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();
/* Unterschriften: gezeichnet wird ausserhalb, gesetzt wird hier. Die Stelle
bestimmt ein Tipp ins Dokument -- ein festes Feld gibt es in den wenigsten
Vorlagen, und "unten rechts" ist bei einem Formular mit drei
Unterschriftszeilen die falsche Annahme. */
const unterschriften = [];
let wartendeUnterschrift = null;
const platzieren = async (event) => {
if (!wartendeUnterschrift) return;
const seitenEl = event.target.closest(".page");
if (!seitenEl) return;
event.preventDefault();
const nummer = Number(seitenEl.dataset.pageNumber || 0);
if (!nummer) return;
const rechteck = seitenEl.getBoundingClientRect();
const anteilX = (event.clientX - rechteck.left) / rechteck.width;
const anteilY = (event.clientY - rechteck.top) / rechteck.height;
const seite = await dokument.getPage(nummer);
const blick = seite.getViewport({ scale: 1 });
const breite = Math.min(170, blick.width * 0.35);
const hoehe =
(wartendeUnterschrift.hoehe / wartendeUnterschrift.breite) * breite;
const eintrag = {
...wartendeUnterschrift,
seite: nummer,
breite,
// Der Tipp meint die Mitte der Unterschrift, nicht ihre Ecke.
x: Math.max(4, anteilX * blick.width - breite / 2),
y: Math.max(24, (1 - anteilY) * blick.height - hoehe / 2),
};
unterschriften.push(eintrag);
behaelter.classList.remove("viewer__pdfscroll--setzen");
wartendeUnterschrift = null;
// Sichtbar machen, wo sie gelandet ist -- sonst tippt man ins Blaue.
const marke = document.createElement("img");
marke.className = "viewer__unterschrift";
marke.src = eintrag.vorschau;
marke.alt = "";
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
marke.style.width = `${(breite / blick.width) * 100}%`;
seitenEl.append(marke);
behaelter.dispatchEvent(new CustomEvent("unterschrieben", { detail: eintrag }));
};
behaelter.addEventListener("click", (event) => {
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
});
return { return {
hinein: () => { hinein: () => {
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2); viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
@@ -254,8 +642,27 @@ 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, zeuge = "" } = {}) => {
const bytes = await dokument.saveDocument();
// Ohne Bild, ohne Unterschrift und ohne Abschluss braucht es pdf-lib nicht.
if (!bilder.size && !abschliessen && !unterschriften.length) return bytes;
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, {
abschliessen,
unterschriften,
zeuge,
});
},
/** Wartet der Betrachter auf den Tipp, wohin die Unterschrift soll? */
unterschriftAnnehmen: (daten) => {
wartendeUnterschrift = daten;
behaelter.classList.add("viewer__pdfscroll--setzen");
},
unterschriften: () => unterschriften.map(({ name, zeit }) => ({ name, zeit })),
/** Wurde ueberhaupt etwas eingetragen? */ /** Wurde ueberhaupt etwas eingetragen? */
veraendert: () => { veraendert: () => {
const s = dokument.annotationStorage; const s = dokument.annotationStorage;
@@ -294,7 +701,34 @@ 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 zielFeld = stueck("ziel");
const unterschreibenKnopf = stueck("unterschreiben");
const unterschriftPanel = stueck("unterschriftpanel");
const vornameFeld = stueck("vorname");
const nachnameFeld = stueck("nachname");
const padLeinwand = stueck("signpad");
const padLeeren = stueck("signleeren");
const padSetzen = stueck("signsetzen");
const padAbbrechen = stueck("signabbrechen");
const padHinweis = stueck("signhinweis");
const basis = rumpf.dataset.viewerPdfjs || ""; const basis = rumpf.dataset.viewerPdfjs || "";
const konto = rumpf.dataset.viewerKonto || "";
const bezeugen = rumpf.dataset.viewerZeuge === "1";
// Die Uhr des Servers, nicht die des Tablets: eine verstellte Uhr am Geraet
// soll nicht das Datum unter einer Unterschrift bestimmen.
const serverStart = Date.parse(rumpf.dataset.viewerZeit || "") || Date.now();
const geladenSeit = Date.now();
const jetztText = () => {
const zeitpunkt = new Date(serverStart + (Date.now() - geladenSeit));
return zeitpunkt.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari), // Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
// soll nicht als leere Flaeche enden. // soll nicht als leere Flaeche enden.
@@ -321,6 +755,8 @@ export function initFilePreview(wurzel = document) {
rahmen.hidden = art !== "text"; rahmen.hidden = art !== "text";
leiste.hidden = art === "text"; leiste.hidden = art === "text";
if (speichern) speichern.hidden = art !== "pdf"; if (speichern) speichern.hidden = art !== "pdf";
if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf";
if (unterschriftPanel) unterschriftPanel.hidden = true;
}; };
const aufraeumen = () => { const aufraeumen = () => {
@@ -357,7 +793,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,16 +843,67 @@ 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;
}; };
/* Unterschreiben: Name tippen, zeichnen, ins Dokument setzen. */
const pad = padLeinwand ? unterschriftPad(padLeinwand) : null;
const panelZeigen = (offen) => {
if (!unterschriftPanel) return;
unterschriftPanel.hidden = !offen;
if (offen) {
pad?.leeren();
if (padHinweis) padHinweis.textContent = "";
vornameFeld?.focus();
}
};
unterschreibenKnopf?.addEventListener("click", () => {
if (!pdf) return;
panelZeigen(unterschriftPanel?.hidden !== false);
});
padLeeren?.addEventListener("click", () => pad?.leeren());
padAbbrechen?.addEventListener("click", () => panelZeigen(false));
padSetzen?.addEventListener("click", () => {
if (!pdf || !pad) return;
const name = [vornameFeld?.value, nachnameFeld?.value]
.map((wert) => (wert || "").trim())
.filter(Boolean)
.join(" ");
// Beides ist noetig, und beides wird hier gesagt statt stumm abgelehnt.
if (!name) {
if (padHinweis) padHinweis.textContent = "Bitte Vor- und Nachnamen eintragen.";
vornameFeld?.focus();
return;
}
const zeichnung = pad.alsPng();
if (!zeichnung) {
if (padHinweis) padHinweis.textContent = "Bitte im Feld unterschreiben.";
return;
}
pdf.unterschriftAnnehmen({ ...zeichnung, name, zeit: jetztText() });
panelZeigen(false);
window.tesm.showToast("Jetzt auf die Stelle im Dokument tippen.", "info");
});
speichern?.addEventListener("click", () => { speichern?.addEventListener("click", () => {
if (!pdf || !aktuell) return; if (!pdf || !aktuell) return;
vorschlagen(); vorschlagen();
zeile.hidden = false; zeile.hidden = false;
if (modusFeld && pdf.unterschriften().length) {
// Mit Unterschrift gibt es nur einen Weg -- und das Feld soll nicht
// etwas anderes behaupten.
modusFeld.value = "fertig";
modusFeld.disabled = true;
}
nameFeld.focus(); nameFeld.focus();
nameFeld.select(); nameFeld.select();
}); });
@@ -423,6 +912,31 @@ export function initFilePreview(wurzel = document) {
zeile.hidden = true; zeile.hidden = true;
}); });
/* Zielfreigabe: die Ordnerliste gehoert zur gewaehlten Freigabe, nicht zur
geoeffneten. Geholt wird sie erst bei Bedarf -- alle Listen im
Seitenaufbau mitzuschicken waere Ballast fuer einen seltenen Fall. */
const ordnerListe = ordnerFeld ? document.getElementById(ordnerFeld.getAttribute("list")) : null;
zielFeld?.addEventListener("change", async () => {
if (!ordnerListe) return;
const vorlage = zielFeld.dataset.urlVorlage || "";
if (!vorlage) return;
ordnerFeld.value = "";
try {
const antwort = await window.tesm.api(vorlage.replace("__token__", zielFeld.value));
ordnerListe.replaceChildren(
...(antwort.folders || []).map((eintrag) => {
const option = document.createElement("option");
option.value = eintrag;
return option;
})
);
} catch (fehler) {
// Kein Beinbruch: das Feld nimmt auch getippte Pfade.
console.error("Ordnerliste", fehler);
}
});
jetzt?.addEventListener("click", async () => { jetzt?.addEventListener("click", async () => {
if (!pdf || !aktuell) return; if (!pdf || !aktuell) return;
const ziel = speichern.dataset.url; const ziel = speichern.dataset.url;
@@ -430,16 +944,25 @@ 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 gesetzt = pdf.unterschriften();
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
// Unterschrieben heisst abgeschlossen: ein Dokument, in dem nach der
// Unterschrift noch Felder aenderbar waeren, ist keine Unterschrift
// wert.
abschliessen: modusFeld?.value === "fertig" || gesetzt.length > 0,
zeuge: bezeugen ? konto : "",
});
const daten = new FormData(); const daten = new FormData();
daten.append("pfad", aktuell.pfad); daten.append("path", aktuell.pfad);
daten.append("ordner", ordnerFeld.value.trim()); daten.append("ordner", ordnerFeld.value.trim());
daten.append("name", nameFeld.value.trim()); daten.append("name", nameFeld.value.trim());
if (zielFeld?.value) daten.append("ziel", zielFeld.value);
if (gesetzt.length) daten.append("unterschriften", JSON.stringify(gesetzt));
if (ersetzenFeld.checked) daten.append("ersetzen", "1"); if (ersetzenFeld.checked) daten.append("ersetzen", "1");
daten.append( daten.append(
"datei", "datei",
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
@@ -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 &ndash; <strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt &ndash;
Anpassungen bleiben erhalten. Anpassungen bleiben erhalten.
{% if current_user.has('groups.edit') %} {% if current_user.has('groups.edit') %}
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, freischalten=1) }}"> <a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, unlocked=1) }}">
{{ icon('unlock', size=14) }} Rechte freischalten {{ icon('unlock', size=14) }} Rechte freischalten
</a> </a>
{% endif %} {% endif %}
@@ -26,19 +26,19 @@
{{ icon('search', size=15) }} {{ icon('search', size=15) }}
<input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Ziel, Detail, Aktion ..."> <input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Ziel, Detail, Aktion ...">
</span> </span>
<select class="select" name="aktion" style="width:auto;max-width:240px"> <select class="select" name="action" style="width:auto;max-width:240px">
<option value="">alle Aktionen</option> <option value="">alle Aktionen</option>
{% for action in actions %} {% for action in actions %}
<option value="{{ action }}" {% if action == filters.aktion %}selected{% endif %}>{{ action }}</option> <option value="{{ action }}" {% if action == filters.action %}selected{% endif %}>{{ action }}</option>
{% endfor %} {% endfor %}
</select> </select>
<select class="select" name="stufe" style="width:auto"> <select class="select" name="severity" style="width:auto">
<option value="">alle Stufen</option> <option value="">alle Stufen</option>
{% for level in ['info', 'notice', 'warning', 'critical'] %} {% for level in ['info', 'notice', 'warning', 'critical'] %}
<option value="{{ level }}" {% if level == filters.stufe %}selected{% endif %}>{{ level }}</option> <option value="{{ level }}" {% if level == filters.severity %}selected{% endif %}>{{ level }}</option>
{% endfor %} {% endfor %}
</select> </select>
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}" <input class="input" type="text" name="actor" value="{{ filters.actor }}"
placeholder="Benutzer" style="width:150px"> placeholder="Benutzer" style="width:150px">
<button class="btn btn--sm" type="submit">Filtern</button> <button class="btn btn--sm" type="submit">Filtern</button>
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a> <a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
@@ -88,10 +88,10 @@
<span class="fs-sm muted">Seite {{ page }}</span> <span class="fs-sm muted">Seite {{ page }}</span>
<span class="row row--tight"> <span class="row row--tight">
{% if page > 1 %} {% if page > 1 %}
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page-1, **filters) }}">Zurück</a> <a class="btn btn--sm" href="{{ url_for('logs.audit_view', page=page-1, **filters) }}">Zurück</a>
{% endif %} {% endif %}
{% if rows | length == per_page %} {% if rows | length == per_page %}
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a> <a class="btn btn--sm" href="{{ url_for('logs.audit_view', page=page+1, **filters) }}">Weiter</a>
{% endif %} {% endif %}
</span> </span>
</div> </div>
@@ -235,3 +235,69 @@
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span> die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
</div> </div>
{%- endmacro %} {%- endmacro %}
{# --------------------------------------------------------------------------
Navigationsreihenfolge. Die Reihenfolge der versteckten Felder ist die
Reihenfolge -- verschiebt sich eine Zeile, verschiebt sich das Feld mit ihr.
Ziehen mit der Maus oder die zwei Knoepfe je Zeile; auf einem Tablet sind
die Knoepfe der verlaessliche Weg.
-------------------------------------------------------------------------- #}
{% macro _reorder_row(item, field_name, label) -%}
<li class="reorder__row" data-reorder-item draggable="true">
<span class="reorder__grip" aria-hidden="true">{{ icon('menu', size=14) }}</span>
<span class="reorder__pos" data-reorder-position-text>1</span>
<input type="hidden" name="{{ field_name }}" value="{{ item.key }}">
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
<span class="reorder__moves">
<button class="btn btn--icon btn--sm" type="button" data-reorder-move="up"
aria-label="{{ label }} nach oben">{{ icon('chevron-up', size=14) }}</button>
<button class="btn btn--icon btn--sm" type="button" data-reorder-move="down"
aria-label="{{ label }} nach unten">{{ icon('chevron-down', size=14) }}</button>
</span>
</li>
{%- endmacro %}
{% macro nav_order_card(items, order, child_order, action, csrf_field, csrf_value,
sub='Gilt für alle Benutzer; jeder sieht weiter nur, was er darf') -%}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
<div class="card__sub">{{ sub }}</div>
</div>
</div>
<form method="post" action="{{ action }}">
<input type="hidden" name="{{ csrf_field }}" value="{{ csrf_value }}">
<div class="card__body">
<ol class="reorder" data-behavior="reorder">
{% for key in order %}
{% set item = items | selectattr('key', 'equalto', key) | list | first %}
{% if item %}
{{ _reorder_row(item, 'order', item.label) }}
{% if item.children %}
<li class="reorder__group">
<ol class="reorder reorder--child" data-behavior="reorder">
{% set kinder = child_order.get(item.key) or (item.children | map(attribute='key') | list) %}
{% for child_key in kinder %}
{% set child = item.children | selectattr('key', 'equalto', child_key) | list | first %}
{% if child %}
{{ _reorder_row(child, 'child_order_' ~ item.key, item.label ~ ' / ' ~ child.label) }}
{% endif %}
{% endfor %}
</ol>
</li>
{% endif %}
{% endif %}
{% endfor %}
</ol>
<p class="fs-xs faint mt-3">
Zeilen ziehen oder mit den Pfeilen verschieben, dann speichern. Untergeordnete
Punkte werden innerhalb ihrer Gruppe sortiert.
</p>
</div>
<div class="card__footer">
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
</div>
</form>
</section>
{%- endmacro %}
+1
View File
@@ -38,6 +38,7 @@ ICONS: dict[str, str] = {
"menu": '<path d="M4 6h16M4 12h16M4 18h16"/>', "menu": '<path d="M4 6h16M4 12h16M4 18h16"/>',
"chevron-right": '<path d="m9 6 6 6-6 6"/>', "chevron-right": '<path d="m9 6 6 6-6 6"/>',
"chevron-down": '<path d="m6 9 6 6 6-6"/>', "chevron-down": '<path d="m6 9 6 6 6-6"/>',
"chevron-up": '<path d="m6 15 6-6 6 6"/>',
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"/>', "sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"/>',
"moon": '<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>', "moon": '<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>',
"monitor": '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>', "monitor": '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
@@ -31,7 +31,7 @@ from ..extension import core, db, transaction
from ..rbac import service as rbac from ..rbac import service as rbac
from ..security import passwords, sessions as session_store, totp from ..security import passwords, sessions as session_store, totp
bp = Blueprint("account", __name__, url_prefix="/konto") bp = Blueprint("account", __name__, url_prefix="/account")
ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"} ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
MAX_AVATAR_BYTES = 2 * 1024 * 1024 MAX_AVATAR_BYTES = 2 * 1024 * 1024
@@ -54,6 +54,19 @@ def _avatar_dir() -> Path:
def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None: def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None:
"""Protokolliert eine Aenderung am **eigenen** Konto.
Bewusst sparsam benutzt. Das Aenderungsprotokoll beantwortet die Frage
"wer hat woran etwas geaendert" -- und wer sein eigenes Passwort setzt,
ein Profilbild tauscht oder seine Anschrift korrigiert, aendert nichts,
was jemand anderen betrifft. Solche Zeilen fuellten das Protokoll und
schoben die Eintraege aus dem Blick, um die es geht.
Was hier trotzdem stehen bleibt: alles rund um den zweiten Faktor. Ob ein
Konto mit oder ohne zweiten Faktor geschuetzt ist, aendert die Staerke der
Anmeldung -- das ist die eine Selbstbedienung, nach der man nach einem
Vorfall sucht.
"""
audit.log( audit.log(
conn, conn,
actor=current_user.username, actor=current_user.username,
@@ -80,7 +93,7 @@ def overview():
) )
@bp.post("/stammdaten") @bp.post("/profile")
@login_required @login_required
def update_profile(): def update_profile():
if current_user.is_directory_account: if current_user.is_directory_account:
@@ -101,12 +114,11 @@ def update_profile():
current_user.id, current_user.id,
), ),
) )
_audit(conn, "account.profile_updated")
flash("Stammdaten gespeichert.", "success") flash("Stammdaten gespeichert.", "success")
return redirect(url_for("account.overview")) return redirect(url_for("account.overview"))
@bp.post("/profilbild") @bp.post("/avatar")
@login_required @login_required
def upload_avatar(): def upload_avatar():
upload = request.files.get("avatar") upload = request.files.get("avatar")
@@ -142,14 +154,13 @@ def upload_avatar():
"UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?", "UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?",
(target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id), (target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
) )
_audit(conn, "account.avatar_updated")
if previous: if previous:
(_avatar_dir() / str(previous)).unlink(missing_ok=True) (_avatar_dir() / str(previous)).unlink(missing_ok=True)
flash("Profilbild aktualisiert.", "success") flash("Profilbild aktualisiert.", "success")
return redirect(url_for("account.overview")) return redirect(url_for("account.overview"))
@bp.get("/profilbild/<path:filename>") @bp.get("/avatar/<path:filename>")
@login_required @login_required
def avatar(filename: str): def avatar(filename: str):
safe = secure_filename(filename) safe = secure_filename(filename)
@@ -163,7 +174,7 @@ def avatar(filename: str):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/sicherheit") @bp.get("/security")
@login_required @login_required
def security(): def security():
conn = db() conn = db()
@@ -199,7 +210,7 @@ def security():
) )
@bp.post("/passwort") @bp.post("/password")
@login_required @login_required
def change_password(): def change_password():
conn = db() conn = db()
@@ -229,21 +240,15 @@ def change_password():
revoked = session_store.revoke_all_for_user( revoked = session_store.revoke_all_for_user(
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change" conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change"
) )
_audit(
conn,
"account.password_changed",
f"{revoked} weitere Sitzungen beendet",
severity="notice",
)
except rbac.ValidationError as exc: except rbac.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
flash("Passwort geaendert. Andere Sitzungen wurden beendet.", "success") flash(f"Passwort geaendert. {revoked} weitere Sitzung(en) wurden beendet.", "success")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/starten") @bp.post("/two-factor/start")
@login_required @login_required
@reauth_required @reauth_required
def totp_start(): def totp_start():
@@ -252,7 +257,7 @@ def totp_start():
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/bestaetigen") @bp.post("/two-factor/confirm")
@login_required @login_required
@reauth_required @reauth_required
def totp_confirm(): def totp_confirm():
@@ -295,7 +300,7 @@ def totp_confirm():
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/deaktivieren") @bp.post("/two-factor/disable")
@login_required @login_required
@reauth_required @reauth_required
def totp_disable(): def totp_disable():
@@ -317,7 +322,7 @@ def totp_disable():
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/codes-erneuern") @bp.post("/two-factor/recovery-codes")
@login_required @login_required
@reauth_required @reauth_required
def recovery_codes(): def recovery_codes():
@@ -341,7 +346,7 @@ def recovery_codes():
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
@bp.post("/sitzungen/<sid>/beenden") @bp.post("/sessions/<sid>/end")
@login_required @login_required
def revoke_session(sid: str): def revoke_session(sid: str):
conn = db() conn = db()
@@ -350,18 +355,16 @@ def revoke_session(sid: str):
abort(404) abort(404)
with transaction(): with transaction():
session_store.revoke(conn, sid, reason="self") session_store.revoke(conn, sid, reason="self")
_audit(conn, "account.session_revoked", sid[:12])
flash("Sitzung beendet.", "success") flash("Sitzung beendet.", "success")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
@bp.post("/sitzungen/alle-beenden") @bp.post("/sessions/end-all")
@login_required @login_required
def revoke_other_sessions(): def revoke_other_sessions():
with transaction() as conn: with transaction() as conn:
count = session_store.revoke_all_for_user( count = session_store.revoke_all_for_user(
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self" conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self"
) )
_audit(conn, "account.sessions_revoked", f"{count} Sitzungen")
flash(f"{count} weitere Sitzung(en) beendet.", "success") flash(f"{count} weitere Sitzung(en) beendet.", "success")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
+17 -17
View File
@@ -16,7 +16,7 @@ from ..rbac import service as rbac
from ..rbac.model import ACTION_META, Action from ..rbac.model import ACTION_META, Action
from ..security import sessions as session_store from ..security import sessions as session_store
bp = Blueprint("admin", __name__, url_prefix="/verwaltung") bp = Blueprint("admin", __name__, url_prefix="/administration")
def _audit(conn: Any, action: str, target: str = "", detail: str = "", severity: str = "info") -> None: def _audit(conn: Any, action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
@@ -47,7 +47,7 @@ def _int_list(name: str) -> list[int]:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/benutzer") @bp.get("/users")
@permission_required("users.view") @permission_required("users.view")
def users(): def users():
conn = db() conn = db()
@@ -60,7 +60,7 @@ def users():
) )
@bp.post("/benutzer/neu") @bp.post("/users/new")
@permission_required("users.create") @permission_required("users.create")
def user_create(): def user_create():
conn = db() conn = db()
@@ -94,7 +94,7 @@ def user_create():
return redirect(url_for("admin.users")) return redirect(url_for("admin.users"))
@bp.post("/benutzer/<int:user_id>") @bp.post("/users/<int:user_id>")
@permission_required("users.edit") @permission_required("users.edit")
def user_update(user_id: int): def user_update(user_id: int):
conn = db() conn = db()
@@ -137,7 +137,7 @@ def user_update(user_id: int):
return redirect(url_for("admin.users")) return redirect(url_for("admin.users"))
@bp.post("/benutzer/<int:user_id>/loeschen") @bp.post("/users/<int:user_id>/delete")
@permission_required("users.delete") @permission_required("users.delete")
def user_delete(user_id: int): def user_delete(user_id: int):
conn = db() conn = db()
@@ -163,7 +163,7 @@ def user_delete(user_id: int):
return redirect(url_for("admin.users")) return redirect(url_for("admin.users"))
@bp.post("/benutzer/<int:user_id>/sitzungen-beenden") @bp.post("/users/<int:user_id>/end-sessions")
@permission_required("users.edit") @permission_required("users.edit")
def user_revoke_sessions(user_id: int): def user_revoke_sessions(user_id: int):
conn = db() conn = db()
@@ -184,7 +184,7 @@ def user_revoke_sessions(user_id: int):
return redirect(url_for("admin.users")) return redirect(url_for("admin.users"))
@bp.get("/benutzer/verzeichnis-suche") @bp.get("/users/directory-search")
@permission_required("users.create") @permission_required("users.create")
def directory_search(): def directory_search():
conn = db() conn = db()
@@ -200,7 +200,7 @@ def directory_search():
return jsonify({"results": results}) return jsonify({"results": results})
@bp.post("/benutzer/verzeichnis-uebernehmen") @bp.post("/users/directory-import")
@permission_required("users.create") @permission_required("users.create")
def directory_adopt(): def directory_adopt():
"""Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen.""" """Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen."""
@@ -238,7 +238,7 @@ def directory_adopt():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/gruppen") @bp.get("/groups")
@permission_required("groups.view") @permission_required("groups.view")
def groups(): def groups():
conn = db() conn = db()
@@ -254,7 +254,7 @@ def groups():
) )
@bp.get("/gruppen/<int:group_id>") @bp.get("/groups/<int:group_id>")
@permission_required("groups.view") @permission_required("groups.view")
def group_detail(group_id: int): def group_detail(group_id: int):
conn = db() conn = db()
@@ -275,11 +275,11 @@ def group_detail(group_id: int):
(group_id,), (group_id,),
), ),
can_edit=current_user.has("groups.edit"), can_edit=current_user.has("groups.edit"),
unlocked=request.args.get("freischalten") == "1", unlocked=request.args.get("unlocked") == "1",
) )
@bp.post("/gruppen/neu") @bp.post("/groups/new")
@permission_required("groups.create") @permission_required("groups.create")
def group_create(): def group_create():
conn = db() conn = db()
@@ -301,7 +301,7 @@ def group_create():
return redirect(url_for("admin.groups")) return redirect(url_for("admin.groups"))
@bp.post("/gruppen/<int:group_id>") @bp.post("/groups/<int:group_id>")
@permission_required("groups.edit") @permission_required("groups.edit")
def group_update(group_id: int): def group_update(group_id: int):
conn = db() conn = db()
@@ -334,7 +334,7 @@ def group_update(group_id: int):
return redirect(url_for("admin.group_detail", group_id=group_id)) return redirect(url_for("admin.group_detail", group_id=group_id))
@bp.post("/gruppen/<int:group_id>/loeschen") @bp.post("/groups/<int:group_id>/delete")
@permission_required("groups.delete") @permission_required("groups.delete")
def group_delete(group_id: int): def group_delete(group_id: int):
conn = db() conn = db()
@@ -354,7 +354,7 @@ def group_delete(group_id: int):
return redirect(url_for("admin.groups")) return redirect(url_for("admin.groups"))
@bp.post("/gruppen/<int:group_id>/vorlage-anwenden") @bp.post("/groups/<int:group_id>/apply-preset")
@admin_required @admin_required
def group_apply_preset(group_id: int): def group_apply_preset(group_id: int):
"""Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch.""" """Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch."""
@@ -386,7 +386,7 @@ def group_apply_preset(group_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/sitzungen") @bp.get("/sessions")
@admin_required @admin_required
def sessions(): def sessions():
conn = db() conn = db()
@@ -399,7 +399,7 @@ def sessions():
return render_template("tesm_core/sessions.html", sessions=rows) return render_template("tesm_core/sessions.html", sessions=rows)
@bp.post("/sitzungen/<sid>/beenden") @bp.post("/sessions/<sid>/end")
@admin_required @admin_required
def session_revoke(sid: str): def session_revoke(sid: str):
with transaction() as conn: with transaction() as conn:
@@ -24,7 +24,7 @@ from ..keystore import check_private_file
from ..security import ratelimit from ..security import ratelimit
from ..security import sessions as session_store from ..security import sessions as session_store
bp = Blueprint("diagnostics", __name__, url_prefix="/diagnose") bp = Blueprint("diagnostics", __name__, url_prefix="/administration/diagnostics")
@bp.get("/") @bp.get("/")
@@ -88,7 +88,7 @@ def overview():
) )
@bp.post("/aufraeumen") @bp.post("/cleanup")
@admin_required @admin_required
def housekeeping(): def housekeeping():
"""Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle.""" """Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle."""
@@ -116,7 +116,7 @@ def housekeeping():
return redirect(url_for("diagnostics.overview")) return redirect(url_for("diagnostics.overview"))
@bp.post("/schluessel-rotieren") @bp.post("/rotate-keys")
@admin_required @admin_required
def rotate_keys(): def rotate_keys():
"""Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu. """Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu.
@@ -29,7 +29,7 @@ from ..licensing_client import LicenseError
from ..rbac import service as rbac from ..rbac import service as rbac
from ..system import hostnet, webserver from ..system import hostnet, webserver
bp = Blueprint("hostsettings", __name__, url_prefix="/einstellungen") bp = Blueprint("hostsettings", __name__, url_prefix="/administration")
PENDING_NETWORK_KEY = "pending_network_revert" PENDING_NETWORK_KEY = "pending_network_revert"
@@ -64,7 +64,7 @@ def _license_hint(feature: str) -> str:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/netzwerk") @bp.get("/network")
@permission_required("settings_network.view") @permission_required("settings_network.view")
def network(): def network():
extension = core() extension = core()
@@ -80,7 +80,7 @@ def network():
) )
@bp.post("/netzwerk") @bp.post("/network")
@permission_required("settings_network.edit") @permission_required("settings_network.edit")
def network_apply(): def network_apply():
extension = core() extension = core()
@@ -147,7 +147,7 @@ def network_apply():
return redirect(url_for("hostsettings.network")) return redirect(url_for("hostsettings.network"))
@bp.post("/netzwerk/bestaetigen") @bp.post("/network/confirm")
@permission_required("settings_network.edit") @permission_required("settings_network.edit")
def network_confirm(): def network_confirm():
if core().registry.pop(PENDING_NETWORK_KEY, None) is None: if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
@@ -221,7 +221,7 @@ def _web_config() -> webserver.WebConfig:
) )
@bp.get("/webserver") @bp.get("/web")
@permission_required("settings_web.view") @permission_required("settings_web.view")
def web(): def web():
extension = core() extension = core()
@@ -240,7 +240,7 @@ def web():
) )
@bp.post("/webserver") @bp.post("/web")
@permission_required("settings_web.edit") @permission_required("settings_web.edit")
def web_save(): def web_save():
extension = core() extension = core()
@@ -302,7 +302,7 @@ def web_save():
return redirect(url_for("hostsettings.web")) return redirect(url_for("hostsettings.web"))
@bp.post("/webserver/uebernehmen") @bp.post("/web/apply")
@permission_required("settings_web.execute") @permission_required("settings_web.execute")
def web_apply(): def web_apply():
problem = _license_hint("nginx") problem = _license_hint("nginx")
@@ -327,7 +327,7 @@ def web_apply():
return redirect(url_for("hostsettings.web")) return redirect(url_for("hostsettings.web"))
@bp.post("/webserver/zertifikat") @bp.post("/web/certificate")
@permission_required("settings_web.execute") @permission_required("settings_web.execute")
def web_certificate(): def web_certificate():
problem = _license_hint("nginx") problem = _license_hint("nginx")
@@ -364,7 +364,7 @@ def web_certificate():
return redirect(url_for("hostsettings.web")) return redirect(url_for("hostsettings.web"))
@bp.post("/webserver/selbstsigniert") @bp.post("/web/self-signed")
@permission_required("settings_web.execute") @permission_required("settings_web.execute")
def web_self_signed(): def web_self_signed():
"""Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain. """Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain.
@@ -414,7 +414,7 @@ def web_self_signed():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/verzeichnisdienst") @bp.get("/directory")
@permission_required("settings_directory.view") @permission_required("settings_directory.view")
def directory_settings(): def directory_settings():
extension = core() extension = core()
@@ -435,7 +435,7 @@ def directory_settings():
) )
@bp.post("/verzeichnisdienst") @bp.post("/directory")
@permission_required("settings_directory.edit") @permission_required("settings_directory.edit")
def directory_save(): def directory_save():
problem = _license_hint("directory") problem = _license_hint("directory")
@@ -481,7 +481,7 @@ def directory_save():
return redirect(url_for("hostsettings.directory_settings")) return redirect(url_for("hostsettings.directory_settings"))
@bp.post("/verzeichnisdienst/test") @bp.post("/directory/test")
@permission_required("settings_directory.execute") @permission_required("settings_directory.execute")
def directory_test(): def directory_test():
result = directory.test_connection(db()) result = directory.test_connection(db())
@@ -489,7 +489,7 @@ def directory_test():
return redirect(url_for("hostsettings.directory_settings")) return redirect(url_for("hostsettings.directory_settings"))
@bp.get("/verzeichnisdienst/gruppensuche") @bp.get("/directory/group-search")
@permission_required("settings_directory.edit", "fileshare.edit", mode="any") @permission_required("settings_directory.edit", "fileshare.edit", mode="any")
def directory_group_search(): def directory_group_search():
"""Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder. """Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder.
@@ -504,7 +504,7 @@ def directory_group_search():
) )
@bp.post("/verzeichnisdienst/zuordnung") @bp.post("/directory/mapping")
@permission_required("settings_directory.edit") @permission_required("settings_directory.edit")
def directory_mapping_add(): def directory_mapping_add():
conn = db() conn = db()
@@ -534,7 +534,7 @@ def directory_mapping_add():
return redirect(url_for("hostsettings.directory_settings")) return redirect(url_for("hostsettings.directory_settings"))
@bp.post("/verzeichnisdienst/zuordnung/<int:mapping_id>/loeschen") @bp.post("/directory/mapping/<int:mapping_id>/delete")
@permission_required("settings_directory.edit") @permission_required("settings_directory.edit")
def directory_mapping_delete(mapping_id: int): def directory_mapping_delete(mapping_id: int):
with transaction() as conn: with transaction() as conn:
+17 -17
View File
@@ -26,7 +26,7 @@ from ..auth.service import client_ip
from ..extension import core, db, transaction from ..extension import core, db, transaction
from ..security.sessions import fragment from ..security.sessions import fragment
bp = Blueprint("logs", __name__, url_prefix="/protokolle") bp = Blueprint("logs", __name__, url_prefix="/logs")
MAX_TAIL_LINES = 2000 MAX_TAIL_LINES = 2000
@@ -38,7 +38,7 @@ def _live_log():
return log return log
@bp.get("/laufend") @bp.get("/live")
@permission_required("logs_live.view") @permission_required("logs_live.view")
def live(): def live():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES) lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
@@ -52,7 +52,7 @@ def live():
) )
@bp.get("/laufend/inhalt") @bp.get("/live/content")
@permission_required("logs_live.view") @permission_required("logs_live.view")
@fragment @fragment
def live_fragment(): def live_fragment():
@@ -63,7 +63,7 @@ def live_fragment():
) )
@bp.get("/verlauf") @bp.get("/history")
@permission_required("logs_history.view", "logs_history.export", mode="any") @permission_required("logs_history.view", "logs_history.export", mode="any")
def history(): def history():
extension = core() extension = core()
@@ -82,7 +82,7 @@ def history():
) )
@bp.get("/verlauf/datei/<path:name>") @bp.get("/history/file/<path:name>")
@permission_required("logs_history.view") @permission_required("logs_history.view")
def history_file(name: str): def history_file(name: str):
extension = core() extension = core()
@@ -101,7 +101,7 @@ def history_file(name: str):
) )
@bp.get("/verlauf/archiv/<day>") @bp.get("/history/archive/<day>")
@permission_required("logs_history.view") @permission_required("logs_history.view")
def history_archive(day: str): def history_archive(day: str):
if len(day) != 10 or day.count("-") != 2: if len(day) != 10 or day.count("-") != 2:
@@ -112,7 +112,7 @@ def history_archive(day: str):
return render_template("tesm_core/logs_archive.html", day=day, rows=rows) return render_template("tesm_core/logs_archive.html", day=day, rows=rows)
@bp.post("/verlauf/archiv-export") @bp.post("/history/archive-export")
@permission_required("logs_history.export") @permission_required("logs_history.export")
def history_export(): def history_export():
"""Packt die archivierten Audit-Tage in ein ZIP. """Packt die archivierten Audit-Tage in ein ZIP.
@@ -152,7 +152,7 @@ def history_export():
) )
@bp.post("/verlauf/archiv-loeschen") @bp.post("/history/archive-delete")
@permission_required("logs_history.delete") @permission_required("logs_history.delete")
def history_purge(): def history_purge():
removed = 0 removed = 0
@@ -176,20 +176,20 @@ def history_purge():
return redirect(url_for("logs.history")) return redirect(url_for("logs.history"))
@bp.get("/aenderungen") @bp.get("/audit")
@permission_required("logs_audit.view", "logs_audit.export", mode="any") @permission_required("logs_audit.view", "logs_audit.export", mode="any")
def audit_view(): def audit_view():
conn = db() conn = db()
page = max(1, int(request.args.get("seite") or 1)) page = max(1, int(request.args.get("page") or 1))
per_page = 100 per_page = 100
return render_template( return render_template(
"tesm_core/logs_audit.html", "tesm_core/logs_audit.html",
rows=audit.search( rows=audit.search(
conn, conn,
query=(request.args.get("q") or "").strip(), query=(request.args.get("q") or "").strip(),
action=(request.args.get("aktion") or "").strip(), action=(request.args.get("action") or "").strip(),
actor=(request.args.get("benutzer") or "").strip(), actor=(request.args.get("actor") or "").strip(),
severity=(request.args.get("stufe") or "").strip(), severity=(request.args.get("severity") or "").strip(),
limit=per_page, limit=per_page,
offset=(page - 1) * per_page, offset=(page - 1) * per_page,
), ),
@@ -200,14 +200,14 @@ def audit_view():
per_page=per_page, per_page=per_page,
filters={ filters={
"q": request.args.get("q") or "", "q": request.args.get("q") or "",
"aktion": request.args.get("aktion") or "", "action": request.args.get("action") or "",
"benutzer": request.args.get("benutzer") or "", "actor": request.args.get("actor") or "",
"stufe": request.args.get("stufe") or "", "severity": request.args.get("severity") or "",
}, },
) )
@bp.get("/aenderungen/export") @bp.get("/audit/export")
@permission_required("logs_audit.export") @permission_required("logs_audit.export")
def audit_export(): def audit_export():
conn = db() conn = db()
@@ -28,7 +28,7 @@ from ..db import Database
from ..extension import core, db, transaction from ..extension import core, db, transaction
from ..rbac import service as rbac from ..rbac import service as rbac
bp = Blueprint("trash", __name__, url_prefix="/papierkorb") bp = Blueprint("trash", __name__, url_prefix="/administration/trash")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -191,7 +191,7 @@ def _provider_or_404(key: str) -> TrashProvider:
return provider return provider
@bp.post("/<kind>/<ident>/wiederherstellen") @bp.post("/<kind>/<ident>/restore")
@permission_required("trash.edit") @permission_required("trash.edit")
def restore(kind: str, ident: str): def restore(kind: str, ident: str):
extension = core() extension = core()
@@ -222,7 +222,7 @@ def restore(kind: str, ident: str):
return redirect(url_for("trash.overview")) return redirect(url_for("trash.overview"))
@bp.post("/<kind>/<ident>/endgueltig-loeschen") @bp.post("/<kind>/<ident>/purge")
@permission_required("trash.delete") @permission_required("trash.delete")
def purge(kind: str, ident: str): def purge(kind: str, ident: str):
extension = core() extension = core()
@@ -252,7 +252,7 @@ def purge(kind: str, ident: str):
return redirect(url_for("trash.overview")) return redirect(url_for("trash.overview"))
@bp.post("/leeren") @bp.post("/empty")
@permission_required("trash.delete") @permission_required("trash.delete")
def purge_all(): def purge_all():
extension = core() extension = core()
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing-server" name = "tesm-licensing-server"
version = "2.3.0" version = "2.6.0"
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten" description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["tesm-licensing==2.3.0", "cryptography>=41"] dependencies = ["tesm-licensing==2.6.0", "cryptography>=41"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing" name = "tesm-licensing"
version = "2.3.0" version = "2.6.0"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["cryptography>=42"] dependencies = ["cryptography>=42"]