diff --git a/etc/systemd/system/tesm.service b/etc/systemd/system/tesm.service
index 64f55ef..ac13618 100644
--- a/etc/systemd/system/tesm.service
+++ b/etc/systemd/system/tesm.service
@@ -6,6 +6,16 @@ After=network.target
Type=simple
User=root
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()-
# Entwicklungsserver -- siehe requirements.txt für die ausführliche
# Begründung von "--workers 1" (In-Memory-Zustand) und "--worker-class
diff --git a/install.sh b/install.sh
index 907f152..b586e6f 100644
--- a/install.sh
+++ b/install.sh
@@ -114,7 +114,7 @@ fi
# ---- Pakete ----
step "Installing system packages"
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 ----
# NICHT weltweit beschreibbar (0755 reicht) -- sowohl tesm.service als
diff --git a/srv/tesm/SCHEMA_VERSION b/srv/tesm/SCHEMA_VERSION
index d00491f..0cfbf08 100644
--- a/srv/tesm/SCHEMA_VERSION
+++ b/srv/tesm/SCHEMA_VERSION
@@ -1 +1 @@
-1
+2
diff --git a/srv/tesm/VERSION b/srv/tesm/VERSION
index 7ee7020..9084fa2 100644
--- a/srv/tesm/VERSION
+++ b/srv/tesm/VERSION
@@ -1 +1 @@
-1.0.10
+1.1.0
diff --git a/srv/tesm/app.py b/srv/tesm/app.py
index a106ea7..82e2493 100644
--- a/srv/tesm/app.py
+++ b/srv/tesm/app.py
@@ -8,7 +8,7 @@ Switch-/User-Verwaltung, Live-Log, Settings, manueller PoE-Neustart),
lediglich mit modernisiertem Frontend und aufgeräumten/konfigurierbaren
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_bcrypt import Bcrypt
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 = {
@@ -350,6 +364,10 @@ PERMISSION_LABELS = {
"settings_ldap.edit": "LDAP/AD-Konfiguration speichern (Bind-Konto, Gruppenzuordnungen)",
"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)",
+ "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 = []
@@ -384,6 +402,7 @@ NAV_ITEMS = [
]},
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
{"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_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
{"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
if key == "groups":
return user.can_manage_groups
+ if key == "fileshare":
+ return user.has_permission("fileshare.view") and bool(_current_fileshare_mounts())
if key == "settings_system":
return user.can_view_settings_system
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("""
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1333,6 +1365,7 @@ def _ldap_settings():
"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,
"default_group": get_setting("ldap_default_group", "") or "",
+ "required_login_group": get_setting("ldap_required_login_group", "") or "",
}
@@ -1655,6 +1688,36 @@ def _ldap_resolve_app_groups(service_conn, user_dn):
return matched
+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):
"""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
@@ -1683,7 +1746,15 @@ def _ldap_authenticate(username, password):
info = _ldap_entry_info(entry, cfg)
if info["disabled"]:
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["fileshares"] = _ldap_resolve_fileshares(service_conn, user_dn)
except LDAPException:
return False, None
finally:
@@ -1733,6 +1804,403 @@ def _ldap_default_group_id(conn):
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 _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"
+ 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"])
def login():
if request.method == "POST":
@@ -1773,7 +2241,11 @@ def login():
conn.commit()
refreshed = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone()
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"))
conn.close()
flash("Ungültiger Benutzername oder Passwort", "danger")
@@ -1812,7 +2284,11 @@ def login():
new_user = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone()
conn.close()
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"))
except sqlite3.IntegrityError:
conn.close()
@@ -1826,6 +2302,7 @@ def login():
@app.route("/logout")
@login_required
def logout():
+ _unmount_fileshares_for_current_session()
logout_user()
return redirect(url_for("index"))
@@ -4225,6 +4702,7 @@ def settings_ldap():
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_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", "")
if new_bind_dn:
conn = get_db_connection()
@@ -4301,6 +4779,38 @@ def settings_ldap():
log_action("settings.update", "LDAP-Gruppenzuordnung gelöscht", row["ad_group_name"] if row else mapping_id)
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 = request.form.get("fs_share_unc", "").strip().replace("\\", "/")
+ if share_unc and not share_unc.startswith("//"):
+ share_unc = "//" + share_unc.lstrip("/")
+ 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 "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 render_template(
@@ -4308,6 +4818,7 @@ def settings_ldap():
ldap=_ldap_settings(),
ldap_groups=_ldap_groups_for_dropdown(),
mappings=_ldap_group_mappings(),
+ fileshare_mappings=_ldap_fileshare_mappings(),
)
diff --git a/srv/tesm/create_db.py b/srv/tesm/create_db.py
index a9bfa91..e24517b 100644
--- a/srv/tesm/create_db.py
+++ b/srv/tesm/create_db.py
@@ -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("""
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
diff --git a/srv/tesm/templates/base.html b/srv/tesm/templates/base.html
index 01f74c1..338e529 100644
--- a/srv/tesm/templates/base.html
+++ b/srv/tesm/templates/base.html
@@ -22,6 +22,7 @@
"clock": '
| Name | +Größe | +Geändert | +Aktionen | +
|---|---|---|---|
| + {% if e.is_dir %} + + + {{ e.name }} + + {% else %} + + {{ e.name }} + {% endif %} + | +{{ e.size_str }} | +{{ e.mtime_str }} | +
+
+ {% if not e.is_dir %}
+
+
+
+ {% endif %}
+ {% if can_edit %}
+
+
+ {% endif %}
+
+ |
+
| AD-Gruppe | Freigabe | Pfad | Aktionen |
|---|---|---|---|
| {{ m.ad_group_name }} {{ m.ad_group_dn }} |
+ {{ m.share_label }} | +{{ m.share_unc }} | ++ {% if can_edit %} + + {% endif %} + | +
Noch keine Fileshare-Zuordnung angelegt — für niemanden wird eine Freigabe gemountet.
+ {% endif %} +