Compare commits

...
9 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 cb8b929c3c Auditlog: Hinzufuegen gruen, Loeschen rot, Bearbeiten orange (v1.1.5)
- Neue Pill-Farbvarianten (.action-pill--create/--delete/--edit) je nach
  Aktions-Art -- "create"/"upload"/"mkdir" gruen, "delete" rot, alles
  andere (bearbeiten/aktivieren/zuweisen/...) orange als Sammelkategorie.
- Fehlende Klartext-Label fuer die neuen fileshare.*-Aktionen ergaenzt
  (upload/mkdir/delete/rename/download) -- vorher stand dort noch der
  rohe Aktionsschluessel wie "fileshare.delete" statt eines lesbaren
  Textes wie bei allen anderen Aktionen.

Live auf POETEST verifiziert: alle drei Farben korrekt (gruen bei
"Datei(en) hochgeladen"/"Gruppe angelegt", rot bei "Datei/Ordner
gelöscht"/"Gruppe gelöscht", orange bei "Als ZIP heruntergeladen"/
"Gruppe bearbeitet").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:20:13 +02:00
alientimandClaude Sonnet 5 af5dfe30b4 Fileshare: Mehrfachauswahl zum Herunterladen (ZIP) und Loeschen (v1.1.4)
- Checkbox-Spalte in der Dateitabelle (inkl. "alle auswaehlen" im Tabellen-
  kopf) -- sobald mindestens ein Eintrag markiert ist, erscheint eine
  Aktionsleiste mit "Herunterladen (ZIP)" und (mit fileshare.edit)
  "Loeschen".
- Herunterladen buendelt die Auswahl serverseitig zu EINEM ZIP statt
  mehrerer einzelner Downloads (vermeidet Browser-Blockaden bei vielen
  gleichzeitigen Downloads) -- neue Route /fileshare/download-multi,
  Ordner werden dabei rekursiv mit aufgenommen (relativer Pfad als
  Archivpfad).
- Loeschen mehrerer Elemente auf einmal ueber eine neue Route
  /fileshare/delete-multi (ein Bestaetigungsdialog fuer die ganze Auswahl),
  mit Sammel-Erfolgsmeldung analog zum Mehrfach-Upload.
- Die Pfadaufloesung+Validierung fuer ein einzelnes Kind-Element wurde aus
  dem bestehenden Einzel-Loeschen in _fileshare_resolve_child() extrahiert
  und von Einzel-Loeschen, Mehrfach-Loeschen UND Mehrfach-Download
  gemeinsam genutzt (kein duplizierter Sicherheitscode).

Live auf POETEST verifiziert: Mehrfachauswahl inkl. "alle auswaehlen",
ZIP-Download zweier Dateien (Inhalt geprueft), ZIP-Download eines Ordners
(rekursiv, korrekter Archivpfad), Mehrfach-Loeschen mit Sammelmeldung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:10:21 +02:00
alientimandClaude Sonnet 5 efb0d2aa01 Gruppen-Bearbeiten als Modal, Mobile-Fix, Mehrfach-Upload (v1.1.3)
- Gruppen bearbeiten (Name + Rechte) laeuft jetzt ueber ein Modal statt der
  bisherigen Inline-Ausklapp-Zeile -- gleiche Optik wie "Neue Gruppe". Der
  Gruppenname war dabei bisher ein verstecktes, nie wirklich editierbares
  Feld; jetzt ein normales Texteingabefeld (Backend unterstuetzte das
  Umbenennen inkl. Systemgruppen-Schutz bereits vollstaendig, es fehlte nur
  die Eingabemoeglichkeit im Formular). Admin-Rechte-Ansicht und die
  "Freischalten"-Ausnahme fuer die Systemgruppe "Benutzer" ziehen ins
  jeweilige Modal mit um.
- Fileshare-Baum + Tabelle nebeneinander sprengte auf Tablet-/Handybreite
  die Seite -- stapelt jetzt (Baum oben, Tabelle darunter) ab der
  bestehenden 900px-Sidebar-Umschaltgrenze.
- Mehrfach-Upload: Datei-Eingabefeld erlaubt jetzt echte Mehrfachauswahl
  (mehrere Dateien in einem Dialog) UND mehrmaliges Hinzufuegen
  nacheinander (per DataTransfer angesammelt, bevor "Hochladen" gedrueckt
  wird) -- funktioniert nativ auch auf Mobilgeraeten, da kein Custom-
  Upload-Mechanismus noetig ist. Backend verarbeitet jetzt eine Liste
  statt einer einzelnen Datei (request.files.getlist), mit Sammel-
  Erfolgsmeldung und pro Datei separater Namensvalidierung.

