Version 2.5.0: englische Adressen entlang der Navigation, sparsameres Protokoll

This commit is contained in:
alientim committed 2026-09-11 18:57:23 +02:00
1 parent f2d5324449
commit 1ac5dc1637
33 files changed
+431 -175

No files matched your search

@@ -62,6 +62,17 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
minimum=1,
maximum=3650,
),
SettingSpec(
"session_absolute_hours",
12,
"int",
"Laengstmoegliche Sitzungsdauer (Stunden)",
hint="Danach ist Schluss, auch bei staendiger Arbeit -- eine Sitzung soll nicht "
"ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.",
section="system",
minimum=1,
maximum=168,
),
SettingSpec(
"session_idle_minutes",
30,
@@ -135,7 +146,7 @@ def create_base_app(
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Anwendungen.
auth_tagline: str = "",
app_version: str = "2.4.1",
app_version: str = "2.5.0",
built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True,
template_folder: str | None = None,
@@ -230,12 +241,21 @@ def create_base_app(
wert = 0
return wert if wert > 0 else config.security.session_idle_minutes
def _gesamtdauer(conn: Any) -> int:
"""Gesamtdauer aus den Einstellungen -- ueber dieselbe offene Verbindung."""
try:
wert = int(settings.get(conn, "session_absolute_hours") or 0)
except (TypeError, ValueError):
wert = 0
return wert if wert > 0 else config.security.session_absolute_hours
app.session_interface = SqliteSessionInterface(
database,
cookie_name=app.config["SESSION_COOKIE_NAME"],
idle_minutes=config.security.session_idle_minutes,
absolute_hours=config.security.session_absolute_hours,
idle_lookup=_leerlauf,
absolute_lookup=_gesamtdauer,
)
# -- Schema und Startzustand ------------------------------------------ #
@@ -399,7 +419,7 @@ def create_base_app(
with app.app_context():
trash_views.register_core_providers()
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"])
app.add_url_rule("/health", "health", _health, methods=["GET"])
return app
@@ -66,7 +66,7 @@ def login():
)
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"])
@bp.route("/login/second-factor", methods=["GET", "POST"])
def second_factor():
conn = db()
user_row = service.pending_totp_user(conn)
@@ -116,7 +116,7 @@ def logout_get():
return redirect(url_for("auth.login"))
@bp.route("/bestaetigen", methods=["GET", "POST"])
@bp.route("/confirm", methods=["GET", "POST"])
@login_required
def reauth():
target = _safe_next(session.get("_after_reauth"))
@@ -257,6 +257,24 @@ def _verify_credentials(
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
def _ist_erstanmeldung(user_row: dict[str, Any]) -> bool:
"""Meldet sich dieses Konto zum ersten Mal ueberhaupt an?
Das Aenderungsprotokoll soll Aenderungen zeigen. Eine taegliche Anmeldung
ist keine -- sie erzeugte aber jedes Mal eine Zeile und schob damit echte
Eintraege aus dem Blickfeld. Interessant ist genau die erste: mit ihr
entsteht das Konto aus dem Verzeichnisdienst und betritt jemand die
Anwendung, der vorher nicht darin war.
Nicht betroffen: fehlgeschlagene Anmeldungen und Sperren. Die stehen
weiterhin **jedes Mal** im Protokoll -- dort liegt der sicherheitsrelevante
Teil, und Haeufung ist dort das Signal. Wer wann zuletzt angemeldet war,
steht ausserdem unveraendert am Konto und in der Sitzungsuebersicht.
"""
return not str(user_row.get("last_login_at") or "").strip()
def complete_login(
conn: sqlite3.Connection,
user_row: dict[str, Any],
@@ -275,21 +293,26 @@ def complete_login(
"""
from ..security import csrf
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
erstanmeldung = _ist_erstanmeldung(user_row)
with core().database.transaction(conn):
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
conn.execute(
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
)
audit.log(
conn,
actor=str(user_row["username"]),
actor_id=int(user_row["id"]),
action="auth.login",
target=str(user_row["username"]),
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
ip=ip,
request_id=getattr(g, "request_id", ""),
)
if erstanmeldung:
audit.log(
conn,
actor=str(user_row["username"]),
actor_id=int(user_row["id"]),
action="auth.first_login",
target=str(user_row["username"]),
detail=f"Erste Anmeldung -- Quelle: {user_row.get('auth_source') or 'local'}",
ip=ip,
request_id=getattr(g, "request_id", ""),
severity="notice",
)
session.clear()
session[SESSION_USER_KEY] = int(user_row["id"])
@@ -163,6 +163,7 @@ class SqliteSessionInterface(SessionInterface):
idle_minutes: int,
absolute_hours: int,
idle_lookup: Any = None,
absolute_lookup: Any = None,
) -> None:
self.database = database
self.cookie_name = cookie_name
@@ -172,6 +173,16 @@ class SqliteSessionInterface(SessionInterface):
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
self._idle_lookup = idle_lookup
self._absolute_lookup = absolute_lookup
def _absolute(self, conn: Any) -> int:
"""Gesamtdauer in Stunden -- einstellbar wie die Leerlaufgrenze."""
if self._absolute_lookup is None:
return self.absolute_hours
try:
return int(self._absolute_lookup(conn)) or self.absolute_hours
except Exception: # pragma: no cover - Einstellung unlesbar
return self.absolute_hours
def _idle(self, conn: Any) -> int:
if self._idle_lookup is None:
@@ -280,7 +291,10 @@ class SqliteSessionInterface(SessionInterface):
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
with self.database.session() as conn, self.database.transaction(conn):
if session.new or not session.sid:
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
# Eine vorhandene ID nicht wegwerfen: nach einem Wechsel beim
# Anmelden steht sie schon, und andere haben sie sich gemerkt.
if not session.sid:
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
conn.execute(
"INSERT INTO user_sessions "
"(id, user_id, created_at, last_seen_at, idle_expires_at, "
@@ -292,7 +306,7 @@ class SqliteSessionInterface(SessionInterface):
_iso(now),
_iso(now),
_iso(now + timedelta(minutes=self._idle(conn))),
_iso(now + timedelta(hours=self.absolute_hours)),
_iso(now + timedelta(hours=self._absolute(conn))),
session.get("_ip", ""),
session.get("_ua", ""),
payload,
@@ -371,7 +385,12 @@ def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "p
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
(_iso(_now()), reason, old_sid),
)
session.sid = ""
# Die neue ID entsteht **jetzt** und nicht erst beim Speichern der Antwort.
# Zwischen beidem laufen die Anmeldehaken, und einer davon bindet die
# Dateifreigaben ein und schreibt sich die Sitzung dazu. Mit einer leeren
# Sitzung hielt der Aufraeumer diese Einbindungen fuer verwaist und loeste
# sie binnen Minuten wieder.
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
session.new = True
session.modified = True
return old_sid
@@ -1315,3 +1315,72 @@
.hide-sm { display: none !important; }
}
}
/* -- Sortierbare Liste (Navigationsreihenfolge) --------------------------- */
/* Symbol und Beschriftung stehen in einer Zeile -- so wie im Menue selbst.
Vorher brach die Beschriftung unter das Symbol, und die Liste sah aus wie
etwas anderes als das, was sie sortiert. */
.reorder {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.reorder__row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--surface-2);
cursor: grab;
}
.reorder__row > .flex-1 {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reorder__row.is-dragging {
opacity: 0.5;
}
.reorder__grip {
color: var(--text-muted);
display: inline-flex;
}
.reorder__pos {
min-width: 1.6em;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: var(--fs-xs);
}
.reorder__moves {
display: inline-flex;
gap: 2px;
}
.reorder__group {
list-style: none;
}
.reorder--child {
margin: 4px 0 4px 28px;
}
.reorder--child .reorder__row {
background: transparent;
border-style: dashed;
}
@@ -856,6 +856,81 @@ function initTileFilters() {
});
}
/* -- Reihenfolge aendern -------------------------------------------------- */
/**
* Eine Liste, deren Reihenfolge man aendern kann -- ohne Bibliothek.
*
* Die Reihenfolge steckt in der Reihenfolge der versteckten Felder: das
* Formular schickt sie als wiederholtes Feld, und der Server liest sie als
* Liste. Verschiebt man einen Eintrag, verschiebt sich das Feld mit ihm.
*
* Zwei Wege, weil es zwei Arten von Geraeten gibt: Ziehen mit der Maus und
* zwei Knoepfe je Zeile. Die Knoepfe sind nicht die Notloesung -- auf einem
* Tablet sind sie der einzige Weg, der zuverlaessig funktioniert, und mit der
* Tastatur ebenso.
*/
function initReorder() {
$$('[data-behavior="reorder"]').forEach((liste) => {
// Nur die eigenen Zeilen: Gruppen enthalten wiederum eine sortierbare
// Liste, und deren Zeilen gehoeren der inneren Liste, nicht dieser.
const zeilen = () => Array.from(liste.querySelectorAll(":scope > [data-reorder-item]"));
const nummerieren = () => {
zeilen().forEach((zeile, index) => {
const nummer = zeile.querySelector("[data-reorder-position-text]");
if (nummer) nummer.textContent = String(index + 1);
const hoch = zeile.querySelector('[data-reorder-move="up"]');
const runter = zeile.querySelector('[data-reorder-move="down"]');
if (hoch) hoch.disabled = index === 0;
if (runter) runter.disabled = index === zeilen().length - 1;
});
};
liste.addEventListener("click", (event) => {
const knopf = event.target.closest("[data-reorder-move]");
if (!knopf) return;
const zeile = knopf.closest("[data-reorder-item]");
if (!zeile || zeile.parentElement !== liste) return;
event.preventDefault();
if (knopf.dataset.reorderMove === "up") {
const davor = zeile.previousElementSibling;
if (davor) liste.insertBefore(zeile, davor);
} else {
const danach = zeile.nextElementSibling;
if (danach) liste.insertBefore(danach, zeile);
}
nummerieren();
zeile.querySelector(`[data-reorder-move="${knopf.dataset.reorderMove}"]`)?.focus();
});
let gezogen = null;
liste.addEventListener("dragstart", (event) => {
const zeile = event.target.closest("[data-reorder-item]");
if (!zeile || zeile.parentElement !== liste) return;
gezogen = zeile;
gezogen.classList.add("is-dragging");
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
});
liste.addEventListener("dragend", () => {
if (gezogen) gezogen.classList.remove("is-dragging");
gezogen = null;
nummerieren();
});
liste.addEventListener("dragover", (event) => {
if (!gezogen) return;
event.preventDefault();
const ziel = event.target.closest("[data-reorder-item]");
if (!ziel || ziel === gezogen || ziel.parentElement !== liste) return;
const kasten = ziel.getBoundingClientRect();
const obereHaelfte = event.clientY < kasten.top + kasten.height / 2;
liste.insertBefore(gezogen, obereHaelfte ? ziel : ziel.nextElementSibling);
});
nummerieren();
});
}
/* -- Start ---------------------------------------------------------------- */
/**
@@ -893,6 +968,7 @@ function boot() {
initPalette();
initSubmitGuards();
initTileFilters();
initReorder();
initLogTails();
unsavedGuard.init();
initViewer();
@@ -651,7 +651,7 @@ export function initFilePreview(wurzel = document) {
abschliessen: modusFeld?.value === "fertig",
});
const daten = new FormData();
daten.append("pfad", aktuell.pfad);
daten.append("path", aktuell.pfad);
daten.append("ordner", ordnerFeld.value.trim());
daten.append("name", nameFeld.value.trim());
if (ersetzenFeld.checked) daten.append("ersetzen", "1");
@@ -24,7 +24,7 @@
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt &ndash;
Anpassungen bleiben erhalten.
{% if current_user.has('groups.edit') %}
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, freischalten=1) }}">
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, unlocked=1) }}">
{{ icon('unlock', size=14) }} Rechte freischalten
</a>
{% endif %}
@@ -26,19 +26,19 @@
{{ icon('search', size=15) }}
<input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Ziel, Detail, Aktion ...">
</span>
<select class="select" name="aktion" style="width:auto;max-width:240px">
<select class="select" name="action" style="width:auto;max-width:240px">
<option value="">alle Aktionen</option>
{% for action in actions %}
<option value="{{ action }}" {% if action == filters.aktion %}selected{% endif %}>{{ action }}</option>
<option value="{{ action }}" {% if action == filters.action %}selected{% endif %}>{{ action }}</option>
{% endfor %}
</select>
<select class="select" name="stufe" style="width:auto">
<select class="select" name="severity" style="width:auto">
<option value="">alle Stufen</option>
{% for level in ['info', 'notice', 'warning', 'critical'] %}
<option value="{{ level }}" {% if level == filters.stufe %}selected{% endif %}>{{ level }}</option>
<option value="{{ level }}" {% if level == filters.severity %}selected{% endif %}>{{ level }}</option>
{% endfor %}
</select>
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
<input class="input" type="text" name="actor" value="{{ filters.actor }}"
placeholder="Benutzer" style="width:150px">
<button class="btn btn--sm" type="submit">Filtern</button>
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
@@ -88,10 +88,10 @@
<span class="fs-sm muted">Seite {{ page }}</span>
<span class="row row--tight">
{% if page > 1 %}
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page-1, **filters) }}">Zurück</a>
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', page=page-1, **filters) }}">Zurück</a>
{% endif %}
{% if rows | length == per_page %}
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a>
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', page=page+1, **filters) }}">Weiter</a>
{% endif %}
</span>
</div>
@@ -235,3 +235,69 @@
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
</div>
{%- endmacro %}
{# --------------------------------------------------------------------------
Navigationsreihenfolge. Die Reihenfolge der versteckten Felder ist die
Reihenfolge -- verschiebt sich eine Zeile, verschiebt sich das Feld mit ihr.
Ziehen mit der Maus oder die zwei Knoepfe je Zeile; auf einem Tablet sind
die Knoepfe der verlaessliche Weg.
-------------------------------------------------------------------------- #}
{% macro _reorder_row(item, field_name, label) -%}
<li class="reorder__row" data-reorder-item draggable="true">
<span class="reorder__grip" aria-hidden="true">{{ icon('menu', size=14) }}</span>
<span class="reorder__pos" data-reorder-position-text>1</span>
<input type="hidden" name="{{ field_name }}" value="{{ item.key }}">
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
<span class="reorder__moves">
<button class="btn btn--icon btn--sm" type="button" data-reorder-move="up"
aria-label="{{ label }} nach oben">{{ icon('chevron-up', size=14) }}</button>
<button class="btn btn--icon btn--sm" type="button" data-reorder-move="down"
aria-label="{{ label }} nach unten">{{ icon('chevron-down', size=14) }}</button>
</span>
</li>
{%- endmacro %}
{% macro nav_order_card(items, order, child_order, action, csrf_field, csrf_value,
sub='Gilt für alle Benutzer; jeder sieht weiter nur, was er darf') -%}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
<div class="card__sub">{{ sub }}</div>
</div>
</div>
<form method="post" action="{{ action }}">
<input type="hidden" name="{{ csrf_field }}" value="{{ csrf_value }}">
<div class="card__body">
<ol class="reorder" data-behavior="reorder">
{% for key in order %}
{% set item = items | selectattr('key', 'equalto', key) | list | first %}
{% if item %}
{{ _reorder_row(item, 'order', item.label) }}
{% if item.children %}
<li class="reorder__group">
<ol class="reorder reorder--child" data-behavior="reorder">
{% set kinder = child_order.get(item.key) or (item.children | map(attribute='key') | list) %}
{% for child_key in kinder %}
{% set child = item.children | selectattr('key', 'equalto', child_key) | list | first %}
{% if child %}
{{ _reorder_row(child, 'child_order_' ~ item.key, item.label ~ ' / ' ~ child.label) }}
{% endif %}
{% endfor %}
</ol>
</li>
{% endif %}
{% endif %}
{% endfor %}
</ol>
<p class="fs-xs faint mt-3">
Zeilen ziehen oder mit den Pfeilen verschieben, dann speichern. Untergeordnete
Punkte werden innerhalb ihrer Gruppe sortiert.
</p>
</div>
<div class="card__footer">
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
</div>
</form>
</section>
{%- endmacro %}
+1
View File
@@ -38,6 +38,7 @@ ICONS: dict[str, str] = {
"menu": '<path d="M4 6h16M4 12h16M4 18h16"/>',
"chevron-right": '<path d="m9 6 6 6-6 6"/>',
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
"chevron-up": '<path d="m6 15 6-6 6 6"/>',
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"/>',
"moon": '<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>',
"monitor": '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
@@ -31,7 +31,7 @@ from ..extension import core, db, transaction
from ..rbac import service as rbac
from ..security import passwords, sessions as session_store, totp
bp = Blueprint("account", __name__, url_prefix="/konto")
bp = Blueprint("account", __name__, url_prefix="/account")
ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
MAX_AVATAR_BYTES = 2 * 1024 * 1024
@@ -54,6 +54,19 @@ def _avatar_dir() -> Path:
def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None:
"""Protokolliert eine Aenderung am **eigenen** Konto.
Bewusst sparsam benutzt. Das Aenderungsprotokoll beantwortet die Frage
"wer hat woran etwas geaendert" -- und wer sein eigenes Passwort setzt,
ein Profilbild tauscht oder seine Anschrift korrigiert, aendert nichts,
was jemand anderen betrifft. Solche Zeilen fuellten das Protokoll und
schoben die Eintraege aus dem Blick, um die es geht.
Was hier trotzdem stehen bleibt: alles rund um den zweiten Faktor. Ob ein
Konto mit oder ohne zweiten Faktor geschuetzt ist, aendert die Staerke der
Anmeldung -- das ist die eine Selbstbedienung, nach der man nach einem
Vorfall sucht.
"""
audit.log(
conn,
actor=current_user.username,
@@ -80,7 +93,7 @@ def overview():
)
@bp.post("/stammdaten")
@bp.post("/profile")
@login_required
def update_profile():
if current_user.is_directory_account:
@@ -101,12 +114,11 @@ def update_profile():
current_user.id,
),
)
_audit(conn, "account.profile_updated")
flash("Stammdaten gespeichert.", "success")
return redirect(url_for("account.overview"))
@bp.post("/profilbild")
@bp.post("/avatar")
@login_required
def upload_avatar():
upload = request.files.get("avatar")
@@ -142,14 +154,13 @@ def upload_avatar():
"UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?",
(target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
)
_audit(conn, "account.avatar_updated")
if previous:
(_avatar_dir() / str(previous)).unlink(missing_ok=True)
flash("Profilbild aktualisiert.", "success")
return redirect(url_for("account.overview"))
@bp.get("/profilbild/<path:filename>")
@bp.get("/avatar/<path:filename>")
@login_required
def avatar(filename: str):
safe = secure_filename(filename)
@@ -163,7 +174,7 @@ def avatar(filename: str):
# --------------------------------------------------------------------------- #
@bp.get("/sicherheit")
@bp.get("/security")
@login_required
def security():
conn = db()
@@ -199,7 +210,7 @@ def security():
)
@bp.post("/passwort")
@bp.post("/password")
@login_required
def change_password():
conn = db()
@@ -229,21 +240,15 @@ def change_password():
revoked = session_store.revoke_all_for_user(
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change"
)
_audit(
conn,
"account.password_changed",
f"{revoked} weitere Sitzungen beendet",
severity="notice",
)
except rbac.ValidationError as exc:
flash(str(exc), "danger")
return redirect(url_for("account.security"))
flash("Passwort geaendert. Andere Sitzungen wurden beendet.", "success")
flash(f"Passwort geaendert. {revoked} weitere Sitzung(en) wurden beendet.", "success")
return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/starten")
@bp.post("/two-factor/start")
@login_required
@reauth_required
def totp_start():
@@ -252,7 +257,7 @@ def totp_start():
return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/bestaetigen")
@bp.post("/two-factor/confirm")
@login_required
@reauth_required
def totp_confirm():
@@ -295,7 +300,7 @@ def totp_confirm():
return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/deaktivieren")
@bp.post("/two-factor/disable")
@login_required
@reauth_required
def totp_disable():
@@ -317,7 +322,7 @@ def totp_disable():
return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/codes-erneuern")
@bp.post("/two-factor/recovery-codes")
@login_required
@reauth_required
def recovery_codes():
@@ -341,7 +346,7 @@ def recovery_codes():
return redirect(url_for("account.security"))
@bp.post("/sitzungen/<sid>/beenden")
@bp.post("/sessions/<sid>/end")
@login_required
def revoke_session(sid: str):
conn = db()
@@ -350,18 +355,16 @@ def revoke_session(sid: str):
abort(404)
with transaction():
session_store.revoke(conn, sid, reason="self")
_audit(conn, "account.session_revoked", sid[:12])
flash("Sitzung beendet.", "success")
return redirect(url_for("account.security"))
@bp.post("/sitzungen/alle-beenden")
@bp.post("/sessions/end-all")
@login_required
def revoke_other_sessions():
with transaction() as conn:
count = session_store.revoke_all_for_user(
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self"
)
_audit(conn, "account.sessions_revoked", f"{count} Sitzungen")
flash(f"{count} weitere Sitzung(en) beendet.", "success")
return redirect(url_for("account.security"))
+17 -17
View File
@@ -16,7 +16,7 @@ from ..rbac import service as rbac
from ..rbac.model import ACTION_META, Action
from ..security import sessions as session_store
bp = Blueprint("admin", __name__, url_prefix="/verwaltung")
bp = Blueprint("admin", __name__, url_prefix="/administration")
def _audit(conn: Any, action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
@@ -47,7 +47,7 @@ def _int_list(name: str) -> list[int]:
# --------------------------------------------------------------------------- #
@bp.get("/benutzer")
@bp.get("/users")
@permission_required("users.view")
def users():
conn = db()
@@ -60,7 +60,7 @@ def users():
)
@bp.post("/benutzer/neu")
@bp.post("/users/new")
@permission_required("users.create")
def user_create():
conn = db()
@@ -94,7 +94,7 @@ def user_create():
return redirect(url_for("admin.users"))
@bp.post("/benutzer/<int:user_id>")
@bp.post("/users/<int:user_id>")
@permission_required("users.edit")
def user_update(user_id: int):
conn = db()
@@ -137,7 +137,7 @@ def user_update(user_id: int):
return redirect(url_for("admin.users"))
@bp.post("/benutzer/<int:user_id>/loeschen")
@bp.post("/users/<int:user_id>/delete")
@permission_required("users.delete")
def user_delete(user_id: int):
conn = db()
@@ -163,7 +163,7 @@ def user_delete(user_id: int):
return redirect(url_for("admin.users"))
@bp.post("/benutzer/<int:user_id>/sitzungen-beenden")
@bp.post("/users/<int:user_id>/end-sessions")
@permission_required("users.edit")
def user_revoke_sessions(user_id: int):
conn = db()
@@ -184,7 +184,7 @@ def user_revoke_sessions(user_id: int):
return redirect(url_for("admin.users"))
@bp.get("/benutzer/verzeichnis-suche")
@bp.get("/users/directory-search")
@permission_required("users.create")
def directory_search():
conn = db()
@@ -200,7 +200,7 @@ def directory_search():
return jsonify({"results": results})
@bp.post("/benutzer/verzeichnis-uebernehmen")
@bp.post("/users/directory-import")
@permission_required("users.create")
def directory_adopt():
"""Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen."""
@@ -238,7 +238,7 @@ def directory_adopt():
# --------------------------------------------------------------------------- #
@bp.get("/gruppen")
@bp.get("/groups")
@permission_required("groups.view")
def groups():
conn = db()
@@ -254,7 +254,7 @@ def groups():
)
@bp.get("/gruppen/<int:group_id>")
@bp.get("/groups/<int:group_id>")
@permission_required("groups.view")
def group_detail(group_id: int):
conn = db()
@@ -275,11 +275,11 @@ def group_detail(group_id: int):
(group_id,),
),
can_edit=current_user.has("groups.edit"),
unlocked=request.args.get("freischalten") == "1",
unlocked=request.args.get("unlocked") == "1",
)
@bp.post("/gruppen/neu")
@bp.post("/groups/new")
@permission_required("groups.create")
def group_create():
conn = db()
@@ -301,7 +301,7 @@ def group_create():
return redirect(url_for("admin.groups"))
@bp.post("/gruppen/<int:group_id>")
@bp.post("/groups/<int:group_id>")
@permission_required("groups.edit")
def group_update(group_id: int):
conn = db()
@@ -334,7 +334,7 @@ def group_update(group_id: int):
return redirect(url_for("admin.group_detail", group_id=group_id))
@bp.post("/gruppen/<int:group_id>/loeschen")
@bp.post("/groups/<int:group_id>/delete")
@permission_required("groups.delete")
def group_delete(group_id: int):
conn = db()
@@ -354,7 +354,7 @@ def group_delete(group_id: int):
return redirect(url_for("admin.groups"))
@bp.post("/gruppen/<int:group_id>/vorlage-anwenden")
@bp.post("/groups/<int:group_id>/apply-preset")
@admin_required
def group_apply_preset(group_id: int):
"""Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch."""
@@ -386,7 +386,7 @@ def group_apply_preset(group_id: int):
# --------------------------------------------------------------------------- #
@bp.get("/sitzungen")
@bp.get("/sessions")
@admin_required
def sessions():
conn = db()
@@ -399,7 +399,7 @@ def sessions():
return render_template("tesm_core/sessions.html", sessions=rows)
@bp.post("/sitzungen/<sid>/beenden")
@bp.post("/sessions/<sid>/end")
@admin_required
def session_revoke(sid: str):
with transaction() as conn:
@@ -24,7 +24,7 @@ from ..keystore import check_private_file
from ..security import ratelimit
from ..security import sessions as session_store
bp = Blueprint("diagnostics", __name__, url_prefix="/diagnose")
bp = Blueprint("diagnostics", __name__, url_prefix="/administration/diagnostics")
@bp.get("/")
@@ -88,7 +88,7 @@ def overview():
)
@bp.post("/aufraeumen")
@bp.post("/cleanup")
@admin_required
def housekeeping():
"""Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle."""
@@ -116,7 +116,7 @@ def housekeeping():
return redirect(url_for("diagnostics.overview"))
@bp.post("/schluessel-rotieren")
@bp.post("/rotate-keys")
@admin_required
def rotate_keys():
"""Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu.
@@ -29,7 +29,7 @@ from ..licensing_client import LicenseError
from ..rbac import service as rbac
from ..system import hostnet, webserver
bp = Blueprint("hostsettings", __name__, url_prefix="/einstellungen")
bp = Blueprint("hostsettings", __name__, url_prefix="/administration")
PENDING_NETWORK_KEY = "pending_network_revert"
@@ -64,7 +64,7 @@ def _license_hint(feature: str) -> str:
# --------------------------------------------------------------------------- #
@bp.get("/netzwerk")
@bp.get("/network")
@permission_required("settings_network.view")
def network():
extension = core()
@@ -80,7 +80,7 @@ def network():
)
@bp.post("/netzwerk")
@bp.post("/network")
@permission_required("settings_network.edit")
def network_apply():
extension = core()
@@ -147,7 +147,7 @@ def network_apply():
return redirect(url_for("hostsettings.network"))
@bp.post("/netzwerk/bestaetigen")
@bp.post("/network/confirm")
@permission_required("settings_network.edit")
def network_confirm():
if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
@@ -221,7 +221,7 @@ def _web_config() -> webserver.WebConfig:
)
@bp.get("/webserver")
@bp.get("/web")
@permission_required("settings_web.view")
def web():
extension = core()
@@ -240,7 +240,7 @@ def web():
)
@bp.post("/webserver")
@bp.post("/web")
@permission_required("settings_web.edit")
def web_save():
extension = core()
@@ -302,7 +302,7 @@ def web_save():
return redirect(url_for("hostsettings.web"))
@bp.post("/webserver/uebernehmen")
@bp.post("/web/apply")
@permission_required("settings_web.execute")
def web_apply():
problem = _license_hint("nginx")
@@ -327,7 +327,7 @@ def web_apply():
return redirect(url_for("hostsettings.web"))
@bp.post("/webserver/zertifikat")
@bp.post("/web/certificate")
@permission_required("settings_web.execute")
def web_certificate():
problem = _license_hint("nginx")
@@ -364,7 +364,7 @@ def web_certificate():
return redirect(url_for("hostsettings.web"))
@bp.post("/webserver/selbstsigniert")
@bp.post("/web/self-signed")
@permission_required("settings_web.execute")
def web_self_signed():
"""Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain.
@@ -414,7 +414,7 @@ def web_self_signed():
# --------------------------------------------------------------------------- #
@bp.get("/verzeichnisdienst")
@bp.get("/directory")
@permission_required("settings_directory.view")
def directory_settings():
extension = core()
@@ -435,7 +435,7 @@ def directory_settings():
)
@bp.post("/verzeichnisdienst")
@bp.post("/directory")
@permission_required("settings_directory.edit")
def directory_save():
problem = _license_hint("directory")
@@ -481,7 +481,7 @@ def directory_save():
return redirect(url_for("hostsettings.directory_settings"))
@bp.post("/verzeichnisdienst/test")
@bp.post("/directory/test")
@permission_required("settings_directory.execute")
def directory_test():
result = directory.test_connection(db())
@@ -489,7 +489,7 @@ def directory_test():
return redirect(url_for("hostsettings.directory_settings"))
@bp.get("/verzeichnisdienst/gruppensuche")
@bp.get("/directory/group-search")
@permission_required("settings_directory.edit", "fileshare.edit", mode="any")
def directory_group_search():
"""Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder.
@@ -504,7 +504,7 @@ def directory_group_search():
)
@bp.post("/verzeichnisdienst/zuordnung")
@bp.post("/directory/mapping")
@permission_required("settings_directory.edit")
def directory_mapping_add():
conn = db()
@@ -534,7 +534,7 @@ def directory_mapping_add():
return redirect(url_for("hostsettings.directory_settings"))
@bp.post("/verzeichnisdienst/zuordnung/<int:mapping_id>/loeschen")
@bp.post("/directory/mapping/<int:mapping_id>/delete")
@permission_required("settings_directory.edit")
def directory_mapping_delete(mapping_id: int):
with transaction() as conn:
+17 -17
View File
@@ -26,7 +26,7 @@ from ..auth.service import client_ip
from ..extension import core, db, transaction
from ..security.sessions import fragment
bp = Blueprint("logs", __name__, url_prefix="/protokolle")
bp = Blueprint("logs", __name__, url_prefix="/logs")
MAX_TAIL_LINES = 2000
@@ -38,7 +38,7 @@ def _live_log():
return log
@bp.get("/laufend")
@bp.get("/live")
@permission_required("logs_live.view")
def live():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
@@ -52,7 +52,7 @@ def live():
)
@bp.get("/laufend/inhalt")
@bp.get("/live/content")
@permission_required("logs_live.view")
@fragment
def live_fragment():
@@ -63,7 +63,7 @@ def live_fragment():
)
@bp.get("/verlauf")
@bp.get("/history")
@permission_required("logs_history.view", "logs_history.export", mode="any")
def history():
extension = core()
@@ -82,7 +82,7 @@ def history():
)
@bp.get("/verlauf/datei/<path:name>")
@bp.get("/history/file/<path:name>")
@permission_required("logs_history.view")
def history_file(name: str):
extension = core()
@@ -101,7 +101,7 @@ def history_file(name: str):
)
@bp.get("/verlauf/archiv/<day>")
@bp.get("/history/archive/<day>")
@permission_required("logs_history.view")
def history_archive(day: str):
if len(day) != 10 or day.count("-") != 2:
@@ -112,7 +112,7 @@ def history_archive(day: str):
return render_template("tesm_core/logs_archive.html", day=day, rows=rows)
@bp.post("/verlauf/archiv-export")
@bp.post("/history/archive-export")
@permission_required("logs_history.export")
def history_export():
"""Packt die archivierten Audit-Tage in ein ZIP.
@@ -152,7 +152,7 @@ def history_export():
)
@bp.post("/verlauf/archiv-loeschen")
@bp.post("/history/archive-delete")
@permission_required("logs_history.delete")
def history_purge():
removed = 0
@@ -176,20 +176,20 @@ def history_purge():
return redirect(url_for("logs.history"))
@bp.get("/aenderungen")
@bp.get("/audit")
@permission_required("logs_audit.view", "logs_audit.export", mode="any")
def audit_view():
conn = db()
page = max(1, int(request.args.get("seite") or 1))
page = max(1, int(request.args.get("page") or 1))
per_page = 100
return render_template(
"tesm_core/logs_audit.html",
rows=audit.search(
conn,
query=(request.args.get("q") or "").strip(),
action=(request.args.get("aktion") or "").strip(),
actor=(request.args.get("benutzer") or "").strip(),
severity=(request.args.get("stufe") or "").strip(),
action=(request.args.get("action") or "").strip(),
actor=(request.args.get("actor") or "").strip(),
severity=(request.args.get("severity") or "").strip(),
limit=per_page,
offset=(page - 1) * per_page,
),
@@ -200,14 +200,14 @@ def audit_view():
per_page=per_page,
filters={
"q": request.args.get("q") or "",
"aktion": request.args.get("aktion") or "",
"benutzer": request.args.get("benutzer") or "",
"stufe": request.args.get("stufe") or "",
"action": request.args.get("action") or "",
"actor": request.args.get("actor") or "",
"severity": request.args.get("severity") or "",
},
)
@bp.get("/aenderungen/export")
@bp.get("/audit/export")
@permission_required("logs_audit.export")
def audit_export():
conn = db()
@@ -28,7 +28,7 @@ from ..db import Database
from ..extension import core, db, transaction
from ..rbac import service as rbac
bp = Blueprint("trash", __name__, url_prefix="/papierkorb")
bp = Blueprint("trash", __name__, url_prefix="/administration/trash")
@dataclass(frozen=True, slots=True)
@@ -191,7 +191,7 @@ def _provider_or_404(key: str) -> TrashProvider:
return provider
@bp.post("/<kind>/<ident>/wiederherstellen")
@bp.post("/<kind>/<ident>/restore")
@permission_required("trash.edit")
def restore(kind: str, ident: str):
extension = core()
@@ -222,7 +222,7 @@ def restore(kind: str, ident: str):
return redirect(url_for("trash.overview"))
@bp.post("/<kind>/<ident>/endgueltig-loeschen")
@bp.post("/<kind>/<ident>/purge")
@permission_required("trash.delete")
def purge(kind: str, ident: str):
extension = core()
@@ -252,7 +252,7 @@ def purge(kind: str, ident: str):
return redirect(url_for("trash.overview"))
@bp.post("/leeren")
@bp.post("/empty")
@permission_required("trash.delete")
def purge_all():
extension = core()