Compare commits

...
1 Commits
10 changed files with 168 additions and 21 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.7.3" version = "2.7.4"
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 = [
+19 -3
View File
@@ -272,6 +272,19 @@ def save_form(token: str):
if upload_file is None or not upload_file.filename: if upload_file is None or not upload_file.filename:
return {"error": "Es kam keine Datei an."}, 400 return {"error": "Es kam keine Datei an."}, 400
# Ein unterschriebenes Dokument ist abgeschlossen. Es als Vorlage fuer eine
# neue Fassung zu benutzen hiesse, eine Unterschrift unter einen anderen
# Text zu stellen -- die Oberflaeche blendet das aus, hier wird es
# abgewiesen.
if service.signaturstand(db(), mount, relative).get("status") in {
"unveraendert",
"veraendert",
}:
return {
"error": "Dieses Dokument ist unterschrieben und damit abgeschlossen. "
"Legen Sie eine neue Fassung aus der ursprünglichen Vorlage an."
}, 409
grenze = uploads.limit_bytes(db()) grenze = uploads.limit_bytes(db())
daten = upload_file.stream.read(grenze + 1) daten = upload_file.stream.read(grenze + 1)
if len(daten) > grenze: if len(daten) > grenze:
@@ -343,10 +356,13 @@ def save_form(token: str):
# da: der Name **in** der Unterschrift ist eine Angabe des # da: der Name **in** der Unterschrift ist eine Angabe des
# Unterschreibenden -- wie auf Papier. Wer die Datei abgelegt hat, steht # Unterschreibenden -- wie auf Papier. Wer die Datei abgelegt hat, steht
# ohnehin als Handelnder in der Zeile und ist belegt. # ohnehin als Handelnder in der Zeile und ist belegt.
beschreibung = f"{ziel_mount.label}:{wohin} sha256={pruefsumme}" # Zwei Zeilen: oben, was ein Mensch liest; unten die Pruefsumme, die
# niemand liest, aber jeder vergleichen koennen muss. In einer einzigen
# Zeile drueckte der Hash den Rest aus der Spalte.
kopf = f"{'Signiert' if unterschriften else 'Gespeichert'}: {wohin} ({ziel_mount.label})"
if unterschriften: if unterschriften:
namen = ", ".join(unterschriften) kopf += f" – angegeben von: {', '.join(unterschriften)}"
beschreibung = f"{beschreibung} -- angegeben unterschrieben von: {namen}" beschreibung = f"{kopf}{chr(10)}sha256={pruefsumme}"
_audit( _audit(
"fileshare.form_signed" if unterschriften else "fileshare.form_saved", "fileshare.form_signed" if unterschriften else "fileshare.form_saved",
f"{mount.label}:{relative}", f"{mount.label}:{relative}",
+4 -1
View File
@@ -696,7 +696,10 @@ def signaturdetail_lesen(detail: str) -> dict[str, Any]:
von: <Namen>``. Gelesen wird sie an zwei Stellen -- beim Uebernehmen von: <Namen>``. Gelesen wird sie an zwei Stellen -- beim Uebernehmen
alter Eintraege und wenn das Verzeichnis nichts weiss. alter Eintraege und wenn das Verzeichnis nichts weiss.
""" """
ort, _, rest = detail.partition(" sha256=") # Der Trenner ist heute ein Zeilenumbruch, frueher ein Leerzeichen -- die
# Zerlegung muss beides vertragen, sonst sind alte Eintraege stumm.
ort, _, rest = detail.partition("sha256=")
ort = ort.strip()
label, _, pfad = ort.partition(":") label, _, pfad = ort.partition(":")
summe = rest.split(" ", 1)[0].strip() if rest else "" summe = rest.split(" ", 1)[0].strip() if rest else ""
return { return {
+49
View File
@@ -64,6 +64,13 @@ CATEGORIES: tuple[Category, ...] = (
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True), Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True), Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True), Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
Category(
"signatures",
"Unterschriften",
"Pruefsummen unterschriebener Dokumente. Ohne sie sagt die Vorschau nach einer "
"Wiederherstellung zu jeder alten Fassung \"unbekannt\" – auch zu einer "
"veraenderten.",
),
) )
CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES} CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
@@ -186,6 +193,16 @@ def build_export(
# Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben. # Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben.
if not str(row["key"]).startswith("license_") if not str(row["key"]).startswith("license_")
} }
if "signatures" in wanted:
# Das Verzeichnis der Unterschriften. Der Beleg selbst bleibt das
# Aenderungsprotokoll (Hash-Kette) und das Journal neben dem Archiv --
# beides wandert nicht in die Sicherung. Ohne diese Zeilen waere nach
# einer Wiederherstellung jede unterschriebene Fassung "unbekannt".
payload["signatures"] = Database.all(
conn,
"SELECT sha256, share_label, path, signed_at, actor, names "
"FROM document_signatures ORDER BY id",
)
return payload return payload
@@ -625,6 +642,38 @@ def apply_import(
(user_id, int(group_id)), (user_id, int(group_id)),
) )
if "signatures" in wanted:
for eintrag in payload.get("signatures") or []:
if not isinstance(eintrag, dict):
continue
summe = str(eintrag.get("sha256") or "").strip()
pfad = str(eintrag.get("path") or "").strip()
if not summe:
continue
# Zweimal einlesen darf nichts verdoppeln: verglichen wird ueber
# Pruefsumme und Ablageort, genau wie beim Journal.
vorhanden = Database.value(
conn,
"SELECT 1 FROM document_signatures WHERE sha256=? AND path=? LIMIT 1",
(summe, pfad),
)
if vorhanden:
bump("signatures", "skipped")
continue
conn.execute(
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, "
"actor, names, audit_id) VALUES (?,?,?,?,?,?,NULL)",
(
summe,
str(eintrag.get("share_label") or ""),
pfad,
str(eintrag.get("signed_at") or stamp),
str(eintrag.get("actor") or ""),
str(eintrag.get("names") or ""),
),
)
bump("signatures", "created")
if "settings" in wanted: if "settings" in wanted:
for key, value in (payload.get("settings") or {}).items(): for key, value in (payload.get("settings") or {}).items():
if str(key).startswith("license_"): if str(key).startswith("license_"):
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.7.3" version = "2.7.4"
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.7.3", app_version: str = "2.7.4",
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,
@@ -1486,7 +1486,7 @@
font-size: 5.6cqw; font-size: 5.6cqw;
line-height: 1.15; line-height: 1.15;
color: #111; color: #111;
border-top: 1px solid rgba(0, 0, 0, 0.35); border-top: 1px solid rgba(0, 0, 0, 0.85);
padding-top: 1px; padding-top: 1px;
pointer-events: none; pointer-events: none;
white-space: nowrap; white-space: nowrap;
@@ -1581,3 +1581,62 @@
color: var(--danger); color: var(--danger);
background: rgba(201, 42, 42, 0.12); background: rgba(201, 42, 42, 0.12);
} }
/* -- Tabelle, die schmal zu Bloecken wird -------------------------------- */
/* Eine Protokollzeile besteht aus sieben Angaben, von denen eine lang ist:
das Detail. Auf einem breiten Schirm passt das nebeneinander. Wird es eng,
ist eine Spalte von zwei Zentimetern fuer eine Pruefsumme keine Loesung --
dann steht jede Angabe auf ihrer eigenen Zeile, beschriftet. */
.table--stapelbar .td--detail {
/* Zeilenumbrueche aus dem Eintrag bleiben stehen: die Pruefsumme steht
unter dem lesbaren Teil, nicht mitten darin. Und ein Hash ohne Trennstelle
darf notfalls hart umbrechen. */
white-space: pre-line;
overflow-wrap: anywhere;
max-width: 52ch;
}
@media (max-width: 1100px) {
.table--stapelbar thead {
display: none;
}
.table--stapelbar,
.table--stapelbar tbody,
.table--stapelbar tr,
.table--stapelbar td {
display: block;
width: auto;
}
.table--stapelbar tr {
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border);
}
.table--stapelbar td {
display: grid;
grid-template-columns: 8.5rem 1fr;
gap: var(--sp-2);
align-items: baseline;
border: 0;
padding: 2px var(--sp-3);
white-space: normal;
}
.table--stapelbar td::before {
content: attr(data-spalte);
color: var(--text-muted);
font-size: var(--fs-xs);
}
.table--stapelbar .td--detail {
max-width: none;
}
/* Leere Zellen brauchen keine Zeile. */
.table--stapelbar td:empty {
display: none;
}
}
@@ -308,11 +308,14 @@ async function nachbearbeiten(
color: PDFLib.rgb(0.1, 0.1, 0.1), color: PDFLib.rgb(0.1, 0.1, 0.1),
}); });
} }
// Kraeftig genug, um neben den Linien eines Formulars als eigener Strich
// erkennbar zu bleiben: ein halber Punkt in hellem Grau ging zwischen
// den Tabellenlinien schlicht unter.
seite.drawLine({ seite.drawLine({
start: { x: eintrag.x, y: eintrag.y - 1 }, start: { x: eintrag.x, y: eintrag.y - 1.5 },
end: { x: eintrag.x + textbreite, y: eintrag.y - 1 }, end: { x: eintrag.x + textbreite, y: eintrag.y - 1.5 },
thickness: 0.5, thickness: 0.9,
color: PDFLib.rgb(0.4, 0.4, 0.4), color: PDFLib.rgb(0.15, 0.15, 0.15),
}); });
} }
} }
@@ -901,6 +904,7 @@ export function initFilePreview(wurzel = document) {
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 (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf";
if (speichern) speichern.hidden = art !== "pdf";
if (unterschriftPanel) unterschriftPanel.hidden = true; if (unterschriftPanel) unterschriftPanel.hidden = true;
}; };
@@ -942,6 +946,15 @@ export function initFilePreview(wurzel = document) {
if (!adresse) return; if (!adresse) return;
try { try {
const stand = await window.tesm.api(adresse); const stand = await window.tesm.api(adresse);
if (stand.status === "unveraendert" || stand.status === "veraendert") {
// Ein unterschriebenes Dokument ist abgeschlossen. Wer daraus eine
// neue Fassung machen koennte, koennte auch eine Unterschrift unter
// einen veraenderten Text setzen -- also gibt es hier nichts mehr zu
// bearbeiten. Der Server weist es zusaetzlich ab.
if (unterschreibenKnopf) unterschreibenKnopf.hidden = true;
if (speichern) speichern.hidden = true;
if (zeile) zeile.hidden = true;
}
if (stand.status === "unveraendert") { if (stand.status === "unveraendert") {
pdfBehaelter.classList.add("viewer__pdf--unveraendert"); pdfBehaelter.classList.add("viewer__pdf--unveraendert");
if (pruefzeile) { if (pruefzeile) {
@@ -44,8 +44,11 @@
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a> <a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
</form> </form>
{# "table--stapelbar": schmal wird aus jeder Zeile ein kleiner Block. Vorher
quetschte sich das Detail mit Pruefsumme in eine Spalte von zwei
Zentimetern und lief ueber zehn Zeilen -- lesbar war das nicht. #}
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact" data-behavior="sortable"> <table class="table table--compact table--stapelbar" data-behavior="sortable">
<thead> <thead>
<tr> <tr>
<th class="sortable">Zeit</th> <th class="sortable">Zeit</th>
@@ -60,12 +63,16 @@
<tbody> <tbody>
{% for row in rows %} {% for row in rows %}
<tr> <tr>
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td> <td class="fs-sm nowrap" data-spalte="Zeit" data-sort-value="{{ row.ts }}">
<td class="fs-sm fw-600">{{ row.actor }}</td> {{ row.ts | dt }}
<td class="mono fs-xs">{{ row.action }}</td> </td>
<td class="fs-sm">{{ row.target }}</td> <td class="fs-sm fw-600" data-spalte="Benutzer">{{ row.actor }}</td>
<td class="fs-sm muted break-all">{{ row.detail | audit_detail }}</td> <td class="mono fs-xs" data-spalte="Aktion">{{ row.action }}</td>
<td> <td class="fs-sm" data-spalte="Ziel">{{ row.target }}</td>
<td class="fs-sm muted break-all td--detail" data-spalte="Detail">
{{ row.detail | audit_detail }}
</td>
<td data-spalte="Stufe">
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info {# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
@@ -76,7 +83,7 @@
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }} {% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }}
{% else %}{{ pill('Info', 'online') }}{% endif %} {% else %}{{ pill('Info', 'online') }}{% endif %}
</td> </td>
<td class="mono fs-xs faint">{{ row.ip or '&ndash;' | safe }}</td> <td class="mono fs-xs faint" data-spalte="Herkunft">{{ row.ip or '&ndash;' | safe }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing" name = "tesm-licensing"
version = "2.7.3" version = "2.7.4"
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"]