Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb8b929c3c | ||
|
|
af5dfe30b4 | ||
|
|
efb0d2aa01 | ||
|
|
d461f8ca42 | ||
|
|
1b7fe81507 | ||
|
|
2c9ec8a100 | ||
|
|
09b222615a | ||
|
|
0074e48e09 | ||
|
|
a3f5f028f2 |
@@ -29,6 +29,11 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
# nginx' Standard (1m) reicht für Fileshare-Uploads nicht -- etwas
|
||||||
|
# großzügiger als Flasks eigenes MAX_CONTENT_LENGTH (siehe app.py),
|
||||||
|
# damit bei einer knapp 15MB großen Datei nginx nicht schon vor
|
||||||
|
# Flask mit seiner eigenen, unschöneren 413-Seite abbricht.
|
||||||
|
client_max_body_size 16m;
|
||||||
proxy_pass http://127.0.0.1:5000;
|
proxy_pass http://127.0.0.1:5000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ After=network.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
WorkingDirectory=/srv/tesm
|
WorkingDirectory=/srv/tesm
|
||||||
|
# Markiert genau DIESEN Prozess als den echten Web-App-Dienst -- app.py
|
||||||
|
# wird nämlich NICHT nur hierüber gestartet, sondern auch von
|
||||||
|
# generate_ips.py (via "from app import ...") als reines Hilfsmodul
|
||||||
|
# importiert, z.B. aus poe.sh/tesm-check.service heraus, alle paar
|
||||||
|
# Sekunden. Ein einfacher Import führt JEDEN Modul-Level-Code in app.py
|
||||||
|
# erneut aus -- ohne dieses Flag würde jeder generate_ips.py-Aufruf
|
||||||
|
# _fileshare_cleanup_all_on_startup() erneut auslösen und damit gerade
|
||||||
|
# aktive Fileshare-Mounts anderer, echter Sitzungen sofort wieder
|
||||||
|
# aushängen. Siehe die Prüfung auf TESM_WEB_PROCESS in app.py.
|
||||||
|
Environment=TESM_WEB_PROCESS=1
|
||||||
# Produktiver WSGI-Server (gunicorn) statt Flasks eigenem app.run()-
|
# Produktiver WSGI-Server (gunicorn) statt Flasks eigenem app.run()-
|
||||||
# Entwicklungsserver -- siehe requirements.txt für die ausführliche
|
# Entwicklungsserver -- siehe requirements.txt für die ausführliche
|
||||||
# Begründung von "--workers 1" (In-Memory-Zustand) und "--worker-class
|
# Begründung von "--workers 1" (In-Memory-Zustand) und "--worker-class
|
||||||
|
|||||||
+2
-1
@@ -114,7 +114,7 @@ fi
|
|||||||
# ---- Pakete ----
|
# ---- Pakete ----
|
||||||
step "Installing system packages"
|
step "Installing system packages"
|
||||||
sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update"
|
sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update"
|
||||||
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate certbot >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed"
|
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate certbot cifs-utils >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed"
|
||||||
|
|
||||||
# ---- Log-Verzeichnis ----
|
# ---- Log-Verzeichnis ----
|
||||||
# NICHT weltweit beschreibbar (0755 reicht) -- sowohl tesm.service als
|
# NICHT weltweit beschreibbar (0755 reicht) -- sowohl tesm.service als
|
||||||
@@ -134,6 +134,7 @@ sudo chmod 755 /var/log/tesm
|
|||||||
step "Deploying application to /srv/tesm"
|
step "Deploying application to /srv/tesm"
|
||||||
sudo mkdir -p /srv/tesm
|
sudo mkdir -p /srv/tesm
|
||||||
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
||||||
|
--exclude 'known_hosts' \
|
||||||
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
|
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
|
||||||
print_status "Application files copied"
|
print_status "Application files copied"
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1
|
2
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.0.8
|
1.1.5
|
||||||
|
|||||||
+863
-4
@@ -8,7 +8,7 @@ Switch-/User-Verwaltung, Live-Log, Settings, manueller PoE-Neustart),
|
|||||||
lediglich mit modernisiertem Frontend und aufgeräumten/konfigurierbaren
|
lediglich mit modernisiertem Frontend und aufgeräumten/konfigurierbaren
|
||||||
Pfaden im Backend.
|
Pfaden im Backend.
|
||||||
"""
|
"""
|
||||||
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify
|
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, session, send_file, abort
|
||||||
from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user
|
from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user
|
||||||
from flask_bcrypt import Bcrypt
|
from flask_bcrypt import Bcrypt
|
||||||
from flask_sock import Sock
|
from flask_sock import Sock
|
||||||
@@ -19,7 +19,7 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|||||||
from cryptography.hazmat.primitives import hashes, serialization
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback
|
import base64, csv, io, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback, zipfile
|
||||||
import paramiko
|
import paramiko
|
||||||
import yaml
|
import yaml
|
||||||
import ssl
|
import ssl
|
||||||
@@ -164,6 +164,14 @@ DEVICE_MAINTENANCE_CATEGORY = "linux"
|
|||||||
os.makedirs(AVATAR_DIR, exist_ok=True)
|
os.makedirs(AVATAR_DIR, exist_ok=True)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
# Fileshare-Uploads: bislang gab es keine Grenze (nginx' eigenes Limit vor
|
||||||
|
# app.py griff mangels client_max_body_size faktisch bei 1MB, siehe
|
||||||
|
# etc/nginx/sites-available/tesm bzw. _NGINX_PROXY_LOCATIONS -- beides jetzt
|
||||||
|
# passend auf 15/16MB angehoben). Ohne dieses Flask-seitige Limit würde ein
|
||||||
|
# zu großer Upload erst ganz am Ende, nach vollständigem Empfang, an
|
||||||
|
# secure_filename()/os.path-Prüfungen scheitern -- mit MAX_CONTENT_LENGTH
|
||||||
|
# bricht Werkzeug den Request sofort ab (413), siehe Fehlerbehandlung unten.
|
||||||
|
app.config["MAX_CONTENT_LENGTH"] = 15 * 1024 * 1024
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_app_log_handler = logging.FileHandler(TESM_APP_LOG_PATH, encoding="utf-8")
|
_app_log_handler = logging.FileHandler(TESM_APP_LOG_PATH, encoding="utf-8")
|
||||||
@@ -175,6 +183,18 @@ except OSError:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(413)
|
||||||
|
def _handle_request_too_large(_e):
|
||||||
|
"""Greift z.B. beim Fileshare-Upload (siehe MAX_CONTENT_LENGTH oben) --
|
||||||
|
ohne diesen Handler würde Werkzeug eine nackte 413-Fehlerseite ohne
|
||||||
|
App-Look ausliefern. request.referrer statt einer festen Route, damit
|
||||||
|
das auch für andere, spätere Uploads (nicht nur Fileshare) die richtige
|
||||||
|
Seite trifft."""
|
||||||
|
max_mb = (app.config.get("MAX_CONTENT_LENGTH") or 0) // (1024 * 1024)
|
||||||
|
flash(f"Die Datei ist zu groß (Limit: {max_mb} MB).", "danger")
|
||||||
|
return redirect(request.referrer or url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
def _load_or_create_secret() -> str:
|
def _load_or_create_secret() -> str:
|
||||||
env_secret = os.environ.get("TESM_SECRET_KEY")
|
env_secret = os.environ.get("TESM_SECRET_KEY")
|
||||||
if env_secret:
|
if env_secret:
|
||||||
@@ -316,6 +336,20 @@ PERMISSIONS = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"fileshare_group": {
|
||||||
|
"label": "Dateifreigaben",
|
||||||
|
"view_key": "fileshare_group.view",
|
||||||
|
"children": {
|
||||||
|
"fileshare": {
|
||||||
|
"label": "Dateifreigaben",
|
||||||
|
"rows": {
|
||||||
|
"view": "fileshare.view",
|
||||||
|
"create": "fileshare.create",
|
||||||
|
"edit": "fileshare.edit",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
PERMISSION_LABELS = {
|
PERMISSION_LABELS = {
|
||||||
@@ -350,6 +384,10 @@ PERMISSION_LABELS = {
|
|||||||
"settings_ldap.edit": "LDAP/AD-Konfiguration speichern (Bind-Konto, Gruppenzuordnungen)",
|
"settings_ldap.edit": "LDAP/AD-Konfiguration speichern (Bind-Konto, Gruppenzuordnungen)",
|
||||||
"settings_nginx.view": "NGINX-Konfiguration und Zertifikatsstatus lesen",
|
"settings_nginx.view": "NGINX-Konfiguration und Zertifikatsstatus lesen",
|
||||||
"settings_nginx.edit": "NGINX-Konfiguration ändern (Domain/Ports, Zertifikat hochladen oder per Let's Encrypt anfordern, HTTPS/HSTS aktivieren)",
|
"settings_nginx.edit": "NGINX-Konfiguration ändern (Domain/Ports, Zertifikat hochladen oder per Let's Encrypt anfordern, HTTPS/HSTS aktivieren)",
|
||||||
|
"fileshare_group.view": "Dateifreigaben-Bereich anzeigen",
|
||||||
|
"fileshare.view": "Dateifreigaben lesen (Browsen/Herunterladen der über LDAP-Gruppen zugewiesenen Freigaben -- OHNE dieses Recht wird für den Nutzer nichts gemountet)",
|
||||||
|
"fileshare.create": "Dateien in Freigaben hochladen",
|
||||||
|
"fileshare.edit": "Dateien in Freigaben überschreiben, umbenennen oder löschen",
|
||||||
}
|
}
|
||||||
|
|
||||||
ALL_PERMISSION_KEYS = []
|
ALL_PERMISSION_KEYS = []
|
||||||
@@ -384,6 +422,7 @@ NAV_ITEMS = [
|
|||||||
]},
|
]},
|
||||||
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
|
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
|
||||||
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
|
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
|
||||||
|
{"key": "fileshare", "label": "Dateifreigaben", "icon": "folder", "endpoint": "fileshare"},
|
||||||
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
|
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
|
||||||
{"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
|
{"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
|
||||||
{"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"},
|
{"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"},
|
||||||
@@ -420,6 +459,8 @@ def _nav_key_visible(key, user):
|
|||||||
return user.can_manage_users
|
return user.can_manage_users
|
||||||
if key == "groups":
|
if key == "groups":
|
||||||
return user.can_manage_groups
|
return user.can_manage_groups
|
||||||
|
if key == "fileshare":
|
||||||
|
return user.has_permission("fileshare.view") and bool(_current_fileshare_mounts())
|
||||||
if key == "settings_system":
|
if key == "settings_system":
|
||||||
return user.can_view_settings_system
|
return user.can_view_settings_system
|
||||||
if key == "settings_importexport":
|
if key == "settings_importexport":
|
||||||
@@ -811,6 +852,17 @@ def _ensure_schema():
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS ldap_fileshare_mappings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ad_group_dn TEXT NOT NULL,
|
||||||
|
ad_group_name TEXT NOT NULL,
|
||||||
|
share_label TEXT NOT NULL,
|
||||||
|
share_unc TEXT NOT NULL,
|
||||||
|
UNIQUE(ad_group_dn, share_unc)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -1333,6 +1385,7 @@ def _ldap_settings():
|
|||||||
"base_dn": get_setting("ldap_base_dn", "") or "",
|
"base_dn": get_setting("ldap_base_dn", "") or "",
|
||||||
"filter_attr": get_setting("ldap_user_filter_attr", LDAP_DEFAULT_FILTER_ATTR) or LDAP_DEFAULT_FILTER_ATTR,
|
"filter_attr": get_setting("ldap_user_filter_attr", LDAP_DEFAULT_FILTER_ATTR) or LDAP_DEFAULT_FILTER_ATTR,
|
||||||
"default_group": get_setting("ldap_default_group", "") or "",
|
"default_group": get_setting("ldap_default_group", "") or "",
|
||||||
|
"required_login_group": get_setting("ldap_required_login_group", "") or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1655,6 +1708,46 @@ def _ldap_resolve_app_groups(service_conn, user_dn):
|
|||||||
return matched
|
return matched
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_share_unc(raw):
|
||||||
|
"""Normalisiert einen vom Formular übergebenen Freigabe-Pfad auf das
|
||||||
|
von mount.cifs benötigte "//server/freigabe"-Format -- ein
|
||||||
|
Windows-Admin gibt naturgemäß "\\\\server\\freigabe" ein."""
|
||||||
|
unc = raw.strip().replace("\\", "/")
|
||||||
|
if unc and not unc.startswith("//"):
|
||||||
|
unc = "//" + unc.lstrip("/")
|
||||||
|
return unc
|
||||||
|
|
||||||
|
|
||||||
|
def _ldap_fileshare_mappings():
|
||||||
|
conn = get_db_connection()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, ad_group_dn, ad_group_name, share_label, share_unc "
|
||||||
|
"FROM ldap_fileshare_mappings ORDER BY ad_group_name ASC"
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _ldap_resolve_fileshares(service_conn, user_dn):
|
||||||
|
"""Analog zu _ldap_resolve_app_groups, nur für Dateifreigaben: prüft für
|
||||||
|
jede konfigurierte AD-Gruppe -> Freigabe-Zuordnung die (rekursive)
|
||||||
|
Mitgliedschaft und gibt die Liste der passenden Freigaben zurück.
|
||||||
|
Bewusst getrennt vom TESM-eigenen Rechtesystem -- OB überhaupt gemountet
|
||||||
|
werden darf, entscheidet current_user.has_permission('fileshare.view')
|
||||||
|
(siehe _mount_fileshares_for_login), WELCHE Freigaben es im Erfolgsfall
|
||||||
|
sind, ausschließlich die AD-Gruppenmitgliedschaft hier."""
|
||||||
|
matched = []
|
||||||
|
seen_labels = set()
|
||||||
|
for mapping in _ldap_fileshare_mappings():
|
||||||
|
if _ldap_is_member_of(service_conn, user_dn, mapping["ad_group_dn"]):
|
||||||
|
label = mapping["share_label"]
|
||||||
|
if label in seen_labels:
|
||||||
|
continue
|
||||||
|
seen_labels.add(label)
|
||||||
|
matched.append({"label": label, "unc": mapping["share_unc"]})
|
||||||
|
return matched
|
||||||
|
|
||||||
|
|
||||||
def _ldap_authenticate(username, password):
|
def _ldap_authenticate(username, password):
|
||||||
"""Prüft Zugangsdaten per LDAP/AD (Search+Bind, siehe _ldap_find_entry
|
"""Prüft Zugangsdaten per LDAP/AD (Search+Bind, siehe _ldap_find_entry
|
||||||
für die Details der Suche). Gibt (True, info) bei Erfolg zurück (info
|
für die Details der Suche). Gibt (True, info) bei Erfolg zurück (info
|
||||||
@@ -1683,7 +1776,15 @@ def _ldap_authenticate(username, password):
|
|||||||
info = _ldap_entry_info(entry, cfg)
|
info = _ldap_entry_info(entry, cfg)
|
||||||
if info["disabled"]:
|
if info["disabled"]:
|
||||||
return False, None
|
return False, None
|
||||||
|
if cfg["required_login_group"] and not _ldap_is_member_of(service_conn, user_dn, cfg["required_login_group"]):
|
||||||
|
# Kein Mitglied der erforderlichen AD-Gruppe -- Login wird
|
||||||
|
# abgelehnt wie bei falschen Zugangsdaten, ohne den eigentlichen
|
||||||
|
# Passwort-Bind unten überhaupt erst zu versuchen. Bewusst
|
||||||
|
# dieselbe generische Fehlermeldung wie jeder andere
|
||||||
|
# Login-Fehlschlag, um keine Gruppenmitgliedschaft zu verraten.
|
||||||
|
return False, None
|
||||||
info["app_groups"] = _ldap_resolve_app_groups(service_conn, user_dn)
|
info["app_groups"] = _ldap_resolve_app_groups(service_conn, user_dn)
|
||||||
|
info["fileshares"] = _ldap_resolve_fileshares(service_conn, user_dn)
|
||||||
except LDAPException:
|
except LDAPException:
|
||||||
return False, None
|
return False, None
|
||||||
finally:
|
finally:
|
||||||
@@ -1733,6 +1834,675 @@ def _ldap_default_group_id(conn):
|
|||||||
return row["id"] if row else None
|
return row["id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Dateifreigaben (Fileshare) -- sessionabhängiges Mounten von SMB/CIFS-
|
||||||
|
# Freigaben je nach AD-Gruppenmitgliedschaft, analog zu einer
|
||||||
|
# GPO-Laufwerkszuordnung, nur serverseitig im Browser statt als
|
||||||
|
# Laufwerksbuchstabe auf dem Client. Zwei UNABHÄNGIGE Voraussetzungen
|
||||||
|
# müssen zutreffen, bevor überhaupt gemountet wird:
|
||||||
|
# 1. Das TESM-eigene Recht fileshare.view (Rechtesystem-Gate).
|
||||||
|
# 2. Mindestens eine per LDAP-Gruppe zugeordnete Freigabe (siehe
|
||||||
|
# _ldap_resolve_fileshares) -- WELCHE Freigaben es sind, entscheidet
|
||||||
|
# ausschließlich die AD-Gruppenmitgliedschaft, nicht das Rechtesystem.
|
||||||
|
# Gemountet wird mit den eigenen AD-Zugangsdaten des Nutzers (aus dem
|
||||||
|
# Login-Vorgang, nie gespeichert), NICHT mit einem festen Service-Konto --
|
||||||
|
# Dateiserver-eigene ACLs bleiben dadurch individuell wirksam.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
FILESHARE_MOUNT_ROOT = os.environ.get("TESM_FILESHARE_MOUNT_ROOT", "/mnt/tesm-shares")
|
||||||
|
FILESHARE_MAX_AGE_SECONDS = 12 * 3600
|
||||||
|
FILESHARE_SWEEP_INTERVAL_SECONDS = 1800
|
||||||
|
|
||||||
|
# Inline-Vorschau (/fileshare/view, siehe unten): pro Endung, welche Art von
|
||||||
|
# Vorschau der Client bauen soll (steuert nur die UI/JS-Verzweigung) und mit
|
||||||
|
# welchem Content-Type die Datei ausgeliefert wird. Bewusst eine feste
|
||||||
|
# Positivliste -- alles andere bekommt gar keinen Vorschau-Button und die
|
||||||
|
# View-Route liefert für unbekannte Endungen 415 statt "irgendwas" mit vom
|
||||||
|
# Dateinamen geratenem Content-Type auszuliefern. .doc (altes Word-Binär-
|
||||||
|
# format) ist bewusst NICHT dabei -- mammoth.js kann nur .docx (OOXML)
|
||||||
|
# zuverlässig konvertieren; SheetJS dagegen liest sowohl alte .xls- als auch
|
||||||
|
# .xlsx-Dateien ordentlich, deshalb dort beide.
|
||||||
|
_FILESHARE_PREVIEW_KINDS = {
|
||||||
|
".pdf": "pdf",
|
||||||
|
".jpg": "image", ".jpeg": "image", ".png": "image", ".gif": "image",
|
||||||
|
".webp": "image", ".bmp": "image", ".svg": "image",
|
||||||
|
".txt": "text", ".csv": "text", ".log": "text", ".md": "text", ".json": "text",
|
||||||
|
".docx": "docx",
|
||||||
|
".xlsx": "xlsx", ".xls": "xlsx",
|
||||||
|
}
|
||||||
|
_FILESHARE_PREVIEW_MIME = {
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
|
||||||
|
".webp": "image/webp", ".bmp": "image/bmp", ".svg": "image/svg+xml",
|
||||||
|
".txt": "text/plain; charset=utf-8", ".csv": "text/plain; charset=utf-8",
|
||||||
|
".log": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
|
||||||
|
".json": "text/plain; charset=utf-8",
|
||||||
|
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
".xls": "application/vnd.ms-excel",
|
||||||
|
}
|
||||||
|
|
||||||
|
_active_fileshare_mounts = {}
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
ext = os.path.splitext(entry.name)[1].lower()
|
||||||
|
entries.append({
|
||||||
|
"name": entry.name,
|
||||||
|
"is_dir": is_dir,
|
||||||
|
"size_str": "" if is_dir else _format_log_size(st.st_size),
|
||||||
|
"mtime_str": datetime.fromtimestamp(st.st_mtime).strftime("%d.%m.%Y %H:%M"),
|
||||||
|
"preview_kind": None if is_dir else _FILESHARE_PREVIEW_KINDS.get(ext),
|
||||||
|
})
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
entries.sort(key=lambda e: (not e["is_dir"], e["name"].lower()))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _fileshare_tree_ancestors(label, rel_path):
|
||||||
|
"""Für die Baumansicht: liefert für JEDE Ebene von der Freigabe-Wurzel
|
||||||
|
bis zum aktuellen Pfad die dortigen Unterordner (nur Ordner, keine
|
||||||
|
Dateien) -- damit der Baum serverseitig schon bis zur aktuellen
|
||||||
|
Position aufgeklappt gerendert werden kann. Alles darüber hinaus
|
||||||
|
(Geschwister-Ordner, die der Nutzer selbst aufklappt) lädt der Client
|
||||||
|
bei Bedarf über /fileshare/subfolders nach. Schlüssel ist der jeweilige
|
||||||
|
Teilpfad ("" für die Freigabe-Wurzel selbst)."""
|
||||||
|
segments = [p for p in rel_path.split("/") if p]
|
||||||
|
expanded = {}
|
||||||
|
acc = []
|
||||||
|
for depth in range(len(segments) + 1):
|
||||||
|
current_rel = "/".join(acc)
|
||||||
|
abs_path = _fileshare_resolve_path(label, current_rel)
|
||||||
|
if not abs_path or not os.path.isdir(abs_path):
|
||||||
|
break
|
||||||
|
folders = [e["name"] for e in _fileshare_list_dir(abs_path) if e["is_dir"]]
|
||||||
|
expanded[current_rel] = folders
|
||||||
|
if depth < len(segments):
|
||||||
|
acc.append(segments[depth])
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/fileshare")
|
||||||
|
@login_required
|
||||||
|
def fileshare():
|
||||||
|
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"),
|
||||||
|
tree_expanded=_fileshare_tree_ancestors(selected_share, rel_path) if abs_path else {"": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/fileshare/subfolders")
|
||||||
|
@login_required
|
||||||
|
def fileshare_subfolders():
|
||||||
|
"""Lazy-Nachladen EINER Baumebene (nur Unterordner) für die Baum-
|
||||||
|
Navigation der Fileshare-Seite -- die Wurzel-bis-aktuell-Kette liefert
|
||||||
|
die Hauptroute bereits serverseitig mit (siehe tree_expanded), alles
|
||||||
|
andere (vom Nutzer aufgeklappte Geschwisterordner) holt der Client
|
||||||
|
gezielt über diese Route nach, ohne die Freigabe komplett zu durchlaufen."""
|
||||||
|
if not current_user.has_permission("fileshare.view"):
|
||||||
|
return jsonify({"error": "Keine Berechtigung."}), 403
|
||||||
|
abs_path = _fileshare_resolve_path(request.args.get("share", ""), request.args.get("path", ""))
|
||||||
|
if not abs_path or not os.path.isdir(abs_path):
|
||||||
|
return jsonify({"error": "Ungültiger Pfad."}), 404
|
||||||
|
return jsonify({"folders": [e["name"] for e in _fileshare_list_dir(abs_path) if e["is_dir"]]})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/fileshare/view")
|
||||||
|
@login_required
|
||||||
|
def fileshare_view():
|
||||||
|
"""Inline-Vorschau (Content-Disposition NICHT 'attachment', anders als
|
||||||
|
/fileshare/download) für eine feste Positivliste von Dateitypen (siehe
|
||||||
|
_FILESHARE_PREVIEW_MIME) -- alles andere liefert bewusst 415 statt mit
|
||||||
|
geratenem Content-Type etwas potenziell Falsches inline auszuliefern.
|
||||||
|
nosniff + eine restriktive CSP zusätzlich als Tiefenverteidigung, falls
|
||||||
|
diese URL direkt (statt über das Vorschau-Modal) aufgerufen wird."""
|
||||||
|
if not current_user.has_permission("fileshare.view"):
|
||||||
|
return "Keine Berechtigung.", 403
|
||||||
|
abs_path = _fileshare_resolve_path(request.args.get("share", ""), request.args.get("path", ""))
|
||||||
|
if not abs_path or not os.path.isfile(abs_path):
|
||||||
|
return "Datei nicht gefunden.", 404
|
||||||
|
ext = os.path.splitext(abs_path)[1].lower()
|
||||||
|
mimetype = _FILESHARE_PREVIEW_MIME.get(ext)
|
||||||
|
if not mimetype:
|
||||||
|
return "Vorschau für diesen Dateityp nicht verfügbar.", 415
|
||||||
|
resp = send_file(abs_path, as_attachment=False, mimetype=mimetype, conditional=True)
|
||||||
|
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
resp.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/fileshare/download")
|
||||||
|
@login_required
|
||||||
|
def fileshare_download():
|
||||||
|
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)
|
||||||
|
# request.files.getlist() statt .get(): das Upload-Feld erlaubt jetzt
|
||||||
|
# Mehrfachauswahl (name="file" multiple) -- ein einzelner Dateiauswahl-
|
||||||
|
# Dialog liefert dann mehrere Files unter demselben Feldnamen, klassisch
|
||||||
|
# eine Datei liefert genauso eine Liste mit einem Element.
|
||||||
|
files = [f for f in request.files.getlist("file") if f and f.filename]
|
||||||
|
if not abs_dir or not os.path.isdir(abs_dir) or not files:
|
||||||
|
flash("Ungültiges Ziel oder keine Datei ausgewählt.", "danger")
|
||||||
|
else:
|
||||||
|
uploaded, rejected = [], []
|
||||||
|
for file in files:
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
dest = os.path.join(abs_dir, filename) if filename else None
|
||||||
|
if not filename or os.path.dirname(os.path.realpath(dest)) != os.path.realpath(abs_dir):
|
||||||
|
rejected.append(file.filename)
|
||||||
|
continue
|
||||||
|
file.save(dest)
|
||||||
|
uploaded.append(filename)
|
||||||
|
if uploaded:
|
||||||
|
log_action("fileshare.upload", share, f"{rel_path}/".strip("/") + f" ({len(uploaded)} Datei(en): {', '.join(uploaded)})")
|
||||||
|
if len(uploaded) == 1:
|
||||||
|
flash(f"„{uploaded[0]}“ hochgeladen.", "success")
|
||||||
|
else:
|
||||||
|
flash(f"{len(uploaded)} Dateien hochgeladen: {', '.join(uploaded)}.", "success")
|
||||||
|
if rejected:
|
||||||
|
flash(f"Ungültiger Dateiname, übersprungen: {', '.join(rejected)}.", "danger")
|
||||||
|
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||||
|
|
||||||
|
|
||||||
|
@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))
|
||||||
|
|
||||||
|
|
||||||
|
def _fileshare_resolve_child(share, rel_path, name):
|
||||||
|
"""Löst EINEN Kind-Eintrag (Datei oder Ordner) von rel_path auf und
|
||||||
|
stellt zusätzlich sicher, dass er auch tatsächlich direkt DARIN liegt
|
||||||
|
(per os.sep-Präfix-Vergleich des bereits Path-Traversal-geprüften
|
||||||
|
_fileshare_resolve_path) -- von delete/delete-multi/download-multi
|
||||||
|
gemeinsam genutzt. None bei jedem ungültigen Fall, wirft nie."""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
parent_abs = _fileshare_resolve_path(share, rel_path)
|
||||||
|
target_abs = _fileshare_resolve_path(share, f"{rel_path}/{name}".strip("/"))
|
||||||
|
if not parent_abs or not target_abs or not target_abs.startswith(parent_abs + os.sep):
|
||||||
|
return None
|
||||||
|
return target_abs
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/fileshare/delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def fileshare_delete():
|
||||||
|
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", "")
|
||||||
|
target_abs = _fileshare_resolve_child(share, rel_path, name)
|
||||||
|
if not target_abs:
|
||||||
|
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/delete-multi", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def fileshare_delete_multi():
|
||||||
|
"""Wie /fileshare/delete, nur für eine per Checkbox ausgewählte Menge
|
||||||
|
an Dateien/Ordnern auf einmal (Mehrfachauswahl in der Tabelle)."""
|
||||||
|
share = request.form.get("share", "")
|
||||||
|
rel_path = request.form.get("path", "")
|
||||||
|
if not current_user.has_permission("fileshare.edit"):
|
||||||
|
flash("Keine Berechtigung, Dateien/Ordner zu löschen.", "danger")
|
||||||
|
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||||
|
|
||||||
|
names = [n for n in request.form.getlist("names") if n]
|
||||||
|
if not names:
|
||||||
|
flash("Keine Elemente ausgewählt.", "danger")
|
||||||
|
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||||
|
|
||||||
|
deleted, failed = [], []
|
||||||
|
for name in names:
|
||||||
|
target_abs = _fileshare_resolve_child(share, rel_path, name)
|
||||||
|
if not target_abs:
|
||||||
|
failed.append(name)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if os.path.isdir(target_abs):
|
||||||
|
shutil.rmtree(target_abs)
|
||||||
|
else:
|
||||||
|
os.remove(target_abs)
|
||||||
|
deleted.append(name)
|
||||||
|
except OSError:
|
||||||
|
failed.append(name)
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
log_action("fileshare.delete", share, f"{rel_path}/".strip("/") + f" ({len(deleted)} Element(e): {', '.join(deleted)})")
|
||||||
|
flash(f"{len(deleted)} Element(e) gelöscht: {', '.join(deleted)}.", "success")
|
||||||
|
if failed:
|
||||||
|
flash(f"Löschen fehlgeschlagen für: {', '.join(failed)}.", "danger")
|
||||||
|
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/fileshare/download-multi", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def fileshare_download_multi():
|
||||||
|
"""Baut eine Auswahl aus mehreren Dateien/Ordnern zu EINEM ZIP zusammen
|
||||||
|
-- vermeidet, dass der Browser bei vielen einzelnen Downloads auf
|
||||||
|
einmal blockiert/nachfragt, und ist die auch anderswo (Drive, Nextcloud
|
||||||
|
etc.) übliche Erwartung bei Mehrfachauswahl. Ordner werden rekursiv mit
|
||||||
|
aufgenommen (relativer Pfad innerhalb des Ordners als Archivpfad)."""
|
||||||
|
share = request.form.get("share", "")
|
||||||
|
rel_path = request.form.get("path", "")
|
||||||
|
if not current_user.has_permission("fileshare.view"):
|
||||||
|
return "Keine Berechtigung.", 403
|
||||||
|
|
||||||
|
names = [n for n in request.form.getlist("names") if n]
|
||||||
|
if not names:
|
||||||
|
flash("Keine Elemente ausgewählt.", "danger")
|
||||||
|
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||||
|
|
||||||
|
targets = []
|
||||||
|
skipped = []
|
||||||
|
for name in names:
|
||||||
|
target_abs = _fileshare_resolve_child(share, rel_path, name)
|
||||||
|
if not target_abs or not os.path.exists(target_abs):
|
||||||
|
skipped.append(name)
|
||||||
|
continue
|
||||||
|
targets.append((name, target_abs))
|
||||||
|
if not targets:
|
||||||
|
flash("Keines der ausgewählten Elemente konnte gefunden werden.", "danger")
|
||||||
|
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||||
|
if skipped:
|
||||||
|
flash(f"Übersprungen (nicht gefunden): {', '.join(skipped)}.", "danger")
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for name, target_abs in targets:
|
||||||
|
if os.path.isdir(target_abs):
|
||||||
|
for root, _dirs, files in os.walk(target_abs):
|
||||||
|
for fname in files:
|
||||||
|
full = os.path.join(root, fname)
|
||||||
|
arcname = os.path.join(name, os.path.relpath(full, target_abs))
|
||||||
|
zf.write(full, arcname)
|
||||||
|
else:
|
||||||
|
zf.write(target_abs, name)
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
if len(targets) == 1:
|
||||||
|
base_name, _ext = os.path.splitext(targets[0][0])
|
||||||
|
zip_name = secure_filename(base_name if not os.path.isdir(targets[0][1]) else targets[0][0]) or "Download"
|
||||||
|
else:
|
||||||
|
zip_name = secure_filename(f"{share}-Auswahl") or "Download"
|
||||||
|
log_action("fileshare.download", share, f"{len(targets)} Element(e) als ZIP: {', '.join(n for n, _ in targets)}")
|
||||||
|
return send_file(buffer, as_attachment=True, download_name=f"{zip_name}.zip", mimetype="application/zip")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/fileshare/rename", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def fileshare_rename():
|
||||||
|
share = request.form.get("share", "")
|
||||||
|
rel_path = request.form.get("path", "")
|
||||||
|
if not current_user.has_permission("fileshare.edit"):
|
||||||
|
flash("Keine Berechtigung zum Umbenennen.", "danger")
|
||||||
|
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||||
|
|
||||||
|
old_name = request.form.get("old_name", "")
|
||||||
|
new_name = secure_filename(request.form.get("new_name", "").strip())
|
||||||
|
parent_abs = _fileshare_resolve_path(share, rel_path)
|
||||||
|
old_abs = _fileshare_resolve_path(share, f"{rel_path}/{old_name}".strip("/")) if old_name else None
|
||||||
|
if not parent_abs or not old_abs or not new_name:
|
||||||
|
flash("Ungültige Angabe.", "danger")
|
||||||
|
else:
|
||||||
|
new_abs = os.path.join(parent_abs, new_name)
|
||||||
|
if os.path.dirname(os.path.realpath(new_abs)) != os.path.realpath(parent_abs):
|
||||||
|
flash("Ungültiger neuer Name.", "danger")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.rename(old_abs, new_abs)
|
||||||
|
log_action("fileshare.rename", share, f"{old_name} -> {new_name}")
|
||||||
|
flash(f"„{old_name}“ umbenannt zu „{new_name}“.", "success")
|
||||||
|
except OSError as e:
|
||||||
|
flash(f"Umbenennen fehlgeschlagen: {e}", "danger")
|
||||||
|
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/login", methods=["GET", "POST"])
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -1773,7 +2543,11 @@ def login():
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
refreshed = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone()
|
refreshed = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
login_user(_build_user(refreshed))
|
logged_in_user = _build_user(refreshed)
|
||||||
|
login_user(logged_in_user)
|
||||||
|
_mount_fileshares_for_login(
|
||||||
|
logged_in_user, info.get("username") or username, password, info.get("fileshares") or [],
|
||||||
|
)
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("Ungültiger Benutzername oder Passwort", "danger")
|
flash("Ungültiger Benutzername oder Passwort", "danger")
|
||||||
@@ -1812,7 +2586,11 @@ def login():
|
|||||||
new_user = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone()
|
new_user = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
log_action_system("user.ldap_provision", canonical_username, "Erstes erfolgreiches AD/LDAP-Login")
|
log_action_system("user.ldap_provision", canonical_username, "Erstes erfolgreiches AD/LDAP-Login")
|
||||||
login_user(_build_user(new_user))
|
logged_in_user = _build_user(new_user)
|
||||||
|
login_user(logged_in_user)
|
||||||
|
_mount_fileshares_for_login(
|
||||||
|
logged_in_user, canonical_username, password, info.get("fileshares") or [],
|
||||||
|
)
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -1826,6 +2604,7 @@ def login():
|
|||||||
@app.route("/logout")
|
@app.route("/logout")
|
||||||
@login_required
|
@login_required
|
||||||
def logout():
|
def logout():
|
||||||
|
_unmount_fileshares_for_current_session()
|
||||||
logout_user()
|
logout_user()
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
@@ -2599,6 +3378,11 @@ _NGINX_PROXY_LOCATIONS = """ location /ws/ {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
# nginx' Standard (1m) reicht für Fileshare-Uploads nicht -- etwas
|
||||||
|
# großzügiger als Flasks eigenes MAX_CONTENT_LENGTH (siehe app.py),
|
||||||
|
# damit bei einer knapp 15MB großen Datei nginx nicht schon vor
|
||||||
|
# Flask mit seiner eigenen, unschöneren 413-Seite abbricht.
|
||||||
|
client_max_body_size 16m;
|
||||||
proxy_pass http://127.0.0.1:5000;
|
proxy_pass http://127.0.0.1:5000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -4225,6 +5009,7 @@ def settings_ldap():
|
|||||||
set_setting("ldap_base_dn", request.form.get("ldap_base_dn", "").strip())
|
set_setting("ldap_base_dn", request.form.get("ldap_base_dn", "").strip())
|
||||||
set_setting("ldap_user_filter_attr", request.form.get("ldap_user_filter_attr", "").strip() or LDAP_DEFAULT_FILTER_ATTR)
|
set_setting("ldap_user_filter_attr", request.form.get("ldap_user_filter_attr", "").strip() or LDAP_DEFAULT_FILTER_ATTR)
|
||||||
set_setting("ldap_default_group", request.form.get("ldap_default_group", "").strip())
|
set_setting("ldap_default_group", request.form.get("ldap_default_group", "").strip())
|
||||||
|
set_setting("ldap_required_login_group", request.form.get("ldap_required_login_group", "").strip())
|
||||||
new_bind_password = request.form.get("ldap_bind_password", "")
|
new_bind_password = request.form.get("ldap_bind_password", "")
|
||||||
if new_bind_dn:
|
if new_bind_dn:
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -4291,6 +5076,27 @@ def settings_ldap():
|
|||||||
log_action("settings.update", "LDAP-Gruppenzuordnung", f"{ad_group_name or ad_group_dn} → {app_group_id}")
|
log_action("settings.update", "LDAP-Gruppenzuordnung", f"{ad_group_name or ad_group_dn} → {app_group_id}")
|
||||||
flash("Gruppenzuordnung gespeichert.", "success")
|
flash("Gruppenzuordnung gespeichert.", "success")
|
||||||
|
|
||||||
|
elif "edit_ldap_group_mapping" in request.form:
|
||||||
|
mapping_id = request.form.get("edit_ldap_group_mapping")
|
||||||
|
ad_group_dn = request.form.get("ad_group_dn", "").strip()
|
||||||
|
ad_group_name = request.form.get("ad_group_name", "").strip()
|
||||||
|
app_group_id = request.form.get("app_group_id", "").strip()
|
||||||
|
if not ad_group_dn or not app_group_id:
|
||||||
|
flash("AD-Gruppe und Rechtegruppe müssen ausgewählt werden.", "danger")
|
||||||
|
else:
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE ldap_group_mappings SET ad_group_dn=?, ad_group_name=?, app_group_id=? WHERE id=?",
|
||||||
|
(ad_group_dn, ad_group_name or ad_group_dn, app_group_id, mapping_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
log_action("settings.update", "LDAP-Gruppenzuordnung geändert", f"{ad_group_name or ad_group_dn} → {app_group_id}")
|
||||||
|
flash("Gruppenzuordnung aktualisiert.", "success")
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
flash("Diese AD-Gruppe ist bereits einer anderen Rechtegruppe zugeordnet.", "danger")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
elif "delete_ldap_group_mapping" in request.form:
|
elif "delete_ldap_group_mapping" in request.form:
|
||||||
mapping_id = request.form.get("delete_ldap_group_mapping")
|
mapping_id = request.form.get("delete_ldap_group_mapping")
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -4301,6 +5107,58 @@ def settings_ldap():
|
|||||||
log_action("settings.update", "LDAP-Gruppenzuordnung gelöscht", row["ad_group_name"] if row else mapping_id)
|
log_action("settings.update", "LDAP-Gruppenzuordnung gelöscht", row["ad_group_name"] if row else mapping_id)
|
||||||
flash("Gruppenzuordnung gelöscht.", "success")
|
flash("Gruppenzuordnung gelöscht.", "success")
|
||||||
|
|
||||||
|
elif "add_fileshare_mapping" in request.form:
|
||||||
|
ad_group_dn = request.form.get("fs_ad_group_dn", "").strip()
|
||||||
|
ad_group_name = request.form.get("fs_ad_group_name", "").strip()
|
||||||
|
share_label = request.form.get("fs_share_label", "").strip()
|
||||||
|
share_unc = _normalize_share_unc(request.form.get("fs_share_unc", ""))
|
||||||
|
if not ad_group_dn or not share_label or not share_unc:
|
||||||
|
flash("AD-Gruppe, Bezeichnung und Freigabe-Pfad müssen angegeben werden.", "danger")
|
||||||
|
else:
|
||||||
|
conn = get_db_connection()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ldap_fileshare_mappings (ad_group_dn, ad_group_name, share_label, share_unc) "
|
||||||
|
"VALUES (?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(ad_group_dn, share_unc) DO UPDATE SET ad_group_name=excluded.ad_group_name, share_label=excluded.share_label",
|
||||||
|
(ad_group_dn, ad_group_name or ad_group_dn, share_label, share_unc),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
log_action("settings.update", "Fileshare-Gruppenzuordnung", f"{ad_group_name or ad_group_dn} → {share_label} ({share_unc})")
|
||||||
|
flash("Fileshare-Zuordnung gespeichert.", "success")
|
||||||
|
|
||||||
|
elif "edit_fileshare_mapping" in request.form:
|
||||||
|
mapping_id = request.form.get("edit_fileshare_mapping")
|
||||||
|
ad_group_dn = request.form.get("fs_ad_group_dn", "").strip()
|
||||||
|
ad_group_name = request.form.get("fs_ad_group_name", "").strip()
|
||||||
|
share_label = request.form.get("fs_share_label", "").strip()
|
||||||
|
share_unc = _normalize_share_unc(request.form.get("fs_share_unc", ""))
|
||||||
|
if not ad_group_dn or not share_label or not share_unc:
|
||||||
|
flash("AD-Gruppe, Bezeichnung und Freigabe-Pfad müssen angegeben werden.", "danger")
|
||||||
|
else:
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE ldap_fileshare_mappings SET ad_group_dn=?, ad_group_name=?, share_label=?, share_unc=? WHERE id=?",
|
||||||
|
(ad_group_dn, ad_group_name or ad_group_dn, share_label, share_unc, mapping_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
log_action("settings.update", "Fileshare-Gruppenzuordnung geändert", f"{ad_group_name or ad_group_dn} → {share_label} ({share_unc})")
|
||||||
|
flash("Fileshare-Zuordnung aktualisiert.", "success")
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
flash("Diese Kombination aus AD-Gruppe und Freigabe-Pfad existiert bereits.", "danger")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
elif "delete_fileshare_mapping" in request.form:
|
||||||
|
mapping_id = request.form.get("delete_fileshare_mapping")
|
||||||
|
conn = get_db_connection()
|
||||||
|
row = conn.execute("SELECT share_label FROM ldap_fileshare_mappings WHERE id=?", (mapping_id,)).fetchone()
|
||||||
|
conn.execute("DELETE FROM ldap_fileshare_mappings WHERE id=?", (mapping_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
log_action("settings.update", "Fileshare-Zuordnung gelöscht", row["share_label"] if row else mapping_id)
|
||||||
|
flash("Fileshare-Zuordnung gelöscht.", "success")
|
||||||
|
|
||||||
return redirect(url_for("settings_ldap"))
|
return redirect(url_for("settings_ldap"))
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -4308,6 +5166,7 @@ def settings_ldap():
|
|||||||
ldap=_ldap_settings(),
|
ldap=_ldap_settings(),
|
||||||
ldap_groups=_ldap_groups_for_dropdown(),
|
ldap_groups=_ldap_groups_for_dropdown(),
|
||||||
mappings=_ldap_group_mappings(),
|
mappings=_ldap_group_mappings(),
|
||||||
|
fileshare_mappings=_ldap_fileshare_mappings(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,17 @@ CREATE TABLE IF NOT EXISTS ldap_group_mappings (
|
|||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS ldap_fileshare_mappings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ad_group_dn TEXT NOT NULL,
|
||||||
|
ad_group_name TEXT NOT NULL,
|
||||||
|
share_label TEXT NOT NULL,
|
||||||
|
share_unc TEXT NOT NULL,
|
||||||
|
UNIQUE(ad_group_dn, share_unc)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
c.execute("""
|
c.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -197,13 +197,23 @@ button { font-family: inherit; }
|
|||||||
.nav-group.expanded .nav-group-children { display: flex; }
|
.nav-group.expanded .nav-group-children { display: flex; }
|
||||||
.nav-group-children .nav-item { font-size: 13px; padding: 8px 12px; }
|
.nav-group-children .nav-item { font-size: 13px; padding: 8px 12px; }
|
||||||
|
|
||||||
/* Kacheln nebeneinander (z.B. Im-/Export, Konto-Seite) */
|
/* Kacheln nebeneinander (z.B. Im-/Export, Konto-Seite). minmax(min(...,
|
||||||
|
100%), 1fr) statt eines nackten Pixelwerts -- eine Spalten-Mindestbreite
|
||||||
|
von z.B. 340px würde auf einem 375px-iPhone (abzüglich .content-Padding)
|
||||||
|
sonst waagerechten Overflow der ganzen Seite erzwingen, weil "auto-fit"
|
||||||
|
erst ab einer Breite größer als der Mindestwert umbricht. */
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(340px, 100%), 1fr));
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
/* Modifier für Seiten, die etwas breitere Karten brauchen (z.B. NGINX,
|
||||||
|
LDAP) -- ersetzt frühere inline style="grid-template-columns:..."
|
||||||
|
Overrides, die denselben 100%-Fallback nicht hatten. */
|
||||||
|
.settings-grid--wide {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
/* Navbar-Reihenfolge (Settings) */
|
/* Navbar-Reihenfolge (Settings) */
|
||||||
.nav-order-list {
|
.nav-order-list {
|
||||||
@@ -506,6 +516,11 @@ button { font-family: inherit; }
|
|||||||
.pill.action-pill { background: var(--muted-dim); color: var(--text-dim); }
|
.pill.action-pill { background: var(--muted-dim); color: var(--text-dim); }
|
||||||
.pill.action-pill::before { display: none; }
|
.pill.action-pill::before { display: none; }
|
||||||
.pill.action-pill svg { width: 13px; height: 13px; }
|
.pill.action-pill svg { width: 13px; height: 13px; }
|
||||||
|
/* Auditlog: Hinzufuegen gruen, Loeschen rot, alles andere (Bearbeiten/
|
||||||
|
Aktivieren/Zuweisen/...) orange -- siehe activity_log.html */
|
||||||
|
.pill.action-pill--create { background: var(--success-dim); color: var(--success); }
|
||||||
|
.pill.action-pill--delete { background: var(--danger-dim); color: var(--danger); }
|
||||||
|
.pill.action-pill--edit { background: var(--accent-dim); color: var(--accent-strong); }
|
||||||
|
|
||||||
.avatar-sm {
|
.avatar-sm {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -683,8 +698,19 @@ button { font-family: inherit; }
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-soft);
|
border: 1px solid var(--border-soft);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
/* 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;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.table-wrap table.data-table { min-width: 560px; }
|
||||||
|
|
||||||
.table-toolbar {
|
.table-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -792,6 +818,12 @@ table.data-table {
|
|||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
.field { margin-bottom: 15px; }
|
.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 {
|
.field label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
@@ -1156,8 +1188,14 @@ select {
|
|||||||
Logs mit nur R). Feste/zu schmale Breiten haben die D-Spalte bei
|
Logs mit nur R). Feste/zu schmale Breiten haben die D-Spalte bei
|
||||||
Geräte zuvor lautlos in den Overflow geschoben. */
|
Geräte zuvor lautlos in den Overflow geschoben. */
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
padding-right: 28px;
|
padding-right: 28px;
|
||||||
border-right: 1px solid var(--border-soft);
|
border-right: 1px solid var(--border-soft);
|
||||||
|
/* Fällt eine einzelne Spalte (z.B. Geräte mit vielen Rechten-Spalten)
|
||||||
|
trotz .permission-groups-row's flex-wrap auf einem schmalen Screen
|
||||||
|
für sich genommen noch zu breit aus, scrollt nur diese eine Spalte
|
||||||
|
waagerecht statt die Seite aufzureißen. */
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.permission-group-col:last-child { border-right: none; padding-right: 0; }
|
.permission-group-col:last-child { border-right: none; padding-right: 0; }
|
||||||
.permission-group-header-cell { padding-left: 0 !important; }
|
.permission-group-header-cell { padding-left: 0 !important; }
|
||||||
@@ -1259,6 +1297,92 @@ select {
|
|||||||
}
|
}
|
||||||
.xterm-container .xterm { height: 100%; }
|
.xterm-container .xterm { height: 100%; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Fileshare (Baum-Navigation + Vorschau)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* align-items:stretch (statt flex-start) + die main-Seite selbst als
|
||||||
|
Flex-Spalte mit table-wrap{flex:1}, damit beide Kacheln (Baum links,
|
||||||
|
Tabelle rechts) immer gleich hoch sind, unabhängig davon welche Seite
|
||||||
|
gerade mehr Inhalt hat. */
|
||||||
|
.fileshare-layout { display: flex; align-items: stretch; gap: 16px; }
|
||||||
|
.fileshare-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.fileshare-main .table-wrap { flex: 1; }
|
||||||
|
|
||||||
|
.fileshare-tree {
|
||||||
|
flex: 0 0 260px;
|
||||||
|
max-width: 260px;
|
||||||
|
padding: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.fileshare-tree-title {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-weight: 650;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-root, .tree-children { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.tree-children { padding-left: 16px; }
|
||||||
|
|
||||||
|
.tree-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.tree-row:hover { background: var(--bg-card-hover); }
|
||||||
|
.tree-row.active { background: var(--bg-card-hover); color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
|
.tree-toggle {
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border: none; background: transparent; color: var(--text-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.tree-toggle:hover { color: var(--text); }
|
||||||
|
.tree-toggle:disabled { visibility: hidden; }
|
||||||
|
|
||||||
|
.tree-label {
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vorschau: von mammoth.js/SheetJS erzeugter bzw. selbst gebauter Inhalt */
|
||||||
|
.docx-preview { font-size: 14px; line-height: 1.65; }
|
||||||
|
.docx-preview table { border-collapse: collapse; margin: 10px 0; }
|
||||||
|
.docx-preview table td, .docx-preview table th { border: 1px solid var(--border); padding: 6px 10px; }
|
||||||
|
.docx-preview img { max-width: 100%; }
|
||||||
|
.xlsx-preview-sheet-title { margin: 20px 0 8px; font-size: 13px; font-weight: 650; }
|
||||||
|
.xlsx-preview-sheet-title:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Angesammelte Dateien im Upload-Modal (Mehrfachauswahl, siehe fileshare.html) */
|
||||||
|
.upload-file-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.upload-file-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.upload-file-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.upload-file-remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none; background: transparent; color: var(--text-faint);
|
||||||
|
font-size: 16px; line-height: 1; cursor: pointer; padding: 0 2px;
|
||||||
|
}
|
||||||
|
.upload-file-remove:hover { color: var(--danger); }
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
Utilities
|
Utilities
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
@@ -1286,14 +1410,63 @@ select {
|
|||||||
.content { padding: 18px 16px 40px; }
|
.content { padding: 18px 16px 40px; }
|
||||||
.topbar { padding: 10px 16px; }
|
.topbar { padding: 10px 16px; }
|
||||||
.topbar-logo { opacity: 0.5; }
|
.topbar-logo { opacity: 0.5; }
|
||||||
|
|
||||||
|
/* Ab hier iPad-Breite (900px ist bereits der bestehende Sidebar-
|
||||||
|
Umschaltpunkt) und schmaler: generische Button-/Toolbar-Zeilen
|
||||||
|
(.flex) sollen umbrechen statt seitlich überzulaufen -- betrifft vor
|
||||||
|
allem Kopfzeilen mit mehreren Aktions-Buttons (z.B. NGINX-, Logs-,
|
||||||
|
Verlauf-Seite) und nebeneinander angeordnete Formularfelder. */
|
||||||
|
.flex { flex-wrap: wrap; }
|
||||||
|
.section-head { flex-wrap: wrap; }
|
||||||
|
.term-toolbar, .log-toolbar { flex-wrap: wrap; row-gap: 8px; }
|
||||||
|
.detail-row { flex-wrap: wrap; }
|
||||||
|
.detail-row .v { text-align: left; }
|
||||||
|
|
||||||
|
/* Modal-Fußzeilen (Speichern/Abbrechen) und Tabellen-Kopfzeilen
|
||||||
|
(Suche + Aktion) sollen bei wenig Platz ebenfalls umbrechen statt
|
||||||
|
Buttons/Suchfeld ineinanderzuschieben. */
|
||||||
|
.modal-footer { flex-wrap: wrap; }
|
||||||
|
.table-toolbar .search-input { min-width: 0; flex: 1 1 160px; }
|
||||||
|
|
||||||
|
/* Fileshare-Baum + Tabelle nebeneinander sprengt auf Tablet-/Handy-
|
||||||
|
Breite die Seite (Baum-Spalte ist fest 260px breit) -- Baum stapelt
|
||||||
|
stattdessen oben, Tabelle darunter in voller Breite. */
|
||||||
|
.fileshare-layout { flex-direction: column; }
|
||||||
|
.fileshare-tree { flex: 1 1 auto; max-width: 100%; max-height: 240px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.topbar-logo { display: none; }
|
.topbar-logo { display: none; }
|
||||||
|
|
||||||
|
/* Formulare mit zwei nebeneinander angeordneten Feldern (class="flex
|
||||||
|
gap-2" um zwei .field-Divs, z.B. HTTP-/HTTPS-Port bei NGINX) stapeln
|
||||||
|
auf Handy-Breite volle Breite pro Feld statt zwei sehr schmale
|
||||||
|
Eingaben nebeneinander zu erzwingen. */
|
||||||
|
form .flex > .field { flex-basis: 100%; }
|
||||||
|
|
||||||
|
.modal-header, .modal-body, .modal-footer { padding-left: 16px; padding-right: 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.device-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
.device-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||||
.topbar-title { font-size: 16px; }
|
.topbar-title { font-size: 16px; }
|
||||||
.stat-row { grid-template-columns: repeat(2, 1fr); }
|
.stat-row { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
|
||||||
|
/* Button-Leisten in Kopfzeilen (z.B. section-head/card-Kopf mit
|
||||||
|
"RAW anzeigen" o.ä.) nehmen auf sehr schmalen Screens die volle
|
||||||
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+22
File diff suppressed because one or more lines are too long
@@ -29,6 +29,11 @@
|
|||||||
"profile.update": "Profil aktualisiert",
|
"profile.update": "Profil aktualisiert",
|
||||||
"profile.password": "Passwort geändert",
|
"profile.password": "Passwort geändert",
|
||||||
"check.run_now": "Prüfung manuell gestartet",
|
"check.run_now": "Prüfung manuell gestartet",
|
||||||
|
"fileshare.upload": "Datei(en) hochgeladen",
|
||||||
|
"fileshare.mkdir": "Ordner angelegt",
|
||||||
|
"fileshare.delete": "Datei/Ordner gelöscht",
|
||||||
|
"fileshare.rename": "Datei/Ordner umbenannt",
|
||||||
|
"fileshare.download": "Als ZIP heruntergeladen",
|
||||||
} %}
|
} %}
|
||||||
{% set action_icons = {
|
{% set action_icons = {
|
||||||
"delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
|
"delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
|
||||||
@@ -37,6 +42,12 @@
|
|||||||
"activate": '<path d="M20 6L9 17l-5-5"/>',
|
"activate": '<path d="M20 6L9 17l-5-5"/>',
|
||||||
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
|
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
|
||||||
} %}
|
} %}
|
||||||
|
{% set action_kind_class = {
|
||||||
|
"create": "action-pill--create",
|
||||||
|
"upload": "action-pill--create",
|
||||||
|
"mkdir": "action-pill--create",
|
||||||
|
"delete": "action-pill--delete",
|
||||||
|
} %}
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
@@ -56,7 +67,7 @@
|
|||||||
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
|
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
|
||||||
<td class="cell-name">{{ e['username'] }}</td>
|
<td class="cell-name">{{ e['username'] }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="pill action-pill">
|
<span class="pill action-pill {{ action_kind_class.get(kind, 'action-pill--edit') }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
|
||||||
{{ action_labels.get(e['action'], e['action']) }}
|
{{ action_labels.get(e['action'], e['action']) }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>',
|
"clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>',
|
||||||
"shield": '<path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/>',
|
"shield": '<path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/>',
|
||||||
"server": '<rect x="2" y="3" width="20" height="7" rx="1.5"/><rect x="2" y="14" width="20" height="7" rx="1.5"/><path d="M6 6.5h.01M6 17.5h.01"/>',
|
"server": '<rect x="2" y="3" width="20" height="7" rx="1.5"/><rect x="2" y="14" width="20" height="7" rx="1.5"/><path d="M6 6.5h.01M6 17.5h.01"/>',
|
||||||
|
"folder": '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
|
||||||
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
|
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
|
||||||
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
|
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
|
||||||
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
|
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
|
||||||
|
|||||||
@@ -0,0 +1,617 @@
|
|||||||
|
{% 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 %}
|
||||||
|
|
||||||
|
{% macro render_tree_node(share, path, name, expanded_map, active_path) %}
|
||||||
|
<li class="tree-node" data-share="{{ share }}" data-path="{{ path }}">
|
||||||
|
<div class="tree-row{{ ' active' if path == active_path else '' }}">
|
||||||
|
{% if path in expanded_map %}
|
||||||
|
<button type="button" class="tree-toggle" aria-expanded="true">▼</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="button" class="tree-toggle" aria-expanded="false">▶</button>
|
||||||
|
{% endif %}
|
||||||
|
<span class="tree-label">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px; height:14px; margin-right:5px; vertical-align:-2px; color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||||
|
{{ name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="tree-children{{ '' if path in expanded_map else ' hidden' }}"{% if path in expanded_map %} data-loaded="1"{% endif %}>
|
||||||
|
{% if path in expanded_map %}
|
||||||
|
{% for child in expanded_map[path] %}
|
||||||
|
{{ render_tree_node(share, (path ~ '/' ~ child) if path else child, child, expanded_map, active_path) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Dateifreigaben</h2>
|
||||||
|
<div class="hint">Freigaben je nach AD-Gruppenmitgliedschaft für diese Sitzung gemountet — wird beim Abmelden wieder ausgehängt.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fileshare-layout">
|
||||||
|
<div class="fileshare-tree card">
|
||||||
|
<div class="fileshare-tree-title">Freigaben</div>
|
||||||
|
<ul class="tree-root" id="fileshareTree">
|
||||||
|
{% for s in shares %}
|
||||||
|
{{ render_tree_node(s, '', s, tree_expanded if s == selected_share else {}, rel_path if s == selected_share else None) }}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fileshare-main">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<div class="search-input">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div id="bulkActionsBar" class="flex gap-2 hidden" data-share="{{ selected_share }}" data-path="{{ rel_path }}" style="align-items:center; margin-bottom:10px; flex-wrap:wrap;">
|
||||||
|
<span id="bulkSelectedCount" class="text-faint" style="font-size:12.5px; font-weight:600;"></span>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkDownload()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||||
|
Herunterladen (ZIP)
|
||||||
|
</button>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="button" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);" onclick="bulkDelete()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if entries %}
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table" id="fileTable" data-sortable>
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:1%;"><input type="checkbox" id="selectAllFiles"></th>
|
||||||
|
<th data-sort-key="name">Name</th>
|
||||||
|
<th data-sort-key="size">Größe</th>
|
||||||
|
<th data-sort-key="mtime">Geändert</th>
|
||||||
|
<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><input type="checkbox" class="row-select" value="{{ e.name }}"></td>
|
||||||
|
<td class="cell-name">
|
||||||
|
{% if e.is_dir %}
|
||||||
|
<a href="{{ url_for('fileshare', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||||
|
<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 e.preview_kind %}
|
||||||
|
<button type="button" class="icon-btn" title="Vorschau"
|
||||||
|
data-preview-kind="{{ e.preview_kind }}"
|
||||||
|
data-preview-name="{{ e.name }}"
|
||||||
|
data-preview-url="{{ url_for('fileshare_view', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if not e.is_dir %}
|
||||||
|
<a class="icon-btn" title="Herunterladen" href="{{ url_for('fileshare_download', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</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(en) hochladen</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Datei(en)</label>
|
||||||
|
<input type="file" name="file" id="uploadFileInput" multiple required>
|
||||||
|
<div id="uploadFileList" class="upload-file-list"></div>
|
||||||
|
<div class="field-hint">Insgesamt maximal 15 MB pro Upload-Vorgang. Mehrfachauswahl möglich (auch mehrmals nacheinander — bereits hinzugefügte Dateien bleiben dabei erhalten).</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint">Wird in „{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}“ hochgeladen. Eine bereits vorhandene Datei gleichen Namens wird überschrieben.</div>
|
||||||
|
</div>
|
||||||
|
<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 %}
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="previewModal">
|
||||||
|
<div class="modal" style="max-width:900px; width:90vw;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="previewTitle">Vorschau</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="previewBody" style="max-height:75vh; overflow:auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/vendor/mammoth.browser.min.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/vendor/xlsx.full.min.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
const FILESHARE_SUBFOLDERS_URL = "{{ url_for('fileshare_subfolders') }}";
|
||||||
|
const FILESHARE_BASE_URL = "{{ url_for('fileshare') }}";
|
||||||
|
const FILESHARE_DOWNLOAD_MULTI_URL = "{{ url_for('fileshare_download_multi') }}";
|
||||||
|
const FILESHARE_DELETE_MULTI_URL = "{{ url_for('fileshare_delete_multi') }}";
|
||||||
|
|
||||||
|
function openRenameModal(name) {
|
||||||
|
document.getElementById("renameOldName").value = name;
|
||||||
|
document.getElementById("renameNewName").value = name;
|
||||||
|
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";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Mehrfachauswahl (Herunterladen als ZIP / Löschen) ---------------- */
|
||||||
|
|
||||||
|
function getSelectedFileNames() {
|
||||||
|
return Array.from(document.querySelectorAll("#fileTable .row-select:checked")).map(function (cb) { return cb.value; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkBar() {
|
||||||
|
const names = getSelectedFileNames();
|
||||||
|
const bar = document.getElementById("bulkActionsBar");
|
||||||
|
const countEl = document.getElementById("bulkSelectedCount");
|
||||||
|
if (!bar) return;
|
||||||
|
if (names.length > 0) {
|
||||||
|
bar.classList.remove("hidden");
|
||||||
|
countEl.textContent = names.length + " ausgewählt";
|
||||||
|
} else {
|
||||||
|
bar.classList.add("hidden");
|
||||||
|
}
|
||||||
|
const selectAll = document.getElementById("selectAllFiles");
|
||||||
|
const allBoxes = document.querySelectorAll("#fileTable .row-select");
|
||||||
|
if (selectAll && allBoxes.length) {
|
||||||
|
selectAll.checked = names.length === allBoxes.length;
|
||||||
|
selectAll.indeterminate = names.length > 0 && names.length < allBoxes.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitBulkForm(actionUrl, names) {
|
||||||
|
// data-share/data-path statt Jinja-Werte direkt in einen JS-String-
|
||||||
|
// Literal zu setzen -- Ordnernamen kommen von der echten Freigabe und
|
||||||
|
// koennten Anfuehrungszeichen o.ae. enthalten, ueber HTML-Attribute
|
||||||
|
// (von Jinja automatisch escaped) ist das unproblematisch.
|
||||||
|
const bar = document.getElementById("bulkActionsBar");
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.method = "post";
|
||||||
|
form.action = actionUrl;
|
||||||
|
form.style.display = "none";
|
||||||
|
[["share", bar.dataset.share], ["path", bar.dataset.path]].forEach(function (pair) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = pair[0];
|
||||||
|
input.value = pair[1];
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
names.forEach(function (name) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = "names";
|
||||||
|
input.value = name;
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkDownload() {
|
||||||
|
const names = getSelectedFileNames();
|
||||||
|
if (!names.length) return;
|
||||||
|
submitBulkForm(FILESHARE_DOWNLOAD_MULTI_URL, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkDelete() {
|
||||||
|
const names = getSelectedFileNames();
|
||||||
|
if (!names.length) return;
|
||||||
|
window.confirmAction(
|
||||||
|
names.length + " ausgewählte Elemente wirklich endgültig löschen?",
|
||||||
|
function () { submitBulkForm(FILESHARE_DELETE_MULTI_URL, names); },
|
||||||
|
"Auswahl löschen?"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const selectAll = document.getElementById("selectAllFiles");
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.addEventListener("change", function () {
|
||||||
|
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) { cb.checked = selectAll.checked; });
|
||||||
|
updateBulkBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) {
|
||||||
|
cb.addEventListener("change", updateBulkBar);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------- Mehrfach-Upload (Multiauswahl + mehrmals nacheinander) ---------------- */
|
||||||
|
/* Ein <input type=file multiple> ERSETZT bei jeder erneuten Dateiauswahl
|
||||||
|
die vorherige -- fuer "mehrmals nacheinander hinzufuegen" wird deshalb
|
||||||
|
selbst eine "angesammelte" Auswahl per DataTransfer gepflegt und nach
|
||||||
|
jeder Aenderung zurueck auf das Input-Feld geschrieben, sodass das
|
||||||
|
normale <form>-Submit (kein fetch() noetig) am Ende alle gesammelten
|
||||||
|
Dateien mitschickt. DataTransfer-Zuweisung an .files wird von allen
|
||||||
|
gaengigen Mobil-Browsern (Android Chrome, iOS Safari) mitgetragen; falls
|
||||||
|
nicht, faellt es einfach auf das native Verhalten (letzte Auswahl zaehlt)
|
||||||
|
zurueck, ohne den Upload an sich zu verhindern. */
|
||||||
|
(function () {
|
||||||
|
const input = document.getElementById("uploadFileInput");
|
||||||
|
const listEl = document.getElementById("uploadFileList");
|
||||||
|
if (!input || !listEl) return;
|
||||||
|
let staged = null;
|
||||||
|
try { staged = new DataTransfer(); } catch (e) { staged = null; }
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
if (!staged) return;
|
||||||
|
Array.from(staged.files).forEach(function (file, idx) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "upload-file-row";
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.textContent = file.name;
|
||||||
|
const removeBtn = document.createElement("button");
|
||||||
|
removeBtn.type = "button";
|
||||||
|
removeBtn.className = "upload-file-remove";
|
||||||
|
removeBtn.setAttribute("aria-label", "Entfernen");
|
||||||
|
removeBtn.textContent = "×";
|
||||||
|
removeBtn.addEventListener("click", function () {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
Array.from(staged.files).forEach(function (f, i) {
|
||||||
|
if (i !== idx) dt.items.add(f);
|
||||||
|
});
|
||||||
|
staged = dt;
|
||||||
|
input.files = staged.files;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
row.appendChild(name);
|
||||||
|
row.appendChild(removeBtn);
|
||||||
|
listEl.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("change", function () {
|
||||||
|
if (!staged) return; // kein DataTransfer-Support -- natives Verhalten greift
|
||||||
|
Array.from(input.files).forEach(function (file) { staged.items.add(file); });
|
||||||
|
input.files = staged.files;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Beim (Wieder-)Oeffnen des Modals eine frische Sammlung starten, statt
|
||||||
|
// Dateien aus einem vorherigen, bereits abgeschickten Upload-Vorgang
|
||||||
|
// versehentlich mitzuschleppen.
|
||||||
|
document.querySelectorAll('[data-open-modal="uploadModal"]').forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
try { staged = new DataTransfer(); } catch (e) { staged = null; }
|
||||||
|
input.value = "";
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ---------------- Baum-Navigation (Freigaben links) ---------------- */
|
||||||
|
|
||||||
|
function buildTreeNode(share, path, name) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "tree-node";
|
||||||
|
li.dataset.share = share;
|
||||||
|
li.dataset.path = path;
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "tree-row";
|
||||||
|
|
||||||
|
const toggle = document.createElement("button");
|
||||||
|
toggle.type = "button";
|
||||||
|
toggle.className = "tree-toggle";
|
||||||
|
toggle.textContent = "▶";
|
||||||
|
toggle.setAttribute("aria-expanded", "false");
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "tree-label";
|
||||||
|
label.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:5px;vertical-align:-2px;color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>';
|
||||||
|
label.append(document.createTextNode(name));
|
||||||
|
|
||||||
|
row.appendChild(toggle);
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
const childUl = document.createElement("ul");
|
||||||
|
childUl.className = "tree-children hidden";
|
||||||
|
|
||||||
|
li.appendChild(row);
|
||||||
|
li.appendChild(childUl);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTreeNode(btn) {
|
||||||
|
const li = btn.closest(".tree-node");
|
||||||
|
const childUl = li.querySelector(":scope > .tree-children");
|
||||||
|
if (childUl.classList.contains("hidden")) {
|
||||||
|
if (childUl.dataset.loaded === "1") {
|
||||||
|
childUl.classList.remove("hidden");
|
||||||
|
btn.textContent = "▼";
|
||||||
|
btn.setAttribute("aria-expanded", "true");
|
||||||
|
} else {
|
||||||
|
loadTreeChildren(li, childUl, btn);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
childUl.classList.add("hidden");
|
||||||
|
btn.textContent = "▶";
|
||||||
|
btn.setAttribute("aria-expanded", "false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTreeChildren(li, childUl, btn) {
|
||||||
|
const share = li.dataset.share;
|
||||||
|
const path = li.dataset.path;
|
||||||
|
const prevLabel = btn.textContent;
|
||||||
|
btn.textContent = "…";
|
||||||
|
fetch(FILESHARE_SUBFOLDERS_URL + "?share=" + encodeURIComponent(share) + "&path=" + encodeURIComponent(path))
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
childUl.innerHTML = "";
|
||||||
|
(data.folders || []).forEach(name => {
|
||||||
|
const childPath = path ? path + "/" + name : name;
|
||||||
|
childUl.appendChild(buildTreeNode(share, childPath, name));
|
||||||
|
});
|
||||||
|
childUl.dataset.loaded = "1";
|
||||||
|
childUl.classList.remove("hidden");
|
||||||
|
btn.textContent = "▼";
|
||||||
|
btn.setAttribute("aria-expanded", "true");
|
||||||
|
})
|
||||||
|
.catch(() => { btn.textContent = prevLabel; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTree(share, path) {
|
||||||
|
window.location.href = FILESHARE_BASE_URL + "?share=" + encodeURIComponent(share) + "&path=" + encodeURIComponent(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("fileshareTree").addEventListener("click", function (e) {
|
||||||
|
const toggle = e.target.closest(".tree-toggle");
|
||||||
|
if (toggle) { toggleTreeNode(toggle); return; }
|
||||||
|
const label = e.target.closest(".tree-label");
|
||||||
|
if (label) {
|
||||||
|
const li = label.closest(".tree-node");
|
||||||
|
navigateTree(li.dataset.share, li.dataset.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------- Datei-Vorschau ---------------- */
|
||||||
|
|
||||||
|
const fileTableEl = document.getElementById("fileTable");
|
||||||
|
if (fileTableEl) {
|
||||||
|
fileTableEl.addEventListener("click", function (e) {
|
||||||
|
const btn = e.target.closest("[data-preview-kind]");
|
||||||
|
if (!btn) return;
|
||||||
|
openPreview(btn.dataset.previewKind, btn.dataset.previewName, btn.dataset.previewUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreview(kind, name, url) {
|
||||||
|
document.getElementById("previewTitle").textContent = name;
|
||||||
|
const body = document.getElementById("previewBody");
|
||||||
|
body.innerHTML = "";
|
||||||
|
PoeUI.openModal("previewModal");
|
||||||
|
|
||||||
|
if (kind === "pdf") {
|
||||||
|
const iframe = document.createElement("iframe");
|
||||||
|
iframe.src = url;
|
||||||
|
iframe.style.width = "100%";
|
||||||
|
iframe.style.height = "70vh";
|
||||||
|
iframe.style.border = "0";
|
||||||
|
body.appendChild(iframe);
|
||||||
|
} else if (kind === "image") {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = url;
|
||||||
|
img.alt = name;
|
||||||
|
img.style.maxWidth = "100%";
|
||||||
|
img.style.display = "block";
|
||||||
|
img.style.margin = "0 auto";
|
||||||
|
body.appendChild(img);
|
||||||
|
} else if (kind === "text") {
|
||||||
|
body.textContent = "Lade …";
|
||||||
|
// Nur die ersten 512KB anfordern -- bei sehr großen Textdateien
|
||||||
|
// (Logs etc.) reicht das für eine Vorschau, ohne alles auf einmal
|
||||||
|
// laden zu müssen. Server unterstützt Range ueber send_file(conditional=True).
|
||||||
|
fetch(url, { headers: { "Range": "bytes=0-524287" } })
|
||||||
|
.then(r => {
|
||||||
|
// r.status ist bei einem Range-Request praktisch immer 206,
|
||||||
|
// auch wenn die Datei kleiner als die angefragten 512KB ist
|
||||||
|
// (der Server liefert dann trotzdem "206" mit der kompletten
|
||||||
|
// Datei) -- ob wirklich abgeschnitten wurde, steht nur im
|
||||||
|
// Content-Range-Header ("bytes 0-524287/<Gesamtgroesse>").
|
||||||
|
const contentRange = r.headers.get("Content-Range") || "";
|
||||||
|
const m = contentRange.match(/\/(\d+)$/);
|
||||||
|
const truncated = !!m && parseInt(m[1], 10) > 524288;
|
||||||
|
return r.text().then(text => ({ text: text, truncated: truncated }));
|
||||||
|
})
|
||||||
|
.then(({ text, truncated }) => {
|
||||||
|
body.innerHTML = "";
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.style.whiteSpace = "pre-wrap";
|
||||||
|
pre.style.wordBreak = "break-word";
|
||||||
|
pre.style.fontSize = "12.5px";
|
||||||
|
pre.textContent = text;
|
||||||
|
body.appendChild(pre);
|
||||||
|
if (truncated) {
|
||||||
|
const hint = document.createElement("div");
|
||||||
|
hint.className = "text-faint";
|
||||||
|
hint.style.marginTop = "10px";
|
||||||
|
hint.style.fontSize = "12px";
|
||||||
|
hint.textContent = "Nur die ersten 512 KB angezeigt — bitte herunterladen für die komplette Datei.";
|
||||||
|
body.appendChild(hint);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden."; });
|
||||||
|
} else if (kind === "docx") {
|
||||||
|
body.textContent = "Lade …";
|
||||||
|
fetch(url).then(r => r.arrayBuffer())
|
||||||
|
.then(buf => mammoth.convertToHtml({ arrayBuffer: buf }))
|
||||||
|
.then(result => {
|
||||||
|
body.innerHTML = "";
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "docx-preview";
|
||||||
|
// mammoth erzeugt kontrolliertes HTML aus Words eigenem,
|
||||||
|
// begrenztem Formatierungsmodell (Absätze/Tabellen/Listen/
|
||||||
|
// Bilder) -- kein beliebiges, aus der Datei uebernommenes
|
||||||
|
// Skript kann darin stecken, anders als bei generischem
|
||||||
|
// "fremdes HTML direkt einbetten".
|
||||||
|
wrap.innerHTML = result.value;
|
||||||
|
body.appendChild(wrap);
|
||||||
|
})
|
||||||
|
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden (Format evtl. nicht unterstützt)."; });
|
||||||
|
} else if (kind === "xlsx") {
|
||||||
|
body.textContent = "Lade …";
|
||||||
|
fetch(url).then(r => r.arrayBuffer())
|
||||||
|
.then(buf => {
|
||||||
|
const wb = XLSX.read(buf, { type: "array" });
|
||||||
|
body.innerHTML = "";
|
||||||
|
wb.SheetNames.forEach(function (sheetName, idx) {
|
||||||
|
const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheetName], { header: 1, defval: "" });
|
||||||
|
const h4 = document.createElement("div");
|
||||||
|
h4.className = "xlsx-preview-sheet-title";
|
||||||
|
h4.textContent = sheetName;
|
||||||
|
body.appendChild(h4);
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.style.overflowX = "auto";
|
||||||
|
const table = document.createElement("table");
|
||||||
|
table.className = "data-table";
|
||||||
|
// Zellenwerte bewusst per textContent statt ueber die
|
||||||
|
// eingebaute HTML-Ausgabe von SheetJS gesetzt -- so ist
|
||||||
|
// die Vorschau unabhaengig von deren Escaping-Verhalten
|
||||||
|
// garantiert sicher gegen Inhalte in den Zellen.
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
row.forEach(function (cell) {
|
||||||
|
const td = document.createElement("td");
|
||||||
|
td.textContent = (cell === null || cell === undefined) ? "" : String(cell);
|
||||||
|
tr.appendChild(td);
|
||||||
|
});
|
||||||
|
table.appendChild(tr);
|
||||||
|
});
|
||||||
|
wrap.appendChild(table);
|
||||||
|
body.appendChild(wrap);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden."; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -104,8 +104,8 @@
|
|||||||
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
|
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button class="icon-btn" title="Rechte anzeigen" onclick="toggleDetail('detail-admin')">
|
<button class="icon-btn" title="Rechte anzeigen" data-open-modal="adminGroupModal">
|
||||||
<svg id="chev-admin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="adminMembersModal">
|
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="adminMembersModal">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
||||||
@@ -113,16 +113,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="group-detail-row hidden" id="detail-admin">
|
|
||||||
<td colspan="3">
|
|
||||||
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
|
|
||||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
{% for g in groups %}
|
{% for g in groups %}
|
||||||
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
||||||
|
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
||||||
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
|
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="cell-name">
|
<td class="cell-name">
|
||||||
@@ -132,8 +127,12 @@
|
|||||||
<td class="text-dim">{{ g.member_names|length }}</td>
|
<td class="text-dim">{{ g.member_names|length }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button class="icon-btn" title="Rechte anzeigen{{ '/bearbeiten' if can_edit_this else '' }}" onclick="toggleDetail('detail-{{ g.id }}')">
|
<button class="icon-btn" title="{{ 'Bearbeiten' if (can_edit_this or can_unlock_system) else 'Anzeigen' }}" data-open-modal="editGroupModal{{ loop.index }}">
|
||||||
<svg id="chev-{{ g.id }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
{% if can_edit_this or can_unlock_system %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
{% else %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
|
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
||||||
@@ -149,57 +148,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
|
||||||
<tr class="group-detail-row hidden" id="detail-{{ g.id }}">
|
|
||||||
<td colspan="3">
|
|
||||||
{% if can_edit_this %}
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" name="save_group" value="1">
|
|
||||||
<input type="hidden" name="permissions_submitted" value="1">
|
|
||||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
|
||||||
<input type="hidden" name="name" value="{{ g.name }}">
|
|
||||||
{{ permission_tree(g.permissions, false, true) }}
|
|
||||||
<div class="flex" style="justify-content:flex-end; margin-top:16px;">
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
|
||||||
Rechte speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% elif can_unlock_system %}
|
|
||||||
<div id="readonly-{{ g.id }}">
|
|
||||||
{{ permission_tree(g.permissions, true, true) }}
|
|
||||||
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px;">
|
|
||||||
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form method="post" class="hidden" id="unlock-{{ g.id }}">
|
|
||||||
<input type="hidden" name="save_group" value="1">
|
|
||||||
<input type="hidden" name="permissions_submitted" value="1">
|
|
||||||
<input type="hidden" name="unlock_system_group" value="1">
|
|
||||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
|
||||||
<input type="hidden" name="name" value="{{ g.name }}">
|
|
||||||
{{ permission_tree(g.permissions, false, true) }}
|
|
||||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
|
|
||||||
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
|
|
||||||
Rechte hier können den Erst-Login neuer Konten einschränken.
|
|
||||||
</p>
|
|
||||||
<div class="flex" style="justify-content:flex-end;">
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
|
||||||
Rechte speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
{{ permission_tree(g.permissions, true, true) }}
|
|
||||||
{% if g.is_system %}
|
|
||||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tbody data-sort-pinned>
|
<tbody data-sort-pinned>
|
||||||
@@ -210,6 +158,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="adminGroupModal">
|
||||||
|
<div class="modal" style="max-width:1000px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Admin — Rechte</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="adminMembersModal">
|
<div class="modal-overlay" id="adminMembersModal">
|
||||||
<div class="modal" style="max-width:380px;">
|
<div class="modal" style="max-width:380px;">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -238,6 +199,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for g in groups %}
|
{% for g in groups %}
|
||||||
|
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
||||||
|
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
||||||
<div class="modal-overlay" id="membersModal{{ loop.index }}">
|
<div class="modal-overlay" id="membersModal{{ loop.index }}">
|
||||||
<div class="modal" style="max-width:380px;">
|
<div class="modal" style="max-width:380px;">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -268,6 +231,80 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="editGroupModal{{ loop.index }}">
|
||||||
|
<div class="modal" style="max-width:1000px;">
|
||||||
|
{% if can_edit_this %}
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="save_group" value="1">
|
||||||
|
<input type="hidden" name="permissions_submitted" value="1">
|
||||||
|
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Gruppe bearbeiten</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" name="name" value="{{ g.name }}" required>
|
||||||
|
</div>
|
||||||
|
{{ permission_tree(g.permissions, false, true) }}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% elif can_unlock_system %}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Gruppe „{{ g.name }}“</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field"><label>Name</label><input type="text" value="{{ g.name }}" disabled></div>
|
||||||
|
<div id="readonly-{{ g.id }}">
|
||||||
|
{{ permission_tree(g.permissions, true, true) }}
|
||||||
|
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px; flex-wrap:wrap;">
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="hidden" id="unlock-{{ g.id }}">
|
||||||
|
<input type="hidden" name="save_group" value="1">
|
||||||
|
<input type="hidden" name="permissions_submitted" value="1">
|
||||||
|
<input type="hidden" name="unlock_system_group" value="1">
|
||||||
|
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||||
|
<input type="hidden" name="name" value="{{ g.name }}">
|
||||||
|
{{ permission_tree(g.permissions, false, true) }}
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
|
||||||
|
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
|
||||||
|
Rechte hier können den Erst-Login neuer Konten einschränken.
|
||||||
|
</p>
|
||||||
|
<div class="flex" style="justify-content:flex-end;">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Rechte speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Gruppe „{{ g.name }}“</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ permission_tree(g.permissions, true, true) }}
|
||||||
|
{% if g.is_system %}
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="modal-overlay" id="addGroupModal">
|
<div class="modal-overlay" id="addGroupModal">
|
||||||
@@ -317,14 +354,6 @@ function unlockSystemGroup(id) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDetail(id) {
|
|
||||||
const row = document.getElementById(id);
|
|
||||||
if (!row) return;
|
|
||||||
row.classList.toggle("hidden");
|
|
||||||
const chev = document.getElementById(id.replace("detail-", "chev-"));
|
|
||||||
if (chev) chev.style.transform = row.classList.contains("hidden") ? "" : "rotate(180deg)";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPermissionGating() {
|
function applyPermissionGating() {
|
||||||
document.querySelectorAll(".permission-group-col").forEach(function (area) {
|
document.querySelectorAll(".permission-group-col").forEach(function (area) {
|
||||||
const toggle = area.querySelector(".permission-area-toggle-cb");
|
const toggle = area.querySelector(".permission-area-toggle-cb");
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="settings-grid" style="grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));">
|
<div class="settings-grid settings-grid--wide">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
@@ -70,6 +70,18 @@
|
|||||||
</select>
|
</select>
|
||||||
<div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</div>
|
<div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field"><label>Erforderliche AD-Gruppe für Login (optional)</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select name="ldap_required_login_group" id="requiredGroupSelect" style="flex:1;">
|
||||||
|
<option value="">Keine (jeder gültige AD-Benutzer darf sich anmelden)</option>
|
||||||
|
{% if ldap.required_login_group %}
|
||||||
|
<option value="{{ ldap.required_login_group }}" selected>{{ ldap.required_login_group }}</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="requiredGroupLoadBtn">Gruppen laden</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint" id="requiredGroupLoadStatus">Ist hier eine Gruppe ausgewählt, scheitert die Anmeldung für alle Benutzer, die ihr NICHT angehören (rekursiv, auch über verschachtelte Gruppen) — wie bei falschen Zugangsdaten, ohne Hinweis auf den eigentlichen Grund.</div>
|
||||||
|
</div>
|
||||||
<div class="flex gap-2" style="flex-wrap:wrap;">
|
<div class="flex gap-2" style="flex-wrap:wrap;">
|
||||||
<button type="submit" name="save_ldap" value="1" class="btn btn-primary">
|
<button type="submit" name="save_ldap" value="1" class="btn btn-primary">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
@@ -123,12 +135,18 @@
|
|||||||
<td>{{ m.app_group_name or '—' }}</td>
|
<td>{{ m.app_group_name or '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" class="icon-btn" title="Bearbeiten"
|
||||||
|
onclick="openEditLdapMappingModal('{{ m.id }}','{{ m.ad_group_dn|e }}','{{ m.ad_group_name|e }}','{{ m.app_group_id }}')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||||
|
</button>
|
||||||
<form method="post" data-confirm="Zuordnung „{{ m.ad_group_name }} → {{ m.app_group_name }}“ löschen?">
|
<form method="post" data-confirm="Zuordnung „{{ m.ad_group_name }} → {{ m.app_group_name }}“ löschen?">
|
||||||
<input type="hidden" name="delete_ldap_group_mapping" value="{{ m.id }}">
|
<input type="hidden" name="delete_ldap_group_mapping" value="{{ m.id }}">
|
||||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -142,6 +160,63 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Fileshare-Gruppen</h2>
|
||||||
|
<div class="hint">
|
||||||
|
Ist ein AD-Benutzer (rekursiv) Mitglied einer hier zugeordneten AD-Gruppe, wird die zugehörige
|
||||||
|
Freigabe beim Login für ihn gemountet — sofern er zusätzlich das TESM-Recht „Dateifreigaben lesen“
|
||||||
|
hat (siehe Gruppen → Rechte, Bereich „Dateifreigaben“). Fehlt das Recht, wird nicht gemountet und
|
||||||
|
der Menüpunkt „Dateifreigaben“ erscheint nicht, unabhängig von der AD-Gruppenmitgliedschaft.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="button" class="btn btn-primary" data-open-modal="addFileshareMappingModal">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Zuordnung hinzufügen
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if fileshare_mappings %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>AD-Gruppe</th><th>Freigabe</th><th>Pfad</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in fileshare_mappings %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ m.ad_group_name }}<div class="text-faint mono" style="font-size:11px;">{{ m.ad_group_dn }}</div></td>
|
||||||
|
<td>{{ m.share_label }}</td>
|
||||||
|
<td class="mono" style="font-size:12px;">{{ m.share_unc }}</td>
|
||||||
|
<td>
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" class="icon-btn" title="Bearbeiten"
|
||||||
|
onclick="openEditFileshareMappingModal('{{ m.id }}','{{ m.ad_group_dn|e }}','{{ m.ad_group_name|e }}','{{ m.share_label|e }}','{{ m.share_unc|e }}')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||||
|
</button>
|
||||||
|
<form method="post" data-confirm="Fileshare-Zuordnung „{{ m.ad_group_name }} → {{ m.share_label }}“ löschen?">
|
||||||
|
<input type="hidden" name="delete_fileshare_mapping" value="{{ m.id }}">
|
||||||
|
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Noch keine Fileshare-Zuordnung angelegt — für niemanden wird eine Freigabe gemountet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
@@ -183,15 +258,129 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="addFileshareMappingModal">
|
||||||
|
<div class="modal">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="add_fileshare_mapping" value="1">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Fileshare-Zuordnung hinzufügen</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var btn = document.getElementById('ldapMappingLoadGroupsBtn');
|
function wireAdGroupLoader(btnId, selectId, nameFieldId, statusId, includeEmptyOption) {
|
||||||
var select = document.getElementById('ldapMappingAdGroup');
|
var btn = document.getElementById(btnId);
|
||||||
var nameField = document.getElementById('ldapMappingAdGroupName');
|
var select = document.getElementById(selectId);
|
||||||
var status = document.getElementById('ldapMappingLoadStatus');
|
var nameField = nameFieldId ? document.getElementById(nameFieldId) : null;
|
||||||
|
var status = document.getElementById(statusId);
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
|
// Aktuellen Wert (z.B. die vorher gespeicherte, noch nicht per
|
||||||
|
// Klartext-Namen aufgeloeste Gruppe) merken -- bleibt nach dem
|
||||||
|
// Neuaufbau der Optionsliste ausgewaehlt, falls sie unter den
|
||||||
|
// geladenen Gruppen auftaucht.
|
||||||
|
var currentValue = select.value;
|
||||||
status.textContent = 'Lade Gruppen …';
|
status.textContent = 'Lade Gruppen …';
|
||||||
fetch("{{ url_for('settings_ldap_ad_groups') }}")
|
fetch("{{ url_for('settings_ldap_ad_groups') }}")
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
@@ -201,8 +390,14 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
|
if (includeEmptyOption) {
|
||||||
|
var emptyOpt = document.createElement('option');
|
||||||
|
emptyOpt.value = '';
|
||||||
|
emptyOpt.textContent = 'Keine (jeder gültige AD-Benutzer darf sich anmelden)';
|
||||||
|
select.appendChild(emptyOpt);
|
||||||
|
}
|
||||||
if (!groups.length) {
|
if (!groups.length) {
|
||||||
select.innerHTML = '<option value="">Keine Gruppen gefunden</option>';
|
if (!includeEmptyOption) select.innerHTML = '<option value="">Keine Gruppen gefunden</option>';
|
||||||
status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.';
|
status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -211,19 +406,63 @@
|
|||||||
opt.value = g.dn;
|
opt.value = g.dn;
|
||||||
opt.textContent = g.name;
|
opt.textContent = g.name;
|
||||||
opt.dataset.name = g.name;
|
opt.dataset.name = g.name;
|
||||||
|
if (g.dn === currentValue) opt.selected = true;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
nameField.value = select.options[select.selectedIndex].dataset.name || '';
|
if (nameField) nameField.value = (select.options[select.selectedIndex] && select.options[select.selectedIndex].dataset.name) || '';
|
||||||
status.textContent = groups.length + ' Gruppe(n) geladen.';
|
status.textContent = groups.length + ' Gruppe(n) geladen.';
|
||||||
})
|
})
|
||||||
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; });
|
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; });
|
||||||
});
|
});
|
||||||
|
|
||||||
select.addEventListener('change', function () {
|
select.addEventListener('change', function () {
|
||||||
|
if (!nameField) return;
|
||||||
var opt = select.options[select.selectedIndex];
|
var opt = select.options[select.selectedIndex];
|
||||||
nameField.value = (opt && opt.dataset.name) || '';
|
nameField.value = (opt && opt.dataset.name) || '';
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wireAdGroupLoader('ldapMappingLoadGroupsBtn', 'ldapMappingAdGroup', 'ldapMappingAdGroupName', 'ldapMappingLoadStatus', false);
|
||||||
|
wireAdGroupLoader('fsMappingLoadGroupsBtn', 'fsMappingAdGroup', 'fsMappingAdGroupName', 'fsMappingLoadStatus', false);
|
||||||
|
wireAdGroupLoader('requiredGroupLoadBtn', 'requiredGroupSelect', null, 'requiredGroupLoadStatus', true);
|
||||||
|
wireAdGroupLoader('editLdapMappingLoadGroupsBtn', 'editLdapMappingAdGroup', 'editLdapMappingAdGroupName', 'editLdapMappingLoadStatus', false);
|
||||||
|
wireAdGroupLoader('editFsMappingLoadGroupsBtn', 'editFsMappingAdGroup', 'editFsMappingAdGroupName', 'editFsMappingLoadStatus', false);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Bearbeiten-Modals vorbefuellen -- die AD-Gruppe steht dabei zunaechst nur
|
||||||
|
// als DN+Name aus der Datenbank zur Verfuegung (ohne erneute LDAP-Abfrage);
|
||||||
|
// "Gruppen laden" ersetzt die Auswahlliste bei Bedarf durch die vollstaendige,
|
||||||
|
// aktuelle AD-Gruppenliste und behaelt den bisherigen Wert dabei bei (siehe
|
||||||
|
// wireAdGroupLoader oben).
|
||||||
|
function seedMappingSelect(selectId, dn, name) {
|
||||||
|
var select = document.getElementById(selectId);
|
||||||
|
select.innerHTML = '';
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = dn;
|
||||||
|
opt.textContent = name || dn;
|
||||||
|
opt.dataset.name = name || dn;
|
||||||
|
opt.selected = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditLdapMappingModal(id, dn, name, appGroupId) {
|
||||||
|
document.getElementById('editLdapMappingId').value = id;
|
||||||
|
seedMappingSelect('editLdapMappingAdGroup', dn, name);
|
||||||
|
document.getElementById('editLdapMappingAdGroupName').value = name;
|
||||||
|
document.getElementById('editLdapMappingAppGroup').value = appGroupId;
|
||||||
|
document.getElementById('editLdapMappingLoadStatus').textContent = 'Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.';
|
||||||
|
PoeUI.openModal('editLdapMappingModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditFileshareMappingModal(id, dn, name, label, unc) {
|
||||||
|
document.getElementById('editFsMappingId').value = id;
|
||||||
|
seedMappingSelect('editFsMappingAdGroup', dn, name);
|
||||||
|
document.getElementById('editFsMappingAdGroupName').value = name;
|
||||||
|
document.getElementById('editFsMappingLabel').value = label;
|
||||||
|
document.getElementById('editFsMappingUnc').value = unc;
|
||||||
|
document.getElementById('editFsMappingLoadStatus').textContent = 'Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.';
|
||||||
|
PoeUI.openModal('editFileshareMappingModal');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="settings-grid" style="grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));">
|
<div class="settings-grid settings-grid--wide">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
@@ -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 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>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<div class="field" style="flex:1;">
|
<div class="field field--half">
|
||||||
<label for="http_port">HTTP-Port</label>
|
<label for="http_port">HTTP-Port</label>
|
||||||
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
|
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="flex:1;">
|
<div class="field field--half">
|
||||||
<label for="https_port">HTTPS-Port</label>
|
<label for="https_port">HTTPS-Port</label>
|
||||||
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
|
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user