Compare commits

...
2 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 2c9ec8a100 Fileshare: Hostname-Aufloesung fuer UNC-Pfade + Zuordnungen bearbeitbar
- Freigabe-Hostnamen (statt nur IP) werden jetzt unterstuetzt: mount.cifs
  bekommt die tatsaechliche Ziel-IP gezielt per "ip="-Option mitgegeben,
  aufgeloest ueber _resolve_unc_host_ip(). Deckt zwei reale Faelle ab, die
  beim Live-Test mit einem echten Server (Kurzname "s2025") auftraten:
  1. Ein bloer Kurzname ohne DNS-Suffixsuche loest auf diesem Server gar
     nicht auf -- Fallback probiert zusaetzlich den aus der LDAP-Server-
     Einstellung abgeleiteten AD-Domaenensuffix (a la ".ad.eertmoed.net").
  2. IPv4 wird bevorzugt aufgeloest, IPv6 nur als Fallback genutzt, falls
     fuer den Hostnamen keine IPv4-Adresse existiert.
  Der Hostname bleibt dabei unveraendert in der UNC sichtbar/gespeichert.
- AD-Gruppenzuordnungen (App-Rechte) und Fileshare-Gruppen sind jetzt per
  Bearbeiten-Button (Stift-Icon) direkt aenderbar statt nur loeschen+neu
  anlegen zu koennen -- neue edit_ldap_group_mapping/edit_fileshare_mapping
  POST-Routen (UPDATE per id), Bearbeiten-Modals vorbefuellt inkl.
  AD-Gruppen-Dropdown (gleiches Lade-Muster wie beim Anlegen).
- UX: "Erforderliche AD-Gruppe fuer Login" ist jetzt ein Dropdown mit
  "Gruppen laden" (identisches Muster wie die anderen AD-Gruppenfelder)
  statt eines Freitextfelds, in das der volle DN von Hand einzutragen war.
- _normalize_share_unc()-Hilfsfunktion aus dem Add-Handler herausgezogen,
  jetzt auch vom Edit-Handler genutzt (kein duplizierter Code).

Live auf POETEST verifiziert: Hostname-Mount ("s2025") erfolgreich nach
Fix, Bearbeiten-Modals korrekt vorbefuellt und persistiert, Dropdown laedt
55 AD-Gruppen und behaelt die aktuelle Auswahl beim Neuladen bei.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 16:56:40 +02:00
alientimandClaude Sonnet 5 09b222615a Fileshare-Feature: LDAP-gruppengesteuerte Netzlaufwerke im Browser (v1.1.0)
- Neue ldap_fileshare_mappings-Tabelle (AD-Gruppe -> Freigabe-UNC+Label),
  verwaltet in Einstellungen -> LDAP wie die bestehende App-Gruppenzuordnung
  (inkl. AD-Gruppen-Dropdown per "Gruppen laden", UNC-Backslash/Slash-
  Normalisierung).
- Login-Gate: optionale "Erforderliche AD-Gruppe fuer Login" (Dropdown,
  gleiches Lade-Muster) -- ohne Mitgliedschaft scheitert der Login wie bei
  falschen Zugangsdaten, ohne Hinweis auf den eigentlichen Grund.
- Sessionabhaengiges Mounten: beim LDAP-Login werden alle per AD-Gruppe
  zugeordneten Freigaben mit den eigenen (nie gespeicherten) Zugangsdaten
  per mount.cifs unter /mnt/tesm-shares/<session-token>/ gemountet, beim
  Logout wieder ausgehaengt. Passwort läuft ausschliesslich ueber die
  PASSWD-Umgebungsvariable von mount.cifs. Aufraeum-Sweep (30 Min-Takt,
  12h-Schwelle) und Cleanup-on-Startup als Sicherheitsnetz.
- Zwei unabhaengige Gates: TESM-Rechtesystem (fileshare.view/.create/.edit,
  neue PERMISSIONS-Gruppe fileshare_group) UND AD-Gruppenmitgliedschaft
  muessen beide zutreffen, sonst wird nicht gemountet und der Menuepunkt
  "Dateifreigaben" bleibt verborgen.
- Neuer Dateibrowser (/fileshare, .../download, .../upload, .../mkdir,
  .../delete, .../rename) mit Breadcrumbs, Upload/Download, Anlegen/
  Umbenennen/Loeschen -- Pfadaufloesung ausschliesslich ueber
  os.path.realpath-Vergleich gegen die eigene Mount-Root (Path-Traversal-
  Schutz), zusaetzlich secure_filename() fuer Datei-/Ordnernamen.
- KRITISCHER Bugfix waehrend Live-Test gefunden: generate_ips.py importiert
  app.py als Hilfsmodul (u.a. aus poe.sh/tesm-check.service heraus, alle
  paar Sekunden) -- das fuehrte Modul-Level-Code erneut aus und hat aktive
  Fileshare-Mounts anderer Sitzungen sofort wieder ausgehaengt. Neues
  TESM_WEB_PROCESS-Env-Flag (nur in tesm.service gesetzt) schuetzt
  Cleanup-on-Startup und den Sweep-Thread davor, bei einem reinen
  Modul-Import erneut zu laufen.
- install.sh: cifs-utils ergaenzt (liefert mount.cifs).
- SCHEMA_VERSION 1 -> 2, VERSION 1.0.10 -> 1.1.0.

