Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a55b20f8 | ||
|
|
ed17bce40a | ||
|
|
5282cd8f2d |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.6.0"
|
version = "2.7.1"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -215,6 +215,22 @@ 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")
|
@bp.get("/<token>/folders")
|
||||||
@permission_required("fileshare.view")
|
@permission_required("fileshare.view")
|
||||||
def folders(token: str):
|
def folders(token: str):
|
||||||
@@ -304,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.
|
||||||
@@ -332,6 +348,19 @@ def save_form(token: str):
|
|||||||
beschreibung,
|
beschreibung,
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
|
if unterschriften:
|
||||||
|
# Zusaetzlich ins Signaturverzeichnis: das Protokoll wird irgendwann
|
||||||
|
# archiviert, die Frage "ist diese Datei noch die unterschriebene?"
|
||||||
|
# stellt sich aber noch Jahre spaeter.
|
||||||
|
with transaction() as conn:
|
||||||
|
service.signatur_merken(
|
||||||
|
conn,
|
||||||
|
sha256=pruefsumme,
|
||||||
|
label=ziel_mount.label,
|
||||||
|
pfad=wohin,
|
||||||
|
actor=current_user.username,
|
||||||
|
namen=unterschriften,
|
||||||
|
)
|
||||||
return {"message": f"Gespeichert als {wohin}.", "path": wohin, "sha256": pruefsumme}
|
return {"message": f"Gespeichert als {wohin}.", "path": wohin, "sha256": pruefsumme}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -308,6 +308,69 @@ INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Die Pruefung "unveraendert seit der Unterschrift" hing am Aenderungsprotokoll
|
||||||
|
# -- und das wird ab 20.000 Zeilen tageweise in Dateien ausgelagert. Mit dem
|
||||||
|
# ausgelagerten Tag waere die Pruefsumme aus der Datenbank verschwunden und
|
||||||
|
# jede aeltere unterschriebene Fassung waere still zu "unbekannt" geworden.
|
||||||
|
# Schlimmer noch: auch eine **veraenderte** Fassung, denn ohne Vergleichswert
|
||||||
|
# gibt es keinen Widerspruch.
|
||||||
|
#
|
||||||
|
# Deshalb eine eigene, winzige Tabelle: eine Zeile je Unterschrift, nie
|
||||||
|
# archiviert. Sie ist ein Verzeichnis, keine Wahrheit -- die Wahrheit bleibt
|
||||||
|
# das Aenderungsprotokoll mit seiner Hash-Kette, auch im Archiv.
|
||||||
|
V7 = """
|
||||||
|
CREATE TABLE IF NOT EXISTS document_signatures (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
share_label TEXT NOT NULL DEFAULT '',
|
||||||
|
path TEXT NOT NULL DEFAULT '',
|
||||||
|
signed_at TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL DEFAULT '',
|
||||||
|
names TEXT NOT NULL DEFAULT '',
|
||||||
|
audit_id INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_document_signatures_sha
|
||||||
|
ON document_signatures(sha256);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_document_signatures_ort
|
||||||
|
ON document_signatures(share_label, path);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def uebernehme_signaturen(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Traegt die bereits protokollierten Unterschriften ins Verzeichnis ein.
|
||||||
|
|
||||||
|
Ohne diesen Schritt waeren alle vor dem Update unterschriebenen Fassungen
|
||||||
|
ab sofort "unbekannt" -- richtig waere das nicht, die Eintraege stehen ja
|
||||||
|
noch da.
|
||||||
|
"""
|
||||||
|
from .services import fileshare as service
|
||||||
|
|
||||||
|
zeilen = conn.execute(
|
||||||
|
"SELECT id, ts, actor, detail FROM audit_log WHERE action='fileshare.form_signed' "
|
||||||
|
"ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
for zeile in zeilen:
|
||||||
|
detail = str(zeile["detail"] or "")
|
||||||
|
gelesen = service.signaturdetail_lesen(detail)
|
||||||
|
if not gelesen["sha256"]:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, actor, "
|
||||||
|
"names, audit_id) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
gelesen["sha256"],
|
||||||
|
gelesen["label"],
|
||||||
|
gelesen["pfad"],
|
||||||
|
str(zeile["ts"]),
|
||||||
|
str(zeile["actor"] or ""),
|
||||||
|
", ".join(gelesen["namen"]),
|
||||||
|
int(zeile["id"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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),
|
||||||
@@ -315,4 +378,5 @@ TESM_MIGRATIONS: tuple[Migration, ...] = (
|
|||||||
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),
|
Migration(version=6, name="wartung-eigener-bereich", sql=V6),
|
||||||
|
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
|
||||||
)
|
)
|
||||||
@@ -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,135 @@ 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 signaturdetail_lesen(detail: str) -> dict[str, Any]:
|
||||||
|
"""Zerlegt die Protokollzeile einer Unterschrift.
|
||||||
|
|
||||||
|
Aufbau: ``<Freigabe>:<Pfad> sha256=<Summe> -- angegeben unterschrieben
|
||||||
|
von: <Namen>``. Gelesen wird sie an zwei Stellen -- beim Uebernehmen
|
||||||
|
alter Eintraege und wenn das Verzeichnis nichts weiss.
|
||||||
|
"""
|
||||||
|
ort, _, rest = detail.partition(" sha256=")
|
||||||
|
label, _, pfad = ort.partition(":")
|
||||||
|
summe = rest.split(" ", 1)[0].strip() if rest else ""
|
||||||
|
return {
|
||||||
|
"sha256": summe,
|
||||||
|
"label": label.strip(),
|
||||||
|
"pfad": pfad.strip(),
|
||||||
|
"namen": _namen_aus_detail(detail),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def signatur_merken(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
sha256: str,
|
||||||
|
label: str,
|
||||||
|
pfad: str,
|
||||||
|
actor: str,
|
||||||
|
namen: Iterable[str],
|
||||||
|
audit_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Haelt eine Unterschrift im Verzeichnis fest.
|
||||||
|
|
||||||
|
Das Aenderungsprotokoll bleibt der Beleg -- mit Hash-Kette und, nach der
|
||||||
|
Archivierung, in einer Datei. Dieses Verzeichnis ist nur der schnelle Weg
|
||||||
|
dorthin und wird nie ausgelagert; eine Zeile misst ein paar hundert Byte.
|
||||||
|
"""
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, actor, "
|
||||||
|
"names, audit_id) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
sha256,
|
||||||
|
label,
|
||||||
|
pfad,
|
||||||
|
_iso(_now()),
|
||||||
|
actor,
|
||||||
|
", ".join(namen),
|
||||||
|
audit_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Nachgeschlagen wird im Signaturverzeichnis (``document_signatures``) und
|
||||||
|
nicht im Aenderungsprotokoll: das wird ab 20.000 Zeilen tageweise in
|
||||||
|
Dateien ausgelagert, und mit dem ausgelagerten Tag waere die Pruefsumme aus
|
||||||
|
der Datenbank verschwunden. Eine aeltere unterschriebene Fassung waere
|
||||||
|
still zu "unbekannt" geworden -- und eine **veraenderte** ebenso, weil ohne
|
||||||
|
Vergleichswert niemand widerspricht. Das Verzeichnis wird nie archiviert.
|
||||||
|
"""
|
||||||
|
ziel = resolve_path(mount, relative)
|
||||||
|
if not ziel.is_file():
|
||||||
|
return {"status": "unbekannt"}
|
||||||
|
hash_ = pruefsumme(ziel)
|
||||||
|
|
||||||
|
zeile = Database.one(
|
||||||
|
conn,
|
||||||
|
"SELECT signed_at, actor, names FROM document_signatures WHERE sha256=? "
|
||||||
|
"ORDER BY id DESC LIMIT 1",
|
||||||
|
(hash_,),
|
||||||
|
)
|
||||||
|
if zeile is not None:
|
||||||
|
return _stand("unveraendert", hash_, zeile)
|
||||||
|
|
||||||
|
# Nichts mit dieser Pruefsumme -- aber vielleicht etwas zu diesem Ort.
|
||||||
|
zeile = Database.one(
|
||||||
|
conn,
|
||||||
|
"SELECT signed_at, actor, names FROM document_signatures WHERE share_label=? "
|
||||||
|
"AND path=? ORDER BY id DESC LIMIT 1",
|
||||||
|
(mount.label, relative),
|
||||||
|
)
|
||||||
|
if zeile is not None:
|
||||||
|
return _stand("veraendert", hash_, zeile)
|
||||||
|
return {"status": "unbekannt", "sha256": hash_}
|
||||||
|
|
||||||
|
|
||||||
|
def _stand(status: str, hash_: str, zeile: Any) -> dict[str, Any]:
|
||||||
|
namen = [teil.strip() for teil in str(zeile["names"] or "").split(",") if teil.strip()]
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"sha256": hash_,
|
||||||
|
"zeit": str(zeile["signed_at"]),
|
||||||
|
"konto": str(zeile["actor"] or ""),
|
||||||
|
"namen": namen,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
||||||
|
|||||||
@@ -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 }}"
|
||||||
@@ -313,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>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.6.0"
|
version = "2.7.1"
|
||||||
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.6.0",
|
app_version: str = "2.7.1",
|
||||||
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 {
|
||||||
@@ -1439,3 +1458,50 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 3;
|
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);
|
||||||
|
}
|
||||||
@@ -601,9 +601,18 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
const anteilY = (event.clientY - rechteck.top) / rechteck.height;
|
const anteilY = (event.clientY - rechteck.top) / rechteck.height;
|
||||||
const seite = await dokument.getPage(nummer);
|
const seite = await dokument.getPage(nummer);
|
||||||
const blick = seite.getViewport({ scale: 1 });
|
const blick = seite.getViewport({ scale: 1 });
|
||||||
const breite = Math.min(170, blick.width * 0.35);
|
// Ein menschliches Mass: rund fuenf Zentimeter breit, hoechstens knapp
|
||||||
const hoehe =
|
// zwei hoch. Ohne die Hoehengrenze wird aus einem hohen Gekritzel ein
|
||||||
(wartendeUnterschrift.hoehe / wartendeUnterschrift.breite) * breite;
|
// 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 = {
|
const eintrag = {
|
||||||
...wartendeUnterschrift,
|
...wartendeUnterschrift,
|
||||||
seite: nummer,
|
seite: nummer,
|
||||||
@@ -699,6 +708,8 @@ 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");
|
||||||
@@ -760,6 +771,7 @@ export function initFilePreview(wurzel = document) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const aufraeumen = () => {
|
const aufraeumen = () => {
|
||||||
|
pruefungZuruecksetzen();
|
||||||
bild.removeAttribute("src");
|
bild.removeAttribute("src");
|
||||||
bilder.zuruecksetzen();
|
bilder.zuruecksetzen();
|
||||||
rahmen.src = "about:blank";
|
rahmen.src = "about:blank";
|
||||||
@@ -777,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";
|
||||||
@@ -792,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",
|
||||||
@@ -846,8 +905,18 @@ 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";
|
// Der Zusatz sagt, was in der Datei steht: unterschrieben schlaegt
|
||||||
|
// ausgefuellt. Und einmal reicht -- wer eine Fassung noch einmal oeffnet
|
||||||
|
// und speichert, bekam sonst "...-ausgefuellt-ausgefuellt".
|
||||||
|
const rumpfname = datei.replace(/\.pdf$/i, "");
|
||||||
|
const zusatz = pdf?.unterschriften().length ? "-unterschrieben" : "-ausgefuellt";
|
||||||
|
const bereits = new RegExp(`${zusatz}$`, "i").test(rumpfname);
|
||||||
|
// Eine unterschriebene Fassung aus einer ausgefuellten: der alte Zusatz
|
||||||
|
// haette sonst zweimal dran gestanden ("-ausgefuellt-unterschrieben").
|
||||||
|
const blank = rumpfname.replace(/-(ausgefuellt|unterschrieben)$/i, "");
|
||||||
|
nameFeld.value = bereits ? rumpfname : `${blank}${zusatz}`;
|
||||||
ersetzenFeld.checked = false;
|
ersetzenFeld.checked = false;
|
||||||
|
ersetzenKasten?.classList.remove("is-gefragt");
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Unterschreiben: Name tippen, zeichnen, ins Dokument setzen. */
|
/* Unterschreiben: Name tippen, zeichnen, ins Dokument setzen. */
|
||||||
@@ -973,6 +1042,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.6.0"
|
version = "2.7.1"
|
||||||
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