Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de9c0d4fbd | ||
|
|
724052ae28 | ||
|
|
e16236fa65 | ||
|
|
c52b3626b2 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-license"
|
||||
version = "2.5.0"
|
||||
version = "2.6.1"
|
||||
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -54,6 +54,8 @@ def system():
|
||||
"trash_retention_days",
|
||||
"log_rotate_interval",
|
||||
"log_rotate_keep",
|
||||
"session_idle_minutes",
|
||||
"session_absolute_hours",
|
||||
],
|
||||
),
|
||||
specs={spec.key: spec for spec in extension.settings.specs()},
|
||||
@@ -98,6 +100,8 @@ def system_save():
|
||||
"trash_retention_days",
|
||||
"log_rotate_interval",
|
||||
"log_rotate_keep",
|
||||
"session_idle_minutes",
|
||||
"session_absolute_hours",
|
||||
):
|
||||
spec = extension.settings.spec(key)
|
||||
if spec.kind == "bool":
|
||||
|
||||
@@ -28,7 +28,7 @@ def _is_master(_user: Any) -> bool:
|
||||
|
||||
NAVIGATION = NavTree(
|
||||
[
|
||||
NavItem(key="index", label="Uebersicht", icon="grid", endpoint="dashboard.index"),
|
||||
NavItem(key="index", label="Übersicht", icon="grid", endpoint="dashboard.index"),
|
||||
NavItem(
|
||||
key="customers",
|
||||
label="Kunden",
|
||||
|
||||
@@ -60,9 +60,6 @@
|
||||
selected=values.log_rotate_interval) }}
|
||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||
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)',
|
||||
value=values.trash_retention_days, type='number') }}
|
||||
</div>
|
||||
@@ -73,6 +70,34 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.5.0"
|
||||
version = "2.6.1"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -13,10 +13,12 @@ import secrets
|
||||
from datetime import datetime, timezone
|
||||
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 . import audit, logging_setup, ui
|
||||
from . import audit, legacy_urls, logging_setup, ui
|
||||
from .config import CoreConfig
|
||||
from .core_schema import CORE_MIGRATIONS
|
||||
from .db import Database
|
||||
@@ -146,7 +148,7 @@ def create_base_app(
|
||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||
#: Anwendungen.
|
||||
auth_tagline: str = "",
|
||||
app_version: str = "2.5.0",
|
||||
app_version: str = "2.6.1",
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
@@ -356,6 +358,16 @@ def create_base_app(
|
||||
# -- Fehlerseiten ------------------------------------------------------ #
|
||||
@app.errorhandler(HTTPException)
|
||||
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:
|
||||
# "Request Entity Too Large" allein hilft niemandem weiter: die
|
||||
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
|
||||
|
||||
@@ -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(
|
||||
key="backup",
|
||||
label="Sicherung und Uebertragung",
|
||||
label="Sicherung und Übertragung",
|
||||
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
||||
hint="Verschluesselter Export und Import",
|
||||
action_hints={
|
||||
@@ -121,7 +121,7 @@ LOGS_HISTORY = Resource(
|
||||
|
||||
LOGS_AUDIT = Resource(
|
||||
key="logs_audit",
|
||||
label="Aenderungsprotokoll",
|
||||
label="Änderungsprotokoll",
|
||||
actions=(A.VIEW, A.EXPORT),
|
||||
hint="Wer hat wann was geaendert",
|
||||
)
|
||||
|
||||
@@ -847,6 +847,25 @@
|
||||
flex: 1 1 12rem;
|
||||
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
|
||||
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
|
||||
.viewer__feld {
|
||||
@@ -1384,3 +1403,58 @@
|
||||
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;
|
||||
}
|
||||
@@ -228,7 +228,12 @@ async function pdflibLaden(adresse) {
|
||||
* ``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 } = {}) {
|
||||
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();
|
||||
@@ -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) {
|
||||
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
|
||||
// gewollt: so kann niemand mehr etwas verstellen.
|
||||
@@ -271,6 +310,101 @@ async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } =
|
||||
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 ------------------------------------------------------------------- */
|
||||
|
||||
let pdfjsGeladen = null;
|
||||
@@ -448,6 +582,56 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
||||
});
|
||||
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 {
|
||||
hinein: () => {
|
||||
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
||||
@@ -463,12 +647,22 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
||||
* 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 } = {}) => {
|
||||
speichern: async (pdflibAdresse, { abschliessen = false, zeuge = "" } = {}) => {
|
||||
const bytes = await dokument.saveDocument();
|
||||
// Ohne Bild und ohne Abschluss braucht es pdf-lib gar nicht.
|
||||
if (!bilder.size && !abschliessen) return bytes;
|
||||
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, { abschliessen });
|
||||
// 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? */
|
||||
veraendert: () => {
|
||||
const s = dokument.annotationStorage;
|
||||
@@ -505,10 +699,37 @@ export function initFilePreview(wurzel = document) {
|
||||
const ordnerFeld = stueck("ordner");
|
||||
const nameFeld = stueck("name");
|
||||
const ersetzenFeld = stueck("ersetzen");
|
||||
const ersetzenKasten = stueck("ersetzenfeld");
|
||||
const jetzt = stueck("jetzt");
|
||||
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 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),
|
||||
// soll nicht als leere Flaeche enden.
|
||||
@@ -535,6 +756,8 @@ export function initFilePreview(wurzel = document) {
|
||||
rahmen.hidden = art !== "text";
|
||||
leiste.hidden = art === "text";
|
||||
if (speichern) speichern.hidden = art !== "pdf";
|
||||
if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf";
|
||||
if (unterschriftPanel) unterschriftPanel.hidden = true;
|
||||
};
|
||||
|
||||
const aufraeumen = () => {
|
||||
@@ -624,14 +847,68 @@ export function initFilePreview(wurzel = document) {
|
||||
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
|
||||
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
|
||||
// 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;
|
||||
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", () => {
|
||||
if (!pdf || !aktuell) return;
|
||||
vorschlagen();
|
||||
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.select();
|
||||
});
|
||||
@@ -640,6 +917,31 @@ export function initFilePreview(wurzel = document) {
|
||||
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 () => {
|
||||
if (!pdf || !aktuell) return;
|
||||
const ziel = speichern.dataset.url;
|
||||
@@ -647,13 +949,20 @@ export function initFilePreview(wurzel = document) {
|
||||
const vorher = jetzt.textContent;
|
||||
jetzt.textContent = "Speichern ...";
|
||||
try {
|
||||
const gesetzt = pdf.unterschriften();
|
||||
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();
|
||||
daten.append("path", aktuell.pfad);
|
||||
daten.append("ordner", ordnerFeld.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");
|
||||
daten.append(
|
||||
"datei",
|
||||
@@ -669,6 +978,12 @@ export function initFilePreview(wurzel = document) {
|
||||
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
|
||||
console.error("Formular speichern", fehler);
|
||||
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 {
|
||||
jetzt.disabled = false;
|
||||
jetzt.textContent = vorher;
|
||||
|
||||
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing-server"
|
||||
version = "2.5.0"
|
||||
version = "2.6.1"
|
||||
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["tesm-licensing==2.5.0", "cryptography>=41"]
|
||||
dependencies = ["tesm-licensing==2.6.1", "cryptography>=41"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing"
|
||||
version = "2.5.0"
|
||||
version = "2.6.1"
|
||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["cryptography>=42"]
|
||||
|
||||
Reference in new issue
Block a user