Compare commits

...
4 Commits
13 changed files with 184 additions and 30 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-license" name = "tesm-license"
version = "2.7.4" version = "2.7.7"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.7.4" version = "2.7.7"
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.4", app_version: str = "2.7.7",
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,
+44 -1
View File
@@ -28,7 +28,7 @@ import sqlite3
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Collection, Iterable, Mapping, Sequence
from .db import Database from .db import Database
@@ -116,6 +116,49 @@ def log(
return int(cursor.lastrowid or 0) return int(cursor.lastrowid or 0)
def diff(
vorher: Mapping[str, Any] | None,
nachher: Mapping[str, Any] | None,
*,
felder: Sequence[tuple[str, str]] | None = None,
geheim: Collection[str] = (),
) -> str:
"""Was sich geaendert hat: ``Feld: alt -> neu``, mit Komma getrennt.
Damit steht im Protokoll nicht nur *dass* etwas geaendert wurde, sondern
auch *von was auf was* -- der Weg zurueck steht damit in der Zeile selbst.
``felder`` gibt Auswahl und Beschriftung vor (``[("ssh_port", "SSH-Port")]``);
ohne Angabe werden alle gemeinsamen Felder verglichen. Namen in ``geheim``
werden nie im Klartext geschrieben -- dort steht nur, dass sie sich
geaendert haben.
"""
if vorher is None or nachher is None:
return ""
paare = felder or [(name, name) for name in sorted(set(vorher) & set(nachher))]
teile: list[str] = []
for name, beschriftung in paare:
if name not in vorher or name not in nachher:
continue
alt, neu = vorher[name], nachher[name]
if alt == neu:
continue
if name in geheim:
teile.append(f"{beschriftung}: geaendert")
continue
teile.append(f"{beschriftung}: {_wert(alt)} -> {_wert(neu)}")
return ", ".join(teile)
def _wert(wert: Any) -> str:
if wert is None or wert == "":
return "(leer)"
if isinstance(wert, bool):
return "ein" if wert else "aus"
text = " ".join(str(wert).split())
return text if len(text) <= 80 else text[:79] + "…"
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]: def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``.""" """Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
sql = "SELECT * FROM audit_log ORDER BY id" sql = "SELECT * FROM audit_log ORDER BY id"
@@ -359,15 +359,11 @@ def logout(conn: sqlite3.Connection) -> None:
current_app.logger.exception( current_app.logger.exception(
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook) "Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
) )
with core().database.transaction(conn): # Kein Protokolleintrag fuer das Abmelden: eine Abmeldung aendert
audit.log( # nichts. Sie stand als "Info" in jeder zweiten Zeile und verdraengte
conn, # die Eintraege, um die es geht. Wer wissen will, wer wann da war,
actor=user.username, # liest die Sitzungsuebersicht; fehlgeschlagene Anmeldungen und
actor_id=user.id, # Sperren stehen weiterhin jedes Mal im Protokoll.
action="auth.logout",
target=user.username,
ip=client_ip(),
)
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined] session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
g.user = ANONYMOUS g.user = ANONYMOUS
@@ -556,14 +556,20 @@ def update_user(
) )
fields: dict[str, Any] = {} fields: dict[str, Any] = {}
# Mit Werten, nicht nur "geaendert": aus dem Protokoll soll man den
# vorherigen Stand ablesen koennen, ohne eine Sicherung auszupacken.
beschriftungen = {"first_name": "Vorname", "last_name": "Nachname", "email": "E-Mail"}
for column, value in ( for column, value in (
("first_name", first_name), ("first_name", first_name),
("last_name", last_name), ("last_name", last_name),
("email", email), ("email", email),
): ):
if value is not None and value.strip() != str(target.get(column) or ""): alt = str(target.get(column) or "")
if value is not None and value.strip() != alt:
fields[column] = value.strip() fields[column] = value.strip()
changes.append(f"{column} geändert") changes.append(
f"{beschriftungen[column]}: {alt or '(leer)'} -> {value.strip() or '(leer)'}"
)
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]): if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
if not getattr(actor, "is_admin", False): if not getattr(actor, "is_admin", False):
@@ -67,6 +67,45 @@ class SettingSpec:
return "" if value is None else str(value) return "" if value is None else str(value)
#: So lang darf ein Wert im Protokoll werden -- danach wird gekuerzt. Ein
#: JSON-Feld mit der halben Navigationsreihenfolge macht die Zeile unlesbar.
WERT_GRENZE = 80
class Aenderung(str):
"""Eine geaenderte Einstellung, lesbar als ``schluessel: alt -> neu``.
Eine Zeichenkette und kein eigener Typ, weil die Aufrufer die Liste
weiterhin mit ``", ".join(...)`` in einen Protokolleintrag schreiben --
so gibt es keine Stelle, die man beim Umbau vergessen kann.
"""
key: str
alt: str
neu: str
def __new__(cls, key: str, alt: Any, neu: Any, *, geheim: bool = False) -> "Aenderung":
alt_text = "(gesetzt)" if geheim and alt else _kurz(alt)
neu_text = "(geaendert)" if geheim else _kurz(neu)
selbst = super().__new__(cls, f"{key}: {alt_text} -> {neu_text}")
selbst.key = key
selbst.alt = alt_text
selbst.neu = neu_text
return selbst
def _kurz(wert: Any) -> str:
"""Ein Wert als kurzer Text -- leer wird sichtbar, lang wird gekuerzt."""
if wert is None or wert == "":
return "(leer)"
if isinstance(wert, bool):
return "ein" if wert else "aus"
text = " ".join(str(wert).split())
if len(text) > WERT_GRENZE:
return text[: WERT_GRENZE - 1] + "…"
return text
class SettingsStore: class SettingsStore:
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung.""" """Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
@@ -140,16 +179,24 @@ class SettingsStore:
def set_many( def set_many(
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = "" self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
) -> list[str]: ) -> list["Aenderung"]:
"""Setzt mehrere Werte, gibt die tatsaechlich geaenderten Schluessel zurueck.""" """Setzt mehrere Werte und gibt die Aenderungen zurueck.
changed: list[str] = []
Jede Aenderung ist eine Zeichenkette der Form ``schluessel: alt -> neu``
-- und traegt die Einzelteile als Attribute. Damit steht im
Aenderungsprotokoll, was sich geaendert hat, ohne dass jede
Einstellungsseite es einzeln zusammenbauen muss. Vorher stand dort nur
der Schluessel, und die Frage "was war vorher eingestellt?" liess sich
aus dem Protokoll nicht beantworten.
"""
changed: list[Aenderung] = []
current = self.get_many(conn, list(values)) current = self.get_many(conn, list(values))
for key, value in values.items(): for key, value in values.items():
spec = self.spec(key) spec = self.spec(key)
if spec.serialize(current.get(key)) == spec.serialize(value): if spec.serialize(current.get(key)) == spec.serialize(value):
continue continue
self.set(conn, key, value, actor=actor) self.set(conn, key, value, actor=actor)
changed.append(key) changed.append(Aenderung(key, current.get(key), value, geheim=spec.kind == "secret"))
return changed return changed
def delete(self, conn: sqlite3.Connection, key: str) -> None: def delete(self, conn: sqlite3.Connection, key: str) -> None:
@@ -1461,7 +1461,6 @@
z-index: 3; z-index: 3;
cursor: grab; cursor: grab;
touch-action: none; touch-action: none;
container-type: inline-size;
border: 1px dashed transparent; border: 1px dashed transparent;
} }
@@ -1483,7 +1482,14 @@
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
anderes als die Datei. */ anderes als die Datei. */
.viewer__unterschrifttext { .viewer__unterschrifttext {
font-size: 5.6cqw; /* Helvetica: dieselbe Schrift, die das PDF benutzt (StandardFonts.Helvetica).
Mit der Hausschrift sah die Vorschau anders aus als das Ergebnis. Die
Groesse setzt das Skript je Unterschrift -- hier steht nur ein Rueckfall. */
font-family: Helvetica, Arial, sans-serif;
/* Die tatsaechliche Groesse setzt das Skript in Pixeln (Punkte mal
Pixel-je-Punkt der Seite). Dieser Wert gilt nur, solange sie noch nicht
gerechnet wurde -- geerbt wuerde sonst die Schriftgroesse der Seite. */
font-size: 8px;
line-height: 1.15; line-height: 1.15;
color: #111; color: #111;
border-top: 1px solid rgba(0, 0, 0, 0.85); border-top: 1px solid rgba(0, 0, 0, 0.85);
@@ -1640,3 +1646,17 @@
display: none; display: none;
} }
} }
/* -- Dateiliste: Symbol und Name in einer Zeile -------------------------- */
/* Das Symbol ist ein Block-SVG; ohne diese Regel stand der Name darunter und
eine Ordnerzeile sah doppelt so hoch aus wie eine Dateizeile. */
.file-cell {
display: flex;
align-items: center;
gap: 6px;
}
.file-cell > svg {
flex: 0 0 auto;
}
@@ -346,8 +346,8 @@ async function nachbearbeiten(
* *
* Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht * Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht
* Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe * Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe
* Rechnung benutzt die Vorschau (als ``cqw``), damit auf dem Schirm steht, * Rechnung benutzt die Vorschau -- in Pixeln umgerechnet --, damit auf dem
* was spaeter im PDF steht. * Schirm steht, was spaeter im PDF steht.
*/ */
function schriftgroesse(breite) { function schriftgroesse(breite) {
return Math.max(5, Math.min(10, breite / 18)); return Math.max(5, Math.min(10, breite / 18));
@@ -637,6 +637,8 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
const MAX_BREITE = 150; const MAX_BREITE = 150;
const MAX_HOEHE = 50; const MAX_HOEHE = 50;
const unterschriften = []; const unterschriften = [];
//: Je gesetzte Unterschrift eine Funktion, die ihre Marke neu vermisst.
const neuzeichnen = new Set();
let wartendeUnterschrift = null; let wartendeUnterschrift = null;
let zeugenzeile = () => ""; let zeugenzeile = () => "";
@@ -684,11 +686,20 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
const bild = document.createElement("img"); const bild = document.createElement("img");
bild.src = eintrag.vorschau; bild.src = eintrag.vorschau;
bild.alt = ""; bild.alt = "";
// Genau der Aufbau der spaeteren Datei: eine Zeile mit Namen und
// Zeitpunkt, darunter -- als eigene Zeile -- der Zeugenvermerk. In der
// Vorschau standen beide hintereinander, getrennt durch einen
// Schraegstrich; beim Positionieren sah der Block dadurch breiter und
// flacher aus als das Ergebnis.
const text = document.createElement("div"); const text = document.createElement("div");
text.className = "viewer__unterschrifttext"; text.className = "viewer__unterschrifttext";
text.textContent = `${eintrag.name} | ${eintrag.zeit}`; const namenszeile = document.createElement("div");
namenszeile.textContent = `${eintrag.name} | ${eintrag.zeit}`;
text.append(namenszeile);
if (zeuge && eintrag === unterschriften[0]) { if (zeuge && eintrag === unterschriften[0]) {
text.textContent += ` / In TESM abgelegt durch ${zeuge}`; const zeugenzeile = document.createElement("div");
zeugenzeile.textContent = `In TESM abgelegt durch ${zeuge}`;
text.append(zeugenzeile);
} }
const griff = document.createElement("button"); const griff = document.createElement("button");
griff.type = "button"; griff.type = "button";
@@ -708,13 +719,24 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
marke.style.left = `${(eintrag.x / blick.width) * 100}%`; marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`; marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
marke.style.width = `${(eintrag.breite / blick.width) * 100}%`; marke.style.width = `${(eintrag.breite / blick.width) * 100}%`;
// Die Schriftgroesse wird gerechnet, nicht vom Stylesheet abgeleitet:
// Punkte mal Pixel-je-Punkt der gerade dargestellten Seite. Ueber
// Container-Einheiten ("cqw") ging das auch -- aber nur dort, wo der
// Browser sie kennt; wo nicht, erbte der Block die Schriftgroesse der
// Seite, und der Text war um die Haelfte zu breit. Genau das war der
// Unterschied zwischen Vorschau und Datei.
const proPunkt = seitenEl.getBoundingClientRect().width / blick.width;
text.style.fontSize = `${schriftgroesse(eintrag.breite) * proPunkt}px`;
}; };
// Beim Zoomen aendert sich Pixel-je-Punkt -- dann neu rechnen.
neuzeichnen.add(zeichnen);
zeichnen(); zeichnen();
weg.addEventListener("click", (event) => { weg.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
const stelle = unterschriften.indexOf(eintrag); const stelle = unterschriften.indexOf(eintrag);
if (stelle >= 0) unterschriften.splice(stelle, 1); if (stelle >= 0) unterschriften.splice(stelle, 1);
neuzeichnen.delete(zeichnen);
marke.remove(); marke.remove();
}); });
@@ -766,6 +788,18 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler)); platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
}); });
// Nach dem Zoomen stehen die Marken anteilig richtig, aber die Schrift
// haengt an der Pixelgroesse der Seite -- die ist jetzt eine andere.
eventBus.on("scalechanging", () => {
for (const zeichnen of neuzeichnen) {
try {
zeichnen();
} catch (fehler) {
console.error("Unterschrift neu vermessen", fehler);
}
}
});
return { return {
hinein: () => { hinein: () => {
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2); viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
@@ -69,8 +69,10 @@
<td class="fs-sm fw-600" data-spalte="Benutzer">{{ row.actor }}</td> <td class="fs-sm fw-600" data-spalte="Benutzer">{{ row.actor }}</td>
<td class="mono fs-xs" data-spalte="Aktion">{{ row.action }}</td> <td class="mono fs-xs" data-spalte="Aktion">{{ row.action }}</td>
<td class="fs-sm" data-spalte="Ziel">{{ row.target }}</td> <td class="fs-sm" data-spalte="Ziel">{{ row.target }}</td>
{# Ohne die Minuszeichen stuende bei "white-space: pre-line" die
Einrueckung der Vorlage als leere erste Zeile in der Zelle. #}
<td class="fs-sm muted break-all td--detail" data-spalte="Detail"> <td class="fs-sm muted break-all td--detail" data-spalte="Detail">
{{ row.detail | audit_detail }} {{- row.detail | audit_detail -}}
</td> </td>
<td data-spalte="Stufe"> <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
@@ -427,9 +427,15 @@ def nav_order():
if submitted: if submitted:
child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted) child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted)
with transaction() as conn: with transaction() as conn:
vorher = extension.settings.get(conn, "nav_order") or extension.nav.default_order()
extension.settings.set(conn, "nav_order", order, actor=current_user.username) extension.settings.set(conn, "nav_order", order, actor=current_user.username)
extension.settings.set(conn, "nav_child_order", child_order, actor=current_user.username) extension.settings.set(conn, "nav_child_order", child_order, actor=current_user.username)
_audit(conn, "system.nav_order_changed", severity="info") _audit(
conn,
"system.nav_order_changed",
detail=f"{', '.join(vorher)} -> {', '.join(order)}",
severity="info",
)
flash("Reihenfolge der Navigation gespeichert.", "success") flash("Reihenfolge der Navigation gespeichert.", "success")
return redirect(request.referrer or url_for("index")) return redirect(request.referrer or url_for("index"))
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing-server" name = "tesm-licensing-server"
version = "2.7.4" version = "2.7.7"
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten" description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["tesm-licensing==2.7.4", "cryptography>=41"] dependencies = ["tesm-licensing==2.7.7", "cryptography>=41"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing" name = "tesm-licensing"
version = "2.7.4" version = "2.7.7"
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"]