Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed17bce40a | ||
|
|
5282cd8f2d | ||
|
|
754cbea6bb | ||
|
|
07ae69e3b7 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.5.1"
|
version = "2.7.0"
|
||||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -47,6 +50,28 @@ def _audit(action: str, target: str, detail: str = "", severity: str = "info") -
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unterschriften(roh: str) -> list[str]:
|
||||||
|
"""Die Namen aus dem Unterschriftsfeld -- Text, keine Struktur.
|
||||||
|
|
||||||
|
Was hier ankommt, hat der Browser geschickt und damit der Mensch davor.
|
||||||
|
Es wird protokolliert, nicht geglaubt: laenge begrenzt, Zeilenumbrueche
|
||||||
|
raus, mehr passiert damit nicht.
|
||||||
|
"""
|
||||||
|
namen: list[str] = []
|
||||||
|
try:
|
||||||
|
eintraege = json.loads(roh) if roh else []
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
if not isinstance(eintraege, list):
|
||||||
|
return []
|
||||||
|
for eintrag in eintraege[:20]:
|
||||||
|
name = str(eintrag if isinstance(eintrag, str) else eintrag.get("name", ""))
|
||||||
|
name = " ".join(name.split())[:120]
|
||||||
|
if name:
|
||||||
|
namen.append(name)
|
||||||
|
return namen
|
||||||
|
|
||||||
|
|
||||||
def _mount_or_404(token: str) -> service.Mount:
|
def _mount_or_404(token: str) -> service.Mount:
|
||||||
mounts = service.active_mounts(db(), current_user.id)
|
mounts = service.active_mounts(db(), current_user.id)
|
||||||
for mount in mounts:
|
for mount in mounts:
|
||||||
@@ -82,6 +107,10 @@ def browse():
|
|||||||
max_upload_mb=uploads.limit_mb(conn),
|
max_upload_mb=uploads.limit_mb(conn),
|
||||||
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
|
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
|
||||||
verzeichnisse=(service.all_directories(mount) if listing else []),
|
verzeichnisse=(service.all_directories(mount) if listing else []),
|
||||||
|
signature_witness=bool(core().settings.get(conn, "signature_witness_enabled")),
|
||||||
|
# Die Uhr des Servers. Das Datum unter einer Unterschrift soll nicht
|
||||||
|
# davon abhaengen, wie das Tablet gestellt ist.
|
||||||
|
serverzeit=datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -186,6 +215,37 @@ def upload(token: str):
|
|||||||
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<token>/signature")
|
||||||
|
@permission_required("fileshare.view")
|
||||||
|
def signature_state(token: str):
|
||||||
|
"""Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll?
|
||||||
|
|
||||||
|
Die Vorschau fragt das beim Oeffnen. Eine unterschriebene Fassung, die
|
||||||
|
seither veraendert wurde, sieht man einer PDF sonst nicht an -- und genau
|
||||||
|
das ist die Frage, die man sich vor einer Unterschrift stellt.
|
||||||
|
"""
|
||||||
|
mount = _mount_or_404(token)
|
||||||
|
try:
|
||||||
|
return service.signaturstand(db(), mount, request.args.get("path") or "")
|
||||||
|
except service.FileshareError as exc:
|
||||||
|
return {"error": str(exc)}, 400
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<token>/folders")
|
||||||
|
@permission_required("fileshare.view")
|
||||||
|
def folders(token: str):
|
||||||
|
"""Die Ordner **dieser** Freigabe als Vorschlagsliste.
|
||||||
|
|
||||||
|
Gebraucht, seit das Speichern in eine andere Freigabe gehen darf: die
|
||||||
|
Vorschlaege muessen der gewaehlten Freigabe folgen. Alle Listen beim
|
||||||
|
Seitenaufbau mitzuschicken waere die Alternative gewesen -- bei vier
|
||||||
|
eingebundenen Freigaben mit je ein paar hundert Ordnern ist das eine
|
||||||
|
viertel Megabyte, die fast nie gebraucht wird.
|
||||||
|
"""
|
||||||
|
mount = _mount_or_404(token)
|
||||||
|
return {"folders": service.all_directories(mount)}
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/form")
|
@bp.post("/<token>/form")
|
||||||
@permission_required("fileshare_pdf.create")
|
@permission_required("fileshare_pdf.create")
|
||||||
def save_form(token: str):
|
def save_form(token: str):
|
||||||
@@ -196,6 +256,12 @@ def save_form(token: str):
|
|||||||
die alte Datei danach loeschen -- das ist eine bewusste Handlung.
|
die alte Datei danach loeschen -- das ist eine bewusste Handlung.
|
||||||
"""
|
"""
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
|
# Das Ziel darf eine andere Freigabe sein: die Vorlage liegt oft in einer
|
||||||
|
# Vorlagenfreigabe, die ausgefuellte Fassung gehoert in die Ablage des
|
||||||
|
# Bereichs. Geprueft wird es wie jede andere Einbindung -- nur eine, die
|
||||||
|
# diesem Benutzer gerade gehoert, kommt in Frage.
|
||||||
|
ziel_token = (request.form.get("ziel") or "").strip()
|
||||||
|
ziel_mount = _mount_or_404(ziel_token) if ziel_token else mount
|
||||||
relative = request.form.get("path") or ""
|
relative = request.form.get("path") or ""
|
||||||
upload_file = request.files.get("datei")
|
upload_file = request.files.get("datei")
|
||||||
if upload_file is None or not upload_file.filename:
|
if upload_file is None or not upload_file.filename:
|
||||||
@@ -228,15 +294,22 @@ def save_form(token: str):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
original = service.resolve_path(mount, relative)
|
original = service.resolve_path(mount, relative)
|
||||||
|
# Ohne Namen landet die Datei neben dem Original -- das geht nur in
|
||||||
|
# derselben Freigabe.
|
||||||
|
if ziel_mount.token != mount.token and not wunschname:
|
||||||
|
return {"error": "Fuer eine andere Freigabe braucht es einen Dateinamen."}, 400
|
||||||
# Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis,
|
# Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis,
|
||||||
# so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt
|
# so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt
|
||||||
# es beim Ordner des Originals.
|
# es beim Ordner des Originals.
|
||||||
if wunschname:
|
if wunschname:
|
||||||
zielordner = service.resolve_path(mount, ordner)
|
zielordner = service.resolve_path(ziel_mount, ordner)
|
||||||
else:
|
else:
|
||||||
zielordner = service.resolve_path(mount, ordner) if ordner else original.parent
|
zielordner = service.resolve_path(mount, ordner) if ordner else original.parent
|
||||||
if not zielordner.is_dir():
|
if not zielordner.is_dir():
|
||||||
return {"error": f"Den Ordner '{ordner}' gibt es in dieser Freigabe nicht."}, 400
|
return {
|
||||||
|
"error": f"Den Ordner '{ordner}' gibt es in der Freigabe "
|
||||||
|
f"'{ziel_mount.label}' nicht."
|
||||||
|
}, 400
|
||||||
if wunschname:
|
if wunschname:
|
||||||
# Eingegeben wird der Name ohne Endung; die kommt von hier. Wer
|
# Eingegeben wird der Name ohne Endung; die kommt von hier. Wer
|
||||||
# sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche
|
# sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche
|
||||||
@@ -247,8 +320,8 @@ def save_form(token: str):
|
|||||||
ziel = zielordner / name
|
ziel = zielordner / name
|
||||||
if ziel.exists() and not ersetzen:
|
if ziel.exists() and not ersetzen:
|
||||||
return {
|
return {
|
||||||
"error": f"'{name}' gibt es dort schon. Anderen Namen waehlen oder "
|
"error": f"In '{ziel_mount.label}' gibt es '{name}' schon. Anderen "
|
||||||
"'Vorhandene ersetzen' ankreuzen."
|
"Namen waehlen oder 'Vorhandene ersetzen' ankreuzen."
|
||||||
}, 409
|
}, 409
|
||||||
else:
|
else:
|
||||||
# Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben.
|
# Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben.
|
||||||
@@ -257,9 +330,25 @@ def save_form(token: str):
|
|||||||
except service.FileshareError as exc:
|
except service.FileshareError as exc:
|
||||||
return {"error": str(exc)}, 400
|
return {"error": str(exc)}, 400
|
||||||
|
|
||||||
wohin = ziel.relative_to(mount.path.resolve()).as_posix()
|
wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix()
|
||||||
_audit("fileshare.form_saved", f"{mount.label}:{relative}", wohin, severity="notice")
|
pruefsumme = hashlib.sha256(daten).hexdigest()
|
||||||
return {"message": f"Gespeichert als {wohin}.", "path": wohin}
|
unterschriften = _unterschriften(request.form.get("unterschriften") or "")
|
||||||
|
|
||||||
|
# Zwei Angaben, die man nicht verwechseln darf, und deshalb stehen beide
|
||||||
|
# da: der Name **in** der Unterschrift ist eine Angabe des
|
||||||
|
# Unterschreibenden -- wie auf Papier. Wer die Datei abgelegt hat, steht
|
||||||
|
# ohnehin als Handelnder in der Zeile und ist belegt.
|
||||||
|
beschreibung = f"{ziel_mount.label}:{wohin} sha256={pruefsumme}"
|
||||||
|
if unterschriften:
|
||||||
|
namen = ", ".join(unterschriften)
|
||||||
|
beschreibung = f"{beschreibung} -- angegeben unterschrieben von: {namen}"
|
||||||
|
_audit(
|
||||||
|
"fileshare.form_signed" if unterschriften else "fileshare.form_saved",
|
||||||
|
f"{mount.label}:{relative}",
|
||||||
|
beschreibung,
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
|
return {"message": f"Gespeichert als {wohin}.", "path": wohin, "sha256": pruefsumme}
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/folder")
|
@bp.post("/<token>/folder")
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ def system():
|
|||||||
"terminal_switches_enabled",
|
"terminal_switches_enabled",
|
||||||
"terminal_clients_enabled",
|
"terminal_clients_enabled",
|
||||||
"terminal_idle_timeout",
|
"terminal_idle_timeout",
|
||||||
|
"signature_witness_enabled",
|
||||||
"trash_retention_days",
|
"trash_retention_days",
|
||||||
"session_idle_minutes",
|
"session_idle_minutes",
|
||||||
"session_absolute_hours",
|
"session_absolute_hours",
|
||||||
@@ -116,6 +117,7 @@ def system_save():
|
|||||||
"terminal_switches_enabled",
|
"terminal_switches_enabled",
|
||||||
"terminal_clients_enabled",
|
"terminal_clients_enabled",
|
||||||
"terminal_idle_timeout",
|
"terminal_idle_timeout",
|
||||||
|
"signature_witness_enabled",
|
||||||
"trash_retention_days",
|
"trash_retention_days",
|
||||||
"session_idle_minutes",
|
"session_idle_minutes",
|
||||||
"session_absolute_hours",
|
"session_absolute_hours",
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ CREDENTIALS = Resource(
|
|||||||
|
|
||||||
MAINTENANCE = Resource(
|
MAINTENANCE = Resource(
|
||||||
key="maintenance",
|
key="maintenance",
|
||||||
label="Wartung",
|
# Nicht "Wartung": so heisst der Bereich. Die Zeile sagt, was sie erlaubt.
|
||||||
|
label="Aktualisierung und Neustart",
|
||||||
actions=(A.VIEW, A.EXECUTE),
|
actions=(A.VIEW, A.EXECUTE),
|
||||||
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
|
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
|
||||||
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."},
|
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."},
|
||||||
@@ -128,9 +129,22 @@ MONITORING_AREA = Area(
|
|||||||
key="monitoring",
|
key="monitoring",
|
||||||
label="Überwachung",
|
label="Überwachung",
|
||||||
icon="cpu",
|
icon="cpu",
|
||||||
hint="Geraete, Switche, Zugangsdaten und Wartung. Ohne diesen Zugang bleibt der "
|
hint="Geraete, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
|
||||||
"gesamte Bereich verborgen.",
|
"gesamte Bereich verborgen.",
|
||||||
resources=(DEVICES, SWITCHES, CREDENTIALS, MAINTENANCE),
|
resources=(DEVICES, SWITCHES, CREDENTIALS),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als
|
||||||
|
#: eigener Punkt im Menue. Der Bereichsschluessel heisst ``servicing``, weil
|
||||||
|
#: ``maintenance`` schon die Ressource ist und Bereiche und Ressourcen sich
|
||||||
|
#: einen Namensraum teilen. Nach aussen heisst beides "Wartung".
|
||||||
|
MAINTENANCE_AREA = Area(
|
||||||
|
key="servicing",
|
||||||
|
label="Wartung",
|
||||||
|
icon="wrench",
|
||||||
|
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
|
||||||
|
"verborgen -- auch wenn einzelne Rechte darunter gesetzt sind.",
|
||||||
|
resources=(MAINTENANCE,),
|
||||||
)
|
)
|
||||||
|
|
||||||
NETWORK_AREA = Area(
|
NETWORK_AREA = Area(
|
||||||
@@ -150,6 +164,7 @@ FILES_AREA = Area(
|
|||||||
PERMISSIONS = PermissionTree(
|
PERMISSIONS = PermissionTree(
|
||||||
[
|
[
|
||||||
MONITORING_AREA,
|
MONITORING_AREA,
|
||||||
|
MAINTENANCE_AREA,
|
||||||
NETWORK_AREA,
|
NETWORK_AREA,
|
||||||
FILES_AREA,
|
FILES_AREA,
|
||||||
observability_area(LOGS_DHCP),
|
observability_area(LOGS_DHCP),
|
||||||
|
|||||||
@@ -289,10 +289,30 @@ DELETE FROM settings WHERE key = 'terminal_enabled';
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Wartung ist ein eigener Bereich geworden und hat damit einen eigenen
|
||||||
|
# Kill-Switch. Ohne diesen Schritt waere sie nach dem Update bei allen weg:
|
||||||
|
# das neue Bereichsrecht ist bei niemandem gesetzt, und ohne Bereichsrecht
|
||||||
|
# greift kein Recht darunter.
|
||||||
|
#
|
||||||
|
# Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich
|
||||||
|
# Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update
|
||||||
|
# darf Zugang erhalten, aber keinen neuen schaffen.
|
||||||
|
V6 = """
|
||||||
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
|
SELECT DISTINCT group_id, 'servicing.view'
|
||||||
|
FROM group_permissions
|
||||||
|
WHERE permission IN ('maintenance.view', 'maintenance.execute')
|
||||||
|
AND group_id IN (
|
||||||
|
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.view'
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
TESM_MIGRATIONS: tuple[Migration, ...] = (
|
TESM_MIGRATIONS: tuple[Migration, ...] = (
|
||||||
Migration(version=1, name="baseline", sql=V1),
|
Migration(version=1, name="baseline", sql=V1),
|
||||||
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
||||||
Migration(version=3, name="restart-events", sql=V3),
|
Migration(version=3, name="restart-events", sql=V3),
|
||||||
Migration(version=4, name="fileshare-pdf-rechte", sql=V4),
|
Migration(version=4, name="fileshare-pdf-rechte", sql=V4),
|
||||||
Migration(version=5, name="terminal-getrennt", sql=V5),
|
Migration(version=5, name="terminal-getrennt", sql=V5),
|
||||||
|
Migration(version=6, name="wartung-eigener-bereich", sql=V6),
|
||||||
)
|
)
|
||||||
@@ -21,6 +21,7 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import zlib
|
import zlib
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -604,6 +605,83 @@ def resolve_path(mount: Mount, relative: str) -> Path:
|
|||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
#: Wie eine unterschriebene Fassung im Aenderungsprotokoll steht. Die
|
||||||
|
#: Pruefsumme ist der Anker -- sie steht im selben Eintrag wie der Name, den
|
||||||
|
#: der Unterschreibende angegeben hat.
|
||||||
|
SIGNATUR_AKTION = "fileshare.form_signed"
|
||||||
|
|
||||||
|
|
||||||
|
def pruefsumme(pfad: Path) -> str:
|
||||||
|
"""SHA-256 der Datei, blockweise gelesen."""
|
||||||
|
kessel = hashlib.sha256()
|
||||||
|
with pfad.open("rb") as strom:
|
||||||
|
for block in iter(lambda: strom.read(1024 * 1024), b""):
|
||||||
|
kessel.update(block)
|
||||||
|
return kessel.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def signaturstand(conn: sqlite3.Connection, mount: "Mount", relative: str) -> dict[str, Any]:
|
||||||
|
"""Ist diese Datei die, die unterschrieben wurde?
|
||||||
|
|
||||||
|
Drei moegliche Antworten, und die dritte ist die wichtigste:
|
||||||
|
|
||||||
|
* ``unveraendert`` -- es gibt einen Protokolleintrag mit genau dieser
|
||||||
|
Pruefsumme. Die Datei ist Byte fuer Byte die unterschriebene.
|
||||||
|
* ``veraendert`` -- es gibt einen Eintrag zu genau diesem Ablageort, aber
|
||||||
|
mit anderer Pruefsumme. Jemand hat nach der Unterschrift etwas an der
|
||||||
|
Datei geaendert. Das ist der Fall, wegen dem es diese Pruefung gibt.
|
||||||
|
* ``unbekannt`` -- kein Eintrag. Kein Verdacht: die meisten Dateien in
|
||||||
|
einer Freigabe sind nie durch TESM gegangen.
|
||||||
|
|
||||||
|
Gesucht wird nur in der Datenbank. Ausgelagerte Protokolltage (Archiv)
|
||||||
|
bleiben aussen vor -- ein Dateidialog darf nicht minutenlang suchen.
|
||||||
|
"""
|
||||||
|
ziel = resolve_path(mount, relative)
|
||||||
|
if not ziel.is_file():
|
||||||
|
return {"status": "unbekannt"}
|
||||||
|
hash_ = pruefsumme(ziel)
|
||||||
|
|
||||||
|
zeile = Database.one(
|
||||||
|
conn,
|
||||||
|
"SELECT ts, actor, detail FROM audit_log WHERE action=? AND detail LIKE ? "
|
||||||
|
"ORDER BY id DESC LIMIT 1",
|
||||||
|
(SIGNATUR_AKTION, f"%sha256={hash_}%"),
|
||||||
|
)
|
||||||
|
if zeile is not None:
|
||||||
|
return {
|
||||||
|
"status": "unveraendert",
|
||||||
|
"sha256": hash_,
|
||||||
|
"zeit": str(zeile["ts"]),
|
||||||
|
"konto": str(zeile["actor"]),
|
||||||
|
"namen": _namen_aus_detail(str(zeile["detail"])),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nichts mit dieser Pruefsumme -- aber vielleicht etwas zu diesem Ort.
|
||||||
|
zeile = Database.one(
|
||||||
|
conn,
|
||||||
|
"SELECT ts, actor, detail FROM audit_log WHERE action=? AND detail LIKE ? "
|
||||||
|
"ORDER BY id DESC LIMIT 1",
|
||||||
|
(SIGNATUR_AKTION, f"{mount.label}:{relative} sha256=%"),
|
||||||
|
)
|
||||||
|
if zeile is not None:
|
||||||
|
return {
|
||||||
|
"status": "veraendert",
|
||||||
|
"sha256": hash_,
|
||||||
|
"zeit": str(zeile["ts"]),
|
||||||
|
"konto": str(zeile["actor"]),
|
||||||
|
"namen": _namen_aus_detail(str(zeile["detail"])),
|
||||||
|
}
|
||||||
|
return {"status": "unbekannt", "sha256": hash_}
|
||||||
|
|
||||||
|
|
||||||
|
def _namen_aus_detail(detail: str) -> list[str]:
|
||||||
|
"""Die angegebenen Namen aus dem Protokolltext -- ohne sie zu deuten."""
|
||||||
|
marke = "angegeben unterschrieben von: "
|
||||||
|
if marke not in detail:
|
||||||
|
return []
|
||||||
|
return [teil.strip() for teil in detail.split(marke, 1)[1].split(",") if teil.strip()]
|
||||||
|
|
||||||
|
|
||||||
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
||||||
path = resolve_path(mount, relative)
|
path = resolve_path(mount, relative)
|
||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
|
|||||||
@@ -98,6 +98,18 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
minimum=30,
|
minimum=30,
|
||||||
maximum=1800,
|
maximum=1800,
|
||||||
),
|
),
|
||||||
|
# -- Unterschriften ------------------------------------------------------ #
|
||||||
|
SettingSpec(
|
||||||
|
"signature_witness_enabled",
|
||||||
|
True,
|
||||||
|
"bool",
|
||||||
|
"Unterschriften mit dem ablegenden Konto bezeugen",
|
||||||
|
hint="Unter jede Unterschrift kommt eine Zeile, wer das Dokument in TESM abgelegt "
|
||||||
|
"hat. Der Name in der Unterschrift ist eine Angabe des Unterschreibenden -- wie "
|
||||||
|
"auf Papier; das Konto daneben ist belegt. Eine Passwortabfrage gibt es dafuer "
|
||||||
|
"nicht.",
|
||||||
|
section="fileshare",
|
||||||
|
),
|
||||||
# -- Terminal ----------------------------------------------------------- #
|
# -- Terminal ----------------------------------------------------------- #
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"terminal_switches_enabled",
|
"terminal_switches_enabled",
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
<button class="link-icon file-name" type="button"
|
<button class="link-icon file-name" type="button"
|
||||||
data-dialog-open="file-preview"
|
data-dialog-open="file-preview"
|
||||||
data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
|
data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
|
||||||
|
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
|
||||||
data-fill-Kind="{{ entry.preview_kind }}"
|
data-fill-Kind="{{ entry.preview_kind }}"
|
||||||
data-fill-Pfad="{{ entry.relative }}"
|
data-fill-Pfad="{{ entry.relative }}"
|
||||||
data-fill-Name="{{ entry.name }}"
|
data-fill-Name="{{ entry.name }}"
|
||||||
@@ -131,6 +132,7 @@
|
|||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="file-preview"
|
data-dialog-open="file-preview"
|
||||||
data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
|
data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
|
||||||
|
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
|
||||||
data-fill-Kind="{{ entry.preview_kind }}"
|
data-fill-Kind="{{ entry.preview_kind }}"
|
||||||
data-fill-Pfad="{{ entry.relative }}"
|
data-fill-Pfad="{{ entry.relative }}"
|
||||||
data-fill-Name="{{ entry.name }}"
|
data-fill-Name="{{ entry.name }}"
|
||||||
@@ -224,7 +226,10 @@
|
|||||||
wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #}
|
wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #}
|
||||||
data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.view') else '' }}"
|
data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.view') else '' }}"
|
||||||
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
|
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
|
||||||
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}">
|
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"
|
||||||
|
data-viewer-konto="{{ current_user.username }}"
|
||||||
|
data-viewer-zeuge="{{ '1' if signature_witness else '0' }}"
|
||||||
|
data-viewer-zeit="{{ serverzeit }}">
|
||||||
<div class="viewer">
|
<div class="viewer">
|
||||||
<div class="viewer__stage" data-viewer-bildbuehne hidden>
|
<div class="viewer__stage" data-viewer-bildbuehne hidden>
|
||||||
<img class="viewer__img" alt="" data-viewer-bild>
|
<img class="viewer__img" alt="" data-viewer-bild>
|
||||||
@@ -243,7 +248,49 @@
|
|||||||
eine Zeile Formular über jeder Bildvorschau. Und nur, wer Formulare
|
eine Zeile Formular über jeder Bildvorschau. Und nur, wer Formulare
|
||||||
ablegen darf, sieht sie überhaupt. #}
|
ablegen darf, sieht sie überhaupt. #}
|
||||||
{% if current_user.has('fileshare_pdf.create') %}
|
{% if current_user.has('fileshare_pdf.create') %}
|
||||||
|
{# Unterschreiben: Name tippen, mit dem Finger zeichnen, ins Dokument
|
||||||
|
setzen. Der Name daneben ist eine Angabe des Unterschreibenden – wie
|
||||||
|
auf Papier; wer abgelegt hat, steht im Änderungsprotokoll. #}
|
||||||
|
<div class="viewer__sign" data-viewer-unterschriftpanel hidden>
|
||||||
|
<div class="viewer__signfelder">
|
||||||
|
<label class="viewer__feld">
|
||||||
|
<span class="field__label">Vorname</span>
|
||||||
|
<input class="input" data-viewer-vorname autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label class="viewer__feld">
|
||||||
|
<span class="field__label">Nachname</span>
|
||||||
|
<input class="input" data-viewer-nachname autocomplete="off">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<canvas class="viewer__signpad" data-viewer-signpad width="700" height="200"
|
||||||
|
aria-label="Unterschriftsfeld"></canvas>
|
||||||
|
<div class="viewer__signaktionen">
|
||||||
|
<span class="fs-xs faint" data-viewer-signhinweis></span>
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-signleeren>Leeren</button>
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-signabbrechen>Abbrechen</button>
|
||||||
|
<button class="btn btn--sm btn--primary" type="button" data-viewer-signsetzen>
|
||||||
|
Unterschrift setzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="fs-xs faint">
|
||||||
|
Nach „Unterschrift setzen“ auf die Stelle im Dokument tippen. Mit Unterschrift
|
||||||
|
wird immer abgeschlossen gespeichert – die Felder sind danach gesperrt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="viewer__save" data-viewer-speichernzeile hidden>
|
<div class="viewer__save" data-viewer-speichernzeile hidden>
|
||||||
|
{# Zwei eingebundene Freigaben, ein Formular: die Vorlage liegt in der
|
||||||
|
einen, die ausgefüllte Fassung gehört in die andere. #}
|
||||||
|
<label class="viewer__feld">
|
||||||
|
<span class="field__label">Freigabe</span>
|
||||||
|
<select class="select" data-viewer-ziel
|
||||||
|
data-url-vorlage="{{ url_for('fileshare.folders', token='__token__') }}">
|
||||||
|
{% for eintrag in mounts %}
|
||||||
|
<option value="{{ eintrag.token }}" {% if eintrag.token == token %}selected{% endif %}>
|
||||||
|
{{ eintrag.label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="viewer__feld">
|
<label class="viewer__feld">
|
||||||
<span class="field__label">Ordner</span>
|
<span class="field__label">Ordner</span>
|
||||||
<input class="input" list="freigabe-ordner" data-viewer-ordner
|
<input class="input" list="freigabe-ordner" data-viewer-ordner
|
||||||
@@ -268,14 +315,28 @@
|
|||||||
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
|
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="check">
|
{# Haken über den Knöpfen und beides rechts: gelesen wird die Zeile von
|
||||||
<input type="checkbox" data-viewer-ersetzen>
|
links nach rechts (wohin, wie es heißt, wie gespeichert), und die
|
||||||
<span>Vorhandene ersetzen</span>
|
Entscheidung steht am Ende. #}
|
||||||
</label>
|
<div class="viewer__saveaktionen">
|
||||||
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>Speichern</button>
|
<label class="check" data-viewer-ersetzenfeld>
|
||||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>Abbrechen</button>
|
<input type="checkbox" data-viewer-ersetzen>
|
||||||
|
<span>Vorhandene ersetzen</span>
|
||||||
|
</label>
|
||||||
|
<div class="viewer__saveknoepfe">
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{# Das Ergebnis der Pruefung steht über der Leiste – wer eine
|
||||||
|
unterschriebene Fassung öffnet, soll es sehen, bevor er liest. #}
|
||||||
|
<p class="viewer__pruefung" data-viewer-pruefung hidden></p>
|
||||||
<div class="viewer__bar" data-viewer-leiste hidden>
|
<div class="viewer__bar" data-viewer-leiste hidden>
|
||||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
|
||||||
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
|
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
|
||||||
@@ -293,6 +354,9 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('fileshare_pdf.create') %}
|
{% if current_user.has('fileshare_pdf.create') %}
|
||||||
|
<button class="btn btn--sm" type="button" data-viewer-unterschreiben hidden>
|
||||||
|
{{ icon('pencil', size=15) }} Unterschreiben
|
||||||
|
</button>
|
||||||
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
|
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
|
||||||
data-url="{{ url_for('fileshare.save_form', token=token) }}">
|
data-url="{{ url_for('fileshare.save_form', token=token) }}">
|
||||||
{{ icon('save', size=15) }} Ausgefüllt speichern
|
{{ icon('save', size=15) }} Ausgefüllt speichern
|
||||||
|
|||||||
@@ -78,6 +78,27 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('pencil', size=17) }} Unterschriften</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Der Name in der Unterschrift ist eine Angabe des Unterschreibenden – wie auf
|
||||||
|
Papier. Wer das Dokument in TESM abgelegt hat, ist dagegen belegt.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<div class="card__body stack">
|
||||||
|
{{ checkbox('signature_witness_enabled', 'Unterschriften mit dem ablegenden Konto bezeugen',
|
||||||
|
checked=values.signature_witness_enabled,
|
||||||
|
hint_text=specs['signature_witness_enabled'].hint) }}
|
||||||
|
</div>
|
||||||
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
{# Eigene Karte: die Abmeldung stand zwischen Protokollrotation und
|
{# Eigene Karte: die Abmeldung stand zwischen Protokollrotation und
|
||||||
Papierkorb -- drei Dinge ohne Zusammenhang. Wer die Sitzungsdauer sucht,
|
Papierkorb -- drei Dinge ohne Zusammenhang. Wer die Sitzungsdauer sucht,
|
||||||
sucht sie nicht unter "Protokolle". #}
|
sucht sie nicht unter "Protokolle". #}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.5.1"
|
version = "2.7.0"
|
||||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -148,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.5.1",
|
app_version: str = "2.7.0",
|
||||||
built_in_trusted_keys: tuple[str, ...] = (),
|
built_in_trusted_keys: tuple[str, ...] = (),
|
||||||
enable_license: bool = True,
|
enable_license: bool = True,
|
||||||
template_folder: str | None = None,
|
template_folder: str | None = None,
|
||||||
|
|||||||
@@ -847,6 +847,25 @@
|
|||||||
flex: 1 1 12rem;
|
flex: 1 1 12rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
/* Die Entscheidung steht rechts und der Haken darueber: er gehoert zum
|
||||||
|
Speichern, nicht zu den Feldern davor. */
|
||||||
|
.viewer__saveaktionen {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.viewer__saveknoepfe {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
/* Nach "gibt es schon": der Haken ist die Antwort -- also faellt er auf. */
|
||||||
|
.viewer__saveaktionen .check.is-gefragt {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: var(--r-xs);
|
||||||
|
}
|
||||||
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
|
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
|
||||||
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
|
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
|
||||||
.viewer__feld {
|
.viewer__feld {
|
||||||
@@ -1384,3 +1403,105 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Unterschrift --------------------------------------------------------- */
|
||||||
|
/* Die Zeichenflaeche braucht "touch-action: none": sonst scrollt ein iPad die
|
||||||
|
Seite, statt einen Strich zu zeichnen -- und es sieht aus, als reagierte das
|
||||||
|
Feld nicht. */
|
||||||
|
|
||||||
|
.viewer__sign {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signfelder {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signpad {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
border: 1px dashed var(--border-strong, var(--border));
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: #fff;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signaktionen {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdfscroll--setzen {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdfscroll--setzen .page {
|
||||||
|
outline: 2px dashed var(--accent, #c8791f);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die gesetzte Unterschrift liegt auf der Seite -- in Prozent, damit sie beim
|
||||||
|
Zoomen an ihrer Stelle bleibt. */
|
||||||
|
.viewer__unterschrift {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Ergebnis der Signaturpruefung ---------------------------------------- */
|
||||||
|
/* Der Schleier liegt ueber der Seite und nimmt keine Klicks an: ein
|
||||||
|
unterschriebenes Dokument ist flach, aber bei einem Irrtum soll die
|
||||||
|
Kennzeichnung nicht auch noch die Bedienung blockieren. */
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert {
|
||||||
|
box-shadow: inset 0 0 0 3px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert::after,
|
||||||
|
.viewer__pdf--veraendert::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert::after {
|
||||||
|
background: rgba(47, 158, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--veraendert {
|
||||||
|
box-shadow: inset 0 0 0 3px var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--veraendert::after {
|
||||||
|
background: rgba(201, 42, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung--gut {
|
||||||
|
color: var(--success);
|
||||||
|
background: rgba(47, 158, 68, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung--schlecht {
|
||||||
|
color: var(--danger);
|
||||||
|
background: rgba(201, 42, 42, 0.12);
|
||||||
|
}
|
||||||
@@ -228,7 +228,12 @@ async function pdflibLaden(adresse) {
|
|||||||
* ``abschliessen`` rechnet danach alle Felder flach in die Seite: heraus kommt
|
* ``abschliessen`` rechnet danach alle Felder flach in die Seite: heraus kommt
|
||||||
* ein PDF ohne Formular -- nichts mehr zu verstellen.
|
* ein PDF ohne Formular -- nichts mehr zu verstellen.
|
||||||
*/
|
*/
|
||||||
async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } = {}) {
|
async function nachbearbeiten(
|
||||||
|
bytes,
|
||||||
|
bilder,
|
||||||
|
adresse,
|
||||||
|
{ abschliessen = false, unterschriften = [], zeuge = "" } = {}
|
||||||
|
) {
|
||||||
const PDFLib = await pdflibLaden(adresse);
|
const PDFLib = await pdflibLaden(adresse);
|
||||||
const dokument = await PDFLib.PDFDocument.load(bytes);
|
const dokument = await PDFLib.PDFDocument.load(bytes);
|
||||||
const formular = dokument.getForm();
|
const formular = dokument.getForm();
|
||||||
@@ -263,6 +268,40 @@ async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (abschliessen) {
|
||||||
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
|
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
|
||||||
// gewollt: so kann niemand mehr etwas verstellen.
|
// gewollt: so kann niemand mehr etwas verstellen.
|
||||||
@@ -271,6 +310,101 @@ async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } =
|
|||||||
return dokument.save();
|
return dokument.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die gezeichnete Unterschrift als PNG -- auf das Gezeichnete beschnitten.
|
||||||
|
*
|
||||||
|
* Ungeschnitten waere jede Unterschrift so gross wie die Zeichenflaeche, mit
|
||||||
|
* viel Luft ringsum. Im Dokument sieht das aus wie ein verrutschter Stempel.
|
||||||
|
*/
|
||||||
|
function unterschriftAlsPng(leinwand, kasten) {
|
||||||
|
const rand = 6;
|
||||||
|
const links = Math.max(0, Math.floor(kasten.x1 - rand));
|
||||||
|
const oben = Math.max(0, Math.floor(kasten.y1 - rand));
|
||||||
|
const breite = Math.min(leinwand.width - links, Math.ceil(kasten.x2 - kasten.x1 + rand * 2));
|
||||||
|
const hoehe = Math.min(leinwand.height - oben, Math.ceil(kasten.y2 - kasten.y1 + rand * 2));
|
||||||
|
const ziel = document.createElement("canvas");
|
||||||
|
ziel.width = Math.max(1, breite);
|
||||||
|
ziel.height = Math.max(1, hoehe);
|
||||||
|
ziel
|
||||||
|
.getContext("2d")
|
||||||
|
.drawImage(leinwand, links, oben, ziel.width, ziel.height, 0, 0, ziel.width, ziel.height);
|
||||||
|
// Ueber eine data:-Adresse und nicht ueber blob: -- die Sicherheitsregeln
|
||||||
|
// der Anwendung erlauben blob: nicht, und ein stilles "img-src" waere ein
|
||||||
|
// Loch, das man fuer eine Unterschrift nicht aufmacht.
|
||||||
|
const adresse = ziel.toDataURL("image/png");
|
||||||
|
const roh = atob(adresse.slice(adresse.indexOf(",") + 1));
|
||||||
|
const bytes = new Uint8Array(roh.length);
|
||||||
|
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i);
|
||||||
|
return { bytes, vorschau: adresse, breite: ziel.width, hoehe: ziel.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichenflaeche fuer die Unterschrift -- Finger, Stift oder Maus.
|
||||||
|
*
|
||||||
|
* ``touch-action: none`` steht im Stylesheet: ohne das scrollt ein iPad die
|
||||||
|
* Seite, statt einen Strich zu zeichnen.
|
||||||
|
*/
|
||||||
|
function unterschriftPad(leinwand) {
|
||||||
|
const stift = leinwand.getContext("2d");
|
||||||
|
let kasten = null;
|
||||||
|
let zeichnet = false;
|
||||||
|
|
||||||
|
const punkt = (event) => {
|
||||||
|
const rechteck = leinwand.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: ((event.clientX - rechteck.left) / rechteck.width) * leinwand.width,
|
||||||
|
y: ((event.clientY - rechteck.top) / rechteck.height) * leinwand.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const merken = ({ x, y }) => {
|
||||||
|
kasten = kasten
|
||||||
|
? {
|
||||||
|
x1: Math.min(kasten.x1, x),
|
||||||
|
y1: Math.min(kasten.y1, y),
|
||||||
|
x2: Math.max(kasten.x2, x),
|
||||||
|
y2: Math.max(kasten.y2, y),
|
||||||
|
}
|
||||||
|
: { x1: x, y1: y, x2: x, y2: y };
|
||||||
|
};
|
||||||
|
|
||||||
|
stift.lineWidth = 2.5;
|
||||||
|
stift.lineCap = "round";
|
||||||
|
stift.lineJoin = "round";
|
||||||
|
stift.strokeStyle = "#111";
|
||||||
|
|
||||||
|
leinwand.addEventListener("pointerdown", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
leinwand.setPointerCapture(event.pointerId);
|
||||||
|
zeichnet = true;
|
||||||
|
const p = punkt(event);
|
||||||
|
merken(p);
|
||||||
|
stift.beginPath();
|
||||||
|
stift.moveTo(p.x, p.y);
|
||||||
|
});
|
||||||
|
leinwand.addEventListener("pointermove", (event) => {
|
||||||
|
if (!zeichnet) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const p = punkt(event);
|
||||||
|
merken(p);
|
||||||
|
stift.lineTo(p.x, p.y);
|
||||||
|
stift.stroke();
|
||||||
|
});
|
||||||
|
const beenden = () => {
|
||||||
|
zeichnet = false;
|
||||||
|
};
|
||||||
|
leinwand.addEventListener("pointerup", beenden);
|
||||||
|
leinwand.addEventListener("pointercancel", beenden);
|
||||||
|
leinwand.addEventListener("pointerleave", beenden);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leeren: () => {
|
||||||
|
stift.clearRect(0, 0, leinwand.width, leinwand.height);
|
||||||
|
kasten = null;
|
||||||
|
},
|
||||||
|
leer: () => kasten === null,
|
||||||
|
alsPng: () => (kasten ? unterschriftAlsPng(leinwand, kasten) : null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* -- PDF ------------------------------------------------------------------- */
|
/* -- PDF ------------------------------------------------------------------- */
|
||||||
|
|
||||||
let pdfjsGeladen = null;
|
let pdfjsGeladen = null;
|
||||||
@@ -448,6 +582,65 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
});
|
});
|
||||||
await bildknoepfeAufsetzen();
|
await bildknoepfeAufsetzen();
|
||||||
|
|
||||||
|
/* Unterschriften: gezeichnet wird ausserhalb, gesetzt wird hier. Die Stelle
|
||||||
|
bestimmt ein Tipp ins Dokument -- ein festes Feld gibt es in den wenigsten
|
||||||
|
Vorlagen, und "unten rechts" ist bei einem Formular mit drei
|
||||||
|
Unterschriftszeilen die falsche Annahme. */
|
||||||
|
const unterschriften = [];
|
||||||
|
let wartendeUnterschrift = null;
|
||||||
|
|
||||||
|
const platzieren = async (event) => {
|
||||||
|
if (!wartendeUnterschrift) return;
|
||||||
|
const seitenEl = event.target.closest(".page");
|
||||||
|
if (!seitenEl) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const nummer = Number(seitenEl.dataset.pageNumber || 0);
|
||||||
|
if (!nummer) return;
|
||||||
|
const rechteck = seitenEl.getBoundingClientRect();
|
||||||
|
const anteilX = (event.clientX - rechteck.left) / rechteck.width;
|
||||||
|
const anteilY = (event.clientY - rechteck.top) / rechteck.height;
|
||||||
|
const seite = await dokument.getPage(nummer);
|
||||||
|
const blick = seite.getViewport({ scale: 1 });
|
||||||
|
// Ein menschliches Mass: rund fuenf Zentimeter breit, hoechstens knapp
|
||||||
|
// zwei hoch. Ohne die Hoehengrenze wird aus einem hohen Gekritzel ein
|
||||||
|
// Stempel, der die halbe Seite einnimmt -- das Seitenverhaeltnis kommt ja
|
||||||
|
// aus dem Zuschnitt, nicht aus der Vorlage.
|
||||||
|
const MAX_BREITE = 150;
|
||||||
|
const MAX_HOEHE = 50;
|
||||||
|
let breite = Math.min(MAX_BREITE, blick.width * 0.3);
|
||||||
|
let hoehe = (wartendeUnterschrift.hoehe / wartendeUnterschrift.breite) * breite;
|
||||||
|
if (hoehe > MAX_HOEHE) {
|
||||||
|
breite *= MAX_HOEHE / hoehe;
|
||||||
|
hoehe = MAX_HOEHE;
|
||||||
|
}
|
||||||
|
const eintrag = {
|
||||||
|
...wartendeUnterschrift,
|
||||||
|
seite: nummer,
|
||||||
|
breite,
|
||||||
|
// Der Tipp meint die Mitte der Unterschrift, nicht ihre Ecke.
|
||||||
|
x: Math.max(4, anteilX * blick.width - breite / 2),
|
||||||
|
y: Math.max(24, (1 - anteilY) * blick.height - hoehe / 2),
|
||||||
|
};
|
||||||
|
unterschriften.push(eintrag);
|
||||||
|
behaelter.classList.remove("viewer__pdfscroll--setzen");
|
||||||
|
wartendeUnterschrift = null;
|
||||||
|
|
||||||
|
// Sichtbar machen, wo sie gelandet ist -- sonst tippt man ins Blaue.
|
||||||
|
const marke = document.createElement("img");
|
||||||
|
marke.className = "viewer__unterschrift";
|
||||||
|
marke.src = eintrag.vorschau;
|
||||||
|
marke.alt = "";
|
||||||
|
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
|
||||||
|
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
|
||||||
|
marke.style.width = `${(breite / blick.width) * 100}%`;
|
||||||
|
seitenEl.append(marke);
|
||||||
|
behaelter.dispatchEvent(new CustomEvent("unterschrieben", { detail: eintrag }));
|
||||||
|
};
|
||||||
|
|
||||||
|
behaelter.addEventListener("click", (event) => {
|
||||||
|
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hinein: () => {
|
hinein: () => {
|
||||||
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
||||||
@@ -463,12 +656,22 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
|
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
|
||||||
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
|
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
|
||||||
*/
|
*/
|
||||||
speichern: async (pdflibAdresse, { abschliessen = false } = {}) => {
|
speichern: async (pdflibAdresse, { abschliessen = false, zeuge = "" } = {}) => {
|
||||||
const bytes = await dokument.saveDocument();
|
const bytes = await dokument.saveDocument();
|
||||||
// Ohne Bild und ohne Abschluss braucht es pdf-lib gar nicht.
|
// Ohne Bild, ohne Unterschrift und ohne Abschluss braucht es pdf-lib nicht.
|
||||||
if (!bilder.size && !abschliessen) return bytes;
|
if (!bilder.size && !abschliessen && !unterschriften.length) return bytes;
|
||||||
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, { abschliessen });
|
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;
|
||||||
@@ -505,10 +708,38 @@ export function initFilePreview(wurzel = document) {
|
|||||||
const ordnerFeld = stueck("ordner");
|
const ordnerFeld = stueck("ordner");
|
||||||
const nameFeld = stueck("name");
|
const nameFeld = stueck("name");
|
||||||
const ersetzenFeld = stueck("ersetzen");
|
const ersetzenFeld = stueck("ersetzen");
|
||||||
|
const ersetzenKasten = stueck("ersetzenfeld");
|
||||||
|
const pruefzeile = stueck("pruefung");
|
||||||
const jetzt = stueck("jetzt");
|
const jetzt = stueck("jetzt");
|
||||||
const abbrechen = stueck("abbrechen");
|
const abbrechen = stueck("abbrechen");
|
||||||
const modusFeld = stueck("modus");
|
const 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.
|
||||||
@@ -535,9 +766,12 @@ export function initFilePreview(wurzel = document) {
|
|||||||
rahmen.hidden = art !== "text";
|
rahmen.hidden = art !== "text";
|
||||||
leiste.hidden = art === "text";
|
leiste.hidden = art === "text";
|
||||||
if (speichern) speichern.hidden = art !== "pdf";
|
if (speichern) speichern.hidden = art !== "pdf";
|
||||||
|
if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf";
|
||||||
|
if (unterschriftPanel) unterschriftPanel.hidden = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const aufraeumen = () => {
|
const aufraeumen = () => {
|
||||||
|
pruefungZuruecksetzen();
|
||||||
bild.removeAttribute("src");
|
bild.removeAttribute("src");
|
||||||
bilder.zuruecksetzen();
|
bilder.zuruecksetzen();
|
||||||
rahmen.src = "about:blank";
|
rahmen.src = "about:blank";
|
||||||
@@ -555,6 +789,52 @@ export function initFilePreview(wurzel = document) {
|
|||||||
aktuell = null;
|
aktuell = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Unterschrieben und unveraendert? Die Antwort kommt aus dem
|
||||||
|
Aenderungsprotokoll: dort steht die Pruefsumme der abgelegten Fassung.
|
||||||
|
Stimmt sie, bekommt die Seite einen gruenen Schleier -- man soll es sehen,
|
||||||
|
bevor man liest, nicht danach. */
|
||||||
|
|
||||||
|
const pruefungZuruecksetzen = () => {
|
||||||
|
pdfBehaelter.classList.remove("viewer__pdf--unveraendert", "viewer__pdf--veraendert");
|
||||||
|
if (pruefzeile) {
|
||||||
|
pruefzeile.hidden = true;
|
||||||
|
pruefzeile.textContent = "";
|
||||||
|
pruefzeile.className = "viewer__pruefung";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pruefen = async (adresse) => {
|
||||||
|
pruefungZuruecksetzen();
|
||||||
|
if (!adresse) return;
|
||||||
|
try {
|
||||||
|
const stand = await window.tesm.api(adresse);
|
||||||
|
if (stand.status === "unveraendert") {
|
||||||
|
pdfBehaelter.classList.add("viewer__pdf--unveraendert");
|
||||||
|
if (pruefzeile) {
|
||||||
|
pruefzeile.hidden = false;
|
||||||
|
pruefzeile.className = "viewer__pruefung viewer__pruefung--gut";
|
||||||
|
const namen = (stand.namen || []).join(", ");
|
||||||
|
pruefzeile.textContent =
|
||||||
|
`Unveraendert seit der Unterschrift${namen ? ` von ${namen}` : ""}` +
|
||||||
|
` -- Pruefsumme stimmt mit dem Aenderungsprotokoll ueberein.`;
|
||||||
|
}
|
||||||
|
} else if (stand.status === "veraendert") {
|
||||||
|
pdfBehaelter.classList.add("viewer__pdf--veraendert");
|
||||||
|
if (pruefzeile) {
|
||||||
|
pruefzeile.hidden = false;
|
||||||
|
pruefzeile.className = "viewer__pruefung viewer__pruefung--schlecht";
|
||||||
|
pruefzeile.textContent =
|
||||||
|
"Diese Datei wurde nach der Unterschrift veraendert -- die Pruefsumme " +
|
||||||
|
"weicht vom Aenderungsprotokoll ab.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
// Keine Aussage ist besser als eine falsche: ohne Antwort bleibt die
|
||||||
|
// Seite schlicht ungekennzeichnet.
|
||||||
|
console.error("Signaturpruefung", fehler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const oeffnen = async (ausloeser) => {
|
const oeffnen = async (ausloeser) => {
|
||||||
aufraeumen();
|
aufraeumen();
|
||||||
const art = ausloeser.dataset.fillKind || "text";
|
const art = ausloeser.dataset.fillKind || "text";
|
||||||
@@ -570,6 +850,7 @@ export function initFilePreview(wurzel = document) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
|
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
|
||||||
|
pruefen(ausloeser.dataset.fillPruefung || "");
|
||||||
try {
|
try {
|
||||||
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
|
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
|
||||||
formulare: rumpf.dataset.viewerFormulare === "1",
|
formulare: rumpf.dataset.viewerFormulare === "1",
|
||||||
@@ -624,14 +905,68 @@ export function initFilePreview(wurzel = document) {
|
|||||||
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
|
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
|
||||||
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
|
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
|
||||||
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
|
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
|
||||||
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt";
|
// Einmal "-ausgefuellt" reicht: wer eine ausgefuellte Fassung noch einmal
|
||||||
|
// oeffnet und speichert, bekam sonst "...-ausgefuellt-ausgefuellt".
|
||||||
|
const rumpfname = datei.replace(/\.pdf$/i, "");
|
||||||
|
nameFeld.value = /-ausgefuellt$/i.test(rumpfname) ? rumpfname : `${rumpfname}-ausgefuellt`;
|
||||||
ersetzenFeld.checked = false;
|
ersetzenFeld.checked = false;
|
||||||
|
ersetzenKasten?.classList.remove("is-gefragt");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Unterschreiben: Name tippen, zeichnen, ins Dokument setzen. */
|
||||||
|
|
||||||
|
const pad = padLeinwand ? unterschriftPad(padLeinwand) : null;
|
||||||
|
|
||||||
|
const panelZeigen = (offen) => {
|
||||||
|
if (!unterschriftPanel) return;
|
||||||
|
unterschriftPanel.hidden = !offen;
|
||||||
|
if (offen) {
|
||||||
|
pad?.leeren();
|
||||||
|
if (padHinweis) padHinweis.textContent = "";
|
||||||
|
vornameFeld?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
unterschreibenKnopf?.addEventListener("click", () => {
|
||||||
|
if (!pdf) return;
|
||||||
|
panelZeigen(unterschriftPanel?.hidden !== false);
|
||||||
|
});
|
||||||
|
|
||||||
|
padLeeren?.addEventListener("click", () => pad?.leeren());
|
||||||
|
padAbbrechen?.addEventListener("click", () => panelZeigen(false));
|
||||||
|
|
||||||
|
padSetzen?.addEventListener("click", () => {
|
||||||
|
if (!pdf || !pad) return;
|
||||||
|
const name = [vornameFeld?.value, nachnameFeld?.value]
|
||||||
|
.map((wert) => (wert || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
// Beides ist noetig, und beides wird hier gesagt statt stumm abgelehnt.
|
||||||
|
if (!name) {
|
||||||
|
if (padHinweis) padHinweis.textContent = "Bitte Vor- und Nachnamen eintragen.";
|
||||||
|
vornameFeld?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const zeichnung = pad.alsPng();
|
||||||
|
if (!zeichnung) {
|
||||||
|
if (padHinweis) padHinweis.textContent = "Bitte im Feld unterschreiben.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pdf.unterschriftAnnehmen({ ...zeichnung, name, zeit: jetztText() });
|
||||||
|
panelZeigen(false);
|
||||||
|
window.tesm.showToast("Jetzt auf die Stelle im Dokument tippen.", "info");
|
||||||
|
});
|
||||||
|
|
||||||
speichern?.addEventListener("click", () => {
|
speichern?.addEventListener("click", () => {
|
||||||
if (!pdf || !aktuell) return;
|
if (!pdf || !aktuell) return;
|
||||||
vorschlagen();
|
vorschlagen();
|
||||||
zeile.hidden = false;
|
zeile.hidden = false;
|
||||||
|
if (modusFeld && pdf.unterschriften().length) {
|
||||||
|
// Mit Unterschrift gibt es nur einen Weg -- und das Feld soll nicht
|
||||||
|
// etwas anderes behaupten.
|
||||||
|
modusFeld.value = "fertig";
|
||||||
|
modusFeld.disabled = true;
|
||||||
|
}
|
||||||
nameFeld.focus();
|
nameFeld.focus();
|
||||||
nameFeld.select();
|
nameFeld.select();
|
||||||
});
|
});
|
||||||
@@ -640,6 +975,31 @@ export function initFilePreview(wurzel = document) {
|
|||||||
zeile.hidden = true;
|
zeile.hidden = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Zielfreigabe: die Ordnerliste gehoert zur gewaehlten Freigabe, nicht zur
|
||||||
|
geoeffneten. Geholt wird sie erst bei Bedarf -- alle Listen im
|
||||||
|
Seitenaufbau mitzuschicken waere Ballast fuer einen seltenen Fall. */
|
||||||
|
const ordnerListe = ordnerFeld ? document.getElementById(ordnerFeld.getAttribute("list")) : null;
|
||||||
|
|
||||||
|
zielFeld?.addEventListener("change", async () => {
|
||||||
|
if (!ordnerListe) return;
|
||||||
|
const vorlage = zielFeld.dataset.urlVorlage || "";
|
||||||
|
if (!vorlage) return;
|
||||||
|
ordnerFeld.value = "";
|
||||||
|
try {
|
||||||
|
const antwort = await window.tesm.api(vorlage.replace("__token__", zielFeld.value));
|
||||||
|
ordnerListe.replaceChildren(
|
||||||
|
...(antwort.folders || []).map((eintrag) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = eintrag;
|
||||||
|
return option;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (fehler) {
|
||||||
|
// Kein Beinbruch: das Feld nimmt auch getippte Pfade.
|
||||||
|
console.error("Ordnerliste", fehler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
jetzt?.addEventListener("click", async () => {
|
jetzt?.addEventListener("click", async () => {
|
||||||
if (!pdf || !aktuell) return;
|
if (!pdf || !aktuell) return;
|
||||||
const ziel = speichern.dataset.url;
|
const ziel = speichern.dataset.url;
|
||||||
@@ -647,13 +1007,20 @@ export function initFilePreview(wurzel = document) {
|
|||||||
const vorher = jetzt.textContent;
|
const vorher = jetzt.textContent;
|
||||||
jetzt.textContent = "Speichern ...";
|
jetzt.textContent = "Speichern ...";
|
||||||
try {
|
try {
|
||||||
|
const gesetzt = pdf.unterschriften();
|
||||||
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
|
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
|
||||||
abschliessen: modusFeld?.value === "fertig",
|
// 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("path", 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",
|
||||||
@@ -669,6 +1036,12 @@ export function initFilePreview(wurzel = document) {
|
|||||||
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
|
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
|
||||||
console.error("Formular speichern", fehler);
|
console.error("Formular speichern", fehler);
|
||||||
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
|
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
|
||||||
|
// Genau dieser Fall hat eine Antwort auf der Zeile -- dann zeigen wir
|
||||||
|
// auch darauf, statt es bei einer Meldung zu belassen.
|
||||||
|
if (fehler.status === 409) {
|
||||||
|
ersetzenKasten?.classList.add("is-gefragt");
|
||||||
|
ersetzenFeld?.focus();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
jetzt.disabled = false;
|
jetzt.disabled = false;
|
||||||
jetzt.textContent = vorher;
|
jetzt.textContent = vorher;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.5.1"
|
version = "2.7.0"
|
||||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["cryptography>=42"]
|
dependencies = ["cryptography>=42"]
|
||||||
|
|||||||
Reference in new issue
Block a user