Compare commits

..
6 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 2eaefc9e1f Auditlog-Archivierung, Verlauf-RAW mit Zeilennummern, UI-Politur (v1.1.6)
- Auditlog waechst nur bis zu einem Schwellenwert (20.000 Zeilen): danach
  werden die aeltesten VOLLSTAENDIGEN Kalendertage (der laufende Tag wird
  nie mitten am Tag angefasst) tageweise in je eine Datei
  audit-YYYY-MM-DD.log unter /var/log/tesm/audit-archive archiviert und
  aus der DB geloescht, bis wieder 15.000 Zeilen unterschritten sind.
  Laeuft als taeglich pruefender Hintergrund-Thread (gate: TESM_WEB_PROCESS,
  wie beim bestehenden Fileshare-Sweep), zusaetzlich einmalig sofort beim
  Dienststart.
- Archivierte Auditlog-Tage sind unter Verlauf einsehbar (RAW-Ansicht) und
  koennen dort als ein ZIP exportiert werden, was sie danach vom Server
  loescht -- bewusst nur manuell ausgeloest statt automatisch, mit eigenem
  Recht logs_history.edit (neue "E"-Spalte bei Verlauf in der
  Gruppen-Rechtematrix, ergibt sich automatisch aus GROUP_ROW_TYPES).
  Aufbewahrung der Archivdateien selbst ist unbegrenzt; bei knappem
  Speicherplatz (< 1 GiB frei im Log-Verzeichnis) erscheint ein Warnhinweis
  auf der Verlauf-Seite.
- Verlauf -> "Komplettes Log (RAW)": Inhalt jetzt mit Zeilennummern (reine
  CSS-Counter-Loesung, kein Extra-Markup pro Zeile) -- Live-Log-RAW und
  nginx-RAW-Konfiguration bleiben bewusst unveraendert (nur an dieser
  Stelle angefragt).
- Auditlog-Tabelle: Avatar-Spalte entfernt, Spaltenbreiten (Zeitpunkt/
  Benutzer/Aktion/Ziel) schrumpfen jetzt auf den Inhalt statt zu strecken,
  "Ziel" nimmt nur noch so viel Platz wie noetig; Aktion-Pills brechen
  generell nicht mehr um (white-space: nowrap).
- "Änderungen" endgueltig zu "Auditlog" umbenannt (Recht-Label, Kommentare);
  die "Änderungen"-Filterkachel selbst (Bearbeiten-Kategorie) behaelt
  bewusst ihren Namen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:06:33 +02:00
alientimandClaude Sonnet 5 cb8b929c3c Auditlog: Hinzufuegen gruen, Loeschen rot, Bearbeiten orange (v1.1.5)
- Neue Pill-Farbvarianten (.action-pill--create/--delete/--edit) je nach
  Aktions-Art -- "create"/"upload"/"mkdir" gruen, "delete" rot, alles
  andere (bearbeiten/aktivieren/zuweisen/...) orange als Sammelkategorie.
- Fehlende Klartext-Label fuer die neuen fileshare.*-Aktionen ergaenzt
  (upload/mkdir/delete/rename/download) -- vorher stand dort noch der
  rohe Aktionsschluessel wie "fileshare.delete" statt eines lesbaren
  Textes wie bei allen anderen Aktionen.

Live auf POETEST verifiziert: alle drei Farben korrekt (gruen bei
"Datei(en) hochgeladen"/"Gruppe angelegt", rot bei "Datei/Ordner
gelöscht"/"Gruppe gelöscht", orange bei "Als ZIP heruntergeladen"/
"Gruppe bearbeitet").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:20:13 +02:00
alientimandClaude Sonnet 5 af5dfe30b4 Fileshare: Mehrfachauswahl zum Herunterladen (ZIP) und Loeschen (v1.1.4)
- Checkbox-Spalte in der Dateitabelle (inkl. "alle auswaehlen" im Tabellen-
  kopf) -- sobald mindestens ein Eintrag markiert ist, erscheint eine
  Aktionsleiste mit "Herunterladen (ZIP)" und (mit fileshare.edit)
  "Loeschen".
- Herunterladen buendelt die Auswahl serverseitig zu EINEM ZIP statt
  mehrerer einzelner Downloads (vermeidet Browser-Blockaden bei vielen
  gleichzeitigen Downloads) -- neue Route /fileshare/download-multi,
  Ordner werden dabei rekursiv mit aufgenommen (relativer Pfad als
  Archivpfad).
- Loeschen mehrerer Elemente auf einmal ueber eine neue Route
  /fileshare/delete-multi (ein Bestaetigungsdialog fuer die ganze Auswahl),
  mit Sammel-Erfolgsmeldung analog zum Mehrfach-Upload.
- Die Pfadaufloesung+Validierung fuer ein einzelnes Kind-Element wurde aus
  dem bestehenden Einzel-Loeschen in _fileshare_resolve_child() extrahiert
  und von Einzel-Loeschen, Mehrfach-Loeschen UND Mehrfach-Download
  gemeinsam genutzt (kein duplizierter Sicherheitscode).

Live auf POETEST verifiziert: Mehrfachauswahl inkl. "alle auswaehlen",
ZIP-Download zweier Dateien (Inhalt geprueft), ZIP-Download eines Ordners
(rekursiv, korrekter Archivpfad), Mehrfach-Loeschen mit Sammelmeldung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:10:21 +02:00
alientimandClaude Sonnet 5 efb0d2aa01 Gruppen-Bearbeiten als Modal, Mobile-Fix, Mehrfach-Upload (v1.1.3)
- Gruppen bearbeiten (Name + Rechte) laeuft jetzt ueber ein Modal statt der
  bisherigen Inline-Ausklapp-Zeile -- gleiche Optik wie "Neue Gruppe". Der
  Gruppenname war dabei bisher ein verstecktes, nie wirklich editierbares
  Feld; jetzt ein normales Texteingabefeld (Backend unterstuetzte das
  Umbenennen inkl. Systemgruppen-Schutz bereits vollstaendig, es fehlte nur
  die Eingabemoeglichkeit im Formular). Admin-Rechte-Ansicht und die
  "Freischalten"-Ausnahme fuer die Systemgruppe "Benutzer" ziehen ins
  jeweilige Modal mit um.
- Fileshare-Baum + Tabelle nebeneinander sprengte auf Tablet-/Handybreite
  die Seite -- stapelt jetzt (Baum oben, Tabelle darunter) ab der
  bestehenden 900px-Sidebar-Umschaltgrenze.
- Mehrfach-Upload: Datei-Eingabefeld erlaubt jetzt echte Mehrfachauswahl
  (mehrere Dateien in einem Dialog) UND mehrmaliges Hinzufuegen
  nacheinander (per DataTransfer angesammelt, bevor "Hochladen" gedrueckt
  wird) -- funktioniert nativ auch auf Mobilgeraeten, da kein Custom-
  Upload-Mechanismus noetig ist. Backend verarbeitet jetzt eine Liste
  statt einer einzelnen Datei (request.files.getlist), mit Sammel-
  Erfolgsmeldung und pro Datei separater Namensvalidierung.