Live auf Testbox (192.168.82.51) und POETEST (192.168.80.202) getestet:
Login-Gate (erlauben/verbieten/reset), Rechte-Gate (Mount+Menuepunkt nur
mit fileshare.view), vollstaendiger Datei-Workflow (Upload/Download/
Umbenennen/Anlegen/Loeschen) gegen eine echte SMB-Freigabe, Path-Traversal
gegen alle 5 Routen (serverseitig verifiziert, kein Escape), sauberes
Unmount bei /logout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 16:34:28 +02:00
9 changed files with 1130 additions and 47 deletions
+10
View File
@@ -6,6 +6,16 @@ After=network.target
Type=simple Type=simple
User=root User=root
WorkingDirectory=/srv/tesm WorkingDirectory=/srv/tesm
# Markiert genau DIESEN Prozess als den echten Web-App-Dienst -- app.py
# wird nämlich NICHT nur hierüber gestartet, sondern auch von
# generate_ips.py (via "from app import ...") als reines Hilfsmodul
# importiert, z.B. aus poe.sh/tesm-check.service heraus, alle paar
# Sekunden. Ein einfacher Import führt JEDEN Modul-Level-Code in app.py
# erneut aus -- ohne dieses Flag würde jeder generate_ips.py-Aufruf
# _fileshare_cleanup_all_on_startup() erneut auslösen und damit gerade
# aktive Fileshare-Mounts anderer, echter Sitzungen sofort wieder
# aushängen. Siehe die Prüfung auf TESM_WEB_PROCESS in app.py.
Environment=TESM_WEB_PROCESS=1
# Produktiver WSGI-Server (gunicorn) statt Flasks eigenem app.run()- # Produktiver WSGI-Server (gunicorn) statt Flasks eigenem app.run()-
# Entwicklungsserver -- siehe requirements.txt für die ausführliche # Entwicklungsserver -- siehe requirements.txt für die ausführliche
# Begründung von "--workers 1" (In-Memory-Zustand) und "--worker-class # Begründung von "--workers 1" (In-Memory-Zustand) und "--worker-class
+1 -1
View File
@@ -114,7 +114,7 @@ fi
# ---- Pakete ---- # ---- Pakete ----
step "Installing system packages" step "Installing system packages"
sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update" sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update"
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate certbot >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed" sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate certbot cifs-utils >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed"
# ---- Log-Verzeichnis ---- # ---- Log-Verzeichnis ----
# NICHT weltweit beschreibbar (0755 reicht) -- sowohl tesm.service als # NICHT weltweit beschreibbar (0755 reicht) -- sowohl tesm.service als
+1 -1
View File
@@ -1 +1 @@
1 2
+1 -1
View File
@@ -1 +1 @@
1.0.10 1.1.0
+622 -3
View File
@@ -8,7 +8,7 @@ Switch-/User-Verwaltung, Live-Log, Settings, manueller PoE-Neustart),
lediglich mit modernisiertem Frontend und aufgeräumten/konfigurierbaren lediglich mit modernisiertem Frontend und aufgeräumten/konfigurierbaren
Pfaden im Backend. Pfaden im Backend.
""" """
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, session, send_file, abort
from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user
from flask_bcrypt import Bcrypt from flask_bcrypt import Bcrypt
from flask_sock import Sock from flask_sock import Sock
@@ -316,6 +316,20 @@ PERMISSIONS = {
}, },
}, },
}, },
"fileshare_group": {
"label": "Dateifreigaben",
"view_key": "fileshare_group.view",
"children": {
"fileshare": {
"label": "Dateifreigaben",
"rows": {
"view": "fileshare.view",
"create": "fileshare.create",
"edit": "fileshare.edit",
},
},
},
},
} }
PERMISSION_LABELS = { PERMISSION_LABELS = {
@@ -350,6 +364,10 @@ PERMISSION_LABELS = {
"settings_ldap.edit": "LDAP/AD-Konfiguration speichern (Bind-Konto, Gruppenzuordnungen)", "settings_ldap.edit": "LDAP/AD-Konfiguration speichern (Bind-Konto, Gruppenzuordnungen)",
"settings_nginx.view": "NGINX-Konfiguration und Zertifikatsstatus lesen", "settings_nginx.view": "NGINX-Konfiguration und Zertifikatsstatus lesen",
"settings_nginx.edit": "NGINX-Konfiguration ändern (Domain/Ports, Zertifikat hochladen oder per Let's Encrypt anfordern, HTTPS/HSTS aktivieren)", "settings_nginx.edit": "NGINX-Konfiguration ändern (Domain/Ports, Zertifikat hochladen oder per Let's Encrypt anfordern, HTTPS/HSTS aktivieren)",
"fileshare_group.view": "Dateifreigaben-Bereich anzeigen",
"fileshare.view": "Dateifreigaben lesen (Browsen/Herunterladen der über LDAP-Gruppen zugewiesenen Freigaben -- OHNE dieses Recht wird für den Nutzer nichts gemountet)",
"fileshare.create": "Dateien in Freigaben hochladen",
"fileshare.edit": "Dateien in Freigaben überschreiben, umbenennen oder löschen",
} }
ALL_PERMISSION_KEYS = [] ALL_PERMISSION_KEYS = []
@@ -384,6 +402,7 @@ NAV_ITEMS = [
]}, ]},
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"}, {"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"}, {"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
{"key": "fileshare", "label": "Dateifreigaben", "icon": "folder", "endpoint": "fileshare"},
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [ {"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
{"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"}, {"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
{"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"}, {"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"},
@@ -420,6 +439,8 @@ def _nav_key_visible(key, user):
return user.can_manage_users return user.can_manage_users
if key == "groups": if key == "groups":
return user.can_manage_groups return user.can_manage_groups
if key == "fileshare":
return user.has_permission("fileshare.view") and bool(_current_fileshare_mounts())
if key == "settings_system": if key == "settings_system":
return user.can_view_settings_system return user.can_view_settings_system
if key == "settings_importexport": if key == "settings_importexport":
@@ -811,6 +832,17 @@ def _ensure_schema():
) )
""") """)
conn.execute("""
CREATE TABLE IF NOT EXISTS ldap_fileshare_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL,
ad_group_name TEXT NOT NULL,
share_label TEXT NOT NULL,
share_unc TEXT NOT NULL,
UNIQUE(ad_group_dn, share_unc)
)
""")
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1333,6 +1365,7 @@ def _ldap_settings():
"base_dn": get_setting("ldap_base_dn", "") or "", "base_dn": get_setting("ldap_base_dn", "") or "",
"filter_attr": get_setting("ldap_user_filter_attr", LDAP_DEFAULT_FILTER_ATTR) or LDAP_DEFAULT_FILTER_ATTR, "filter_attr": get_setting("ldap_user_filter_attr", LDAP_DEFAULT_FILTER_ATTR) or LDAP_DEFAULT_FILTER_ATTR,
"default_group": get_setting("ldap_default_group", "") or "", "default_group": get_setting("ldap_default_group", "") or "",
"required_login_group": get_setting("ldap_required_login_group", "") or "",
} }
@@ -1655,6 +1688,46 @@ def _ldap_resolve_app_groups(service_conn, user_dn):
return matched return matched
def _normalize_share_unc(raw):
"""Normalisiert einen vom Formular übergebenen Freigabe-Pfad auf das
von mount.cifs benötigte "//server/freigabe"-Format -- ein
Windows-Admin gibt naturgemäß "\\\\server\\freigabe" ein."""
unc = raw.strip().replace("\\", "/")
if unc and not unc.startswith("//"):
unc = "//" + unc.lstrip("/")
return unc
def _ldap_fileshare_mappings():
conn = get_db_connection()
rows = conn.execute(
"SELECT id, ad_group_dn, ad_group_name, share_label, share_unc "
"FROM ldap_fileshare_mappings ORDER BY ad_group_name ASC"
).fetchall()
conn.close()
return rows
def _ldap_resolve_fileshares(service_conn, user_dn):
"""Analog zu _ldap_resolve_app_groups, nur für Dateifreigaben: prüft für
jede konfigurierte AD-Gruppe -> Freigabe-Zuordnung die (rekursive)
Mitgliedschaft und gibt die Liste der passenden Freigaben zurück.
Bewusst getrennt vom TESM-eigenen Rechtesystem -- OB überhaupt gemountet
werden darf, entscheidet current_user.has_permission('fileshare.view')
(siehe _mount_fileshares_for_login), WELCHE Freigaben es im Erfolgsfall
sind, ausschließlich die AD-Gruppenmitgliedschaft hier."""
matched = []
seen_labels = set()
for mapping in _ldap_fileshare_mappings():
if _ldap_is_member_of(service_conn, user_dn, mapping["ad_group_dn"]):
label = mapping["share_label"]
if label in seen_labels:
continue
seen_labels.add(label)
matched.append({"label": label, "unc": mapping["share_unc"]})
return matched
def _ldap_authenticate(username, password): def _ldap_authenticate(username, password):
"""Prüft Zugangsdaten per LDAP/AD (Search+Bind, siehe _ldap_find_entry """Prüft Zugangsdaten per LDAP/AD (Search+Bind, siehe _ldap_find_entry
für die Details der Suche). Gibt (True, info) bei Erfolg zurück (info für die Details der Suche). Gibt (True, info) bei Erfolg zurück (info
@@ -1683,7 +1756,15 @@ def _ldap_authenticate(username, password):
info = _ldap_entry_info(entry, cfg) info = _ldap_entry_info(entry, cfg)
if info["disabled"]: if info["disabled"]:
return False, None return False, None
if cfg["required_login_group"] and not _ldap_is_member_of(service_conn, user_dn, cfg["required_login_group"]):
# Kein Mitglied der erforderlichen AD-Gruppe -- Login wird
# abgelehnt wie bei falschen Zugangsdaten, ohne den eigentlichen
# Passwort-Bind unten überhaupt erst zu versuchen. Bewusst
# dieselbe generische Fehlermeldung wie jeder andere
# Login-Fehlschlag, um keine Gruppenmitgliedschaft zu verraten.
return False, None
info["app_groups"] = _ldap_resolve_app_groups(service_conn, user_dn) info["app_groups"] = _ldap_resolve_app_groups(service_conn, user_dn)
info["fileshares"] = _ldap_resolve_fileshares(service_conn, user_dn)
except LDAPException: except LDAPException:
return False, None return False, None
finally: finally:
@@ -1733,6 +1814,460 @@ def _ldap_default_group_id(conn):
return row["id"] if row else None return row["id"] if row else None
# ============================================================================
# Dateifreigaben (Fileshare) -- sessionabhängiges Mounten von SMB/CIFS-
# Freigaben je nach AD-Gruppenmitgliedschaft, analog zu einer
# GPO-Laufwerkszuordnung, nur serverseitig im Browser statt als
# Laufwerksbuchstabe auf dem Client. Zwei UNABHÄNGIGE Voraussetzungen
# müssen zutreffen, bevor überhaupt gemountet wird:
# 1. Das TESM-eigene Recht fileshare.view (Rechtesystem-Gate).
# 2. Mindestens eine per LDAP-Gruppe zugeordnete Freigabe (siehe
# _ldap_resolve_fileshares) -- WELCHE Freigaben es sind, entscheidet
# ausschließlich die AD-Gruppenmitgliedschaft, nicht das Rechtesystem.
# Gemountet wird mit den eigenen AD-Zugangsdaten des Nutzers (aus dem
# Login-Vorgang, nie gespeichert), NICHT mit einem festen Service-Konto --
# Dateiserver-eigene ACLs bleiben dadurch individuell wirksam.
# ============================================================================
FILESHARE_MOUNT_ROOT = os.environ.get("TESM_FILESHARE_MOUNT_ROOT", "/mnt/tesm-shares")
FILESHARE_MAX_AGE_SECONDS = 12 * 3600
FILESHARE_SWEEP_INTERVAL_SECONDS = 1800
_active_fileshare_mounts = {}
def _fileshare_cleanup_all_on_startup():
"""Räumt beim App-Start ALLE evtl. noch vorhandenen Mounts unter
FILESHARE_MOUNT_ROOT aus einer vorherigen Prozess-Lebensdauer auf. Das
In-Memory-Tracking (_active_fileshare_mounts) ist bei jedem Neustart
(Update, Absturz, ...) naturgemäß leer -- ohne dieses Aufräumen blieben
verwaiste Mounts für die App unsichtbar, aber real auf dem System
bestehen."""
if not os.path.isdir(FILESHARE_MOUNT_ROOT):
return
try:
token_dirs = os.listdir(FILESHARE_MOUNT_ROOT)
except OSError:
return
for token_dir in token_dirs:
token_path = os.path.join(FILESHARE_MOUNT_ROOT, token_dir)
if not os.path.isdir(token_path):
continue
try:
share_dirs = os.listdir(token_path)
except OSError:
share_dirs = []
for share_dir in share_dirs:
share_path = os.path.join(token_path, share_dir)
if os.path.ismount(share_path):
subprocess.run(["umount", "-l", share_path], capture_output=True, timeout=15)
try:
os.rmdir(share_path)
except OSError:
pass
try:
os.rmdir(token_path)
except OSError:
pass
# app.py wird nicht nur von gunicorn (tesm.service) gestartet, sondern auch
# von generate_ips.py per "from app import ..." als reines Hilfsmodul
# importiert (u.a. alle paar Sekunden aus poe.sh heraus) -- ein Import
# führt sämtlichen Modul-Level-Code hier erneut aus. Ohne dieses Gate
# würde JEDER dieser Imports _fileshare_cleanup_all_on_startup() erneut
# auslösen und damit gerade aktive Fileshare-Mounts anderer, echter
# Sitzungen sofort wieder aushängen. TESM_WEB_PROCESS wird nur von
# tesm.service selbst gesetzt (siehe dessen Unit-Datei) -- nur dort soll
# das Aufräumen beim (Neu-)Start überhaupt stattfinden.
_IS_WEB_PROCESS = os.environ.get("TESM_WEB_PROCESS") == "1"
if _IS_WEB_PROCESS:
_fileshare_cleanup_all_on_startup()
def _resolve_host_preferring_ipv4(hostname):
"""Löst EINEN Hostnamen auf -- IPv4-Adresse wenn vorhanden, sonst
IPv6, sonst None. IPv4 wird bevorzugt, weil sie in der Praxis
zuverlässiger durchgeroutet ist als in DNS eingetragene IPv6-Adressen
(siehe _resolve_unc_host_ip); eine funktionierende IPv6-Route wird
aber genutzt, wenn es keine IPv4-Adresse gibt, statt komplett
aufzugeben."""
try:
infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
except socket.gaierror:
return None
if not infos:
return None
ipv4 = next((i for i in infos if i[0] == socket.AF_INET), None)
return (ipv4 or infos[0])[4][0]
def _resolve_unc_host_ip(unc):
"""Löst den Host-Teil einer "//host/share"-UNC auf eine IP-Adresse
auf, oder gibt None zurück (Host ist bereits eine IP-Literal, oder es
lässt sich nichts ermitteln). Zwei Probleme werden hier abgefangen,
die beide dazu führen, dass eine Freigabe per IP klappt, per Hostname
aber nicht:
1. Ein reiner Kurzname (z.B. "s2025" statt "s2025.ad.eertmoed.net")
löst über die konfigurierte DNS des Servers oft GAR NICHT auf, weil
hier (anders als bei einem domänenbeigetretenen Windows-Client)
keine DNS-Suffixsuche eingerichtet ist. Als Fallback wird deshalb
zusätzlich mit dem aus der LDAP-Servereinstellung abgeleiteten
AD-Domänensuffix versucht (die dortige AD-DNS-Zone enthält
erfahrungsgemäß auch die Datei-Server).
2. Manche interne DNS-Zonen liefern für Server-Hostnamen NUR
AAAA-Einträge (siehe ad.eertmoed.net) -- IPv4 wird bevorzugt
verwendet, falls zusätzlich vorhanden, s.o.
Der Hostname bleibt in jedem Fall unverändert in der UNC stehen --
nur die tatsächliche Verbindung wird per "ip="-Mount-Option gezielt
auf die ermittelte Adresse gelenkt."""
host = unc[2:].split("/", 1)[0] if unc.startswith("//") else ""
if not host:
return None
try:
socket.inet_aton(host)
return None # Host ist bereits eine IPv4-Literal, nichts zu tun
except OSError:
pass
ip = _resolve_host_preferring_ipv4(host)
if ip:
return ip
if "." not in host:
domain = get_setting("ldap_server", "")
if domain and "." in domain:
ip = _resolve_host_preferring_ipv4(f"{host}.{domain}")
return ip
def _mount_one_fileshare(mount_root, label, unc, username, password):
"""Mountet EINE Freigabe unter mount_root/label per mount.cifs. Das
Passwort wird bewusst über die PASSWD-Umgebungsvariable übergeben statt
in der -o-Optionsliste (die kurzzeitig in der Prozessliste sichtbar
wäre) -- von mount.cifs offiziell unterstützter Mechanismus genau für
diesen Zweck. Gibt (ok, message) zurück, wirft nie -- ein nicht
erreichbarer Server soll weder den Login blockieren noch andere
Freigaben verhindern, nur diese eine fehlt dann."""
target = os.path.join(mount_root, label)
try:
os.makedirs(target, exist_ok=True)
except OSError as e:
return False, str(e)
options = f"username={username},vers=3.0,uid=0,gid=0,file_mode=0770,dir_mode=0770,iocharset=utf8"
resolved_ip = _resolve_unc_host_ip(unc)
if resolved_ip:
options += f",ip={resolved_ip}"
env = dict(os.environ)
env["PASSWD"] = password
try:
result = subprocess.run(
["mount", "-t", "cifs", unc, target, "-o", options],
capture_output=True, text=True, timeout=20, env=env,
)
ok = result.returncode == 0
out = (result.stdout or "") + (result.stderr or "")
except Exception as e:
ok, out = False, str(e)
if not ok:
try:
os.rmdir(target)
except OSError:
pass
return ok, out
def _mount_fileshares_for_login(user_obj, username, password, fileshares):
"""Direkt nach erfolgreichem LDAP-Login aufzurufen, solange das
Klartext-Passwort noch im Scope ist -- wird nie gespeichert, nur für
diesen einen mount-Aufruf verwendet. Mountet NUR, wenn der Nutzer das
TESM-Recht fileshare.view hat (siehe PERMISSIONS/fileshare_group) --
fehlt es, wird bewusst gar nicht erst versucht zu mounten, unabhängig
davon, ob AD-seitig passende Freigaben existieren würden."""
if not fileshares or not user_obj.has_permission("fileshare.view"):
return
token = secrets.token_hex(16)
mount_root = os.path.join(FILESHARE_MOUNT_ROOT, token)
mounted = []
for share in fileshares:
ok, err = _mount_one_fileshare(mount_root, share["label"], share["unc"], username, password)
if ok:
mounted.append({"label": share["label"], "mount_path": os.path.join(mount_root, share["label"])})
else:
app.logger.warning("Fileshare-Mount fehlgeschlagen (%s, Freigabe %s): %s", username, share["label"], err)
if mounted:
session["fileshare_token"] = token
_active_fileshare_mounts[token] = {"mounted_at": time.time(), "shares": mounted}
else:
try:
os.rmdir(mount_root)
except OSError:
pass
def _unmount_fileshare_token(token):
"""Hängt alle Freigaben eines Tokens aus und räumt dessen Mount-Root
weg -- von /logout (mit dem Token der eigenen Session) UND vom
Hintergrund-Sweep (mit einem beliebigen, zu alten Token) genutzt.
"umount -l" (lazy) statt eines normalen umount, damit ein zufällig
noch offener Dateihandle das Aushängen nicht mit "target busy"
blockiert -- das Verzeichnis verschwindet dann, sobald der letzte
Handle geschlossen wird, ohne dass TESM darauf warten muss."""
entry = _active_fileshare_mounts.pop(token, None)
if not entry:
return
for share in entry["shares"]:
subprocess.run(["umount", "-l", share["mount_path"]], capture_output=True, timeout=15)
try:
os.rmdir(share["mount_path"])
except OSError:
pass
mount_root = os.path.join(FILESHARE_MOUNT_ROOT, token)
try:
os.rmdir(mount_root)
except OSError:
pass
def _unmount_fileshares_for_current_session():
token = session.pop("fileshare_token", None)
if token:
_unmount_fileshare_token(token)
def _fileshare_sweep_loop():
"""Hintergrund-Thread (ein einziger Prozess dank --workers 1, siehe
tesm.service): hängt Freigaben aus, deren Session seit
FILESHARE_MAX_AGE_SECONDS besteht, unabhängig davon, ob sich der
Nutzer je explizit abgemeldet hat -- Sicherheitsnetz gegen "Tab
einfach geschlossen statt abgemeldet", da eine normale
Flask-Session (signierter Cookie) dem Server sonst keinerlei Signal
gibt, dass sie nicht mehr genutzt wird."""
while True:
time.sleep(FILESHARE_SWEEP_INTERVAL_SECONDS)
try:
now = time.time()
for token, entry in list(_active_fileshare_mounts.items()):
if now - entry["mounted_at"] > FILESHARE_MAX_AGE_SECONDS:
_unmount_fileshare_token(token)
except Exception:
app.logger.error("Fileshare-Sweep fehlgeschlagen:\n%s", traceback.format_exc())
if _IS_WEB_PROCESS:
threading.Thread(target=_fileshare_sweep_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,
LDAP-Nutzer ohne fileshare.view, ohne passende AD-Gruppe, oder deren
Session wurde inzwischen vom Sweep ausgehängt)."""
token = session.get("fileshare_token")
if not token:
return []
entry = _active_fileshare_mounts.get(token)
return entry["shares"] if entry else []
def _fileshare_resolve_path(label, rel_path):
"""Löst einen vom Client übergebenen relativen Pfad GEGEN DIE MOUNT-
ROOT DER EIGENEN SESSION auf und lehnt alles ab, was per Path-Traversal
(z.B. "../../etc") außerhalb davon landen würde -- der zentrale
Sicherheitspunkt des gesamten Features. Gibt den validierten absoluten
Pfad zurück, oder None bei ungültigem Share/Pfad."""
mounts = {m["label"]: m["mount_path"] for m in _current_fileshare_mounts()}
root = mounts.get(label)
if not root:
return None
root_real = os.path.realpath(root)
candidate = os.path.realpath(os.path.join(root_real, (rel_path or "").lstrip("/\\")))
if candidate != root_real and not candidate.startswith(root_real + os.sep):
return None
return candidate
def _fileshare_list_dir(abs_path):
"""Verzeichnisinhalt für die Fileshare-Seite -- Ordner zuerst, dann
Dateien, jeweils alphabetisch. _format_log_size (bereits für
NGINX-Zertifikate/Log-Dateigrößen genutzt) wird hier wiederverwendet."""
entries = []
try:
with os.scandir(abs_path) as it:
for entry in it:
try:
st = entry.stat(follow_symlinks=False)
except OSError:
continue
is_dir = entry.is_dir(follow_symlinks=False)
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"),
})
except OSError:
pass
entries.sort(key=lambda e: (not e["is_dir"], e["name"].lower()))
return entries
@app.route("/fileshare")
@login_required
def fileshare():
if not current_user.has_permission("fileshare.view"):
flash("Keine Berechtigung, Dateifreigaben einzusehen.", "danger")
return redirect(url_for("index"))
mounts = _current_fileshare_mounts()
if not mounts:
flash(
"Keine Dateifreigabe verfügbar -- keine passende AD-Gruppenmitgliedschaft beim letzten Login, "
"oder das Mounten ist fehlgeschlagen.",
"danger",
)
return redirect(url_for("index"))
labels = [m["label"] for m in mounts]
selected_share = request.args.get("share") or labels[0]
if selected_share not in labels:
selected_share = labels[0]
rel_path = request.args.get("path", "").strip("/\\")
abs_path = _fileshare_resolve_path(selected_share, rel_path)
if not abs_path or not os.path.isdir(abs_path):
flash("Ungültiger Pfad -- zurück zum Freigabe-Root.", "danger")
rel_path = ""
abs_path = _fileshare_resolve_path(selected_share, "")
breadcrumbs = []
acc = []
for part in [p for p in rel_path.split("/") if p]:
acc.append(part)
breadcrumbs.append({"name": part, "path": "/".join(acc)})
return render_template(
"fileshare.html",
shares=labels, selected_share=selected_share, rel_path=rel_path, breadcrumbs=breadcrumbs,
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"),
)
@app.route("/fileshare/download")
@login_required
def fileshare_download():
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
return send_file(abs_path, as_attachment=True, download_name=os.path.basename(abs_path))
@app.route("/fileshare/upload", methods=["POST"])
@login_required
def fileshare_upload():
share = request.form.get("share", "")
rel_path = request.form.get("path", "")
if not current_user.has_permission("fileshare.create"):
flash("Keine Berechtigung, Dateien hochzuladen.", "danger")
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:
flash("Ungültiges Ziel oder keine Datei ausgewählt.", "danger")
else:
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:
file.save(dest)
log_action("fileshare.upload", share, f"{rel_path}/{filename}".strip("/"))
flash(f"{filename}“ hochgeladen.", "success")
return redirect(url_for("fileshare", share=share, path=rel_path))
@app.route("/fileshare/mkdir", methods=["POST"])
@login_required
def fileshare_mkdir():
share = request.form.get("share", "")
rel_path = request.form.get("path", "")
if not current_user.has_permission("fileshare.create"):
flash("Keine Berechtigung, Ordner anzulegen.", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
name = secure_filename(request.form.get("name", "").strip())
abs_dir = _fileshare_resolve_path(share, rel_path)
if not abs_dir or not name:
flash("Ungültiger Ordnername.", "danger")
else:
try:
os.mkdir(os.path.join(abs_dir, name))
log_action("fileshare.mkdir", share, f"{rel_path}/{name}".strip("/"))
flash(f"Ordner „{name}“ angelegt.", "success")
except OSError as e:
flash(f"Ordner konnte nicht angelegt werden: {e}", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
@app.route("/fileshare/delete", methods=["POST"])
@login_required
def fileshare_delete():
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))
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):
flash("Ungültiges Ziel.", "danger")
else:
try:
if os.path.isdir(target_abs):
shutil.rmtree(target_abs)
else:
os.remove(target_abs)
log_action("fileshare.delete", share, f"{rel_path}/{name}".strip("/"))
flash(f"{name}“ gelöscht.", "success")
except OSError as e:
flash(f"Löschen fehlgeschlagen: {e}", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
@app.route("/fileshare/rename", methods=["POST"])
@login_required
def fileshare_rename():
share = request.form.get("share", "")
rel_path = request.form.get("path", "")
if not current_user.has_permission("fileshare.edit"):
flash("Keine Berechtigung zum Umbenennen.", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
old_name = request.form.get("old_name", "")
new_name = secure_filename(request.form.get("new_name", "").strip())
parent_abs = _fileshare_resolve_path(share, rel_path)
old_abs = _fileshare_resolve_path(share, f"{rel_path}/{old_name}".strip("/")) if old_name else None
if not parent_abs or not old_abs or not new_name:
flash("Ungültige Angabe.", "danger")
else:
new_abs = os.path.join(parent_abs, new_name)
if os.path.dirname(os.path.realpath(new_abs)) != os.path.realpath(parent_abs):
flash("Ungültiger neuer Name.", "danger")
else:
try:
os.rename(old_abs, new_abs)
log_action("fileshare.rename", share, f"{old_name} -> {new_name}")
flash(f"{old_name}“ umbenannt zu „{new_name}“.", "success")
except OSError as e:
flash(f"Umbenennen fehlgeschlagen: {e}", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
@app.route("/login", methods=["GET", "POST"]) @app.route("/login", methods=["GET", "POST"])
def login(): def login():
if request.method == "POST": if request.method == "POST":
@@ -1773,7 +2308,11 @@ def login():
conn.commit() conn.commit()
refreshed = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone() refreshed = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone()
conn.close() conn.close()
login_user(_build_user(refreshed)) logged_in_user = _build_user(refreshed)
login_user(logged_in_user)
_mount_fileshares_for_login(
logged_in_user, info.get("username") or username, password, info.get("fileshares") or [],
)
return redirect(url_for("index")) return redirect(url_for("index"))
conn.close() conn.close()
flash("Ungültiger Benutzername oder Passwort", "danger") flash("Ungültiger Benutzername oder Passwort", "danger")
@@ -1812,7 +2351,11 @@ def login():
new_user = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone() new_user = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone()
conn.close() conn.close()
log_action_system("user.ldap_provision", canonical_username, "Erstes erfolgreiches AD/LDAP-Login") log_action_system("user.ldap_provision", canonical_username, "Erstes erfolgreiches AD/LDAP-Login")
login_user(_build_user(new_user)) logged_in_user = _build_user(new_user)
login_user(logged_in_user)
_mount_fileshares_for_login(
logged_in_user, canonical_username, password, info.get("fileshares") or [],
)
return redirect(url_for("index")) return redirect(url_for("index"))
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
conn.close() conn.close()
@@ -1826,6 +2369,7 @@ def login():
@app.route("/logout") @app.route("/logout")
@login_required @login_required
def logout(): def logout():
_unmount_fileshares_for_current_session()
logout_user() logout_user()
return redirect(url_for("index")) return redirect(url_for("index"))
@@ -4225,6 +4769,7 @@ def settings_ldap():
set_setting("ldap_base_dn", request.form.get("ldap_base_dn", "").strip()) set_setting("ldap_base_dn", request.form.get("ldap_base_dn", "").strip())
set_setting("ldap_user_filter_attr", request.form.get("ldap_user_filter_attr", "").strip() or LDAP_DEFAULT_FILTER_ATTR) set_setting("ldap_user_filter_attr", request.form.get("ldap_user_filter_attr", "").strip() or LDAP_DEFAULT_FILTER_ATTR)
set_setting("ldap_default_group", request.form.get("ldap_default_group", "").strip()) set_setting("ldap_default_group", request.form.get("ldap_default_group", "").strip())
set_setting("ldap_required_login_group", request.form.get("ldap_required_login_group", "").strip())
new_bind_password = request.form.get("ldap_bind_password", "") new_bind_password = request.form.get("ldap_bind_password", "")
if new_bind_dn: if new_bind_dn:
conn = get_db_connection() conn = get_db_connection()
@@ -4291,6 +4836,27 @@ def settings_ldap():
log_action("settings.update", "LDAP-Gruppenzuordnung", f"{ad_group_name or ad_group_dn}{app_group_id}") log_action("settings.update", "LDAP-Gruppenzuordnung", f"{ad_group_name or ad_group_dn}{app_group_id}")
flash("Gruppenzuordnung gespeichert.", "success") flash("Gruppenzuordnung gespeichert.", "success")
elif "edit_ldap_group_mapping" in request.form:
mapping_id = request.form.get("edit_ldap_group_mapping")
ad_group_dn = request.form.get("ad_group_dn", "").strip()
ad_group_name = request.form.get("ad_group_name", "").strip()
app_group_id = request.form.get("app_group_id", "").strip()
if not ad_group_dn or not app_group_id:
flash("AD-Gruppe und Rechtegruppe müssen ausgewählt werden.", "danger")
else:
conn = get_db_connection()
try:
conn.execute(
"UPDATE ldap_group_mappings SET ad_group_dn=?, ad_group_name=?, app_group_id=? WHERE id=?",
(ad_group_dn, ad_group_name or ad_group_dn, app_group_id, mapping_id),
)
conn.commit()
log_action("settings.update", "LDAP-Gruppenzuordnung geändert", f"{ad_group_name or ad_group_dn}{app_group_id}")
flash("Gruppenzuordnung aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Diese AD-Gruppe ist bereits einer anderen Rechtegruppe zugeordnet.", "danger")
conn.close()
elif "delete_ldap_group_mapping" in request.form: elif "delete_ldap_group_mapping" in request.form:
mapping_id = request.form.get("delete_ldap_group_mapping") mapping_id = request.form.get("delete_ldap_group_mapping")
conn = get_db_connection() conn = get_db_connection()
@@ -4301,6 +4867,58 @@ def settings_ldap():
log_action("settings.update", "LDAP-Gruppenzuordnung gelöscht", row["ad_group_name"] if row else mapping_id) log_action("settings.update", "LDAP-Gruppenzuordnung gelöscht", row["ad_group_name"] if row else mapping_id)
flash("Gruppenzuordnung gelöscht.", "success") flash("Gruppenzuordnung gelöscht.", "success")
elif "add_fileshare_mapping" in request.form:
ad_group_dn = request.form.get("fs_ad_group_dn", "").strip()
ad_group_name = request.form.get("fs_ad_group_name", "").strip()
share_label = request.form.get("fs_share_label", "").strip()
share_unc = _normalize_share_unc(request.form.get("fs_share_unc", ""))
if not ad_group_dn or not share_label or not share_unc:
flash("AD-Gruppe, Bezeichnung und Freigabe-Pfad müssen angegeben werden.", "danger")
else:
conn = get_db_connection()
conn.execute(
"INSERT INTO ldap_fileshare_mappings (ad_group_dn, ad_group_name, share_label, share_unc) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(ad_group_dn, share_unc) DO UPDATE SET ad_group_name=excluded.ad_group_name, share_label=excluded.share_label",
(ad_group_dn, ad_group_name or ad_group_dn, share_label, share_unc),
)
conn.commit()
conn.close()
log_action("settings.update", "Fileshare-Gruppenzuordnung", f"{ad_group_name or ad_group_dn}{share_label} ({share_unc})")
flash("Fileshare-Zuordnung gespeichert.", "success")
elif "edit_fileshare_mapping" in request.form:
mapping_id = request.form.get("edit_fileshare_mapping")
ad_group_dn = request.form.get("fs_ad_group_dn", "").strip()
ad_group_name = request.form.get("fs_ad_group_name", "").strip()
share_label = request.form.get("fs_share_label", "").strip()
share_unc = _normalize_share_unc(request.form.get("fs_share_unc", ""))
if not ad_group_dn or not share_label or not share_unc:
flash("AD-Gruppe, Bezeichnung und Freigabe-Pfad müssen angegeben werden.", "danger")
else:
conn = get_db_connection()
try:
conn.execute(
"UPDATE ldap_fileshare_mappings SET ad_group_dn=?, ad_group_name=?, share_label=?, share_unc=? WHERE id=?",
(ad_group_dn, ad_group_name or ad_group_dn, share_label, share_unc, mapping_id),
)
conn.commit()
log_action("settings.update", "Fileshare-Gruppenzuordnung geändert", f"{ad_group_name or ad_group_dn}{share_label} ({share_unc})")
flash("Fileshare-Zuordnung aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Diese Kombination aus AD-Gruppe und Freigabe-Pfad existiert bereits.", "danger")
conn.close()
elif "delete_fileshare_mapping" in request.form:
mapping_id = request.form.get("delete_fileshare_mapping")
conn = get_db_connection()
row = conn.execute("SELECT share_label FROM ldap_fileshare_mappings WHERE id=?", (mapping_id,)).fetchone()
conn.execute("DELETE FROM ldap_fileshare_mappings WHERE id=?", (mapping_id,))
conn.commit()
conn.close()
log_action("settings.update", "Fileshare-Zuordnung gelöscht", row["share_label"] if row else mapping_id)
flash("Fileshare-Zuordnung gelöscht.", "success")
return redirect(url_for("settings_ldap")) return redirect(url_for("settings_ldap"))
return render_template( return render_template(
@@ -4308,6 +4926,7 @@ def settings_ldap():
ldap=_ldap_settings(), ldap=_ldap_settings(),
ldap_groups=_ldap_groups_for_dropdown(), ldap_groups=_ldap_groups_for_dropdown(),
mappings=_ldap_group_mappings(), mappings=_ldap_group_mappings(),
fileshare_mappings=_ldap_fileshare_mappings(),
) )
+11
View File
@@ -176,6 +176,17 @@ CREATE TABLE IF NOT EXISTS ldap_group_mappings (
); );
""") """)
c.execute("""
CREATE TABLE IF NOT EXISTS ldap_fileshare_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL,
ad_group_name TEXT NOT NULL,
share_label TEXT NOT NULL,
share_unc TEXT NOT NULL,
UNIQUE(ad_group_dn, share_unc)
);
""")
c.execute(""" c.execute("""
CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
+1
View File
@@ -22,6 +22,7 @@
"clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>', "clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>',
"shield": '<path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/>', "shield": '<path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/>',
"server": '<rect x="2" y="3" width="20" height="7" rx="1.5"/><rect x="2" y="14" width="20" height="7" rx="1.5"/><path d="M6 6.5h.01M6 17.5h.01"/>', "server": '<rect x="2" y="3" width="20" height="7" rx="1.5"/><rect x="2" y="14" width="20" height="7" rx="1.5"/><path d="M6 6.5h.01M6 17.5h.01"/>',
"folder": '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>', "sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>', "logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>', "gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
+203
View File
@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% set active_page = "fileshare" %}
{% block page_title %}Dateifreigaben{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}</div>{% endblock %}
{% block content %}
<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);">
{% for s in shares %}
<option value="{{ s }}" {% if s == selected_share %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
<div class="flex gap-2" style="align-items:center; margin-bottom:14px; flex-wrap:wrap;">
<a href="{{ url_for('fileshare', share=selected_share) }}" class="mono" style="font-size:13px; font-weight:600;">{{ selected_share }}</a>
{% for b in breadcrumbs %}
<span class="text-faint">/</span>
<a href="{{ url_for('fileshare', share=selected_share, path=b.path) }}" class="mono" style="font-size:13px;">{{ b.name }}</a>
{% endfor %}
</div>
<div class="table-wrap" style="margin-bottom:16px;">
<div class="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="fileSearch" placeholder="Datei/Ordner suchen..." oninput="filterTable('fileSearch', 'fileTable')">
</div>
<div class="flex gap-2">
{% if can_create %}
<button type="button" class="btn btn-secondary btn-sm" data-open-modal="mkdirModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/><path d="M12 11v4M10 13h4"/></svg>
Neuer Ordner
</button>
<button type="button" class="btn btn-primary btn-sm" data-open-modal="uploadModal">
<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="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
Hochladen
</button>
{% endif %}
</div>
</div>
{% if entries %}
<div style="overflow-x:auto;">
<table class="data-table" id="fileTable" data-sortable>
<thead><tr>
<th data-sort-key="name">Name</th>
<th data-sort-key="size">Größe</th>
<th data-sort-key="mtime">Geändert</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<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 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) }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:15px; height:15px; margin-right:6px; 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>
{{ e.name }}
</a>
{% else %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:15px; height:15px; margin-right:6px; vertical-align:-2px; color:var(--text-faint);"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
{{ e.name }}
{% endif %}
</td>
<td class="text-faint">{{ e.size_str }}</td>
<td class="text-faint">{{ e.mtime_str }}</td>
<td>
<div class="row-actions">
{% 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>
</a>
{% endif %}
{% if can_edit %}
<button type="button" class="icon-btn" title="Umbenennen" onclick="openRenameModal('{{ e.name|e }}')">
<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>
</button>
<form method="post" action="{{ url_for('fileshare_delete') }}" data-confirm="„{{ e.name }}“ wirklich endgültig löschen?">
<input type="hidden" name="share" value="{{ selected_share }}">
<input type="hidden" name="path" value="{{ rel_path }}">
<input type="hidden" name="name" value="{{ e.name }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<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>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="padding:40px 16px; text-align:center; color:var(--text-faint);">Dieser Ordner ist leer.</div>
{% endif %}
</div>
{% if can_create %}
<div class="modal-overlay" id="uploadModal">
<div class="modal">
<form method="post" action="{{ url_for('fileshare_upload') }}" enctype="multipart/form-data">
<input type="hidden" name="share" value="{{ selected_share }}">
<input type="hidden" name="path" value="{{ rel_path }}">
<div class="modal-header">
<h3>Datei hochladen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Datei</label>
<input type="file" name="file" required>
</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>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Hochladen</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="mkdirModal">
<div class="modal">
<form method="post" action="{{ url_for('fileshare_mkdir') }}">
<input type="hidden" name="share" value="{{ selected_share }}">
<input type="hidden" name="path" value="{{ rel_path }}">
<div class="modal-header">
<h3>Neuer Ordner</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Ordnername</label>
<input type="text" name="name" required autofocus>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Anlegen</button>
</div>
</form>
</div>
</div>
{% endif %}
{% if can_edit %}
<div class="modal-overlay" id="renameModal">
<div class="modal">
<form method="post" action="{{ url_for('fileshare_rename') }}">
<input type="hidden" name="share" value="{{ selected_share }}">
<input type="hidden" name="path" value="{{ rel_path }}">
<input type="hidden" name="old_name" id="renameOldName">
<div class="modal-header">
<h3>Umbenennen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Neuer Name</label>
<input type="text" name="new_name" id="renameNewName" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Umbenennen</button>
</div>
</form>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function openRenameModal(name) {
document.getElementById("renameOldName").value = name;
document.getElementById("renameNewName").value = name;
PoeUI.openModal("renameModal");
}
function filterTable(inputId, tableId) {
const q = document.getElementById(inputId).value.trim().toLowerCase();
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
if (row.classList.contains("empty-row")) return;
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
</script>
{% endblock %}
+245 -6
View File
@@ -70,6 +70,18 @@
</select> </select>
<div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</div> <div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</div>
</div> </div>
<div class="field"><label>Erforderliche AD-Gruppe für Login (optional)</label>
<div class="flex gap-2">
<select name="ldap_required_login_group" id="requiredGroupSelect" style="flex:1;">
<option value="">Keine (jeder gültige AD-Benutzer darf sich anmelden)</option>
{% if ldap.required_login_group %}
<option value="{{ ldap.required_login_group }}" selected>{{ ldap.required_login_group }}</option>
{% endif %}
</select>
<button type="button" class="btn btn-secondary btn-sm" id="requiredGroupLoadBtn">Gruppen laden</button>
</div>
<div class="field-hint" id="requiredGroupLoadStatus">Ist hier eine Gruppe ausgewählt, scheitert die Anmeldung für alle Benutzer, die ihr NICHT angehören (rekursiv, auch über verschachtelte Gruppen) — wie bei falschen Zugangsdaten, ohne Hinweis auf den eigentlichen Grund.</div>
</div>
<div class="flex gap-2" style="flex-wrap:wrap;"> <div class="flex gap-2" style="flex-wrap:wrap;">
<button type="submit" name="save_ldap" value="1" class="btn btn-primary"> <button type="submit" name="save_ldap" value="1" 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> <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>
@@ -123,12 +135,18 @@
<td>{{ m.app_group_name or '—' }}</td> <td>{{ m.app_group_name or '—' }}</td>
<td> <td>
{% if can_edit %} {% if can_edit %}
<div class="row-actions">
<button type="button" class="icon-btn" title="Bearbeiten"
onclick="openEditLdapMappingModal('{{ m.id }}','{{ m.ad_group_dn|e }}','{{ m.ad_group_name|e }}','{{ m.app_group_id }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
</button>
<form method="post" data-confirm="Zuordnung „{{ m.ad_group_name }} → {{ m.app_group_name }}“ löschen?"> <form method="post" data-confirm="Zuordnung „{{ m.ad_group_name }} → {{ m.app_group_name }}“ löschen?">
<input type="hidden" name="delete_ldap_group_mapping" value="{{ m.id }}"> <input type="hidden" name="delete_ldap_group_mapping" value="{{ m.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen"> <button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<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> <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>
</button> </button>
</form> </form>
</div>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@@ -142,6 +160,63 @@
{% endif %} {% endif %}
</div> </div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Fileshare-Gruppen</h2>
<div class="hint">
Ist ein AD-Benutzer (rekursiv) Mitglied einer hier zugeordneten AD-Gruppe, wird die zugehörige
Freigabe beim Login für ihn gemountet — sofern er zusätzlich das TESM-Recht „Dateifreigaben lesen“
hat (siehe Gruppen → Rechte, Bereich „Dateifreigaben“). Fehlt das Recht, wird nicht gemountet und
der Menüpunkt „Dateifreigaben“ erscheint nicht, unabhängig von der AD-Gruppenmitgliedschaft.
</div>
</div>
{% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addFileshareMappingModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Zuordnung hinzufügen
</button>
{% endif %}
</div>
{% if fileshare_mappings %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>AD-Gruppe</th><th>Freigabe</th><th>Pfad</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for m in fileshare_mappings %}
<tr>
<td>{{ m.ad_group_name }}<div class="text-faint mono" style="font-size:11px;">{{ m.ad_group_dn }}</div></td>
<td>{{ m.share_label }}</td>
<td class="mono" style="font-size:12px;">{{ m.share_unc }}</td>
<td>
{% if can_edit %}
<div class="row-actions">
<button type="button" class="icon-btn" title="Bearbeiten"
onclick="openEditFileshareMappingModal('{{ m.id }}','{{ m.ad_group_dn|e }}','{{ m.ad_group_name|e }}','{{ m.share_label|e }}','{{ m.share_unc|e }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
</button>
<form method="post" data-confirm="Fileshare-Zuordnung „{{ m.ad_group_name }} → {{ m.share_label }}“ löschen?">
<input type="hidden" name="delete_fileshare_mapping" value="{{ m.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<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>
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Noch keine Fileshare-Zuordnung angelegt — für niemanden wird eine Freigabe gemountet.</p>
{% endif %}
</div>
</div> </div>
{% if can_edit %} {% if can_edit %}
@@ -183,15 +258,129 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="addFileshareMappingModal">
<div class="modal">
<form method="post">
<input type="hidden" name="add_fileshare_mapping" value="1">
<div class="modal-header">
<h3>Fileshare-Zuordnung hinzufügen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>AD-Gruppe</label>
<div class="flex gap-2">
<select name="fs_ad_group_dn" id="fsMappingAdGroup" required style="flex:1;">
<option value="">— zuerst laden —</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" id="fsMappingLoadGroupsBtn">Gruppen laden</button>
</div>
<input type="hidden" name="fs_ad_group_name" id="fsMappingAdGroupName">
<div class="field-hint" id="fsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
</div>
<div class="field"><label>Bezeichnung</label>
<input type="text" name="fs_share_label" placeholder="z.B. Vertrieb" required>
<div class="field-hint">Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.</div>
</div>
<div class="field"><label>Freigabe-Pfad (UNC)</label>
<input type="text" name="fs_share_unc" placeholder="//fileserver/freigabe" required>
<div class="field-hint">Beide Schreibweisen funktionieren — <code>\\server\freigabe</code> wird automatisch in das von Linux benötigte <code>//server/freigabe</code> umgewandelt.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="editLdapMappingModal">
<div class="modal">
<form method="post">
<input type="hidden" name="edit_ldap_group_mapping" id="editLdapMappingId" value="">
<div class="modal-header">
<h3>AD-Gruppenzuordnung bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>AD-Gruppe</label>
<div class="flex gap-2">
<select name="ad_group_dn" id="editLdapMappingAdGroup" required style="flex:1;"></select>
<button type="button" class="btn btn-secondary btn-sm" id="editLdapMappingLoadGroupsBtn">Gruppen laden</button>
</div>
<input type="hidden" name="ad_group_name" id="editLdapMappingAdGroupName">
<div class="field-hint" id="editLdapMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
</div>
<div class="field"><label>App-Rechtegruppe</label>
<select name="app_group_id" id="editLdapMappingAppGroup" required>
<option value="">— auswählen —</option>
<option value="admin">Admin (alle Rechte)</option>
{% for g in ldap_groups %}
<option value="{{ g['id'] }}">{{ g['name'] }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="editFileshareMappingModal">
<div class="modal">
<form method="post">
<input type="hidden" name="edit_fileshare_mapping" id="editFsMappingId" value="">
<div class="modal-header">
<h3>Fileshare-Zuordnung bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>AD-Gruppe</label>
<div class="flex gap-2">
<select name="fs_ad_group_dn" id="editFsMappingAdGroup" required style="flex:1;"></select>
<button type="button" class="btn btn-secondary btn-sm" id="editFsMappingLoadGroupsBtn">Gruppen laden</button>
</div>
<input type="hidden" name="fs_ad_group_name" id="editFsMappingAdGroupName">
<div class="field-hint" id="editFsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
</div>
<div class="field"><label>Bezeichnung</label>
<input type="text" name="fs_share_label" id="editFsMappingLabel" placeholder="z.B. Vertrieb" required>
<div class="field-hint">Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.</div>
</div>
<div class="field"><label>Freigabe-Pfad (UNC)</label>
<input type="text" name="fs_share_unc" id="editFsMappingUnc" placeholder="//fileserver/freigabe" required>
<div class="field-hint">Beide Schreibweisen funktionieren — <code>\\server\freigabe</code> wird automatisch in das von Linux benötigte <code>//server/freigabe</code> umgewandelt.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<script> <script>
(function () { (function () {
var btn = document.getElementById('ldapMappingLoadGroupsBtn'); function wireAdGroupLoader(btnId, selectId, nameFieldId, statusId, includeEmptyOption) {
var select = document.getElementById('ldapMappingAdGroup'); var btn = document.getElementById(btnId);
var nameField = document.getElementById('ldapMappingAdGroupName'); var select = document.getElementById(selectId);
var status = document.getElementById('ldapMappingLoadStatus'); var nameField = nameFieldId ? document.getElementById(nameFieldId) : null;
var status = document.getElementById(statusId);
if (!btn) return; if (!btn) return;
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
// Aktuellen Wert (z.B. die vorher gespeicherte, noch nicht per
// Klartext-Namen aufgeloeste Gruppe) merken -- bleibt nach dem
// Neuaufbau der Optionsliste ausgewaehlt, falls sie unter den
// geladenen Gruppen auftaucht.
var currentValue = select.value;
status.textContent = 'Lade Gruppen …'; status.textContent = 'Lade Gruppen …';
fetch("{{ url_for('settings_ldap_ad_groups') }}") fetch("{{ url_for('settings_ldap_ad_groups') }}")
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
@@ -201,8 +390,14 @@
return; return;
} }
select.innerHTML = ''; select.innerHTML = '';
if (includeEmptyOption) {
var emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = 'Keine (jeder gültige AD-Benutzer darf sich anmelden)';
select.appendChild(emptyOpt);
}
if (!groups.length) { if (!groups.length) {
select.innerHTML = '<option value="">Keine Gruppen gefunden</option>'; if (!includeEmptyOption) select.innerHTML = '<option value="">Keine Gruppen gefunden</option>';
status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.'; status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.';
return; return;
} }
@@ -211,19 +406,63 @@
opt.value = g.dn; opt.value = g.dn;
opt.textContent = g.name; opt.textContent = g.name;
opt.dataset.name = g.name; opt.dataset.name = g.name;
if (g.dn === currentValue) opt.selected = true;
select.appendChild(opt); select.appendChild(opt);
}); });
nameField.value = select.options[select.selectedIndex].dataset.name || ''; if (nameField) nameField.value = (select.options[select.selectedIndex] && select.options[select.selectedIndex].dataset.name) || '';
status.textContent = groups.length + ' Gruppe(n) geladen.'; status.textContent = groups.length + ' Gruppe(n) geladen.';
}) })
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; }); .catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; });
}); });
select.addEventListener('change', function () { select.addEventListener('change', function () {
if (!nameField) return;
var opt = select.options[select.selectedIndex]; var opt = select.options[select.selectedIndex];
nameField.value = (opt && opt.dataset.name) || ''; nameField.value = (opt && opt.dataset.name) || '';
}); });
}
wireAdGroupLoader('ldapMappingLoadGroupsBtn', 'ldapMappingAdGroup', 'ldapMappingAdGroupName', 'ldapMappingLoadStatus', false);
wireAdGroupLoader('fsMappingLoadGroupsBtn', 'fsMappingAdGroup', 'fsMappingAdGroupName', 'fsMappingLoadStatus', false);
wireAdGroupLoader('requiredGroupLoadBtn', 'requiredGroupSelect', null, 'requiredGroupLoadStatus', true);
wireAdGroupLoader('editLdapMappingLoadGroupsBtn', 'editLdapMappingAdGroup', 'editLdapMappingAdGroupName', 'editLdapMappingLoadStatus', false);
wireAdGroupLoader('editFsMappingLoadGroupsBtn', 'editFsMappingAdGroup', 'editFsMappingAdGroupName', 'editFsMappingLoadStatus', false);
})(); })();
// Bearbeiten-Modals vorbefuellen -- die AD-Gruppe steht dabei zunaechst nur
// als DN+Name aus der Datenbank zur Verfuegung (ohne erneute LDAP-Abfrage);
// "Gruppen laden" ersetzt die Auswahlliste bei Bedarf durch die vollstaendige,
// aktuelle AD-Gruppenliste und behaelt den bisherigen Wert dabei bei (siehe
// wireAdGroupLoader oben).
function seedMappingSelect(selectId, dn, name) {
var select = document.getElementById(selectId);
select.innerHTML = '';
var opt = document.createElement('option');
opt.value = dn;
opt.textContent = name || dn;
opt.dataset.name = name || dn;
opt.selected = true;
select.appendChild(opt);
}
function openEditLdapMappingModal(id, dn, name, appGroupId) {
document.getElementById('editLdapMappingId').value = id;
seedMappingSelect('editLdapMappingAdGroup', dn, name);
document.getElementById('editLdapMappingAdGroupName').value = name;
document.getElementById('editLdapMappingAppGroup').value = appGroupId;
document.getElementById('editLdapMappingLoadStatus').textContent = 'Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.';
PoeUI.openModal('editLdapMappingModal');
}
function openEditFileshareMappingModal(id, dn, name, label, unc) {
document.getElementById('editFsMappingId').value = id;
seedMappingSelect('editFsMappingAdGroup', dn, name);
document.getElementById('editFsMappingAdGroupName').value = name;
document.getElementById('editFsMappingLabel').value = label;
document.getElementById('editFsMappingUnc').value = unc;
document.getElementById('editFsMappingLoadStatus').textContent = 'Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.';
PoeUI.openModal('editFileshareMappingModal');
}
</script> </script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}