Version 2.7.0: Pruefung beim Oeffnen, Unterschrift in menschlichem Mass
This commit is contained in:
1 parent
5282cd8f2d
commit
ed17bce40a
9 files changed
+211
-7
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm"
|
||||
version = "2.6.1"
|
||||
version = "2.7.0"
|
||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -215,6 +215,22 @@ def upload(token: str):
|
||||
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):
|
||||
|
||||
@@ -21,6 +21,7 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import zlib
|
||||
import logging
|
||||
import mimetypes
|
||||
@@ -604,6 +605,83 @@ def resolve_path(mount: Mount, relative: str) -> Path:
|
||||
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]:
|
||||
path = resolve_path(mount, relative)
|
||||
if not path.is_dir():
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
<button class="link-icon file-name" type="button"
|
||||
data-dialog-open="file-preview"
|
||||
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-Pfad="{{ entry.relative }}"
|
||||
data-fill-Name="{{ entry.name }}"
|
||||
@@ -131,6 +132,7 @@
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="file-preview"
|
||||
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-Pfad="{{ entry.relative }}"
|
||||
data-fill-Name="{{ entry.name }}"
|
||||
@@ -332,6 +334,9 @@
|
||||
</div>
|
||||
</div>
|
||||
{% 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>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
|
||||
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.6.1"
|
||||
version = "2.7.0"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -148,7 +148,7 @@ def create_base_app(
|
||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||
#: Anwendungen.
|
||||
auth_tagline: str = "",
|
||||
app_version: str = "2.6.1",
|
||||
app_version: str = "2.7.0",
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
|
||||
@@ -1458,3 +1458,50 @@
|
||||
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);
|
||||
}
|
||||
@@ -601,9 +601,18 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
||||
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;
|
||||
// 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,
|
||||
@@ -700,6 +709,7 @@ export function initFilePreview(wurzel = document) {
|
||||
const nameFeld = stueck("name");
|
||||
const ersetzenFeld = stueck("ersetzen");
|
||||
const ersetzenKasten = stueck("ersetzenfeld");
|
||||
const pruefzeile = stueck("pruefung");
|
||||
const jetzt = stueck("jetzt");
|
||||
const abbrechen = stueck("abbrechen");
|
||||
const modusFeld = stueck("modus");
|
||||
@@ -761,6 +771,7 @@ export function initFilePreview(wurzel = document) {
|
||||
};
|
||||
|
||||
const aufraeumen = () => {
|
||||
pruefungZuruecksetzen();
|
||||
bild.removeAttribute("src");
|
||||
bilder.zuruecksetzen();
|
||||
rahmen.src = "about:blank";
|
||||
@@ -778,6 +789,52 @@ export function initFilePreview(wurzel = document) {
|
||||
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) => {
|
||||
aufraeumen();
|
||||
const art = ausloeser.dataset.fillKind || "text";
|
||||
@@ -793,6 +850,7 @@ export function initFilePreview(wurzel = document) {
|
||||
return;
|
||||
}
|
||||
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
|
||||
pruefen(ausloeser.dataset.fillPruefung || "");
|
||||
try {
|
||||
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
|
||||
formulare: rumpf.dataset.viewerFormulare === "1",
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing"
|
||||
version = "2.6.1"
|
||||
version = "2.7.0"
|
||||
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