Live auf POETEST verifiziert: Umbenennen+Rechte-Speichern ueber das neue
Modal, Admin-Modal (readonly), Mobile-Layout (390px, Baum stapelt korrekt),
echte Mehrfachauswahl (2 Dateien in einem Dialog, beide korrekt
hochgeladen und einzeln in der Sammelmeldung genannt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:17:32 +02:00
alientimandClaude Sonnet 5 d461f8ca42 Fileshare: redundanten Breadcrumb entfernt, Baum/Tabelle gleich hoch (v1.1.2)
- Der Breadcrumb-Link-Zeile ueber der Dateitabelle ("LDAP Share / Test")
  war seit der Baum-Navigation redundant -- der Baum zeigt die aktuelle
  Position ja schon (aufgeklappt + hervorgehoben). Entfernt.
- .fileshare-layout auf align-items:stretch umgestellt (statt flex-start)
  und .fileshare-main als Flex-Spalte mit table-wrap{flex:1}, damit die
  Baum- und die Tabellen-Kachel immer exakt gleich hoch sind, unabhaengig
  vom jeweiligen Inhalt. Das inline margin-bottom auf .table-wrap musste
  dafuer weg (sonst blieb trotzdem eine 16px-Luecke zwischen den unteren
  Kanten).

Live auf POETEST verifiziert: beide Kacheln exakt gleiche Hoehe (166.5px
in beiden Fällen), Breadcrumb-Div nicht mehr im DOM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:51:57 +02:00
alientimandClaude Sonnet 5 1b7fe81507 Fileshare: Baum-Navigation, Datei-Vorschau, 15MB-Uploads (v1.1.1)
- Freigaben-Auswahl von einem Dropdown auf eine Baum-Navigation (links)
  umgestellt: alle gemounteten Freigaben als Wurzelknoten, Ordner beliebig
  aufklappbar. Der Pfad von der Wurzel bis zur aktuellen Position wird
  serverseitig schon aufgeklappt gerendert (_fileshare_tree_ancestors),
  alles andere laedt der Client per Klick lazy nach (neue Route
  /fileshare/subfolders) -- kein Durchlaufen der kompletten Freigabe auf
  einmal noetig. Dateien stehen bewusst nicht im Baum, nur in der
  bestehenden Tabelle rechts.
- Inline-Vorschau fuer PDF (iframe), Bilder, TXT/CSV/LOG/MD/JSON (per Range-
  Request in Haeppchen bis 512KB, mit Hinweis bei Abschneiden -- Erkennung
  ueber den Content-Range-Header, nicht nur den 206-Status, der bei JEDEM
  Range-Request auftritt) sowie Word/Excel rein clientseitig per mammoth.js
  bzw. SheetJS (beide lokal vendored, kein Cloud-Viewer -- interne Dokumente
  verlassen das Netz nicht). Excel-Zellen werden bewusst per textContent
  statt ueber SheetJS' eigene HTML-Ausgabe gesetzt, um unabhaengig von
  deren Escaping-Verhalten sicher zu sein. Neue Route /fileshare/view mit
  fester Endungs-Positivliste, nosniff + restriktiver CSP.
- Uploads bis 15MB erlaubt (MAX_CONTENT_LENGTH in app.py) -- dafuer musste
  auch nginx' client_max_body_size (Standard 1MB) auf 16MB angehoben werden,
  sowohl im Vorlagen-Code (_NGINX_PROXY_LOCATIONS/_render_nginx_config,
  fuer die naechste Anwendung ueber Einstellungen->NGINX) als auch in der
  Fresh-Install-Vorlage. Neuer 413-Errorhandler zeigt eine Flash-Meldung
  statt Werkzeugs nackter Fehlerseite.
- install.sh: known_hosts (liegt unter /srv/tesm) von rsync --delete
  ausgenommen -- fehlte bisher in der Exclude-Liste (anders als sqlite.db/
  fernet.key/secret.key) und waere bei jedem Update verlorengegangen.

Live auf POETEST getestet: Baum-Vorbefuellung, Lazy-Load von
Geschwisterordnern, alle 5 Vorschautypen (PDF-Route direkt verifiziert,
Bild/Text/Word/Excel zusaetzlich visuell), Abschneide-Erkennung sowohl bei
kleiner als auch bei >512KB-Datei.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:40:33 +02:00
12 changed files with 1478 additions and 205 deletions
+5
View File
@@ -29,6 +29,11 @@ server {
}
location / {
# nginx' Standard (1m) reicht für Fileshare-Uploads nicht -- etwas
# großzügiger als Flasks eigenes MAX_CONTENT_LENGTH (siehe app.py),
# damit bei einer knapp 15MB großen Datei nginx nicht schon vor
# Flask mit seiner eigenen, unschöneren 413-Seite abbricht.
client_max_body_size 16m;
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+1
View File
@@ -134,6 +134,7 @@ sudo chmod 755 /var/log/tesm
step "Deploying application to /srv/tesm"
sudo mkdir -p /srv/tesm
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
--exclude 'known_hosts' \
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
print_status "Application files copied"
+1 -1
View File
@@ -1 +1 @@
1.1.0
1.1.6
+513 -25
View File
@@ -19,7 +19,7 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes, serialization
from cryptography import x509
from datetime import datetime, timedelta
import base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback
import base64, csv, io, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback, zipfile
import paramiko
import yaml
import ssl
@@ -94,6 +94,18 @@ TESM_CHANGES_LOG_PATH = os.path.join(TESM_LOG_DIR, "changes.log")
TESM_APP_LOG_PATH = os.path.join(TESM_LOG_DIR, "app.log")
TESM_KEA_LOG_PATH = os.environ.get("TESM_KEA_LOG_PATH", "/var/log/kea/kea-dhcp4.log")
LOGROTATE_CONFIG_PATH = os.environ.get("TESM_LOGROTATE_CONFIG", "/etc/logrotate.d/tesm")
# Auditlog-Archivierung: eigenständig und bewusst UNABHÄNGIG von der
# logrotate-Rotation von changes.log (siehe _archive_old_audit_log_rows).
# Schwellenwerte mit dem Nutzer abgestimmt (20.000 Zeilen Trigger, Ziel
# 15.000) -- Aufbewahrung der Archivdateien selbst ist bewusst unbegrenzt,
# stattdessen nur eine Warnung bei knappem Speicherplatz plus manueller
# Export-und-Löschen-Funktion unter Verlauf.
AUDIT_ARCHIVE_DIR = os.path.join(TESM_LOG_DIR, "audit-archive")
AUDIT_LOG_ARCHIVE_THRESHOLD = 20000
AUDIT_LOG_ARCHIVE_TARGET = 15000
AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS = 24 * 60 * 60
LOG_DISK_SPACE_WARNING_BYTES = 1 * 1024 * 1024 * 1024 # 1 GiB frei
try:
os.makedirs(TESM_LOG_DIR, exist_ok=True)
# War früher 0o777, als noch nicht jeder schreibende Prozess (tesm.service,
@@ -164,6 +176,14 @@ DEVICE_MAINTENANCE_CATEGORY = "linux"
os.makedirs(AVATAR_DIR, exist_ok=True)
app = Flask(__name__)
# Fileshare-Uploads: bislang gab es keine Grenze (nginx' eigenes Limit vor
# app.py griff mangels client_max_body_size faktisch bei 1MB, siehe
# etc/nginx/sites-available/tesm bzw. _NGINX_PROXY_LOCATIONS -- beides jetzt
# passend auf 15/16MB angehoben). Ohne dieses Flask-seitige Limit würde ein
# zu großer Upload erst ganz am Ende, nach vollständigem Empfang, an
# secure_filename()/os.path-Prüfungen scheitern -- mit MAX_CONTENT_LENGTH
# bricht Werkzeug den Request sofort ab (413), siehe Fehlerbehandlung unten.
app.config["MAX_CONTENT_LENGTH"] = 15 * 1024 * 1024
try:
_app_log_handler = logging.FileHandler(TESM_APP_LOG_PATH, encoding="utf-8")
@@ -175,6 +195,18 @@ except OSError:
pass
@app.errorhandler(413)
def _handle_request_too_large(_e):
"""Greift z.B. beim Fileshare-Upload (siehe MAX_CONTENT_LENGTH oben) --
ohne diesen Handler würde Werkzeug eine nackte 413-Fehlerseite ohne
App-Look ausliefern. request.referrer statt einer festen Route, damit
das auch für andere, spätere Uploads (nicht nur Fileshare) die richtige
Seite trifft."""
max_mb = (app.config.get("MAX_CONTENT_LENGTH") or 0) // (1024 * 1024)
flash(f"Die Datei ist zu groß (Limit: {max_mb} MB).", "danger")
return redirect(request.referrer or url_for("index"))
def _load_or_create_secret() -> str:
env_secret = os.environ.get("TESM_SECRET_KEY")
if env_secret:
@@ -261,8 +293,8 @@ PERMISSIONS = {
"view_key": "logs_group.view",
"children": {
"logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}},
"logs_history": {"label": "Verlauf", "rows": {"view": "logs_history.view"}},
"logs_activity": {"label": "Änderungen", "rows": {"view": "logs_activity.view"}},
"logs_history": {"label": "Verlauf", "rows": {"view": "logs_history.view", "edit": "logs_history.edit"}},
"logs_activity": {"label": "Auditlog", "rows": {"view": "logs_activity.view"}},
"logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}},
},
},
@@ -347,9 +379,10 @@ PERMISSION_LABELS = {
"maintenance.edit": "Wartungsaktionen ausführen (SSH-Update, SSH-Neustart)",
"papierkorb.view": "Papierkorb ansehen (alle gelöschten Geräte/Switche/Zugangsdaten/Benutzer/Gruppen, unabhängig von deren eigenen Leserechten)",
"papierkorb.edit": "Papierkorb bearbeiten (beliebige Einträge wiederherstellen oder endgültig löschen, unabhängig vom sonstigen Bearbeiten-Recht der jeweiligen Ressource)",
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen",
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Auditlog lesen",
"logs_kea.view": "Kea-DHCP-Log lesen",
"logs_history.view": "Log-Verlauf lesen (ältere, durch logrotate rotierte Kopien des Live-Logs)",
"logs_history.view": "Log-Verlauf lesen (ältere, durch logrotate rotierte Kopien des Live-Logs, sowie archivierte Auditlog-Tage)",
"logs_history.edit": "Archivierte Auditlog-Tage exportieren (ZIP-Download) und danach vom Server löschen",
"users.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
"users.edit": "Benutzer ändern (inkl. Löschen)",
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
@@ -414,7 +447,7 @@ NAV_ITEMS = [
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
{"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
{"key": "logs_history", "label": "Verlauf", "icon": "clock", "endpoint": "logs_history"},
{"key": "logs_activity", "label": "Änderungen", "icon": "history", "endpoint": "activity_log"},
{"key": "logs_activity", "label": "Auditlog", "icon": "history", "endpoint": "activity_log"},
{"key": "logs_kea", "label": "Kea-DHCP", "icon": "network", "endpoint": "kea_log"},
]},
]
@@ -647,6 +680,14 @@ class User(UserMixin):
automatisch auch durch ältere Logstände blättern zu können."""
return self.has_permission("logs_history.view")
@property
def can_manage_log_history(self):
"""Zusätzlich zum reinen Lesen (can_view_log_history): archivierte
Auditlog-Tage als ZIP exportieren und danach vom Server löschen --
bewusst eigenes Edit-Recht, weil das eine destruktive Aktion ist,
die über reines Einsehen des Verlaufs hinausgeht."""
return self.has_permission("logs_history.edit")
@property
def can_view_activity_log(self):
return self.has_permission("logs_activity.view")
@@ -1166,7 +1207,7 @@ def _append_changes_log(ts, who, action, target, details):
"""Spiegelt jeden Änderungslog-Eintrag zusätzlich in eine eigene, per
logrotate rotierte Datei (TESM_CHANGES_LOG_PATH) die audit_log-Tabelle
bleibt die Quelle für die durchsuchbare/sortierbare Seite unter
Logs Änderungen, wächst aber unbegrenzt weiter; die Datei bekommt
Logs Auditlog, wächst aber unbegrenzt weiter; die Datei bekommt
dieselbe wöchentliche Rotation/Aufbewahrung wie die anderen drei Logs.
Ein Schreibfehler hier darf den eigentlichen, DB-basierten Audit-Trail
nicht gefährden daher bewusst best-effort mit breitem except."""
@@ -1213,6 +1254,151 @@ def log_action_system(action, target=None, details=None):
_append_changes_log(ts, "system", action, target, details)
def _format_audit_line(ts, who, action, target, details):
"""Gleiches Zeilenformat wie _append_changes_log(), damit ein
archivierter Auditlog-Tag optisch identisch zu changes.log aussieht."""
line = f"{ts} [{who}] {action}"
if target:
line += f"{target}"
if details:
line += f": {details}"
return line
def _audit_archive_file_path(day_str):
"""day_str: 'YYYY-MM-DD', ausschließlich aus intern erzeugten
DB-Zeitstempeln abgeleitet, nie aus Nutzereingabe -- anders als bei
_resolve_log_history_file() ist hier kein Path-Traversal-Schutz nötig."""
return os.path.join(AUDIT_ARCHIVE_DIR, f"audit-{day_str}.log")
def _audit_archive_files():
"""Für die Verlauf-Seite: alle bereits archivierten Auditlog-Tage,
neueste zuerst, mit Größe für die Dateiliste."""
if not os.path.isdir(AUDIT_ARCHIVE_DIR):
return []
out = []
for name in os.listdir(AUDIT_ARCHIVE_DIR):
m = re.fullmatch(r"audit-(\d{4}-\d{2}-\d{2})\.log", name)
if not m:
continue
full_path = os.path.join(AUDIT_ARCHIVE_DIR, name)
try:
size = os.path.getsize(full_path)
except OSError:
continue
out.append({"filename": name, "day": m.group(1), "size": size})
out.sort(key=lambda f: f["day"], reverse=True)
return out
def _resolve_audit_archive_file(filename):
"""Path-Traversal-Schutz analog _resolve_log_history_file(): nur exakt
ein Dateiname im erwarteten audit-YYYY-MM-DD.log-Muster, innerhalb von
AUDIT_ARCHIVE_DIR."""
if not filename or not re.fullmatch(r"audit-\d{4}-\d{2}-\d{2}\.log", filename):
return None
full_path = os.path.join(AUDIT_ARCHIVE_DIR, filename)
if os.path.dirname(os.path.abspath(full_path)) != os.path.abspath(AUDIT_ARCHIVE_DIR):
return None
if not os.path.isfile(full_path):
return None
return full_path
def _log_disk_space_warning():
"""None wenn genug Platz frei ist, sonst ein Warnhinweis-Text fürs
Verlauf-Template. Bewusst nur eine Warnung (kein automatisches
Löschen) -- Aufräumen bleibt eine bewusste, manuelle Entscheidung
über den Export-und-Löschen-Button."""
try:
free = shutil.disk_usage(TESM_LOG_DIR).free
except OSError:
return None
if free >= LOG_DISK_SPACE_WARNING_BYTES:
return None
free_mb = free // (1024 * 1024)
return (
f"Wenig Speicherplatz im Log-Verzeichnis (nur noch {free_mb} MB frei). "
f"Archivierte Auditlog-Tage können unten exportiert (ZIP-Download) und "
f"dabei vom Server gelöscht werden, um Platz zu schaffen."
)
def _archive_old_audit_log_rows():
"""Hält die audit_log-Tabelle auf eine vernünftige historische
Zeilenanzahl begrenzt: sobald AUDIT_LOG_ARCHIVE_THRESHOLD überschritten
ist, werden die ältesten VOLLSTÄNDIGEN Kalendertage (alles vor dem
heutigen Tag -- der laufende Tag wird nie mitten am Tag angefasst) in je
eine eigene Datei audit-YYYY-MM-DD.log unter AUDIT_ARCHIVE_DIR
geschrieben und anschließend aus der DB gelöscht, so lange bis
AUDIT_LOG_ARCHIVE_TARGET wieder unterschritten ist oder kein
archivierbarer (nicht-heutiger) Tag mehr übrig ist.
Bewusst unabhängig von der wöchentlichen logrotate-Rotation von
changes.log (siehe _write_logrotate_config): die Dateien hier sind
tagesgenau benannt, damit sie unter Verlauf direkt nach Kalendertag
auswählbar sind -- eine logrotate-Kopie (changes.log.1.gz usw.) lässt
sich nicht so eindeutig einem Tag zuordnen.
Schreiben je Tagesdatei erfolgt im Overwrite-Modus ('w', nicht 'a'):
die DB ist bis zum COMMIT des DELETE die alleinige Quelle der Wahrheit
für einen noch nicht archivierten Tag -- ein erneuter Lauf (z.B. nach
einem Absturz zwischen Schreiben und Löschen) erzeugt exakt denselben
Dateiinhalt erneut, statt Zeilen zu duplizieren."""
conn = get_db_connection()
archived_days = 0
archived_rows = 0
try:
total = conn.execute("SELECT COUNT(*) AS n FROM audit_log").fetchone()["n"]
if total <= AUDIT_LOG_ARCHIVE_THRESHOLD:
return
today_start = datetime.now().strftime("%Y-%m-%d 00:00:00")
os.makedirs(AUDIT_ARCHIVE_DIR, exist_ok=True)
while total > AUDIT_LOG_ARCHIVE_TARGET:
oldest = conn.execute(
"SELECT MIN(ts) AS ts FROM audit_log WHERE ts < ?", (today_start,)
).fetchone()["ts"]
if not oldest:
break # nur noch der heutige Tag übrig -- nicht anfassen
day_str = oldest[:10]
day_start, day_end = f"{day_str} 00:00:00", f"{day_str} 23:59:59"
rows = conn.execute(
"SELECT ts, username, action, target, details FROM audit_log "
"WHERE ts >= ? AND ts <= ? ORDER BY ts, id",
(day_start, day_end),
).fetchall()
if not rows:
break
lines = [
_format_audit_line(r["ts"], r["username"], r["action"], r["target"], r["details"])
for r in rows
]
with open(_audit_archive_file_path(day_str), "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
conn.execute("DELETE FROM audit_log WHERE ts >= ? AND ts <= ?", (day_start, day_end))
conn.commit()
archived_days += 1
archived_rows += len(rows)
total -= len(rows)
finally:
conn.close()
if archived_rows:
log_action_system(
"auditlog.archive",
details=(
f"{archived_rows} Einträge über {archived_days} Tag(e) archiviert "
f"(Schwellenwert {AUDIT_LOG_ARCHIVE_THRESHOLD}, Ziel {AUDIT_LOG_ARCHIVE_TARGET})."
),
)
def touch_record(conn, table, key_col, key_val):
"""Trägt 'zuletzt geändert von/am' direkt am Datensatz ein (Devices/Switches)."""
who = current_user.username if current_user.is_authenticated else "system"
@@ -1265,7 +1451,7 @@ LOG_ROTATION_DEFAULT_KEEP = 4
def _write_logrotate_config():
"""Schreibt/aktualisiert die logrotate-Konfiguration für alle vier
App-Logs (Live, Änderungen, App unter TESM_LOG_DIR; Kea-DHCP separat
App-Logs (Live, Auditlog, App unter TESM_LOG_DIR; Kea-DHCP separat
unter TESM_KEA_LOG_PATH Keas AppArmor-Profil erlaubt nur genau diesen
einen Pfad, siehe Kommentar bei TESM_KEA_LOG_PATH) anhand der aktuellen
Einstellungen (Systemeinstellungen Logs). Läuft sowohl beim App-Start
@@ -1833,6 +2019,35 @@ FILESHARE_MOUNT_ROOT = os.environ.get("TESM_FILESHARE_MOUNT_ROOT", "/mnt/tesm-sh
FILESHARE_MAX_AGE_SECONDS = 12 * 3600
FILESHARE_SWEEP_INTERVAL_SECONDS = 1800
# Inline-Vorschau (/fileshare/view, siehe unten): pro Endung, welche Art von
# Vorschau der Client bauen soll (steuert nur die UI/JS-Verzweigung) und mit
# welchem Content-Type die Datei ausgeliefert wird. Bewusst eine feste
# Positivliste -- alles andere bekommt gar keinen Vorschau-Button und die
# View-Route liefert für unbekannte Endungen 415 statt "irgendwas" mit vom
# Dateinamen geratenem Content-Type auszuliefern. .doc (altes Word-Binär-
# format) ist bewusst NICHT dabei -- mammoth.js kann nur .docx (OOXML)
# zuverlässig konvertieren; SheetJS dagegen liest sowohl alte .xls- als auch
# .xlsx-Dateien ordentlich, deshalb dort beide.
_FILESHARE_PREVIEW_KINDS = {
".pdf": "pdf",
".jpg": "image", ".jpeg": "image", ".png": "image", ".gif": "image",
".webp": "image", ".bmp": "image", ".svg": "image",
".txt": "text", ".csv": "text", ".log": "text", ".md": "text", ".json": "text",
".docx": "docx",
".xlsx": "xlsx", ".xls": "xlsx",
}
_FILESHARE_PREVIEW_MIME = {
".pdf": "application/pdf",
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
".webp": "image/webp", ".bmp": "image/bmp", ".svg": "image/svg+xml",
".txt": "text/plain; charset=utf-8", ".csv": "text/plain; charset=utf-8",
".log": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
".json": "text/plain; charset=utf-8",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xls": "application/vnd.ms-excel",
}
_active_fileshare_mounts = {}
@@ -2057,6 +2272,25 @@ if _IS_WEB_PROCESS:
threading.Thread(target=_fileshare_sweep_loop, daemon=True).start()
def _audit_archive_loop():
"""Prüft einmal täglich (siehe AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS), ob
die audit_log-Tabelle den Schwellenwert überschritten hat, und
archiviert dann bei Bedarf über _archive_old_audit_log_rows(). Läuft
direkt beim Start einmal sofort (statt erst nach 24h zu prüfen), damit
ein frisch aktualisierter/neu gestarteter Dienst eine bereits
übervolle Tabelle nicht einen ganzen Tag lang unangetastet lässt."""
while True:
try:
_archive_old_audit_log_rows()
except Exception:
app.logger.error("Auditlog-Archivierung fehlgeschlagen:\n%s", traceback.format_exc())
time.sleep(AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS)
if _IS_WEB_PROCESS:
threading.Thread(target=_audit_archive_loop, daemon=True).start()
def _current_fileshare_mounts():
"""Für die Fileshare-Seite und die Nav-Sichtbarkeit: gemountete
Freigaben der AKTUELLEN Session, oder eine leere Liste (lokale Nutzer,
@@ -2099,11 +2333,13 @@ def _fileshare_list_dir(abs_path):
except OSError:
continue
is_dir = entry.is_dir(follow_symlinks=False)
ext = os.path.splitext(entry.name)[1].lower()
entries.append({
"name": entry.name,
"is_dir": is_dir,
"size_str": "" if is_dir else _format_log_size(st.st_size),
"mtime_str": datetime.fromtimestamp(st.st_mtime).strftime("%d.%m.%Y %H:%M"),
"preview_kind": None if is_dir else _FILESHARE_PREVIEW_KINDS.get(ext),
})
except OSError:
pass
@@ -2111,6 +2347,29 @@ def _fileshare_list_dir(abs_path):
return entries
def _fileshare_tree_ancestors(label, rel_path):
"""Für die Baumansicht: liefert für JEDE Ebene von der Freigabe-Wurzel
bis zum aktuellen Pfad die dortigen Unterordner (nur Ordner, keine
Dateien) -- damit der Baum serverseitig schon bis zur aktuellen
Position aufgeklappt gerendert werden kann. Alles darüber hinaus
(Geschwister-Ordner, die der Nutzer selbst aufklappt) lädt der Client
bei Bedarf über /fileshare/subfolders nach. Schlüssel ist der jeweilige
Teilpfad ("" für die Freigabe-Wurzel selbst)."""
segments = [p for p in rel_path.split("/") if p]
expanded = {}
acc = []
for depth in range(len(segments) + 1):
current_rel = "/".join(acc)
abs_path = _fileshare_resolve_path(label, current_rel)
if not abs_path or not os.path.isdir(abs_path):
break
folders = [e["name"] for e in _fileshare_list_dir(abs_path) if e["is_dir"]]
expanded[current_rel] = folders
if depth < len(segments):
acc.append(segments[depth])
return expanded
@app.route("/fileshare")
@login_required
def fileshare():
@@ -2150,9 +2409,50 @@ def fileshare():
entries=_fileshare_list_dir(abs_path) if abs_path else [],
can_create=current_user.has_permission("fileshare.create"),
can_edit=current_user.has_permission("fileshare.edit"),
tree_expanded=_fileshare_tree_ancestors(selected_share, rel_path) if abs_path else {"": []},
)
@app.route("/fileshare/subfolders")
@login_required
def fileshare_subfolders():
"""Lazy-Nachladen EINER Baumebene (nur Unterordner) für die Baum-
Navigation der Fileshare-Seite -- die Wurzel-bis-aktuell-Kette liefert
die Hauptroute bereits serverseitig mit (siehe tree_expanded), alles
andere (vom Nutzer aufgeklappte Geschwisterordner) holt der Client
gezielt über diese Route nach, ohne die Freigabe komplett zu durchlaufen."""
if not current_user.has_permission("fileshare.view"):
return jsonify({"error": "Keine Berechtigung."}), 403
abs_path = _fileshare_resolve_path(request.args.get("share", ""), request.args.get("path", ""))
if not abs_path or not os.path.isdir(abs_path):
return jsonify({"error": "Ungültiger Pfad."}), 404
return jsonify({"folders": [e["name"] for e in _fileshare_list_dir(abs_path) if e["is_dir"]]})
@app.route("/fileshare/view")
@login_required
def fileshare_view():
"""Inline-Vorschau (Content-Disposition NICHT 'attachment', anders als
/fileshare/download) für eine feste Positivliste von Dateitypen (siehe
_FILESHARE_PREVIEW_MIME) -- alles andere liefert bewusst 415 statt mit
geratenem Content-Type etwas potenziell Falsches inline auszuliefern.
nosniff + eine restriktive CSP zusätzlich als Tiefenverteidigung, falls
diese URL direkt (statt über das Vorschau-Modal) aufgerufen wird."""
if not current_user.has_permission("fileshare.view"):
return "Keine Berechtigung.", 403
abs_path = _fileshare_resolve_path(request.args.get("share", ""), request.args.get("path", ""))
if not abs_path or not os.path.isfile(abs_path):
return "Datei nicht gefunden.", 404
ext = os.path.splitext(abs_path)[1].lower()
mimetype = _FILESHARE_PREVIEW_MIME.get(ext)
if not mimetype:
return "Vorschau für diesen Dateityp nicht verfügbar.", 415
resp = send_file(abs_path, as_attachment=False, mimetype=mimetype, conditional=True)
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
return resp
@app.route("/fileshare/download")
@login_required
def fileshare_download():
@@ -2174,18 +2474,31 @@ def fileshare_upload():
return redirect(url_for("fileshare", share=share, path=rel_path))
abs_dir = _fileshare_resolve_path(share, rel_path)
file = request.files.get("file")
if not abs_dir or not os.path.isdir(abs_dir) or not file or not file.filename:
# request.files.getlist() statt .get(): das Upload-Feld erlaubt jetzt
# Mehrfachauswahl (name="file" multiple) -- ein einzelner Dateiauswahl-
# Dialog liefert dann mehrere Files unter demselben Feldnamen, klassisch
# eine Datei liefert genauso eine Liste mit einem Element.
files = [f for f in request.files.getlist("file") if f and f.filename]
if not abs_dir or not os.path.isdir(abs_dir) or not files:
flash("Ungültiges Ziel oder keine Datei ausgewählt.", "danger")
else:
uploaded, rejected = [], []
for file in files:
filename = secure_filename(file.filename)
dest = os.path.join(abs_dir, filename) if filename else None
if not filename or os.path.dirname(os.path.realpath(dest)) != os.path.realpath(abs_dir):
flash("Ungültiger Dateiname.", "danger")
else:
rejected.append(file.filename)
continue
file.save(dest)
log_action("fileshare.upload", share, f"{rel_path}/{filename}".strip("/"))
flash(f"{filename}“ hochgeladen.", "success")
uploaded.append(filename)
if uploaded:
log_action("fileshare.upload", share, f"{rel_path}/".strip("/") + f" ({len(uploaded)} Datei(en): {', '.join(uploaded)})")
if len(uploaded) == 1:
flash(f"{uploaded[0]}“ hochgeladen.", "success")
else:
flash(f"{len(uploaded)} Dateien hochgeladen: {', '.join(uploaded)}.", "success")
if rejected:
flash(f"Ungültiger Dateiname, übersprungen: {', '.join(rejected)}.", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
@@ -2212,6 +2525,21 @@ def fileshare_mkdir():
return redirect(url_for("fileshare", share=share, path=rel_path))
def _fileshare_resolve_child(share, rel_path, name):
"""Löst EINEN Kind-Eintrag (Datei oder Ordner) von rel_path auf und
stellt zusätzlich sicher, dass er auch tatsächlich direkt DARIN liegt
(per os.sep-Präfix-Vergleich des bereits Path-Traversal-geprüften
_fileshare_resolve_path) -- von delete/delete-multi/download-multi
gemeinsam genutzt. None bei jedem ungültigen Fall, wirft nie."""
if not name:
return None
parent_abs = _fileshare_resolve_path(share, rel_path)
target_abs = _fileshare_resolve_path(share, f"{rel_path}/{name}".strip("/"))
if not parent_abs or not target_abs or not target_abs.startswith(parent_abs + os.sep):
return None
return target_abs
@app.route("/fileshare/delete", methods=["POST"])
@login_required
def fileshare_delete():
@@ -2222,9 +2550,8 @@ def fileshare_delete():
return redirect(url_for("fileshare", share=share, path=rel_path))
name = request.form.get("name", "")
parent_abs = _fileshare_resolve_path(share, rel_path)
target_abs = _fileshare_resolve_path(share, f"{rel_path}/{name}".strip("/")) if name else None
if not parent_abs or not target_abs or not target_abs.startswith(parent_abs + os.sep):
target_abs = _fileshare_resolve_child(share, rel_path, name)
if not target_abs:
flash("Ungültiges Ziel.", "danger")
else:
try:
@@ -2239,6 +2566,99 @@ def fileshare_delete():
return redirect(url_for("fileshare", share=share, path=rel_path))
@app.route("/fileshare/delete-multi", methods=["POST"])
@login_required
def fileshare_delete_multi():
"""Wie /fileshare/delete, nur für eine per Checkbox ausgewählte Menge
an Dateien/Ordnern auf einmal (Mehrfachauswahl in der Tabelle)."""
share = request.form.get("share", "")
rel_path = request.form.get("path", "")
if not current_user.has_permission("fileshare.edit"):
flash("Keine Berechtigung, Dateien/Ordner zu löschen.", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
names = [n for n in request.form.getlist("names") if n]
if not names:
flash("Keine Elemente ausgewählt.", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
deleted, failed = [], []
for name in names:
target_abs = _fileshare_resolve_child(share, rel_path, name)
if not target_abs:
failed.append(name)
continue
try:
if os.path.isdir(target_abs):
shutil.rmtree(target_abs)
else:
os.remove(target_abs)
deleted.append(name)
except OSError:
failed.append(name)
if deleted:
log_action("fileshare.delete", share, f"{rel_path}/".strip("/") + f" ({len(deleted)} Element(e): {', '.join(deleted)})")
flash(f"{len(deleted)} Element(e) gelöscht: {', '.join(deleted)}.", "success")
if failed:
flash(f"Löschen fehlgeschlagen für: {', '.join(failed)}.", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
@app.route("/fileshare/download-multi", methods=["POST"])
@login_required
def fileshare_download_multi():
"""Baut eine Auswahl aus mehreren Dateien/Ordnern zu EINEM ZIP zusammen
-- vermeidet, dass der Browser bei vielen einzelnen Downloads auf
einmal blockiert/nachfragt, und ist die auch anderswo (Drive, Nextcloud
etc.) übliche Erwartung bei Mehrfachauswahl. Ordner werden rekursiv mit
aufgenommen (relativer Pfad innerhalb des Ordners als Archivpfad)."""
share = request.form.get("share", "")
rel_path = request.form.get("path", "")
if not current_user.has_permission("fileshare.view"):
return "Keine Berechtigung.", 403
names = [n for n in request.form.getlist("names") if n]
if not names:
flash("Keine Elemente ausgewählt.", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
targets = []
skipped = []
for name in names:
target_abs = _fileshare_resolve_child(share, rel_path, name)
if not target_abs or not os.path.exists(target_abs):
skipped.append(name)
continue
targets.append((name, target_abs))
if not targets:
flash("Keines der ausgewählten Elemente konnte gefunden werden.", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
if skipped:
flash(f"Übersprungen (nicht gefunden): {', '.join(skipped)}.", "danger")
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for name, target_abs in targets:
if os.path.isdir(target_abs):
for root, _dirs, files in os.walk(target_abs):
for fname in files:
full = os.path.join(root, fname)
arcname = os.path.join(name, os.path.relpath(full, target_abs))
zf.write(full, arcname)
else:
zf.write(target_abs, name)
buffer.seek(0)
if len(targets) == 1:
base_name, _ext = os.path.splitext(targets[0][0])
zip_name = secure_filename(base_name if not os.path.isdir(targets[0][1]) else targets[0][0]) or "Download"
else:
zip_name = secure_filename(f"{share}-Auswahl") or "Download"
log_action("fileshare.download", share, f"{len(targets)} Element(e) als ZIP: {', '.join(n for n, _ in targets)}")
return send_file(buffer, as_attachment=True, download_name=f"{zip_name}.zip", mimetype="application/zip")
@app.route("/fileshare/rename", methods=["POST"])
@login_required
def fileshare_rename():
@@ -3143,6 +3563,11 @@ _NGINX_PROXY_LOCATIONS = """ location /ws/ {
}
location / {
# nginx' Standard (1m) reicht für Fileshare-Uploads nicht -- etwas
# großzügiger als Flasks eigenes MAX_CONTENT_LENGTH (siehe app.py),
# damit bei einer knapp 15MB großen Datei nginx nicht schon vor
# Flask mit seiner eigenen, unschöneren 413-Seite abbricht.
client_max_body_size 16m;
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -7126,6 +7551,8 @@ def logs_history():
return render_template(
"logs_history.html", files=files, selected_name=selected_name, selected_file=selected_file,
log_content=log_content, total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES,
audit_files=_audit_archive_files(), disk_warning=_log_disk_space_warning(),
audit_threshold=AUDIT_LOG_ARCHIVE_THRESHOLD, audit_target=AUDIT_LOG_ARCHIVE_TARGET,
)
@@ -7147,6 +7574,71 @@ def logs_history_raw():
return response
@app.route("/logs/history/audit-raw")
@login_required
def logs_history_audit_raw():
"""Wie logs_history_raw(), aber für einen einzelnen bereits
archivierten Auditlog-Tag (audit-YYYY-MM-DD.log) statt für eine
Live-Log-Rotation."""
if not current_user.can_view_log_history:
return "Keine Berechtigung.", 403
path = _resolve_audit_archive_file(request.args.get("file", ""))
if not path:
return "Unbekannte oder ungültige Archivdatei.", 404
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError as e:
content = f"Fehler beim Lesen der Datei: {e}"
response = app.response_class(content, mimetype="text/plain")
response.headers["X-Log-Name"] = os.path.basename(path)
return response
@app.route("/logs/history/audit-export", methods=["POST"])
@login_required
def logs_history_audit_export():
"""Exportiert ALLE aktuell archivierten Auditlog-Tage als ein ZIP und
löscht sie danach vom Server -- bewusst eine einzige, manuell
ausgelöste Aktion (Recht logs_history.edit, siehe
User.can_manage_log_history) statt einer automatischen Löschung nach
Ablaufzeit: die Archivdateien selbst bleiben unbegrenzt liegen, bis ein
Admin hier gezielt aufräumt (z.B. wenn _log_disk_space_warning()
anschlägt)."""
if not current_user.can_manage_log_history:
return "Keine Berechtigung.", 403
files = _audit_archive_files()
if not files:
flash("Keine archivierten Auditlog-Tage zum Exportieren vorhanden.", "info")
return redirect(url_for("logs_history"))
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for f in files:
zf.write(os.path.join(AUDIT_ARCHIVE_DIR, f["filename"]), arcname=f["filename"])
buf.seek(0)
deleted = []
for f in files:
try:
os.remove(os.path.join(AUDIT_ARCHIVE_DIR, f["filename"]))
deleted.append(f["filename"])
except OSError:
pass
log_action(
"auditlog.export_delete",
details=f"{len(deleted)} archivierte Auditlog-Datei(en) exportiert und vom Server gelöscht.",
)
today_str = datetime.now().strftime("%Y-%m-%d")
return send_file(
buf, mimetype="application/zip", as_attachment=True,
download_name=f"auditlog-archiv-{today_str}.zip",
)
@app.route("/logs")
@login_required
def logs():
@@ -7195,7 +7687,7 @@ def get_kea_log():
@login_required
def kea_log():
"""Eigene Log-Seite für den Kea-DHCP-Dienst, konsistent mit Live-Log/
Änderungen die Konfiguration (siehe _render_kea_config) leitet Keas
Auditlog die Konfiguration (siehe _render_kea_config) leitet Keas
eigene Logging-Ausgabe explizit in TESM_KEA_LOG_PATH statt in den
distributionsabhängigen Default (meist Syslog), damit sie sich hier
genauso wie die anderen Logs einsehen und per logrotate rotieren
@@ -7217,22 +7709,18 @@ def kea_log():
@app.route("/logs/aenderungen")
@login_required
def activity_log():
"""Änderungslog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen,
"""Auditlog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen,
Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts die stehen im Live-Log."""
if not current_user.can_view_activity_log:
flash("Keine Berechtigung, das Änderungslog einzusehen.", "danger")
flash("Keine Berechtigung, das Auditlog einzusehen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
entries = conn.execute(
"SELECT id, ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT 500"
).fetchall()
avatars = {
row["username"]: row["avatar_filename"]
for row in conn.execute("SELECT username, avatar_filename FROM users").fetchall()
}
conn.close()
return render_template("activity_log.html", entries=entries, avatars=avatars)
return render_template("activity_log.html", entries=entries)
+135
View File
@@ -446,6 +446,18 @@ button { font-family: inherit; }
.card-pad { padding: 20px 22px; }
.notice-banner {
padding: 12px 16px;
border-radius: var(--radius);
font-size: 12.5px;
line-height: 1.5;
}
.notice-banner--warning {
background: var(--warning-dim);
color: var(--warning);
border: 1px solid var(--warning);
}
.section-head {
display: flex;
align-items: flex-start;
@@ -498,6 +510,7 @@ button { font-family: inherit; }
border-radius: 999px;
font-size: 12px;
font-weight: 650;
white-space: nowrap;
}
.pill svg { width: 12px; height: 12px; }
.pill::before {
@@ -516,6 +529,11 @@ button { font-family: inherit; }
.pill.action-pill { background: var(--muted-dim); color: var(--text-dim); }
.pill.action-pill::before { display: none; }
.pill.action-pill svg { width: 13px; height: 13px; }
/* Auditlog: Hinzufuegen gruen, Loeschen rot, alles andere (Bearbeiten/
Aktivieren/Zuweisen/...) orange -- siehe activity_log.html */
.pill.action-pill--create { background: var(--success-dim); color: var(--success); }
.pill.action-pill--delete { background: var(--danger-dim); color: var(--danger); }
.pill.action-pill--edit { background: var(--accent-dim); color: var(--accent-strong); }
.avatar-sm {
display: inline-flex;
@@ -1146,6 +1164,31 @@ select {
}
[data-theme="light"] .raw-log-content { background: #0e1116; }
/* Zeilennummern im RAW-Log-Popup (aktuell nur "Verlauf") -- Nummer kommt
rein aus CSS-Countern (kein Extra-Markup pro Zeile mit fest eingebrannter
Zahl), damit Filtern/Kopieren des reinen Logtexts unverändert bleibt. */
.raw-log-content.with-line-numbers {
counter-reset: raw-line;
white-space: normal;
}
.raw-log-content.with-line-numbers .raw-log-line {
display: flex;
white-space: pre-wrap;
word-break: break-word;
}
.raw-log-content.with-line-numbers .raw-log-line::before {
counter-increment: raw-line;
content: counter(raw-line);
flex: 0 0 auto;
min-width: 3.5em;
margin-right: 14px;
padding-right: 10px;
border-right: 1px solid var(--border);
text-align: right;
color: var(--text-faint);
user-select: none;
}
/* ==========================================================================
Permission checklists (Gruppen)
========================================================================== */
@@ -1292,6 +1335,92 @@ select {
}
.xterm-container .xterm { height: 100%; }
/* ==========================================================================
Fileshare (Baum-Navigation + Vorschau)
========================================================================== */
/* align-items:stretch (statt flex-start) + die main-Seite selbst als
Flex-Spalte mit table-wrap{flex:1}, damit beide Kacheln (Baum links,
Tabelle rechts) immer gleich hoch sind, unabhängig davon welche Seite
gerade mehr Inhalt hat. */
.fileshare-layout { display: flex; align-items: stretch; gap: 16px; }
.fileshare-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.fileshare-main .table-wrap { flex: 1; }
.fileshare-tree {
flex: 0 0 260px;
max-width: 260px;
padding: 14px;
overflow-y: auto;
}
.fileshare-tree-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
font-weight: 650;
margin-bottom: 8px;
}
.tree-root, .tree-children { list-style: none; margin: 0; padding: 0; }
.tree-children { padding-left: 16px; }
.tree-row {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 6px;
border-radius: 7px;
}
.tree-row:hover { background: var(--bg-card-hover); }
.tree-row.active { background: var(--bg-card-hover); color: var(--accent); font-weight: 600; }
.tree-toggle {
width: 18px; height: 18px;
flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
border: none; background: transparent; color: var(--text-faint);
font-size: 10px;
cursor: pointer;
padding: 0;
}
.tree-toggle:hover { color: var(--text); }
.tree-toggle:disabled { visibility: hidden; }
.tree-label {
font-size: 13px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Vorschau: von mammoth.js/SheetJS erzeugter bzw. selbst gebauter Inhalt */
.docx-preview { font-size: 14px; line-height: 1.65; }
.docx-preview table { border-collapse: collapse; margin: 10px 0; }
.docx-preview table td, .docx-preview table th { border: 1px solid var(--border); padding: 6px 10px; }
.docx-preview img { max-width: 100%; }
.xlsx-preview-sheet-title { margin: 20px 0 8px; font-size: 13px; font-weight: 650; }
.xlsx-preview-sheet-title:first-child { margin-top: 0; }
/* Angesammelte Dateien im Upload-Modal (Mehrfachauswahl, siehe fileshare.html) */
.upload-file-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
.upload-file-row {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 5px 10px;
background: var(--bg-elevated);
border: 1px solid var(--border-soft);
border-radius: 7px;
font-size: 12.5px;
}
.upload-file-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.upload-file-remove {
flex-shrink: 0;
border: none; background: transparent; color: var(--text-faint);
font-size: 16px; line-height: 1; cursor: pointer; padding: 0 2px;
}
.upload-file-remove:hover { color: var(--danger); }
/* ==========================================================================
Utilities
========================================================================== */
@@ -1336,6 +1465,12 @@ select {
Buttons/Suchfeld ineinanderzuschieben. */
.modal-footer { flex-wrap: wrap; }
.table-toolbar .search-input { min-width: 0; flex: 1 1 160px; }
/* Fileshare-Baum + Tabelle nebeneinander sprengt auf Tablet-/Handy-
Breite die Seite (Baum-Spalte ist fest 260px breit) -- Baum stapelt
stattdessen oben, Tabelle darunter in voller Breite. */
.fileshare-layout { flex-direction: column; }
.fileshare-tree { flex: 1 1 auto; max-width: 100%; max-height: 240px; }
}
@media (max-width: 640px) {
+23 -2
View File
@@ -245,12 +245,30 @@
});
}
window.openRawLogModal = function (url, title) {
function renderRawLogLines(container, text) {
// Eine Zeile = ein <div>, Nummer kommt per CSS-Counter (::before) --
// so bleibt der eigentliche Zeileninhalt reiner textContent (kein XSS-
// Risiko) und die Nummerierung muss nirgends von Hand mitgezählt werden.
const lines = text.split("\n");
if (lines.length && lines[lines.length - 1] === "") lines.pop(); // trailing \n erzeugt sonst eine Phantomzeile
container.textContent = "";
const frag = document.createDocumentFragment();
lines.forEach((line) => {
const row = document.createElement("div");
row.className = "raw-log-line";
row.textContent = line;
frag.appendChild(row);
});
container.appendChild(frag);
}
window.openRawLogModal = function (url, title, withLineNumbers) {
if (!url) return;
ensureRawLogModal();
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
const content = document.getElementById("raw-log-modal-content");
const meta = document.getElementById("raw-log-modal-meta");
content.classList.toggle("with-line-numbers", !!withLineNumbers);
content.textContent = "Lade …";
meta.textContent = "";
openModal("raw-log-modal");
@@ -260,7 +278,10 @@
if (logName) meta.textContent = logName;
return r.text();
})
.then((text) => { content.textContent = text; })
.then((text) => {
if (withLineNumbers) renderRawLogLines(content, text);
else content.textContent = text;
})
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+101 -14
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% set active_page = "logs" %}
{% block page_title %}Änderungen{% endblock %}
{% block page_title %}Auditlog{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %}
{% block content %}
@@ -29,6 +29,13 @@
"profile.update": "Profil aktualisiert",
"profile.password": "Passwort geändert",
"check.run_now": "Prüfung manuell gestartet",
"fileshare.upload": "Datei(en) hochgeladen",
"fileshare.mkdir": "Ordner angelegt",
"fileshare.delete": "Datei/Ordner gelöscht",
"fileshare.rename": "Datei/Ordner umbenannt",
"fileshare.download": "Als ZIP heruntergeladen",
"auditlog.archive": "Auditlog archiviert",
"auditlog.export_delete": "Auditlog-Archiv exportiert",
} %}
{% set action_icons = {
"delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
@@ -37,26 +44,63 @@
"activate": '<path d="M20 6L9 17l-5-5"/>',
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
} %}
{% set create_kinds = ("create", "upload", "mkdir") %}
{% macro category_of(kind) %}{% if kind in create_kinds %}create{% elif kind == "delete" %}delete{% else %}edit{% endif %}{% endmacro %}
{% set ns = namespace(create=0, delete=0, edit=0) %}
{% for e in entries %}
{% set cat = category_of(e['action'].split('.')[-1])|trim %}
{% if cat == "create" %}{% set ns.create = ns.create + 1 %}
{% elif cat == "delete" %}{% set ns.delete = ns.delete + 1 %}
{% else %}{% set ns.edit = ns.edit + 1 %}{% endif %}
{% endfor %}
<div class="stat-row" style="margin-bottom:18px;">
<div class="stat-card" data-category-filter="">
<div class="stat-label">Alle</div>
<div class="stat-value">{{ entries|length }}</div>
</div>
<div class="stat-card" data-category-filter="create">
<div class="stat-label">Hinzufügen</div>
<div class="stat-value" style="color:var(--success);">{{ ns.create }}</div>
</div>
<div class="stat-card" data-category-filter="edit">
<div class="stat-label">Änderungen</div>
<div class="stat-value" style="color:var(--accent-strong);">{{ ns.edit }}</div>
</div>
<div class="stat-card" data-category-filter="delete">
<div class="stat-label">Löschungen</div>
<div class="stat-value" style="color:var(--danger);">{{ ns.delete }}</div>
</div>
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" id="auditSearch" placeholder="Benutzer, Aktion, Ziel oder Details durchsuchen...">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th style="width:1%;"></th><th>Zeitpunkt</th><th>Benutzer</th><th>Aktion</th><th>Ziel</th><th>Details</th></tr></thead>
<table class="data-table" id="auditTable" data-sortable>
<thead><tr>
<th data-sort-key="ts" style="width:1%; white-space:nowrap;">Zeitpunkt</th>
<th data-sort-key="user" style="width:1%; white-space:nowrap;">Benutzer</th>
<th data-sort-key="action" style="width:1%; white-space:nowrap;">Aktion</th>
<th data-sort-key="target" style="width:1%; white-space:nowrap;">Ziel</th>
<th>Details</th>
</tr></thead>
<tbody>
{% for e in entries %}
{% set kind = e['action'].split('.')[-1] %}
<tr>
<td>
{% if avatars.get(e['username']) %}
<img class="avatar-sm" src="{{ url_for('static', filename='uploads/avatars/' + avatars[e['username']]) }}" alt="">
{% else %}
<span class="avatar-sm avatar-placeholder">{{ e['username'][:1]|upper }}</span>
{% endif %}
</td>
{% set cat = category_of(kind)|trim %}
<tr data-category="{{ cat }}" data-sort-ts="{{ e['ts'] }}" data-sort-user="{{ e['username']|lower }}"
data-sort-action="{{ action_labels.get(e['action'], e['action'])|lower }}" data-sort-target="{{ (e['target'] or '')|lower }}">
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
<td class="cell-name">{{ e['username'] }}</td>
<td>
<span class="pill action-pill">
<span class="pill action-pill action-pill--{{ cat }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
{{ action_labels.get(e['action'], e['action']) }}
</span>
@@ -65,11 +109,54 @@
<td class="text-dim">{{ e['details'] or '—' }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6">Noch keine Änderungen protokolliert.</td></tr>
<tr class="empty-row"><td colspan="5">Noch keine Änderungen protokolliert.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p id="auditNoResults" class="text-faint hidden" style="padding:16px; text-align:center; font-size:12.5px;">Keine Einträge für diese Auswahl.</p>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const searchInput = document.getElementById("auditSearch");
const noResults = document.getElementById("auditNoResults");
const rows = Array.from(document.querySelectorAll("#auditTable tbody tr"));
let activeCategory = null;
function applyFilters() {
const q = (searchInput ? searchInput.value : "").trim().toLowerCase();
let anyVisible = false;
rows.forEach(function (row) {
if (row.classList.contains("empty-row")) return;
const categoryMatches = !activeCategory || row.dataset.category === activeCategory;
const textMatches = !q || row.innerText.toLowerCase().includes(q);
const match = categoryMatches && textMatches;
row.style.display = match ? "" : "none";
if (match) anyVisible = true;
});
if (noResults) noResults.classList.toggle("hidden", anyVisible);
document.querySelectorAll(".stat-card[data-category-filter]").forEach(function (card) {
const isTotal = card.dataset.categoryFilter === "";
const active = activeCategory === null ? isTotal : card.dataset.categoryFilter === activeCategory;
card.classList.toggle("active", active);
});
}
if (searchInput) searchInput.addEventListener("input", applyFilters);
document.querySelectorAll(".stat-card[data-category-filter]").forEach(function (card) {
card.addEventListener("click", function () {
const key = this.dataset.categoryFilter;
activeCategory = (!key || activeCategory === key) ? null : key;
applyFilters();
});
});
applyFilters();
});
</script>
{% endblock %}
+434 -20
View File
@@ -5,32 +5,48 @@
{% block content %}
{% macro render_tree_node(share, path, name, expanded_map, active_path) %}
<li class="tree-node" data-share="{{ share }}" data-path="{{ path }}">
<div class="tree-row{{ ' active' if path == active_path else '' }}">
{% if path in expanded_map %}
<button type="button" class="tree-toggle" aria-expanded="true"></button>
{% else %}
<button type="button" class="tree-toggle" aria-expanded="false"></button>
{% endif %}
<span class="tree-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px; height:14px; margin-right:5px; vertical-align:-2px; color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
{{ name }}
</span>
</div>
<ul class="tree-children{{ '' if path in expanded_map else ' hidden' }}"{% if path in expanded_map %} data-loaded="1"{% endif %}>
{% if path in expanded_map %}
{% for child in expanded_map[path] %}
{{ render_tree_node(share, (path ~ '/' ~ child) if path else child, child, expanded_map, active_path) }}
{% endfor %}
{% endif %}
</ul>
</li>
{% endmacro %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Dateifreigaben</h2>
<div class="hint">Freigaben je nach AD-Gruppenmitgliedschaft für diese Sitzung gemountet — wird beim Abmelden wieder ausgehängt.</div>
</div>
{% if shares|length > 1 %}
<div class="flex gap-2" style="align-items:center;">
<label for="shareSelect" class="text-faint" style="font-size:12.5px; font-weight:600;">Freigabe:</label>
<select id="shareSelect" onchange="window.location.href='{{ url_for('fileshare') }}?share=' + encodeURIComponent(this.value);">
</div>
<div class="fileshare-layout">
<div class="fileshare-tree card">
<div class="fileshare-tree-title">Freigaben</div>
<ul class="tree-root" id="fileshareTree">
{% for s in shares %}
<option value="{{ s }}" {% if s == selected_share %}selected{% endif %}>{{ s }}</option>
{{ render_tree_node(s, '', s, tree_expanded if s == selected_share else {}, rel_path if s == selected_share else None) }}
{% endfor %}
</select>
</ul>
</div>
{% endif %}
</div>
<div class="flex gap-2" style="align-items:center; margin-bottom:14px; flex-wrap:wrap;">
<a href="{{ url_for('fileshare', share=selected_share) }}" class="mono" style="font-size:13px; font-weight:600;">{{ selected_share }}</a>
{% for b in breadcrumbs %}
<span class="text-faint">/</span>
<a href="{{ url_for('fileshare', share=selected_share, path=b.path) }}" class="mono" style="font-size:13px;">{{ b.name }}</a>
{% endfor %}
</div>
<div class="table-wrap" style="margin-bottom:16px;">
<div class="fileshare-main">
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
@@ -50,10 +66,25 @@
</div>
</div>
<div id="bulkActionsBar" class="flex gap-2 hidden" data-share="{{ selected_share }}" data-path="{{ rel_path }}" style="align-items:center; margin-bottom:10px; flex-wrap:wrap;">
<span id="bulkSelectedCount" class="text-faint" style="font-size:12.5px; font-weight:600;"></span>
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkDownload()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
Herunterladen (ZIP)
</button>
{% if can_edit %}
<button type="button" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);" onclick="bulkDelete()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
Löschen
</button>
{% endif %}
</div>
{% if entries %}
<div style="overflow-x:auto;">
<table class="data-table" id="fileTable" data-sortable>
<thead><tr>
<th style="width:1%;"><input type="checkbox" id="selectAllFiles"></th>
<th data-sort-key="name">Name</th>
<th data-sort-key="size">Größe</th>
<th data-sort-key="mtime">Geändert</th>
@@ -62,6 +93,7 @@
<tbody>
{% for e in entries %}
<tr data-sort-name="{{ e.name|lower }}" data-sort-size="{{ 0 if e.is_dir else e.size_str }}" data-sort-mtime="{{ e.mtime_str }}">
<td><input type="checkbox" class="row-select" value="{{ e.name }}"></td>
<td class="cell-name">
{% if e.is_dir %}
<a href="{{ url_for('fileshare', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
@@ -77,6 +109,14 @@
<td class="text-faint">{{ e.mtime_str }}</td>
<td>
<div class="row-actions">
{% if e.preview_kind %}
<button type="button" class="icon-btn" title="Vorschau"
data-preview-kind="{{ e.preview_kind }}"
data-preview-name="{{ e.name }}"
data-preview-url="{{ url_for('fileshare_view', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
{% endif %}
{% if not e.is_dir %}
<a class="icon-btn" title="Herunterladen" href="{{ url_for('fileshare_download', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
@@ -105,6 +145,8 @@
{% else %}
<div style="padding:40px 16px; text-align:center; color:var(--text-faint);">Dieser Ordner ist leer.</div>
{% endif %}
</div>
</div>
</div>
{% if can_create %}
@@ -114,13 +156,15 @@
<input type="hidden" name="share" value="{{ selected_share }}">
<input type="hidden" name="path" value="{{ rel_path }}">
<div class="modal-header">
<h3>Datei hochladen</h3>
<h3>Datei(en) hochladen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Datei</label>
<input type="file" name="file" required>
<label>Datei(en)</label>
<input type="file" name="file" id="uploadFileInput" multiple required>
<div id="uploadFileList" class="upload-file-list"></div>
<div class="field-hint">Insgesamt maximal 15&nbsp;MB pro Upload-Vorgang. Mehrfachauswahl möglich (auch mehrmals nacheinander — bereits hinzugefügte Dateien bleiben dabei erhalten).</div>
</div>
<div class="field-hint">Wird in „{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}“ hochgeladen. Eine bereits vorhandene Datei gleichen Namens wird überschrieben.</div>
</div>
@@ -182,10 +226,27 @@
</div>
{% endif %}
<div class="modal-overlay" id="previewModal">
<div class="modal" style="max-width:900px; width:90vw;">
<div class="modal-header">
<h3 id="previewTitle">Vorschau</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body" id="previewBody" style="max-height:75vh; overflow:auto;"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/vendor/mammoth.browser.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/vendor/xlsx.full.min.js') }}"></script>
<script>
const FILESHARE_SUBFOLDERS_URL = "{{ url_for('fileshare_subfolders') }}";
const FILESHARE_BASE_URL = "{{ url_for('fileshare') }}";
const FILESHARE_DOWNLOAD_MULTI_URL = "{{ url_for('fileshare_download_multi') }}";
const FILESHARE_DELETE_MULTI_URL = "{{ url_for('fileshare_delete_multi') }}";
function openRenameModal(name) {
document.getElementById("renameOldName").value = name;
document.getElementById("renameNewName").value = name;
@@ -199,5 +260,358 @@ function filterTable(inputId, tableId) {
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
/* ---------------- Mehrfachauswahl (Herunterladen als ZIP / Löschen) ---------------- */
function getSelectedFileNames() {
return Array.from(document.querySelectorAll("#fileTable .row-select:checked")).map(function (cb) { return cb.value; });
}
function updateBulkBar() {
const names = getSelectedFileNames();
const bar = document.getElementById("bulkActionsBar");
const countEl = document.getElementById("bulkSelectedCount");
if (!bar) return;
if (names.length > 0) {
bar.classList.remove("hidden");
countEl.textContent = names.length + " ausgewählt";
} else {
bar.classList.add("hidden");
}
const selectAll = document.getElementById("selectAllFiles");
const allBoxes = document.querySelectorAll("#fileTable .row-select");
if (selectAll && allBoxes.length) {
selectAll.checked = names.length === allBoxes.length;
selectAll.indeterminate = names.length > 0 && names.length < allBoxes.length;
}
}
function submitBulkForm(actionUrl, names) {
// data-share/data-path statt Jinja-Werte direkt in einen JS-String-
// Literal zu setzen -- Ordnernamen kommen von der echten Freigabe und
// koennten Anfuehrungszeichen o.ae. enthalten, ueber HTML-Attribute
// (von Jinja automatisch escaped) ist das unproblematisch.
const bar = document.getElementById("bulkActionsBar");
const form = document.createElement("form");
form.method = "post";
form.action = actionUrl;
form.style.display = "none";
[["share", bar.dataset.share], ["path", bar.dataset.path]].forEach(function (pair) {
const input = document.createElement("input");
input.type = "hidden";
input.name = pair[0];
input.value = pair[1];
form.appendChild(input);
});
names.forEach(function (name) {
const input = document.createElement("input");
input.type = "hidden";
input.name = "names";
input.value = name;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
function bulkDownload() {
const names = getSelectedFileNames();
if (!names.length) return;
submitBulkForm(FILESHARE_DOWNLOAD_MULTI_URL, names);
}
function bulkDelete() {
const names = getSelectedFileNames();
if (!names.length) return;
window.confirmAction(
names.length + " ausgewählte Elemente wirklich endgültig löschen?",
function () { submitBulkForm(FILESHARE_DELETE_MULTI_URL, names); },
"Auswahl löschen?"
);
}
document.addEventListener("DOMContentLoaded", function () {
const selectAll = document.getElementById("selectAllFiles");
if (selectAll) {
selectAll.addEventListener("change", function () {
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) { cb.checked = selectAll.checked; });
updateBulkBar();
});
}
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) {
cb.addEventListener("change", updateBulkBar);
});
});
/* ---------------- Mehrfach-Upload (Multiauswahl + mehrmals nacheinander) ---------------- */
/* Ein <input type=file multiple> ERSETZT bei jeder erneuten Dateiauswahl
die vorherige -- fuer "mehrmals nacheinander hinzufuegen" wird deshalb
selbst eine "angesammelte" Auswahl per DataTransfer gepflegt und nach
jeder Aenderung zurueck auf das Input-Feld geschrieben, sodass das
normale <form>-Submit (kein fetch() noetig) am Ende alle gesammelten
Dateien mitschickt. DataTransfer-Zuweisung an .files wird von allen
gaengigen Mobil-Browsern (Android Chrome, iOS Safari) mitgetragen; falls
nicht, faellt es einfach auf das native Verhalten (letzte Auswahl zaehlt)
zurueck, ohne den Upload an sich zu verhindern. */
(function () {
const input = document.getElementById("uploadFileInput");
const listEl = document.getElementById("uploadFileList");
if (!input || !listEl) return;
let staged = null;
try { staged = new DataTransfer(); } catch (e) { staged = null; }
function render() {
listEl.innerHTML = "";
if (!staged) return;
Array.from(staged.files).forEach(function (file, idx) {
const row = document.createElement("div");
row.className = "upload-file-row";
const name = document.createElement("span");
name.textContent = file.name;
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "upload-file-remove";
removeBtn.setAttribute("aria-label", "Entfernen");
removeBtn.textContent = "×";
removeBtn.addEventListener("click", function () {
const dt = new DataTransfer();
Array.from(staged.files).forEach(function (f, i) {
if (i !== idx) dt.items.add(f);
});
staged = dt;
input.files = staged.files;
render();
});
row.appendChild(name);
row.appendChild(removeBtn);
listEl.appendChild(row);
});
}
input.addEventListener("change", function () {
if (!staged) return; // kein DataTransfer-Support -- natives Verhalten greift
Array.from(input.files).forEach(function (file) { staged.items.add(file); });
input.files = staged.files;
render();
});
// Beim (Wieder-)Oeffnen des Modals eine frische Sammlung starten, statt
// Dateien aus einem vorherigen, bereits abgeschickten Upload-Vorgang
// versehentlich mitzuschleppen.
document.querySelectorAll('[data-open-modal="uploadModal"]').forEach(function (btn) {
btn.addEventListener("click", function () {
try { staged = new DataTransfer(); } catch (e) { staged = null; }
input.value = "";
render();
});
});
})();
/* ---------------- Baum-Navigation (Freigaben links) ---------------- */
function buildTreeNode(share, path, name) {
const li = document.createElement("li");
li.className = "tree-node";
li.dataset.share = share;
li.dataset.path = path;
const row = document.createElement("div");
row.className = "tree-row";
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "tree-toggle";
toggle.textContent = "▶";
toggle.setAttribute("aria-expanded", "false");
const label = document.createElement("span");
label.className = "tree-label";
label.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:5px;vertical-align:-2px;color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>';
label.append(document.createTextNode(name));
row.appendChild(toggle);
row.appendChild(label);
const childUl = document.createElement("ul");
childUl.className = "tree-children hidden";
li.appendChild(row);
li.appendChild(childUl);
return li;
}
function toggleTreeNode(btn) {
const li = btn.closest(".tree-node");
const childUl = li.querySelector(":scope > .tree-children");
if (childUl.classList.contains("hidden")) {
if (childUl.dataset.loaded === "1") {
childUl.classList.remove("hidden");
btn.textContent = "▼";
btn.setAttribute("aria-expanded", "true");
} else {
loadTreeChildren(li, childUl, btn);
}
} else {
childUl.classList.add("hidden");
btn.textContent = "▶";
btn.setAttribute("aria-expanded", "false");
}
}
function loadTreeChildren(li, childUl, btn) {
const share = li.dataset.share;
const path = li.dataset.path;
const prevLabel = btn.textContent;
btn.textContent = "…";
fetch(FILESHARE_SUBFOLDERS_URL + "?share=" + encodeURIComponent(share) + "&path=" + encodeURIComponent(path))
.then(r => r.json())
.then(data => {
childUl.innerHTML = "";
(data.folders || []).forEach(name => {
const childPath = path ? path + "/" + name : name;
childUl.appendChild(buildTreeNode(share, childPath, name));
});
childUl.dataset.loaded = "1";
childUl.classList.remove("hidden");
btn.textContent = "▼";
btn.setAttribute("aria-expanded", "true");
})
.catch(() => { btn.textContent = prevLabel; });
}
function navigateTree(share, path) {
window.location.href = FILESHARE_BASE_URL + "?share=" + encodeURIComponent(share) + "&path=" + encodeURIComponent(path);
}
document.getElementById("fileshareTree").addEventListener("click", function (e) {
const toggle = e.target.closest(".tree-toggle");
if (toggle) { toggleTreeNode(toggle); return; }
const label = e.target.closest(".tree-label");
if (label) {
const li = label.closest(".tree-node");
navigateTree(li.dataset.share, li.dataset.path);
}
});
/* ---------------- Datei-Vorschau ---------------- */
const fileTableEl = document.getElementById("fileTable");
if (fileTableEl) {
fileTableEl.addEventListener("click", function (e) {
const btn = e.target.closest("[data-preview-kind]");
if (!btn) return;
openPreview(btn.dataset.previewKind, btn.dataset.previewName, btn.dataset.previewUrl);
});
}
function openPreview(kind, name, url) {
document.getElementById("previewTitle").textContent = name;
const body = document.getElementById("previewBody");
body.innerHTML = "";
PoeUI.openModal("previewModal");
if (kind === "pdf") {
const iframe = document.createElement("iframe");
iframe.src = url;
iframe.style.width = "100%";
iframe.style.height = "70vh";
iframe.style.border = "0";
body.appendChild(iframe);
} else if (kind === "image") {
const img = document.createElement("img");
img.src = url;
img.alt = name;
img.style.maxWidth = "100%";
img.style.display = "block";
img.style.margin = "0 auto";
body.appendChild(img);
} else if (kind === "text") {
body.textContent = "Lade …";
// Nur die ersten 512KB anfordern -- bei sehr großen Textdateien
// (Logs etc.) reicht das für eine Vorschau, ohne alles auf einmal
// laden zu müssen. Server unterstützt Range ueber send_file(conditional=True).
fetch(url, { headers: { "Range": "bytes=0-524287" } })
.then(r => {
// r.status ist bei einem Range-Request praktisch immer 206,
// auch wenn die Datei kleiner als die angefragten 512KB ist
// (der Server liefert dann trotzdem "206" mit der kompletten
// Datei) -- ob wirklich abgeschnitten wurde, steht nur im
// Content-Range-Header ("bytes 0-524287/<Gesamtgroesse>").
const contentRange = r.headers.get("Content-Range") || "";
const m = contentRange.match(/\/(\d+)$/);
const truncated = !!m && parseInt(m[1], 10) > 524288;
return r.text().then(text => ({ text: text, truncated: truncated }));
})
.then(({ text, truncated }) => {
body.innerHTML = "";
const pre = document.createElement("pre");
pre.style.whiteSpace = "pre-wrap";
pre.style.wordBreak = "break-word";
pre.style.fontSize = "12.5px";
pre.textContent = text;
body.appendChild(pre);
if (truncated) {
const hint = document.createElement("div");
hint.className = "text-faint";
hint.style.marginTop = "10px";
hint.style.fontSize = "12px";
hint.textContent = "Nur die ersten 512 KB angezeigt — bitte herunterladen für die komplette Datei.";
body.appendChild(hint);
}
})
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden."; });
} else if (kind === "docx") {
body.textContent = "Lade …";
fetch(url).then(r => r.arrayBuffer())
.then(buf => mammoth.convertToHtml({ arrayBuffer: buf }))
.then(result => {
body.innerHTML = "";
const wrap = document.createElement("div");
wrap.className = "docx-preview";
// mammoth erzeugt kontrolliertes HTML aus Words eigenem,
// begrenztem Formatierungsmodell (Absätze/Tabellen/Listen/
// Bilder) -- kein beliebiges, aus der Datei uebernommenes
// Skript kann darin stecken, anders als bei generischem
// "fremdes HTML direkt einbetten".
wrap.innerHTML = result.value;
body.appendChild(wrap);
})
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden (Format evtl. nicht unterstützt)."; });
} else if (kind === "xlsx") {
body.textContent = "Lade …";
fetch(url).then(r => r.arrayBuffer())
.then(buf => {
const wb = XLSX.read(buf, { type: "array" });
body.innerHTML = "";
wb.SheetNames.forEach(function (sheetName, idx) {
const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheetName], { header: 1, defval: "" });
const h4 = document.createElement("div");
h4.className = "xlsx-preview-sheet-title";
h4.textContent = sheetName;
body.appendChild(h4);
const wrap = document.createElement("div");
wrap.style.overflowX = "auto";
const table = document.createElement("table");
table.className = "data-table";
// Zellenwerte bewusst per textContent statt ueber die
// eingebaute HTML-Ausgabe von SheetJS gesetzt -- so ist
// die Vorschau unabhaengig von deren Escaping-Verhalten
// garantiert sicher gegen Inhalte in den Zellen.
rows.forEach(function (row) {
const tr = document.createElement("tr");
row.forEach(function (cell) {
const td = document.createElement("td");
td.textContent = (cell === null || cell === undefined) ? "" : String(cell);
tr.appendChild(td);
});
table.appendChild(tr);
});
wrap.appendChild(table);
body.appendChild(wrap);
});
})
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden."; });
}
}
</script>
{% endblock %}
+98 -69
View File
@@ -104,8 +104,8 @@
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
<td>
<div class="row-actions">
<button class="icon-btn" title="Rechte anzeigen" onclick="toggleDetail('detail-admin')">
<svg id="chev-admin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
<button class="icon-btn" title="Rechte anzeigen" data-open-modal="adminGroupModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="adminMembersModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
@@ -113,16 +113,11 @@
</div>
</td>
</tr>
<tr class="group-detail-row hidden" id="detail-admin">
<td colspan="3">
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
</td>
</tr>
</tbody>
{% for g in groups %}
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
{% set can_unlock_system = g.is_system and current_user.is_admin %}
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
<tr>
<td class="cell-name">
@@ -132,8 +127,12 @@
<td class="text-dim">{{ g.member_names|length }}</td>
<td>
<div class="row-actions">
<button class="icon-btn" title="Rechte anzeigen{{ '/bearbeiten' if can_edit_this else '' }}" onclick="toggleDetail('detail-{{ g.id }}')">
<svg id="chev-{{ g.id }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
<button class="icon-btn" title="{{ 'Bearbeiten' if (can_edit_this or can_unlock_system) else 'Anzeigen' }}" data-open-modal="editGroupModal{{ loop.index }}">
{% if can_edit_this or can_unlock_system %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
{% else %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{% endif %}
</button>
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
@@ -149,57 +148,6 @@
</div>
</td>
</tr>
{% set can_unlock_system = g.is_system and current_user.is_admin %}
<tr class="group-detail-row hidden" id="detail-{{ g.id }}">
<td colspan="3">
{% if can_edit_this %}
<form method="post">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="permissions_submitted" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<input type="hidden" name="name" value="{{ g.name }}">
{{ permission_tree(g.permissions, false, true) }}
<div class="flex" style="justify-content:flex-end; margin-top:16px;">
<button type="submit" class="btn btn-primary btn-sm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Rechte speichern
</button>
</div>
</form>
{% elif can_unlock_system %}
<div id="readonly-{{ g.id }}">
{{ permission_tree(g.permissions, true, true) }}
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px;">
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
</div>
</div>
<form method="post" class="hidden" id="unlock-{{ g.id }}">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="permissions_submitted" value="1">
<input type="hidden" name="unlock_system_group" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<input type="hidden" name="name" value="{{ g.name }}">
{{ permission_tree(g.permissions, false, true) }}
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
Rechte hier können den Erst-Login neuer Konten einschränken.
</p>
<div class="flex" style="justify-content:flex-end;">
<button type="submit" class="btn btn-primary btn-sm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Rechte speichern
</button>
</div>
</form>
{% else %}
{{ permission_tree(g.permissions, true, true) }}
{% if g.is_system %}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
{% endif %}
{% endif %}
</td>
</tr>
</tbody>
{% else %}
<tbody data-sort-pinned>
@@ -210,6 +158,19 @@
</div>
</div>
<div class="modal-overlay" id="adminGroupModal">
<div class="modal" style="max-width:1000px;">
<div class="modal-header">
<h3>Admin — Rechte</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
</div>
</div>
</div>
<div class="modal-overlay" id="adminMembersModal">
<div class="modal" style="max-width:380px;">
<form method="post">
@@ -238,6 +199,8 @@
</div>
{% for g in groups %}
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
{% set can_unlock_system = g.is_system and current_user.is_admin %}
<div class="modal-overlay" id="membersModal{{ loop.index }}">
<div class="modal" style="max-width:380px;">
<form method="post">
@@ -268,6 +231,80 @@
</form>
</div>
</div>
<div class="modal-overlay" id="editGroupModal{{ loop.index }}">
<div class="modal" style="max-width:1000px;">
{% if can_edit_this %}
<form method="post">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="permissions_submitted" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<div class="modal-header">
<h3>Gruppe bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Name</label>
<input type="text" name="name" value="{{ g.name }}" required>
</div>
{{ permission_tree(g.permissions, false, true) }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
</div>
</form>
{% elif can_unlock_system %}
<div class="modal-header">
<h3>Gruppe „{{ g.name }}“</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Name</label><input type="text" value="{{ g.name }}" disabled></div>
<div id="readonly-{{ g.id }}">
{{ permission_tree(g.permissions, true, true) }}
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px; flex-wrap:wrap;">
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
</div>
</div>
<form method="post" class="hidden" id="unlock-{{ g.id }}">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="permissions_submitted" value="1">
<input type="hidden" name="unlock_system_group" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<input type="hidden" name="name" value="{{ g.name }}">
{{ permission_tree(g.permissions, false, true) }}
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
Rechte hier können den Erst-Login neuer Konten einschränken.
</p>
<div class="flex" style="justify-content:flex-end;">
<button type="submit" class="btn btn-primary btn-sm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Rechte speichern
</button>
</div>
</form>
</div>
{% else %}
<div class="modal-header">
<h3>Gruppe „{{ g.name }}“</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
{{ permission_tree(g.permissions, true, true) }}
{% if g.is_system %}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
<div class="modal-overlay" id="addGroupModal">
@@ -317,14 +354,6 @@ function unlockSystemGroup(id) {
);
}
function toggleDetail(id) {
const row = document.getElementById(id);
if (!row) return;
row.classList.toggle("hidden");
const chev = document.getElementById(id.replace("detail-", "chev-"));
if (chev) chev.style.transform = row.classList.contains("hidden") ? "" : "rotate(180deg)";
}
function applyPermissionGating() {
document.querySelectorAll(".permission-group-col").forEach(function (area) {
const toggle = area.querySelector(".permission-area-toggle-cb");
+51 -1
View File
@@ -9,6 +9,7 @@
<div>
<h2 style="font-size:16px;">Log-Verlauf</h2>
<div class="hint">Aktuelles Live-Log sowie ältere, von logrotate rotierte Kopien davon — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar, auswählbar nach Zeitraum. Aus Performance-Gründen unformatiert (RAW) dargestellt, ohne farbliche Aufbereitung.</div>
<div class="hint">Zusätzlich weiter unten: bereits archivierte Auditlog-Tage (das Auditlog wird ab {{ "{:,}".format(audit_threshold).replace(",", ".") }} Einträgen automatisch tageweise archiviert, bis {{ "{:,}".format(audit_target).replace(",", ".") }} Einträge unterschritten sind).</div>
</div>
<div class="flex gap-2">
<a href="{{ url_for('logs') }}" class="btn btn-secondary">
@@ -16,7 +17,7 @@
Zurück zur Live-Ansicht
</a>
<button type="button" class="btn btn-secondary"
onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_file.range_label if selected_file else '' }}')"
onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_file.range_label if selected_file else '' }}', true)"
{% if not selected_name %}disabled{% endif %}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
Komplettes Log (RAW)
@@ -50,6 +51,55 @@
<pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre>
</div>
{% endif %}
<div class="section-head" style="margin-top:28px;">
<div>
<h2 style="font-size:16px;">Archivierte Auditlog-Tage</h2>
<div class="hint">Ältere Auditlog-Einträge, tageweise als eigene Datei ausgelagert, sobald die laufende Tabelle den Schwellenwert überschreitet — die Dateien selbst bleiben unbegrenzt erhalten, bis sie hier bewusst exportiert werden.</div>
</div>
{% if current_user.can_manage_log_history and audit_files %}
<form method="post" action="{{ url_for('logs_history_audit_export') }}"
data-confirm="Alle {{ audit_files|length }} archivierten Auditlog-Datei(en) als ZIP herunterladen und danach vom Server löschen?"
data-confirm-title="Export &amp; Löschen">
<button type="submit" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
Alle exportieren &amp; löschen (ZIP)
</button>
</form>
{% endif %}
</div>
{% if disk_warning %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">{{ disk_warning }}</div>
{% endif %}
{% if not audit_files %}
<p class="text-faint" style="font-size:12.5px;">Noch keine archivierten Auditlog-Tage vorhanden.</p>
{% else %}
<div class="table-wrap">
<table class="data-table">
<thead><tr>
<th style="width:1%; white-space:nowrap;">Tag</th>
<th style="width:1%; white-space:nowrap;">Größe</th>
<th></th>
</tr></thead>
<tbody>
{% for f in audit_files %}
<tr>
<td class="mono">{{ f.day }}</td>
<td class="text-dim mono" style="font-size:12.5px;">{{ (f.size / 1024)|round(1) }} KB</td>
<td>
<button type="button" class="btn btn-secondary btn-sm"
onclick="openRawLogModal('{{ url_for('logs_history_audit_raw', file=f.filename) }}', 'Auditlog-Archiv — {{ f.day }}', true)">
Ansehen (RAW)
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
{% block scripts %}