Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c9ec8a100 | ||
|
|
09b222615a | ||
|
|
0074e48e09 |
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
1
|
||||
2
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.9
|
||||
1.1.0
|
||||
|
||||
+622
-3
@@ -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,46 @@ def _ldap_resolve_app_groups(service_conn, user_dn):
|
||||
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):
|
||||
"""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 +1756,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 +1814,460 @@ 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 _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"])
|
||||
def login():
|
||||
if request.method == "POST":
|
||||
@@ -1773,7 +2308,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 +2351,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 +2369,7 @@ def login():
|
||||
@app.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
_unmount_fileshares_for_current_session()
|
||||
logout_user()
|
||||
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_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()
|
||||
@@ -4291,6 +4836,27 @@ def settings_ldap():
|
||||
log_action("settings.update", "LDAP-Gruppenzuordnung", f"{ad_group_name or ad_group_dn} → {app_group_id}")
|
||||
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:
|
||||
mapping_id = request.form.get("delete_ldap_group_mapping")
|
||||
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)
|
||||
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 render_template(
|
||||
@@ -4308,6 +4926,7 @@ def settings_ldap():
|
||||
ldap=_ldap_settings(),
|
||||
ldap_groups=_ldap_groups_for_dropdown(),
|
||||
mappings=_ldap_group_mappings(),
|
||||
fileshare_mappings=_ldap_fileshare_mappings(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -693,14 +693,17 @@ button { font-family: inherit; }
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
/* overflow-x:auto statt hidden -- Tabellen mit vielen Spalten (Geräte,
|
||||
Switche, Papierkorb, ...) würden auf Handy/Tablet sonst entweder die
|
||||
ganze Seite waagerecht aufreißen oder (mit overflow:hidden) Spalten
|
||||
einfach lautlos abschneiden. Scrollt jetzt NUR die Tabelle selbst,
|
||||
der Rest der Seite bleibt an Ort und Stelle -- dasselbe Muster wie
|
||||
Bootstraps .table-responsive. */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
/* Zurück auf overflow:hidden (nur für die abgerundeten Ecken) --
|
||||
JEDES Template, das .table-wrap verwendet, hat bereits einen eigenen
|
||||
<div style="overflow-x:auto;"> exakt um die <table> herum (nicht um
|
||||
die ganze Karte). overflow-x:auto zusätzlich HIER hätte einen zweiten,
|
||||
verschachtelten Scroll-Container um denselben Inhalt erzeugt -- live
|
||||
auf einem iPhone-Viewport nachgewiesen: bei der aufklappbaren
|
||||
Rechte-Tabelle (Gruppen-Seite) landete der Inhalt dadurch dauerhaft
|
||||
unsichtbar bei negativem x. Die Tabellen scrollen also weiterhin
|
||||
horizontal, nur über den bereits vorhandenen inneren Wrapper, nicht
|
||||
zusätzlich über die Karte selbst. */
|
||||
overflow: hidden;
|
||||
}
|
||||
.table-wrap table.data-table { min-width: 560px; }
|
||||
|
||||
@@ -810,6 +813,12 @@ table.data-table {
|
||||
========================================================================== */
|
||||
|
||||
.field { margin-bottom: 15px; }
|
||||
/* Zwei .field nebeneinander in einer .flex-Zeile (z.B. HTTP-/HTTPS-Port
|
||||
bei NGINX) -- als Klasse statt inline style="flex:1", damit die
|
||||
Mobile-Stapel-Regel (siehe @media max-width:640px weiter unten) sie
|
||||
per externem Stylesheet überschreiben kann; ein inline style gewinnt
|
||||
sonst grundsätzlich gegen jede @media-Regel, ganz gleich wie spezifisch. */
|
||||
.field--half { flex: 1; min-width: 0; }
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
@@ -1351,4 +1360,16 @@ select {
|
||||
Breite ein statt als schmaler Block rechts zu kleben. */
|
||||
.section-head > .btn,
|
||||
.section-head > .btn-sm { flex: 1 1 100%; }
|
||||
|
||||
/* Auf einem iPhone quetschen die beiden rechten Topbar-Pillen (Prüf-
|
||||
Countdown + DHCP) .topbar-left (flex:1 1 0%, min-width:0) so weit
|
||||
zusammen, dass der Seitentitel über seine eigene Spaltenbreite
|
||||
hinausläuft und sichtbar mit der Pille kollidiert (per Playwright-
|
||||
Screenshot auf 393px Breite nachgewiesen: .topbar-title ragte 24.5px
|
||||
über .topbar-lefts berechnete Breite hinaus). Titel und Pillen
|
||||
bekommen ab hier je eine eigene Zeile statt sich einen Platz zu
|
||||
teilen, der für beide zusammen nicht reicht. */
|
||||
.topbar { flex-wrap: wrap; row-gap: 6px; }
|
||||
.topbar-left { flex: 1 1 100%; }
|
||||
.topbar-right { flex: 1 1 100%; justify-content: flex-start; }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"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"/>',
|
||||
"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"/>',
|
||||
"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"/>',
|
||||
|
||||
@@ -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>×</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>×</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>×</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 %}
|
||||
@@ -70,6 +70,18 @@
|
||||
</select>
|
||||
<div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</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;">
|
||||
<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>
|
||||
@@ -123,12 +135,18 @@
|
||||
<td>{{ m.app_group_name or '—' }}</td>
|
||||
<td>
|
||||
{% if can_edit %}
|
||||
<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 }}">
|
||||
<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>
|
||||
<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>
|
||||
<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 }}">
|
||||
<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>
|
||||
@@ -142,6 +160,63 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
|
||||
{% if can_edit %}
|
||||
@@ -183,47 +258,211 @@
|
||||
</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>×</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>×</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>×</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>
|
||||
(function () {
|
||||
var btn = document.getElementById('ldapMappingLoadGroupsBtn');
|
||||
var select = document.getElementById('ldapMappingAdGroup');
|
||||
var nameField = document.getElementById('ldapMappingAdGroupName');
|
||||
var status = document.getElementById('ldapMappingLoadStatus');
|
||||
if (!btn) return;
|
||||
function wireAdGroupLoader(btnId, selectId, nameFieldId, statusId, includeEmptyOption) {
|
||||
var btn = document.getElementById(btnId);
|
||||
var select = document.getElementById(selectId);
|
||||
var nameField = nameFieldId ? document.getElementById(nameFieldId) : null;
|
||||
var status = document.getElementById(statusId);
|
||||
if (!btn) return;
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
status.textContent = 'Lade Gruppen …';
|
||||
fetch("{{ url_for('settings_ldap_ad_groups') }}")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (groups) {
|
||||
if (!Array.isArray(groups)) {
|
||||
status.textContent = groups.error || 'Fehler beim Laden.';
|
||||
return;
|
||||
}
|
||||
select.innerHTML = '';
|
||||
if (!groups.length) {
|
||||
select.innerHTML = '<option value="">Keine Gruppen gefunden</option>';
|
||||
status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.';
|
||||
return;
|
||||
}
|
||||
groups.forEach(function (g) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = g.dn;
|
||||
opt.textContent = g.name;
|
||||
opt.dataset.name = g.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
nameField.value = select.options[select.selectedIndex].dataset.name || '';
|
||||
status.textContent = groups.length + ' Gruppe(n) geladen.';
|
||||
})
|
||||
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; });
|
||||
});
|
||||
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 …';
|
||||
fetch("{{ url_for('settings_ldap_ad_groups') }}")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (groups) {
|
||||
if (!Array.isArray(groups)) {
|
||||
status.textContent = groups.error || 'Fehler beim Laden.';
|
||||
return;
|
||||
}
|
||||
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 (!includeEmptyOption) select.innerHTML = '<option value="">Keine Gruppen gefunden</option>';
|
||||
status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.';
|
||||
return;
|
||||
}
|
||||
groups.forEach(function (g) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = g.dn;
|
||||
opt.textContent = g.name;
|
||||
opt.dataset.name = g.name;
|
||||
if (g.dn === currentValue) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if (nameField) nameField.value = (select.options[select.selectedIndex] && select.options[select.selectedIndex].dataset.name) || '';
|
||||
status.textContent = groups.length + ' Gruppe(n) geladen.';
|
||||
})
|
||||
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; });
|
||||
});
|
||||
|
||||
select.addEventListener('change', function () {
|
||||
var opt = select.options[select.selectedIndex];
|
||||
nameField.value = (opt && opt.dataset.name) || '';
|
||||
});
|
||||
select.addEventListener('change', function () {
|
||||
if (!nameField) return;
|
||||
var opt = select.options[select.selectedIndex];
|
||||
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>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -43,11 +43,11 @@
|
||||
<div class="field-hint">"_" ist nginx' Catch-all (Standard für interne Instanzen ohne eigene Domain) — für Let's Encrypt muss hier die tatsächliche, öffentlich auflösbare Domain stehen.</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="field" style="flex:1;">
|
||||
<div class="field field--half">
|
||||
<label for="http_port">HTTP-Port</label>
|
||||
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
|
||||
</div>
|
||||
<div class="field" style="flex:1;">
|
||||
<div class="field field--half">
|
||||
<label for="https_port">HTTPS-Port</label>
|
||||
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user