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": '', "shield": '', "server": '', + "folder": '', "sliders": '', "logout": '', "gear": '', diff --git a/srv/tesm/templates/fileshare.html b/srv/tesm/templates/fileshare.html new file mode 100644 index 0000000..f0c16b9 --- /dev/null +++ b/srv/tesm/templates/fileshare.html @@ -0,0 +1,203 @@ +{% extends "base.html" %} +{% set active_page = "fileshare" %} +{% block page_title %}Dateifreigaben{% endblock %} +{% block page_sub %}
{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}
{% endblock %} + +{% block content %} + +
+
+

Dateifreigaben

+
Freigaben je nach AD-Gruppenmitgliedschaft für diese Sitzung gemountet — wird beim Abmelden wieder ausgehängt.
+
+ {% if shares|length > 1 %} +
+ + +
+ {% endif %} +
+ +
+ {{ selected_share }} + {% for b in breadcrumbs %} + / + {{ b.name }} + {% endfor %} +
+ +
+
+
+ + +
+
+ {% if can_create %} + + + {% endif %} +
+
+ + {% if entries %} +
+ + + + + + + + + {% for e in entries %} + + + + + + + {% endfor %} + +
NameGrößeGeändertAktionen
+ {% 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 %} +
+
+
+ {% else %} +
Dieser Ordner ist leer.
+ {% endif %} +
+ +{% if can_create %} + + + +{% endif %} + +{% if can_edit %} + +{% endif %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/srv/tesm/templates/settings_ldap.html b/srv/tesm/templates/settings_ldap.html index 87b249f..aa730c8 100644 --- a/srv/tesm/templates/settings_ldap.html +++ b/srv/tesm/templates/settings_ldap.html @@ -70,6 +70,18 @@
Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.
+
+
+ + +
+
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.
+
+
+
+
+

Fileshare-Gruppen

+
+ 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. +
+
+ {% if can_edit %} + + {% endif %} +
+ + {% if fileshare_mappings %} +
+
+ + + + {% for m in fileshare_mappings %} + + + + + + + {% endfor %} + +
AD-GruppeFreigabePfadAktionen
{{ m.ad_group_name }}
{{ m.ad_group_dn }}
{{ m.share_label }}{{ m.share_unc }} + {% if can_edit %} +
+ + +
+ {% endif %} +
+
+
+ {% else %} +

Noch keine Fileshare-Zuordnung angelegt — für niemanden wird eine Freigabe gemountet.

+ {% endif %} +
+ {% if can_edit %} @@ -183,46 +246,102 @@ + + {% endif %}