Compare commits
6
Commits
2c9ec8a100
..
v1.1.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eaefc9e1f | ||
|
|
cb8b929c3c | ||
|
|
af5dfe30b4 | ||
|
|
efb0d2aa01 | ||
|
|
d461f8ca42 | ||
|
|
1b7fe81507 |
@@ -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;
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
1.1.0
|
||||
1.1.6
|
||||
|
||||
+513
-25
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
+22
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
<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 %}
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</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>
|
||||
@@ -106,6 +146,8 @@
|
||||
<div style="padding:40px 16px; text-align:center; color:var(--text-faint);">Dieser Ordner ist leer.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_create %}
|
||||
<div class="modal-overlay" id="uploadModal">
|
||||
@@ -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>×</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 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>×</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 %}
|
||||
|
||||
@@ -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>×</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>×</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>×</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>×</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");
|
||||
|
||||
@@ -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 & 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 & 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 %}
|
||||
|
||||
Reference in New Issue
Block a user