Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc94a8e666 | ||
|
|
c639f4f507 | ||
|
|
ad0195bab0 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.7.3"
|
version = "2.7.5"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ def update_subnet(subnet_id: int):
|
|||||||
flash(problem, "danger")
|
flash(problem, "danger")
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
|
# Vor der Aenderung lesen -- danach ist der alte Stand weg, und "Subnetz
|
||||||
|
# gespeichert" allein sagt nicht, was vorher eingestellt war.
|
||||||
|
vorher = Database.one(conn, "SELECT * FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||||
with transaction():
|
with transaction():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
|
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
|
||||||
@@ -189,7 +192,26 @@ def update_subnet(subnet_id: int):
|
|||||||
subnet_id,
|
subnet_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_audit("dhcp.subnet_updated", str(subnet_id), severity="notice")
|
nachher = Database.one(conn, "SELECT * FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||||
|
_audit(
|
||||||
|
"dhcp.subnet_updated",
|
||||||
|
f"{interface} {start}-{end}",
|
||||||
|
audit.diff(
|
||||||
|
vorher,
|
||||||
|
nachher,
|
||||||
|
felder=(
|
||||||
|
("interface", "Schnittstelle"),
|
||||||
|
("range_start", "Von"),
|
||||||
|
("range_end", "Bis"),
|
||||||
|
("gateway", "Gateway"),
|
||||||
|
("dns", "DNS"),
|
||||||
|
("comment", "Kommentar"),
|
||||||
|
("enabled", "Aktiv"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
or "keine inhaltliche Änderung",
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
flash("Subnetz gespeichert.", "success")
|
flash("Subnetz gespeichert.", "success")
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
@@ -395,6 +417,7 @@ def add_reservation():
|
|||||||
)
|
)
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
|
vorher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
|
||||||
with transaction():
|
with transaction():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
|
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
|
||||||
@@ -407,7 +430,18 @@ def add_reservation():
|
|||||||
(request.form.get("comment") or "").strip(),
|
(request.form.get("comment") or "").strip(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_audit("dhcp.reservation_saved", f"{mac} -> {ip}", severity="notice")
|
nachher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
|
||||||
|
_audit(
|
||||||
|
"dhcp.reservation_saved",
|
||||||
|
mac,
|
||||||
|
audit.diff(
|
||||||
|
vorher,
|
||||||
|
nachher,
|
||||||
|
felder=(("ip", "IP"), ("hostname", "Hostname"), ("comment", "Kommentar")),
|
||||||
|
)
|
||||||
|
or f"neu angelegt: {ip}",
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
flash("Reservierung gespeichert.", "success")
|
flash("Reservierung gespeichert.", "success")
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
if unterschriften:
|
if unterschriften:
|
||||||
namen = ", ".join(unterschriften)
|
kopf += f" 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}",
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -892,6 +895,31 @@ def _namen_aus_detail(detail: str) -> list[str]:
|
|||||||
return [teil.strip() for teil in detail.split(marke, 1)[1].split(",") if teil.strip()]
|
return [teil.strip() for teil in detail.split(marke, 1)[1].split(",") if teil.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
#: Mehr als das zaehlt niemand mehr mit dem Auge -- und auf einer
|
||||||
|
#: Netzwerkfreigabe kostet jeder weitere Eintrag Zeit.
|
||||||
|
ZAEHLGRENZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _inhalt_zaehlen(verzeichnis: Path) -> int:
|
||||||
|
"""Wie viele Eintraege liegen im Ordner? Begrenzt gezaehlt.
|
||||||
|
|
||||||
|
Gezeigt wird die Zahl an der Stelle, an der bei einer Datei die Groesse
|
||||||
|
steht -- dort stand bisher ein Gedankenstrich. Bei mehr als
|
||||||
|
``ZAEHLGRENZE`` bricht das Zaehlen ab: die Oberflaeche schreibt dann
|
||||||
|
"500+", und niemand wartet auf eine Freigabe mit 40.000 Dateien.
|
||||||
|
"""
|
||||||
|
anzahl = 0
|
||||||
|
try:
|
||||||
|
with os.scandir(verzeichnis) as eintraege:
|
||||||
|
for _ in eintraege:
|
||||||
|
anzahl += 1
|
||||||
|
if anzahl >= ZAEHLGRENZE:
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
return -1
|
||||||
|
return anzahl
|
||||||
|
|
||||||
|
|
||||||
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():
|
||||||
@@ -913,6 +941,7 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
if entry.is_dir():
|
if entry.is_dir():
|
||||||
|
record["entries"] = _inhalt_zaehlen(entry)
|
||||||
directories.append(record)
|
directories.append(record)
|
||||||
else:
|
else:
|
||||||
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
||||||
|
|||||||
@@ -677,6 +677,12 @@ def update_device(
|
|||||||
("Port", str(row["port"] or ""), clean_port),
|
("Port", str(row["port"] or ""), clean_port),
|
||||||
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
|
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
|
||||||
("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))),
|
("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))),
|
||||||
|
# Diese vier fehlten: eine geaenderte Kategorie oder ein anderer
|
||||||
|
# SSH-Port stand nirgends, obwohl beides das Verhalten aendert.
|
||||||
|
("SSH-Port", str(row["ssh_port"] or ""), str(validate_ssh_port(ssh_port) or "")),
|
||||||
|
("Zugangsdaten", str(row["credential_id"] or ""), str(credential_id or "")),
|
||||||
|
("Kategorie", str(row["category"] or ""), (category or "").strip()),
|
||||||
|
("Standort", str(row["location"] or ""), (location or "").strip()),
|
||||||
):
|
):
|
||||||
if before != after:
|
if before != after:
|
||||||
changes.append(f"{label}: {before or '-'} -> {after or '-'}")
|
changes.append(f"{label}: {before or '-'} -> {after or '-'}")
|
||||||
|
|||||||
@@ -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_"):
|
||||||
|
|||||||
@@ -81,11 +81,24 @@
|
|||||||
{% if current_user.has('fileshare.delete') %}
|
{% if current_user.has('fileshare.delete') %}
|
||||||
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td>
|
{# Symbol und Name in einer Zeile: der Zeilenumbruch dazwischen
|
||||||
|
liess den Ordner aussehen wie zwei halbe Zeilen. #}
|
||||||
|
<td class="file-cell">
|
||||||
{{ icon('folder', size=15) }}
|
{{ icon('folder', size=15) }}
|
||||||
<a href="{{ url_for('fileshare.browse', share=token, path=entry.relative) }}">{{ entry.name }}</a>
|
<a href="{{ url_for('fileshare.browse', share=token, path=entry.relative) }}">{{ entry.name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="numeric faint">–</td>
|
{# An der Stelle der Dateigrösse: wie viel liegt darin. #}
|
||||||
|
<td class="numeric faint fs-sm">
|
||||||
|
{%- if entry.entries is not defined or entry.entries < 0 -%}
|
||||||
|
–
|
||||||
|
{%- elif entry.entries >= 500 -%}
|
||||||
|
500+ Einträge
|
||||||
|
{%- elif entry.entries == 1 -%}
|
||||||
|
1 Eintrag
|
||||||
|
{%- else -%}
|
||||||
|
{{ entry.entries }} Einträge
|
||||||
|
{%- endif -%}
|
||||||
|
</td>
|
||||||
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
||||||
<td class="actions"></td>
|
<td class="actions"></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.7.3"
|
version = "2.7.5"
|
||||||
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.5",
|
||||||
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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,76 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- 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;
|
||||||
|
}
|
||||||
@@ -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,18 @@
|
|||||||
<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>
|
||||||
|
{# 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">
|
||||||
|
{{- 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 +85,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 '–' | safe }}</td>
|
<td class="mono fs-xs faint" data-spalte="Herkunft">{{ row.ip or '–' | safe }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -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,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.7.3"
|
version = "2.7.5"
|
||||||
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