Live auf POETEST verifiziert: Umbenennen+Rechte-Speichern ueber das neue
Modal, Admin-Modal (readonly), Mobile-Layout (390px, Baum stapelt korrekt),
echte Mehrfachauswahl (2 Dateien in einem Dialog, beide korrekt
hochgeladen und einzeln in der Sammelmeldung genannt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:17:32 +02:00
alientimandClaude Sonnet 5 d461f8ca42 Fileshare: redundanten Breadcrumb entfernt, Baum/Tabelle gleich hoch (v1.1.2)
- Der Breadcrumb-Link-Zeile ueber der Dateitabelle ("LDAP Share / Test")
  war seit der Baum-Navigation redundant -- der Baum zeigt die aktuelle
  Position ja schon (aufgeklappt + hervorgehoben). Entfernt.
- .fileshare-layout auf align-items:stretch umgestellt (statt flex-start)
  und .fileshare-main als Flex-Spalte mit table-wrap{flex:1}, damit die
  Baum- und die Tabellen-Kachel immer exakt gleich hoch sind, unabhaengig
  vom jeweiligen Inhalt. Das inline margin-bottom auf .table-wrap musste
  dafuer weg (sonst blieb trotzdem eine 16px-Luecke zwischen den unteren
  Kanten).

Live auf POETEST verifiziert: beide Kacheln exakt gleiche Hoehe (166.5px
in beiden Fällen), Breadcrumb-Div nicht mehr im DOM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:51:57 +02:00
alientimandClaude Sonnet 5 1b7fe81507 Fileshare: Baum-Navigation, Datei-Vorschau, 15MB-Uploads (v1.1.1)
- Freigaben-Auswahl von einem Dropdown auf eine Baum-Navigation (links)
  umgestellt: alle gemounteten Freigaben als Wurzelknoten, Ordner beliebig
  aufklappbar. Der Pfad von der Wurzel bis zur aktuellen Position wird
  serverseitig schon aufgeklappt gerendert (_fileshare_tree_ancestors),
  alles andere laedt der Client per Klick lazy nach (neue Route
  /fileshare/subfolders) -- kein Durchlaufen der kompletten Freigabe auf
  einmal noetig. Dateien stehen bewusst nicht im Baum, nur in der
  bestehenden Tabelle rechts.
- Inline-Vorschau fuer PDF (iframe), Bilder, TXT/CSV/LOG/MD/JSON (per Range-
  Request in Haeppchen bis 512KB, mit Hinweis bei Abschneiden -- Erkennung
  ueber den Content-Range-Header, nicht nur den 206-Status, der bei JEDEM
  Range-Request auftritt) sowie Word/Excel rein clientseitig per mammoth.js
  bzw. SheetJS (beide lokal vendored, kein Cloud-Viewer -- interne Dokumente
  verlassen das Netz nicht). Excel-Zellen werden bewusst per textContent
  statt ueber SheetJS' eigene HTML-Ausgabe gesetzt, um unabhaengig von
  deren Escaping-Verhalten sicher zu sein. Neue Route /fileshare/view mit
  fester Endungs-Positivliste, nosniff + restriktiver CSP.
- Uploads bis 15MB erlaubt (MAX_CONTENT_LENGTH in app.py) -- dafuer musste
  auch nginx' client_max_body_size (Standard 1MB) auf 16MB angehoben werden,
  sowohl im Vorlagen-Code (_NGINX_PROXY_LOCATIONS/_render_nginx_config,
  fuer die naechste Anwendung ueber Einstellungen->NGINX) als auch in der
  Fresh-Install-Vorlage. Neuer 413-Errorhandler zeigt eine Flash-Meldung
  statt Werkzeugs nackter Fehlerseite.
- install.sh: known_hosts (liegt unter /srv/tesm) von rsync --delete
  ausgenommen -- fehlte bisher in der Exclude-Liste (anders als sqlite.db/
  fernet.key/secret.key) und waere bei jedem Update verlorengegangen.

Live auf POETEST getestet: Baum-Vorbefuellung, Lazy-Load von
Geschwisterordnern, alle 5 Vorschautypen (PDF-Route direkt verifiziert,
Bild/Text/Word/Excel zusaetzlich visuell), Abschneide-Erkennung sowohl bei
kleiner als auch bei >512KB-Datei.

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 16:34:28 +02:00
alientimandClaude Sonnet 5 0074e48e09 Responsive-Fixes per echtem Playwright/DevTools-Test verifiziert, zwei Bugs behoben
Auf Nachfrage tatsaechlich mit Browser-DevTools (Playwright/Chromium,
lokal installiert) statt nur per CSS-Analyse geprueft -- echte
Screenshots + automatisierte Overflow-Pruefung auf iPhone-16-Pro- (393x852),
iPad- (820x1180 Hoch-/1180x820 Querformat) und Desktop-Viewport (1440x900)
gegen die echte Live-Instanz auf Testsystem-Update-Restart.

Dabei zwei echte Bugs gefunden und behoben:

1. Topbar-Ueberlappung (ALLE Seiten, jeder eingeloggte Nutzer, jedes
   iPhone/kleine Handy): .topbar-left (Seitentitel) und .topbar-right
   (Pruef-Countdown- + DHCP-Pille) sind beide flex:1 ohne Zeilenumbruch --
   auf 393px Breite quetscht das den Titel auf eine Breite, die kleiner
   ist als sein eigener Inhalt braucht, wodurch der Titeltext sichtbar
   ueber seine Spalte hinauslief und mit der Pille kollidierte (per
   getBoundingClientRect nachgewiesen: 24.5px Ueberlauf). Fix: ab 560px
   Breite bricht die Topbar um, Titel und Pillen bekommen je eine eigene
   Zeile.

2. HTTP-/HTTPS-Port-Felder auf der NGINX-Seite stapelten trotz
   entsprechender Media-Query-Regel nicht auf Handy-Breite -- Ursache:
   inline style="flex:1" auf den .field-Divs hat Vorrang vor jeder externen
   @media-Regel, ganz gleich wie spezifisch. Fix: inline style entfernt,
   neue Klasse .field--half stattdessen -- die Mobile-Stapel-Regel greift
   jetzt wie vorgesehen.

Zusaetzlich EINE eigene Aenderung aus v1.0.9 wieder zurueckgenommen, weil
sie sich beim Testen als unnoetig und riskant herausstellte:
- .table-wrap: overflow-x:auto (aus v1.0.9) wieder auf overflow:hidden
  zurueckgesetzt. Grund: JEDES Template mit .table-wrap hat bereits einen
  eigenen <div style="overflow-x:auto;"> exakt um die <table> -- die
  zusaetzliche Scroll-Faehigkeit der Karte selbst war komplett redundant
  und erzeugte einen verschachtelten doppelten Scroll-Container um
  denselben Inhalt. Beim Testen der aufklappbaren Rechte-Tabelle
  (Gruppen-Seite) fuehrte das zu Inhalten, die bei negativem x dauerhaft
  unsichtbar wurden. table.data-table { min-width: 560px; } bleibt
  bestehen (harmlos, der jeweils vorhandene innere Wrapper scrollt
  weiterhin korrekt).

Eine dritte vermutete Ursache (aufgeklappte Rechte-Tabelle "verschwindet"
beim Anklicken des Chevrons) stellte sich NICHT als echter App-Bug heraus,
sondern als Artefakt von Playwrights eigenem automatischem
"scroll-into-view-vor-Klick"-Verhalten (der Chevron sitzt in der letzten
Tabellenspalte) -- per direktem JS-Aufruf von toggleDetail() ohne
Playwright-Klick liess sich das nicht reproduzieren, die Seite verhaelt
sich für echte Nutzer korrekt.

Alle 8 getesteten Seiten x 4 Viewports (32 Kombinationen) zeigen nach den
Fixes keinerlei horizontalen Overflow mehr (dokumentseitenweite
scrollWidth == clientWidth in jedem Fall).

VERSION 1.0.9 -> 1.0.10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 14:29:48 +02:00
alientimandClaude Sonnet 5 a3f5f028f2 Responsives CSS ausgebaut: Handy (iPhone) und Tablet (iPad) sauber nutzbar
Bestehendes eigenes Design-System beibehalten (kein Bootstrap-Framework
eingebunden), stattdessen die vorhandenen Media Queries/Breakpoints
(Sidebar kollabiert bereits ab 900px) um die fehlenden Faelle ergaenzt:

- .table-wrap: overflow:hidden -> overflow-x:auto. Tabellen mit vielen
  Spalten (Geraete, Switche, Papierkorb, Benutzer, ...) haben auf
  schmalen Screens bisher entweder Spalten lautlos abgeschnitten oder
  die ganze Seite waagerecht aufgerissen -- jetzt scrollt NUR die
  Tabelle selbst (gleiches Prinzip wie Bootstraps .table-responsive).
  table.data-table bekommt zusaetzlich eine min-width als sinnvolle
  Scroll-Schwelle.
- .settings-grid: minmax(340px, 1fr) -> minmax(min(340px, 100%), 1fr) --
  ein nackter Pixel-Mindestwert erzwingt auf einem 375px-iPhone sonst
  waagerechten Overflow der ganzen Seite, sobald die Spalte breiter als
  der verfuegbare Platz ist. Neue Modifier-Klasse .settings-grid--wide
  (420px-Variante) ersetzt zwei betroffene inline
  style="grid-template-columns:minmax(420px,...)"-Overrides (LDAP-,
  NGINX-Seite), die denselben 100%-Fallback nicht hatten.
- .permission-group-col: eigenes overflow-x:auto zusaetzlich zum
  bestehenden flex-wrap der Elternzeile -- falls eine einzelne
  Rechte-Spalte (z.B. Geraete mit R/W/E/D) auch nach dem Umbruch auf
  eine eigene Zeile noch zu breit ist.
- Ab 900px (bestehender Sidebar-Breakpoint) zusaetzlich: .flex (generische
  Button-/Toolbar-Zeilen), .section-head, .term-toolbar/.log-toolbar und
  .detail-row brechen um statt seitlich ueberzulaufen; .modal-footer
  ebenso; .search-input in Tabellen-Toolbars schrumpft flexibel.
- Ab 640px: nebeneinander angeordnete Formularfelder (z.B. HTTP-/HTTPS-
  Port bei NGINX) stapeln sich auf volle Breite statt zwei sehr schmale
  Eingaben zu erzwingen; Modal-Innenabstand etwas schmaler.
- Ab 560px: Buttons in Kopfzeilen (section-head) nehmen die volle Breite
  ein statt als schmaler Block rechts zu kleben.

Live auf Testsystem-Update-Restart verifiziert: alle Kernseiten
(Dashboard, Clients, Switche, Gruppen, LDAP, NGINX, Live-Log, Wartung)
laden weiterhin fehlerfrei (200) nach dem CSS-/Template-Austausch.

WICHTIGER HINWEIS: In dieser Umgebung steht kein Browser-Screenshot-
Werkzeug zur Verfuegung, die Aenderungen wurden also nicht pixelgenau auf
echten iPhone-16-Pro-/iPad-Bildschirmgroessen gegengeprueft, sondern rein
ueber CSS-Analyse und Seiten-Ladetests. Ein Blick auf einem echten Geraet
(oder per Browser-Devtools-Emulation) wird empfohlen.

VERSION 1.0.8 -> 1.0.9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 12:34:35 +02:00
16 changed files with 2123 additions and 124 deletions
+5
View File
@@ -29,6 +29,11 @@ server {
}
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_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+10
View File
@@ -6,6 +6,16 @@ After=network.target
Type=simple
User=root
WorkingDirectory=/srv/tesm
# Markiert genau DIESEN Prozess als den echten Web-App-Dienst -- app.py
# wird nämlich NICHT nur hierüber gestartet, sondern auch von
# generate_ips.py (via "from app import ...") als reines Hilfsmodul
# importiert, z.B. aus poe.sh/tesm-check.service heraus, alle paar
# Sekunden. Ein einfacher Import führt JEDEN Modul-Level-Code in app.py
# erneut aus -- ohne dieses Flag würde jeder generate_ips.py-Aufruf
# _fileshare_cleanup_all_on_startup() erneut auslösen und damit gerade
# aktive Fileshare-Mounts anderer, echter Sitzungen sofort wieder
# aushängen. Siehe die Prüfung auf TESM_WEB_PROCESS in app.py.
Environment=TESM_WEB_PROCESS=1
# Produktiver WSGI-Server (gunicorn) statt Flasks eigenem app.run()-
# Entwicklungsserver -- siehe requirements.txt für die ausführliche
# Begründung von "--workers 1" (In-Memory-Zustand) und "--worker-class
+2 -1
View File
@@ -114,7 +114,7 @@ fi
# ---- Pakete ----
step "Installing system packages"
sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update"
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate certbot >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed"
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate certbot cifs-utils >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed"
# ---- Log-Verzeichnis ----
# NICHT weltweit beschreibbar (0755 reicht) -- sowohl tesm.service als
@@ -134,6 +134,7 @@ sudo chmod 755 /var/log/tesm
step "Deploying application to /srv/tesm"
sudo mkdir -p /srv/tesm
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
print_status "Application files copied"
+1 -1
View File
@@ -1 +1 @@
1
2
+1 -1
View File
@@ -1 +1 @@
1.0.8
1.1.5
+863 -4
View File
@@ -8,7 +8,7 @@ Switch-/User-Verwaltung, Live-Log, Settings, manueller PoE-Neustart),
lediglich mit modernisiertem Frontend und aufgeräumten/konfigurierbaren
Pfaden im Backend.
"""
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, session, send_file, abort
from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user
from flask_bcrypt import Bcrypt
from flask_sock import Sock
@@ -19,7 +19,7 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes, serialization
from cryptography import x509
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 yaml
import ssl
@@ -164,6 +164,14 @@ DEVICE_MAINTENANCE_CATEGORY = "linux"
os.makedirs(AVATAR_DIR, exist_ok=True)
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:
_app_log_handler = logging.FileHandler(TESM_APP_LOG_PATH, encoding="utf-8")
@@ -175,6 +183,18 @@ except OSError:
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:
env_secret = os.environ.get("TESM_SECRET_KEY")
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 = {
@@ -350,6 +384,10 @@ PERMISSION_LABELS = {
"settings_ldap.edit": "LDAP/AD-Konfiguration speichern (Bind-Konto, Gruppenzuordnungen)",
"settings_nginx.view": "NGINX-Konfiguration und Zertifikatsstatus lesen",
"settings_nginx.edit": "NGINX-Konfiguration ändern (Domain/Ports, Zertifikat hochladen oder per Let's Encrypt anfordern, HTTPS/HSTS aktivieren)",
"fileshare_group.view": "Dateifreigaben-Bereich anzeigen",
"fileshare.view": "Dateifreigaben lesen (Browsen/Herunterladen der über LDAP-Gruppen zugewiesenen Freigaben -- OHNE dieses Recht wird für den Nutzer nichts gemountet)",
"fileshare.create": "Dateien in Freigaben hochladen",
"fileshare.edit": "Dateien in Freigaben überschreiben, umbenennen oder löschen",
}
ALL_PERMISSION_KEYS = []
@@ -384,6 +422,7 @@ NAV_ITEMS = [
]},
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
{"key": "fileshare", "label": "Dateifreigaben", "icon": "folder", "endpoint": "fileshare"},
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
{"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
{"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"},
@@ -420,6 +459,8 @@ def _nav_key_visible(key, user):
return user.can_manage_users
if key == "groups":
return user.can_manage_groups
if key == "fileshare":
return user.has_permission("fileshare.view") and bool(_current_fileshare_mounts())
if key == "settings_system":
return user.can_view_settings_system
if key == "settings_importexport":
@@ -811,6 +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("""
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1333,6 +1385,7 @@ def _ldap_settings():
"base_dn": get_setting("ldap_base_dn", "") or "",
"filter_attr": get_setting("ldap_user_filter_attr", LDAP_DEFAULT_FILTER_ATTR) or LDAP_DEFAULT_FILTER_ATTR,
"default_group": get_setting("ldap_default_group", "") or "",
"required_login_group": get_setting("ldap_required_login_group", "") or "",
}
@@ -1655,6 +1708,46 @@ def _ldap_resolve_app_groups(service_conn, user_dn):
return matched
def _normalize_share_unc(raw):
"""Normalisiert einen vom Formular übergebenen Freigabe-Pfad auf das
von mount.cifs benötigte "//server/freigabe"-Format -- ein
Windows-Admin gibt naturgemäß "\\\\server\\freigabe" ein."""
unc = raw.strip().replace("\\", "/")
if unc and not unc.startswith("//"):
unc = "//" + unc.lstrip("/")
return unc
def _ldap_fileshare_mappings():
conn = get_db_connection()
rows = conn.execute(
"SELECT id, ad_group_dn, ad_group_name, share_label, share_unc "
"FROM ldap_fileshare_mappings ORDER BY ad_group_name ASC"
).fetchall()
conn.close()
return rows
def _ldap_resolve_fileshares(service_conn, user_dn):
"""Analog zu _ldap_resolve_app_groups, nur für Dateifreigaben: prüft für
jede konfigurierte AD-Gruppe -> Freigabe-Zuordnung die (rekursive)
Mitgliedschaft und gibt die Liste der passenden Freigaben zurück.
Bewusst getrennt vom TESM-eigenen Rechtesystem -- OB überhaupt gemountet
werden darf, entscheidet current_user.has_permission('fileshare.view')
(siehe _mount_fileshares_for_login), WELCHE Freigaben es im Erfolgsfall
sind, ausschließlich die AD-Gruppenmitgliedschaft hier."""
matched = []
seen_labels = set()
for mapping in _ldap_fileshare_mappings():
if _ldap_is_member_of(service_conn, user_dn, mapping["ad_group_dn"]):
label = mapping["share_label"]
if label in seen_labels:
continue
seen_labels.add(label)
matched.append({"label": label, "unc": mapping["share_unc"]})
return matched
def _ldap_authenticate(username, password):
"""Prüft Zugangsdaten per LDAP/AD (Search+Bind, siehe _ldap_find_entry
für die Details der Suche). Gibt (True, info) bei Erfolg zurück (info
@@ -1683,7 +1776,15 @@ def _ldap_authenticate(username, password):
info = _ldap_entry_info(entry, cfg)
if info["disabled"]:
return False, None
if cfg["required_login_group"] and not _ldap_is_member_of(service_conn, user_dn, cfg["required_login_group"]):
# Kein Mitglied der erforderlichen AD-Gruppe -- Login wird
# abgelehnt wie bei falschen Zugangsdaten, ohne den eigentlichen
# Passwort-Bind unten überhaupt erst zu versuchen. Bewusst
# dieselbe generische Fehlermeldung wie jeder andere
# Login-Fehlschlag, um keine Gruppenmitgliedschaft zu verraten.
return False, None
info["app_groups"] = _ldap_resolve_app_groups(service_conn, user_dn)
info["fileshares"] = _ldap_resolve_fileshares(service_conn, user_dn)
except LDAPException:
return False, None
finally:
@@ -1733,6 +1834,675 @@ def _ldap_default_group_id(conn):
return row["id"] if row else None
# ============================================================================
# Dateifreigaben (Fileshare) -- sessionabhängiges Mounten von SMB/CIFS-
# Freigaben je nach AD-Gruppenmitgliedschaft, analog zu einer
# GPO-Laufwerkszuordnung, nur serverseitig im Browser statt als
# Laufwerksbuchstabe auf dem Client. Zwei UNABHÄNGIGE Voraussetzungen
# müssen zutreffen, bevor überhaupt gemountet wird:
# 1. Das TESM-eigene Recht fileshare.view (Rechtesystem-Gate).
# 2. Mindestens eine per LDAP-Gruppe zugeordnete Freigabe (siehe
# _ldap_resolve_fileshares) -- WELCHE Freigaben es sind, entscheidet
# ausschließlich die AD-Gruppenmitgliedschaft, nicht das Rechtesystem.
# Gemountet wird mit den eigenen AD-Zugangsdaten des Nutzers (aus dem
# Login-Vorgang, nie gespeichert), NICHT mit einem festen Service-Konto --
# Dateiserver-eigene ACLs bleiben dadurch individuell wirksam.
# ============================================================================
FILESHARE_MOUNT_ROOT = os.environ.get("TESM_FILESHARE_MOUNT_ROOT", "/mnt/tesm-shares")
FILESHARE_MAX_AGE_SECONDS = 12 * 3600
FILESHARE_SWEEP_INTERVAL_SECONDS = 1800
# 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"])
def login():
if request.method == "POST":
@@ -1773,7 +2543,11 @@ def login():
conn.commit()
refreshed = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone()
conn.close()
login_user(_build_user(refreshed))
logged_in_user = _build_user(refreshed)
login_user(logged_in_user)
_mount_fileshares_for_login(
logged_in_user, info.get("username") or username, password, info.get("fileshares") or [],
)
return redirect(url_for("index"))
conn.close()
flash("Ungültiger Benutzername oder Passwort", "danger")
@@ -1812,7 +2586,11 @@ def login():
new_user = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone()
conn.close()
log_action_system("user.ldap_provision", canonical_username, "Erstes erfolgreiches AD/LDAP-Login")
login_user(_build_user(new_user))
logged_in_user = _build_user(new_user)
login_user(logged_in_user)
_mount_fileshares_for_login(
logged_in_user, canonical_username, password, info.get("fileshares") or [],
)
return redirect(url_for("index"))
except sqlite3.IntegrityError:
conn.close()
@@ -1826,6 +2604,7 @@ def login():
@app.route("/logout")
@login_required
def logout():
_unmount_fileshares_for_current_session()
logout_user()
return redirect(url_for("index"))
@@ -2599,6 +3378,11 @@ _NGINX_PROXY_LOCATIONS = """ location /ws/ {
}
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_set_header Host $host;
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_user_filter_attr", request.form.get("ldap_user_filter_attr", "").strip() or LDAP_DEFAULT_FILTER_ATTR)
set_setting("ldap_default_group", request.form.get("ldap_default_group", "").strip())
set_setting("ldap_required_login_group", request.form.get("ldap_required_login_group", "").strip())
new_bind_password = request.form.get("ldap_bind_password", "")
if new_bind_dn:
conn = get_db_connection()
@@ -4291,6 +5076,27 @@ def settings_ldap():
log_action("settings.update", "LDAP-Gruppenzuordnung", f"{ad_group_name or ad_group_dn}{app_group_id}")
flash("Gruppenzuordnung gespeichert.", "success")
elif "edit_ldap_group_mapping" in request.form:
mapping_id = request.form.get("edit_ldap_group_mapping")
ad_group_dn = request.form.get("ad_group_dn", "").strip()
ad_group_name = request.form.get("ad_group_name", "").strip()
app_group_id = request.form.get("app_group_id", "").strip()
if not ad_group_dn or not app_group_id:
flash("AD-Gruppe und Rechtegruppe müssen ausgewählt werden.", "danger")
else:
conn = get_db_connection()
try:
conn.execute(
"UPDATE ldap_group_mappings SET ad_group_dn=?, ad_group_name=?, app_group_id=? WHERE id=?",
(ad_group_dn, ad_group_name or ad_group_dn, app_group_id, mapping_id),
)
conn.commit()
log_action("settings.update", "LDAP-Gruppenzuordnung geändert", f"{ad_group_name or ad_group_dn}{app_group_id}")
flash("Gruppenzuordnung aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Diese AD-Gruppe ist bereits einer anderen Rechtegruppe zugeordnet.", "danger")
conn.close()
elif "delete_ldap_group_mapping" in request.form:
mapping_id = request.form.get("delete_ldap_group_mapping")
conn = get_db_connection()
@@ -4301,6 +5107,58 @@ def settings_ldap():
log_action("settings.update", "LDAP-Gruppenzuordnung gelöscht", row["ad_group_name"] if row else mapping_id)
flash("Gruppenzuordnung gelöscht.", "success")
elif "add_fileshare_mapping" in request.form:
ad_group_dn = request.form.get("fs_ad_group_dn", "").strip()
ad_group_name = request.form.get("fs_ad_group_name", "").strip()
share_label = request.form.get("fs_share_label", "").strip()
share_unc = _normalize_share_unc(request.form.get("fs_share_unc", ""))
if not ad_group_dn or not share_label or not share_unc:
flash("AD-Gruppe, Bezeichnung und Freigabe-Pfad müssen angegeben werden.", "danger")
else:
conn = get_db_connection()
conn.execute(
"INSERT INTO ldap_fileshare_mappings (ad_group_dn, ad_group_name, share_label, share_unc) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(ad_group_dn, share_unc) DO UPDATE SET ad_group_name=excluded.ad_group_name, share_label=excluded.share_label",
(ad_group_dn, ad_group_name or ad_group_dn, share_label, share_unc),
)
conn.commit()
conn.close()
log_action("settings.update", "Fileshare-Gruppenzuordnung", f"{ad_group_name or ad_group_dn}{share_label} ({share_unc})")
flash("Fileshare-Zuordnung gespeichert.", "success")
elif "edit_fileshare_mapping" in request.form:
mapping_id = request.form.get("edit_fileshare_mapping")
ad_group_dn = request.form.get("fs_ad_group_dn", "").strip()
ad_group_name = request.form.get("fs_ad_group_name", "").strip()
share_label = request.form.get("fs_share_label", "").strip()
share_unc = _normalize_share_unc(request.form.get("fs_share_unc", ""))
if not ad_group_dn or not share_label or not share_unc:
flash("AD-Gruppe, Bezeichnung und Freigabe-Pfad müssen angegeben werden.", "danger")
else:
conn = get_db_connection()
try:
conn.execute(
"UPDATE ldap_fileshare_mappings SET ad_group_dn=?, ad_group_name=?, share_label=?, share_unc=? WHERE id=?",
(ad_group_dn, ad_group_name or ad_group_dn, share_label, share_unc, mapping_id),
)
conn.commit()
log_action("settings.update", "Fileshare-Gruppenzuordnung geändert", f"{ad_group_name or ad_group_dn}{share_label} ({share_unc})")
flash("Fileshare-Zuordnung aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Diese Kombination aus AD-Gruppe und Freigabe-Pfad existiert bereits.", "danger")
conn.close()
elif "delete_fileshare_mapping" in request.form:
mapping_id = request.form.get("delete_fileshare_mapping")
conn = get_db_connection()
row = conn.execute("SELECT share_label FROM ldap_fileshare_mappings WHERE id=?", (mapping_id,)).fetchone()
conn.execute("DELETE FROM ldap_fileshare_mappings WHERE id=?", (mapping_id,))
conn.commit()
conn.close()
log_action("settings.update", "Fileshare-Zuordnung gelöscht", row["share_label"] if row else mapping_id)
flash("Fileshare-Zuordnung gelöscht.", "success")
return redirect(url_for("settings_ldap"))
return render_template(
@@ -4308,6 +5166,7 @@ def settings_ldap():
ldap=_ldap_settings(),
ldap_groups=_ldap_groups_for_dropdown(),
mappings=_ldap_group_mappings(),
fileshare_mappings=_ldap_fileshare_mappings(),
)
+11
View File
@@ -176,6 +176,17 @@ CREATE TABLE IF NOT EXISTS ldap_group_mappings (
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS ldap_fileshare_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL,
ad_group_name TEXT NOT NULL,
share_label TEXT NOT NULL,
share_unc TEXT NOT NULL,
UNIQUE(ad_group_dn, share_unc)
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+175 -2
View File
@@ -197,13 +197,23 @@ button { font-family: inherit; }
.nav-group.expanded .nav-group-children { display: flex; }
.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 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(340px, 100%), 1fr));
gap: 24px;
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) */
.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::before { display: none; }
.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 {
display: inline-flex;
@@ -683,8 +698,19 @@ button { font-family: inherit; }
background: var(--bg-card);
border: 1px solid var(--border-soft);
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;
}
.table-wrap table.data-table { min-width: 560px; }
.table-toolbar {
display: flex;
@@ -792,6 +818,12 @@ table.data-table {
========================================================================== */
.field { margin-bottom: 15px; }
/* Zwei .field nebeneinander in einer .flex-Zeile (z.B. HTTP-/HTTPS-Port
bei NGINX) -- als Klasse statt inline style="flex:1", damit die
Mobile-Stapel-Regel (siehe @media max-width:640px weiter unten) sie
per externem Stylesheet überschreiben kann; ein inline style gewinnt
sonst grundsätzlich gegen jede @media-Regel, ganz gleich wie spezifisch. */
.field--half { flex: 1; min-width: 0; }
.field label {
display: block;
font-size: 12.5px;
@@ -1156,8 +1188,14 @@ select {
Logs mit nur R). Feste/zu schmale Breiten haben die D-Spalte bei
Geräte zuvor lautlos in den Overflow geschoben. */
flex: 0 0 auto;
max-width: 100%;
padding-right: 28px;
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-header-cell { padding-left: 0 !important; }
@@ -1259,6 +1297,92 @@ select {
}
.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
========================================================================== */
@@ -1286,14 +1410,63 @@ select {
.content { padding: 18px 16px 40px; }
.topbar { padding: 10px 16px; }
.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) {
.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) {
.device-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.topbar-title { font-size: 16px; }
.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
File diff suppressed because one or more lines are too long
+12 -1
View File
@@ -29,6 +29,11 @@
"profile.update": "Profil aktualisiert",
"profile.password": "Passwort geändert",
"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 = {
"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"/>',
"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 style="overflow-x:auto;">
@@ -56,7 +67,7 @@
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
<td class="cell-name">{{ e['username'] }}</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>
{{ action_labels.get(e['action'], e['action']) }}
</span>
+1
View File
@@ -22,6 +22,7 @@
"clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>',
"shield": '<path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/>',
"server": '<rect x="2" y="3" width="20" height="7" rx="1.5"/><rect x="2" y="14" width="20" height="7" rx="1.5"/><path d="M6 6.5h.01M6 17.5h.01"/>',
"folder": '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
+617
View File
@@ -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>&times;</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&nbsp;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>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Ordnername</label>
<input type="text" name="name" required autofocus>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Anlegen</button>
</div>
</form>
</div>
</div>
{% endif %}
{% if can_edit %}
<div class="modal-overlay" id="renameModal">
<div class="modal">
<form method="post" action="{{ url_for('fileshare_rename') }}">
<input type="hidden" name="share" value="{{ selected_share }}">
<input type="hidden" name="path" value="{{ rel_path }}">
<input type="hidden" name="old_name" id="renameOldName">
<div class="modal-header">
<h3>Umbenennen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Neuer Name</label>
<input type="text" name="new_name" id="renameNewName" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Umbenennen</button>
</div>
</form>
</div>
</div>
{% endif %}
<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>&times;</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 %}
+98 -69
View File
@@ -104,8 +104,8 @@
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
<td>
<div class="row-actions">
<button class="icon-btn" title="Rechte anzeigen" onclick="toggleDetail('detail-admin')">
<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>
<button class="icon-btn" title="Rechte anzeigen" data-open-modal="adminGroupModal">
<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 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>
@@ -113,16 +113,11 @@
</div>
</td>
</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>
{% 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 %}
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
<tr>
<td class="cell-name">
@@ -132,8 +127,12 @@
<td class="text-dim">{{ g.member_names|length }}</td>
<td>
<div class="row-actions">
<button class="icon-btn" title="Rechte anzeigen{{ '/bearbeiten' if can_edit_this else '' }}" onclick="toggleDetail('detail-{{ g.id }}')">
<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>
<button class="icon-btn" title="{{ 'Bearbeiten' if (can_edit_this or can_unlock_system) else 'Anzeigen' }}" data-open-modal="editGroupModal{{ loop.index }}">
{% 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 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>
@@ -149,57 +148,6 @@
</div>
</td>
</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>
{% else %}
<tbody data-sort-pinned>
@@ -210,6 +158,19 @@
</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>&times;</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" style="max-width:380px;">
<form method="post">
@@ -238,6 +199,8 @@
</div>
{% 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" style="max-width:380px;">
<form method="post">
@@ -268,6 +231,80 @@
</form>
</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>&times;</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>&times;</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>&times;</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 %}
<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() {
document.querySelectorAll(".permission-group-col").forEach(function (area) {
const toggle = area.querySelector(".permission-area-toggle-cb");
+246 -7
View File
@@ -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 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="section-head" style="margin-bottom:16px;">
@@ -70,6 +70,18 @@
</select>
<div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</div>
</div>
<div class="field"><label>Erforderliche AD-Gruppe für Login (optional)</label>
<div class="flex gap-2">
<select name="ldap_required_login_group" id="requiredGroupSelect" style="flex:1;">
<option value="">Keine (jeder gültige AD-Benutzer darf sich anmelden)</option>
{% if ldap.required_login_group %}
<option value="{{ ldap.required_login_group }}" selected>{{ ldap.required_login_group }}</option>
{% endif %}
</select>
<button type="button" class="btn btn-secondary btn-sm" id="requiredGroupLoadBtn">Gruppen laden</button>
</div>
<div class="field-hint" id="requiredGroupLoadStatus">Ist hier eine Gruppe ausgewählt, scheitert die Anmeldung für alle Benutzer, die ihr NICHT angehören (rekursiv, auch über verschachtelte Gruppen) — wie bei falschen Zugangsdaten, ohne Hinweis auf den eigentlichen Grund.</div>
</div>
<div class="flex gap-2" style="flex-wrap:wrap;">
<button type="submit" name="save_ldap" value="1" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
@@ -123,12 +135,18 @@
<td>{{ m.app_group_name or '—' }}</td>
<td>
{% if can_edit %}
<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?">
<input type="hidden" name="delete_ldap_group_mapping" value="{{ m.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
</div>
{% endif %}
</td>
</tr>
@@ -142,6 +160,63 @@
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Fileshare-Gruppen</h2>
<div class="hint">
Ist ein AD-Benutzer (rekursiv) Mitglied einer hier zugeordneten AD-Gruppe, wird die zugehörige
Freigabe beim Login für ihn gemountet — sofern er zusätzlich das TESM-Recht „Dateifreigaben lesen“
hat (siehe Gruppen → Rechte, Bereich „Dateifreigaben“). Fehlt das Recht, wird nicht gemountet und
der Menüpunkt „Dateifreigaben“ erscheint nicht, unabhängig von der AD-Gruppenmitgliedschaft.
</div>
</div>
{% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addFileshareMappingModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Zuordnung hinzufügen
</button>
{% endif %}
</div>
{% if fileshare_mappings %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>AD-Gruppe</th><th>Freigabe</th><th>Pfad</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for m in fileshare_mappings %}
<tr>
<td>{{ m.ad_group_name }}<div class="text-faint mono" style="font-size:11px;">{{ m.ad_group_dn }}</div></td>
<td>{{ m.share_label }}</td>
<td class="mono" style="font-size:12px;">{{ m.share_unc }}</td>
<td>
{% if can_edit %}
<div class="row-actions">
<button type="button" class="icon-btn" title="Bearbeiten"
onclick="openEditFileshareMappingModal('{{ m.id }}','{{ m.ad_group_dn|e }}','{{ m.ad_group_name|e }}','{{ m.share_label|e }}','{{ m.share_unc|e }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
</button>
<form method="post" data-confirm="Fileshare-Zuordnung „{{ m.ad_group_name }} → {{ m.share_label }}“ löschen?">
<input type="hidden" name="delete_fileshare_mapping" value="{{ m.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Noch keine Fileshare-Zuordnung angelegt — für niemanden wird eine Freigabe gemountet.</p>
{% endif %}
</div>
</div>
{% if can_edit %}
@@ -183,15 +258,129 @@
</div>
</div>
<div class="modal-overlay" id="addFileshareMappingModal">
<div class="modal">
<form method="post">
<input type="hidden" name="add_fileshare_mapping" value="1">
<div class="modal-header">
<h3>Fileshare-Zuordnung hinzufügen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>AD-Gruppe</label>
<div class="flex gap-2">
<select name="fs_ad_group_dn" id="fsMappingAdGroup" required style="flex:1;">
<option value="">— zuerst laden —</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" id="fsMappingLoadGroupsBtn">Gruppen laden</button>
</div>
<input type="hidden" name="fs_ad_group_name" id="fsMappingAdGroupName">
<div class="field-hint" id="fsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
</div>
<div class="field"><label>Bezeichnung</label>
<input type="text" name="fs_share_label" placeholder="z.B. Vertrieb" required>
<div class="field-hint">Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.</div>
</div>
<div class="field"><label>Freigabe-Pfad (UNC)</label>
<input type="text" name="fs_share_unc" placeholder="//fileserver/freigabe" required>
<div class="field-hint">Beide Schreibweisen funktionieren — <code>\\server\freigabe</code> wird automatisch in das von Linux benötigte <code>//server/freigabe</code> umgewandelt.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="editLdapMappingModal">
<div class="modal">
<form method="post">
<input type="hidden" name="edit_ldap_group_mapping" id="editLdapMappingId" value="">
<div class="modal-header">
<h3>AD-Gruppenzuordnung bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>AD-Gruppe</label>
<div class="flex gap-2">
<select name="ad_group_dn" id="editLdapMappingAdGroup" required style="flex:1;"></select>
<button type="button" class="btn btn-secondary btn-sm" id="editLdapMappingLoadGroupsBtn">Gruppen laden</button>
</div>
<input type="hidden" name="ad_group_name" id="editLdapMappingAdGroupName">
<div class="field-hint" id="editLdapMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
</div>
<div class="field"><label>App-Rechtegruppe</label>
<select name="app_group_id" id="editLdapMappingAppGroup" required>
<option value="">— auswählen —</option>
<option value="admin">Admin (alle Rechte)</option>
{% for g in ldap_groups %}
<option value="{{ g['id'] }}">{{ g['name'] }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="editFileshareMappingModal">
<div class="modal">
<form method="post">
<input type="hidden" name="edit_fileshare_mapping" id="editFsMappingId" value="">
<div class="modal-header">
<h3>Fileshare-Zuordnung bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>AD-Gruppe</label>
<div class="flex gap-2">
<select name="fs_ad_group_dn" id="editFsMappingAdGroup" required style="flex:1;"></select>
<button type="button" class="btn btn-secondary btn-sm" id="editFsMappingLoadGroupsBtn">Gruppen laden</button>
</div>
<input type="hidden" name="fs_ad_group_name" id="editFsMappingAdGroupName">
<div class="field-hint" id="editFsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
</div>
<div class="field"><label>Bezeichnung</label>
<input type="text" name="fs_share_label" id="editFsMappingLabel" placeholder="z.B. Vertrieb" required>
<div class="field-hint">Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.</div>
</div>
<div class="field"><label>Freigabe-Pfad (UNC)</label>
<input type="text" name="fs_share_unc" id="editFsMappingUnc" placeholder="//fileserver/freigabe" required>
<div class="field-hint">Beide Schreibweisen funktionieren — <code>\\server\freigabe</code> wird automatisch in das von Linux benötigte <code>//server/freigabe</code> umgewandelt.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<script>
(function () {
var btn = document.getElementById('ldapMappingLoadGroupsBtn');
var select = document.getElementById('ldapMappingAdGroup');
var nameField = document.getElementById('ldapMappingAdGroupName');
var status = document.getElementById('ldapMappingLoadStatus');
function wireAdGroupLoader(btnId, selectId, nameFieldId, statusId, includeEmptyOption) {
var btn = document.getElementById(btnId);
var select = document.getElementById(selectId);
var nameField = nameFieldId ? document.getElementById(nameFieldId) : null;
var status = document.getElementById(statusId);
if (!btn) return;
btn.addEventListener('click', function () {
// Aktuellen Wert (z.B. die vorher gespeicherte, noch nicht per
// Klartext-Namen aufgeloeste Gruppe) merken -- bleibt nach dem
// Neuaufbau der Optionsliste ausgewaehlt, falls sie unter den
// geladenen Gruppen auftaucht.
var currentValue = select.value;
status.textContent = 'Lade Gruppen …';
fetch("{{ url_for('settings_ldap_ad_groups') }}")
.then(function (r) { return r.json(); })
@@ -201,8 +390,14 @@
return;
}
select.innerHTML = '';
if (includeEmptyOption) {
var emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = 'Keine (jeder gültige AD-Benutzer darf sich anmelden)';
select.appendChild(emptyOpt);
}
if (!groups.length) {
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.';
return;
}
@@ -211,19 +406,63 @@
opt.value = g.dn;
opt.textContent = g.name;
opt.dataset.name = g.name;
if (g.dn === currentValue) opt.selected = true;
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.';
})
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; });
});
select.addEventListener('change', function () {
if (!nameField) return;
var opt = select.options[select.selectedIndex];
nameField.value = (opt && opt.dataset.name) || '';
});
}
wireAdGroupLoader('ldapMappingLoadGroupsBtn', 'ldapMappingAdGroup', 'ldapMappingAdGroupName', 'ldapMappingLoadStatus', false);
wireAdGroupLoader('fsMappingLoadGroupsBtn', 'fsMappingAdGroup', 'fsMappingAdGroupName', 'fsMappingLoadStatus', false);
wireAdGroupLoader('requiredGroupLoadBtn', 'requiredGroupSelect', null, 'requiredGroupLoadStatus', true);
wireAdGroupLoader('editLdapMappingLoadGroupsBtn', 'editLdapMappingAdGroup', 'editLdapMappingAdGroupName', 'editLdapMappingLoadStatus', false);
wireAdGroupLoader('editFsMappingLoadGroupsBtn', 'editFsMappingAdGroup', 'editFsMappingAdGroupName', 'editFsMappingLoadStatus', false);
})();
// Bearbeiten-Modals vorbefuellen -- die AD-Gruppe steht dabei zunaechst nur
// als DN+Name aus der Datenbank zur Verfuegung (ohne erneute LDAP-Abfrage);
// "Gruppen laden" ersetzt die Auswahlliste bei Bedarf durch die vollstaendige,
// aktuelle AD-Gruppenliste und behaelt den bisherigen Wert dabei bei (siehe
// wireAdGroupLoader oben).
function seedMappingSelect(selectId, dn, name) {
var select = document.getElementById(selectId);
select.innerHTML = '';
var opt = document.createElement('option');
opt.value = dn;
opt.textContent = name || dn;
opt.dataset.name = name || dn;
opt.selected = true;
select.appendChild(opt);
}
function openEditLdapMappingModal(id, dn, name, appGroupId) {
document.getElementById('editLdapMappingId').value = id;
seedMappingSelect('editLdapMappingAdGroup', dn, name);
document.getElementById('editLdapMappingAdGroupName').value = name;
document.getElementById('editLdapMappingAppGroup').value = appGroupId;
document.getElementById('editLdapMappingLoadStatus').textContent = 'Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.';
PoeUI.openModal('editLdapMappingModal');
}
function openEditFileshareMappingModal(id, dn, name, label, unc) {
document.getElementById('editFsMappingId').value = id;
seedMappingSelect('editFsMappingAdGroup', dn, name);
document.getElementById('editFsMappingAdGroupName').value = name;
document.getElementById('editFsMappingLabel').value = label;
document.getElementById('editFsMappingUnc').value = unc;
document.getElementById('editFsMappingLoadStatus').textContent = 'Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.';
PoeUI.openModal('editFileshareMappingModal');
}
</script>
{% endif %}
{% endblock %}
+3 -3
View File
@@ -5,7 +5,7 @@
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
{% 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="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>
<div class="flex gap-2">
<div class="field" style="flex:1;">
<div class="field field--half">
<label for="http_port">HTTP-Port</label>
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
</div>
<div class="field" style="flex:1;">
<div class="field field--half">
<label for="https_port">HTTPS-Port</label>
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
</div>