diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c447f91 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Auf dem Zielsystem laufen Shell- und Python-Dateien -- CRLF wuerde sie +# unbrauchbar machen ("bad interpreter"). Darum hart auf LF festgelegt. +* text=auto eol=lf +*.sh text eol=lf +*.py text eol=lf +*.html text eol=lf +*.css text eol=lf +*.js text eol=lf +deploy/tesm-helper text eol=lf diff --git a/.gitignore b/.gitignore index 3d2ba76..0884fb7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,24 @@ -# Virtuelle Umgebung (Linux — das Projekt ist ausschließlich für Linux ausgelegt) -/srv/tesm/venv/ +# Instanzdaten und Geheimnisse gehoeren nie ins Repo. +instance/ +*.db +*.db-wal +*.db-shm +data.keys +secret.key +license.json +license_key.json +master_signing_key.json +known_hosts + +# Python __pycache__/ -*.pyc +*.py[cod] +.venv/ +*.egg-info/ +build/ +dist/ -# Logfiles -*.log - -# Secrets — niemals committen -/srv/tesm/sqlite.db -/srv/tesm/fernet.key -/srv/tesm/secret.key -/srv/tesm/known_hosts - -# Laufzeit-Uploads (Profilbilder) — werden bei Bedarf automatisch angelegt -/srv/tesm/static/uploads/ +# Werkzeuge +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md deleted file mode 100644 index 0487713..0000000 --- a/KNOWLEDGE_BASE.md +++ /dev/null @@ -1,540 +0,0 @@ -# TESM — Knowledge Base (aus dem Code entfernte Kommentare) - -Diese Datei sammelt alle erklärenden `#`/`//`/`{# #}`/``-Kommentare, -die am 2026-08-14 aus dem Code entfernt wurden, um den Code selbst schlank -zu halten. Docstrings (`"""..."""`) wurden NICHT angetastet und stehen -weiterhin direkt im Code. - -Organisiert nach Datei, innerhalb von `app.py`/`create_db.py` zusätzlich -nach der Funktion, in der der Kommentar stand (Zeilennummer bezieht sich -auf den Stand VOR dem Entfernen, siehe Git-Historie). - ---- - -## app.py - -### Modulebene -- **Zeile 29** (srv/tesm/app.py): LDAP_MATCHING_RULE_IN_CHAIN — Microsofts OID für rekursive Gruppen- mitgliedschaft (löst auch verschachtelte Gruppen auf), statt nur das flache, nicht-rekursive memberOf-Attribut eines Benutzers zu lesen. - -### `def patched_init` -- **Zeile 81** (srv/tesm/app.py): Konfiguration (per Umgebungsvariable überschreibbar, sinnvolle Defaults passend zum produktiven Deployment unter /srv/tesm) -- **Zeile 91** (srv/tesm/app.py): Alle App-eigenen Logs leben unter einem gemeinsamen Verzeichnis statt verstreut in /var/log — vier getrennte Dateien, damit Live-Check, Änderungen, App-Fehler und Kea-DHCP sich nicht gegenseitig zutexten. Das Verzeichnis wird bewusst weltweit beschreibbar angelegt (siehe unten): tesm.service/poe.sh laufen als root, kea-dhcp4-server dagegen i.d.R. als eigener, unprivilegierter Systembenutzer (_kea) — beide müssen in dasselbe Verzeichnis schreiben können, ohne dass wir hier exakt den richtigen UID/GID-Abgleich nachbilden müssen. -- **Zeile 103** (srv/tesm/app.py): NICHT unter TESM_LOG_DIR: kea-dhcp4s AppArmor-Profil (/etc/apparmor.d/usr.sbin.kea-dhcp4) erlaubt Schreibzugriff exklusiv auf genau diesen Pfad (Dateiname hart einprogrammiert, kein Verzeichnis- Wildcard) — ein anderer Pfad wird mit "DENIED ... mknod ... kea-dhcp.log.lock" abgelehnt (live so reproduziert). Bewusst NICHT das AppArmor-Profil aufgeweicht, um die Sandbox eines Systemdienstes auf einem geteilten Host nicht zu schwächen — die App arbeitet stattdessen mit Keas eigenem, bereits erlaubtem Pfad. -- **Zeile 118** (srv/tesm/app.py): Wird verwendet, wenn ein Switch keinen eigenen ssh_port hinterlegt hat (Feld leer gelassen) — deckt den Standardfall ab, ohne dass er überall explizit eingetragen werden muss. -- **Zeile 124** (srv/tesm/app.py): Zugangsdaten-Kategorien — legt der Admin explizit fest (siehe _ensure_schema()-Kommentar für die Begründung gegen automatische Ping-basierte Erkennung). Nur "linux" ist aktuell für eine Aktion nutzbar (SSH-Update unter "Wartung"); die anderen sind reine Einordnung, z.B. für spätere Erweiterungen (Windows/PowerShell folgt separat). -- **Zeile 136** (srv/tesm/app.py): Kategorie, für die die SSH-Update-Aktion unter "Wartung" angeboten wird. -- **Zeile 142** (srv/tesm/app.py): Eigene, rotierte Datei für App-seitige Fehler/Warnungen ("Syslog"-Äquivalent der App) — vorher landete das nur im journalctl-Output von tesm.service, ohne eigene, von der App aus kontrollierbare Aufbewahrung. Bewusst nur WARNING und höher (kein INFO/Request-Rauschen) — reine Fehlerdiagnose, kein Access-Log. - -### `def _load_or_create_fernet` -- **Zeile 192** (srv/tesm/app.py): Rechtesystem (Gruppen): Admins (is_admin=1) dürfen immer alles — inkl. Benutzer-/Gruppen-/Settingsverwaltung, die es nur für Admins gibt. Für normale Benutzer lassen sich einzelne Verwaltungsrechte über Gruppen gezielt vergeben, ohne sie zu Admins zu machen. -- **Zeile 197** (srv/tesm/app.py): Zweistufig verschachtelt, gespiegelt an der Navbar-Struktur: jeder Top-Level-Bereich (Geräte/Logs/Einstellungen) hat ein eigenes "Bereich anzeigen"-Recht (view_key) und darunter mehrere Unterpunkte (Spalten in der Gruppen-Tabelle). Jeder Unterpunkt liefert "rows": eine Teilmenge der vier kanonischen Rechte-Zeilen (view/create/edit/delete = L/S/Ä/D, siehe PERMISSION_ROW_TYPES) — fehlt eine Zeile für einen Unterpunkt (z.B. "delete" bei Systemeinstellungen), wird die Zelle in der Tabelle grau und nicht setzbar dargestellt statt einfach zu fehlen, damit alle Spalten eines Bereichs bündig nebeneinander in einer kompakten Tabelle stehen. -- **Zeile 221** (srv/tesm/app.py): "Ändern" deckt Löschen überall mit ab, auch hier — es gibt nirgends ein eigenes Löschen-Recht. Die vierte Spalte (D) ist bei Clients KEIN Löschen, sondern das Recht, einen PoE-Neustart über das Dashboard-Popup auszulösen (automatische Neustarts bei Ausfall bleiben davon unberührt). -- **Zeile 235** (srv/tesm/app.py): Kein eigenes Löschen-Recht — "Ändern" deckt das mit ab. -- **Zeile 252** (srv/tesm/app.py): Kein eigenes Löschen — "Ändern" (E) steht hier für das Ausführen von Wartungsaktionen (SSH-Update, SSH-Neustart), nicht für Löschen. Getrennt von devices.restart (D oben): das ist ausschließlich der PoE-Neustart über den Switch. -- **Zeile 298** (srv/tesm/app.py): R = Seite ansehen (wie überall). E = Import ausführen (verändert Bestand, passt zu "Ändern"). X = Export ausführen (eigene Zeile, NICHT an E gekoppelt) -- die Export-Datei enthält Passwörter im Klartext, daher bewusst als eigenständiges, separat vergebbares Recht. -- **Zeile 311** (srv/tesm/app.py): L = Konfiguration/Status/Vorschau ansehen, Ä = Konfiguration speichern und die generierte Reservierungsdatei schreiben. -- **Zeile 323** (srv/tesm/app.py): Menschenlesbare Einzel-Labels je Recht (Tooltips auf den Checkboxen in der Tabelle) — aus PERMISSIONS abgeleitet, damit es nur eine Quelle gibt. -- **Zeile 354** (srv/tesm/app.py): Aus der verschachtelten Struktur abgeleitet: alle vergebbaren Keys, sowie eine Zuordnung jedes Kind-Rechts zum "Bereich anzeigen"-Recht seines Top-Level-Bereichs. Ist das übergeordnete Recht einer Gruppe nicht gesetzt, greift KEIN darunterliegendes Recht mehr — auch wenn es einzeln gewährt wurde (Kill-Switch, siehe User.has_permission). So lässt sich z.B. der gesamte "Geräte"-Bereich für eine Gruppe mit einem Klick sperren, ohne jedes Kind-Recht einzeln zurücknehmen zu müssen. -- **Zeile 370** (srv/tesm/app.py): Welche Spalten (R/W/E/D) für einen Bereich überhaupt vorkommen — z.B. hat nur "Geräte" (wegen Clients' Dashboard-Neustart) eine D-Spalte, Logs/ Einstellungen brauchen sie gar nicht erst anzuzeigen. Auf der Gruppen- Seite bekommt so jeder Bereich nur die Spalten, die er tatsächlich nutzt, statt überall gleich viele (teils komplett ungenutzte) Spalten zu zeigen. -- **Zeile 389** (srv/tesm/app.py): Navbar — die Reihenfolge der TOP-LEVEL-Punkte ist admin-konfigurierbar (siehe /account, gespeichert als JSON-Liste von Keys unter settings.nav_order); standardmäßig wird pro Benutzer nur angezeigt, wofür er auch tatsächlich eine Berechtigung hat — das gilt auch pro Unterpunkt innerhalb einer Gruppe: eine Gruppe erscheint nur, wenn mindestens ein Unterpunkt sichtbar ist, und zeigt dann auch nur die sichtbaren Unterpunkte. - -### `def can_view_settings_importexport` -- **Zeile 648** (srv/tesm/app.py): Jedes der drei Rechte (Seite ansehen/Import/Export) muss für sich allein zu Seiten-/Nav-Sichtbarkeit führen -- sonst wäre z.B. ein Nutzer mit ausschließlich dem Export-Recht (ohne .view) nie in der Lage, die Seite überhaupt zu erreichen, um sein Recht zu nutzen. - -### `def get_db_connection` -- **Zeile 686** (srv/tesm/app.py): Kuratierte Standard-DHCPv4-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) — vorbefüllt in _ensure_schema(), siehe dort für die Begründung. Muss vor dem _ensure_schema()-Aufruf am Ende der Datei stehen. (code, Kea-Name, Anzeige-Typ, Beschreibung). Router (3)/DNS-Server (6)/ Domain-Name (15)/Lease-Zeiten (51/58/59) sind bereits eigene, dedizierte Felder und deshalb bewusst nicht enthalten. Client-/Protokoll-interne Optionen (message-type, parameter-request-list, requested-address, client-identifier, server-identifier, ...) ebenfalls bewusst nicht enthalten — die sind serverseitig nicht sinnvoll setzbar (auch Windows Server listet sie nicht unter "Predefined Options"). "host-name" (12) ebenfalls bewusst NICHT enthalten: Kea beantwortet die Host-Name-Anfrage eines Clients bei einer Reservierung bereits automatisch mit deren eigenem "hostname"-Feld (aus dem Namen der Reservierung abgeleitet, siehe _dhcp_safe_hostname) — ein zusätzlicher option-data-Override für dieselbe Option in derselben Reservierung wäre eine mit der Kea-Doku belegte Dopplung/Quelle für Widersprüche, kein zusätzlicher Nutzen. Alle restlichen Namen live gegen eine echte Kea-2.4.1-Instanz verifiziert (kea-dhcp4 -t je Option einzeln). - -### `def _ensure_schema` -- **Zeile 770** (srv/tesm/app.py): Für den Migrations-Einmal-Guard weiter unten (settings existiert normalerweise schon durch create_db.py, hier nur zur Absicherung defensiv angelegt). -- **Zeile 792** (srv/tesm/app.py): Migration: 'is_default'-/'is_system'-Spalte nachrüsten, falls die Tabelle noch aus einer älteren Version ohne diese Spalten stammt. is_system sperrt Name UND Rechte einer Gruppe komplett gegen Änderungen über die UI (aktuell nur die Standardgruppe 'Benutzer') — getrennt von is_default (steuert nur die automatische Zuordnung neuer Benutzer), damit künftig auch andere, nicht-default Systemgruppen denkbar wären. -- **Zeile 804** (srv/tesm/app.py): Migration: Vor-/Nachname + Profilbild für Benutzer nachrüsten. -- **Zeile 812** (srv/tesm/app.py): 'local' (Passwort in dieser DB, bcrypt) oder 'ldap' (Konto stammt aus einem beim ersten erfolgreichen LDAP/AD-Login automatisch angelegten Eintrag -- das Passwort wird NIE lokal geprüft, siehe _ldap_authenticate() bzw. den Login-Handler; das gespeicherte "password" ist dafür nur ein nie verwendeter Platzhalter-Hash, um die NOT-NULL-Spalte zu befüllen). -- **Zeile 819** (srv/tesm/app.py): E-Mail-Adresse — für lokale Konten frei editierbar (Login zusätzlich per E-Mail statt nur Benutzername möglich), für LDAP/AD-Konten aus dem "mail"-Attribut übernommen und bei jedem Login aufgefrischt. -- **Zeile 824** (srv/tesm/app.py): Sperrt einen Login unabhängig vom Passwort/AD-Status -- der einzige Weg, einem AD-Konto den Zugriff wirklich zu entziehen, ohne es in AD selbst zu deaktivieren (ein simples Löschen des lokalen Schatten- Kontos reicht dafür NICHT: bei erneuter gültiger AD-Anmeldung würde es sonst automatisch neu angelegt). -- **Zeile 832** (srv/tesm/app.py): Service-Accounts (aktuell nur das LDAP-Bind-Konto) -- bewusst NICHT in der users-Tabelle (kein echter, einloggbarer App-Benutzer) und NICHT in der credentials-Tabelle (die ist für Switch-/Geräte-Zugangsdaten, ein anderer Anwendungsfall) -- eine eigene, kleine Tabelle. "purpose" ist UNIQUE, ein neu gespeichertes Konto für denselben Zweck ersetzt das alte (siehe save_ldap-Handler: INSERT ... ON CONFLICT DO UPDATE). -- **Zeile 847** (srv/tesm/app.py): AD-Gruppe -> App-Rechtegruppe: mehrere AD-Gruppen können jeweils einer App-Gruppe zugeordnet werden. Bei Login/Provisionierung wird geprüft, in welchen dieser AD-Gruppen ein Benutzer (auch über verschachtelte Gruppen) Mitglied ist, und die entsprechenden App-Gruppen werden ihm zugewiesen (additiv, siehe _ldap_resolve_app_groups) -- passt zu keine dieser Gruppen, greift die Standardgruppe (ldap_default_group). -- **Zeile 863** (srv/tesm/app.py): Änderungsverlauf (Audit-Log): wer hat wann was geändert. Bewusst ohne PoE-Neustarts (die stehen bereits im Live-Log von poe.sh). -- **Zeile 876** (srv/tesm/app.py): Migration: rpi_ip -> ip (Namensrelikt aus einer früheren, Raspberry-Pi- spezifischen Version -- Geräte sind längst nicht mehr auf Pis beschränkt). RENAME COLUMN erhält bestehende Werte unverändert. -- **Zeile 884** (srv/tesm/app.py): "Wer hat zuletzt geändert" direkt am Objekt, zusätzlich zum Audit-Log. -- **Zeile 890** (srv/tesm/app.py): Zugangsdaten (Credentials): eigenständige, wiederverwendbare SSH-Logins für Switche, statt Username/Passwort direkt am Switch. -- **Zeile 901** (srv/tesm/app.py): Kategorie (Switch/Linux-Client/Windows-Client/Router-Sonstiges) — bestimmt u.a., ob für ein Gerät mit diesen Zugangsdaten die SSH-Update-Aktion unter "Wartung" angeboten wird (siehe DEVICE_MAINTENANCE_CATEGORY). Bewusst KEINE automatische Erkennung per Ping o.ä. — ein reiner TTL-Hinweis ist keine verlässliche Grundlage dafür, unbeaufsichtigt Systembefehle auszuführen; der Admin legt die Kategorie explizit fest. -- **Zeile 918** (srv/tesm/app.py): SSH-Port je Switch (falls nicht Standard 22) — NULL/leer bedeutet überall "22 verwenden" (siehe SWITCH_DEFAULT_SSH_PORT-Fallback). -- **Zeile 923** (srv/tesm/app.py): Optionale SSH-Anbindung eines Clients (devices) — analog zu Switchen, damit z.B. ein Linux-Client für die Wartungs-/Update-Aktion erreichbar ist. NULL/kein credential_id = kein SSH-Zugriff für dieses Gerät. -- **Zeile 932** (srv/tesm/app.py): Papierkorb: "Löschen" setzt für Clients/Switche/Zugangsdaten/lokale Benutzer/Custom-Gruppen nur noch deleted_at statt die Zeile wirklich zu entfernen -- Wiederherstellen setzt es zurück auf NULL. Endgültiges Löschen (manuell über den Papierkorb oder automatisch nach Ablauf der Aufbewahrungsfrist, siehe trash_retention_days) führt DANN erst das echte DELETE aus. AD/LDAP-Benutzer durchlaufen den Papierkorb nicht (siehe users()-Route delete_user) -- ihr Konto wird beim nächsten gültigen Login ohnehin automatisch neu angelegt. -- **Zeile 945** (srv/tesm/app.py): DHCP: eigene Options-Definitionen + Werte (global/per-Client, siehe create_db.py für die ausführliche Begründung von device_mac=''). -- **Zeile 957** (srv/tesm/app.py): Migration: "is_standard"-Spalte nachrüsten, falls die Tabelle noch aus einer Version ohne vorbefüllte Standard-Optionen stammt. -- **Zeile 972** (srv/tesm/app.py): Einmalig eine kuratierte Auswahl an Standard-DHCP-Optionen vorbefüllen (analog den "Predefined Options" eines Windows-DHCP-Servers) — Router/ DNS-Server/Domain-Name/Lease-Zeiten sind bereits eigene, dedizierte Felder (siehe dhcp_subnets/DHCP_DEFAULTS) und deshalb hier bewusst ausgenommen. Alle Namen live gegen eine echte Kea-2.4.1-Instanz verifiziert (kea-dhcp4 -t) — Kea kennt diese Standard-Optionen bereits nativ, sie bekommen deshalb (anders als eigene Options) KEIN eigenes option-def in der generierten Konfiguration (siehe _render_kea_config), sonst würde die eingebaute Definition dupliziert. Per Guard nur einmalig, damit ein bewusst vom Admin gelöschter Eintrag nach einem Neustart nicht stillschweigend wieder auftaucht. -- **Zeile 992** (srv/tesm/app.py): v2: Liste um weitere verifizierte Standard-Optionen erweitert — eigener Guard, damit bereits auf v1 migrierte Installationen die neu hinzugekommenen Optionen ebenfalls bekommen, ohne die gesamte Seed-Logik erneut über alle Zeilen laufen zu lassen. -- **Zeile 1005** (srv/tesm/app.py): v3: "host-name" (12) als Standard-Option wieder entfernt — Kea beantwortet die Host-Name-Anfrage eines Clients bei einer Reservierung bereits automatisch über deren eigenes "hostname"-Feld; ein zusätzlicher option-data-Override dafür ist laut Kea-Doku eine Dopplung/Quelle für Widersprüche (live an einer echten Reservierung nachvollzogen, wo beide gleichzeitig gesetzt waren). Löscht auch bereits gesetzte Werte für diese Option mit, da sie ohnehin nie sinnvoll gewirkt hätten. -- **Zeile 1021** (srv/tesm/app.py): DHCP: manuelle Reservierungen — im Unterschied zu den automatisch aus den Clients (devices-Tabelle) erzeugten Reservierungen für Geräte, die NICHT als Client in dieser App gepflegt werden (z.B. ein fremdes/ externes Gerät, dem bewusst und mit voller Kontrolle eine feste IP gegeben werden soll, statt es unüberwacht automatisch anzubinden). Können im Gegensatz zu automatischen Reservierungen gelöscht werden. -- **Zeile 1036** (srv/tesm/app.py): DHCP: mehrere gleichzeitige Subnetze statt einer einzelnen globalen Range — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz haben, für die alle DHCP angeboten werden soll. gateway/dns sind je Subnetz optional (leer = automatisch erkanntes Gateway des Interfaces bzw. kein DNS-Override); domain/Lease-Zeiten/Ausgabepfad bleiben bewusst global (siehe DHCP_DEFAULTS), da sie Kea-weit gelten. -- **Zeile 1054** (srv/tesm/app.py): Migration: "enabled"-Spalte nachrüsten, falls die Tabelle noch aus einer Version ohne Deaktivieren-Funktion stammt. -- **Zeile 1059** (srv/tesm/app.py): Migration: bisherige globale Einzel-Range (dhcp_interface/_range_*/ _gateway/_dns in der settings-Tabelle) einmalig in eine erste Zeile der neuen Tabelle überführen, statt eine bereits funktionierende Konfiguration beim Upgrade stillschweigend zu verlieren. Per Guard nur einmalig, da die alten settings-Schlüssel absichtlich stehen bleiben (Altlast, wird nicht mehr gelesen) und sonst bei jedem Start erneut eine (dann ggf. schon gelöschte) Zeile anlegen würde. -- **Zeile 1086** (srv/tesm/app.py): Migration: bestehende, direkt am Switch hinterlegte Zugangsdaten (ältere DB-Version) in eigene Credentials-Datensätze überführen. -- **Zeile 1107** (srv/tesm/app.py): Migration (granularer Rechtebaum): devices.toggle -> devices.edit, switches.* -> gespiegelte credentials.*, sowie Backfill des neuen "Bereich sichtbar"-Rechts für Gruppen mit bereits gesetzten Kind-Rechten. WICHTIG: nur EINMALIG (Guard über settings) — sonst würde das Bereichs-Recht bei jedem Neustart automatisch neu gesetzt und den Kill-Switch (siehe User.has_permission) wirkungslos machen. -- **Zeile 1171** (srv/tesm/app.py): Migration v2: "Löschen" als eigenes Recht wieder abgeschafft — "Ändern" deckt das Löschen jetzt überall mit ab (auch bei Clients, keine Ausnahme mehr). Gruppen, die bisher nur *.delete ohne *.edit hatten, würden sonst durch das Update ihr Löschen-Recht stillschweigend verlieren — deshalb erst *.edit nachtragen, dann die veralteten *.delete-Zeilen entfernen. Ebenfalls einmalig per Guard, aus demselben Grund wie bei v1 (kein Zurückschreiben bei jedem Neustart nötig, da *.delete als Recht komplett entfällt und nie wieder vorkommen kann). -- **Zeile 1203** (srv/tesm/app.py): Einmalige Aufräum-Migration: die alten, bei jedem Dienst-Neustart neu angelegten rpi-.log-Dateien unter /var/log sind seit der Umstellung auf eine einzige, per logrotate rotierte Datei (TESM_LIVE_LOG_PATH unter /var/log/tesm/) verwaist — poe.sh räumt sie nicht mehr selbst auf (das war vorher sein eigener 30-Tage-`find -delete`), und die neue logrotate-Konfiguration überwacht ein anderes Verzeichnis. Ohne diese Migration blieben sie für immer liegen. -- **Zeile 1220** (srv/tesm/app.py): Standardgruppe 'Benutzer' — SYSTEMGRUPPE: Name und Rechte sind fix und werden bei jedem Start auf den vorgegebenen Satz zurückgesetzt (nicht nur bei Erstanlage). Das ist unbedenklich, weil is_system die Gruppe in der UI ohnehin komplett gegen Umbenennen/Rechteänderung sperrt — Mitgliedschaft bleibt weiterhin frei verwaltbar. -- **Zeile 1239** (srv/tesm/app.py): Jeden nicht-admin Benutzer ohne jegliche Gruppenzugehörigkeit der Standardgruppe zuordnen (Migrationsfall: bestehende Benutzer sollen durch die Einführung des Rechtesystems keinen Zugriff verlieren). - -### `def _build_user` -- **Zeile 1263** (srv/tesm/app.py): deleted_at IS NULL: Mitglied einer in den Papierkorb verschobenen Gruppe verliert deren Rechte sofort (nicht erst nach Wiederherstellen/ endgültigem Löschen) -- einziger Ort, der geändert werden muss, damit das app-weit für jede has_permission()-Prüfung greift. - -### `def load_user` -- **Zeile 1442** (srv/tesm/app.py): deleted_at IS NULL: eine in den Papierkorb verschobene Benutzerin verliert damit sofort jede bereits laufende Sitzung, nicht erst beim nächsten Login-Versuch. -- **Zeile 1453** (srv/tesm/app.py): LDAP / Active Directory — optionale Anmeldung mit dem Domänen-Passwort zusätzlich zu lokalen Konten (siehe Systemeinstellungen → LDAP). -- **Zeile 1456** (srv/tesm/app.py): WICHTIG: das ist "gleiches Passwort" (Same Sign-On), KEIN automatisches, promptloses Single Sign-On -- der Benutzer tippt sein AD-Passwort weiterhin auf der Login-Seite ein, es wird nur live gegen AD statt gegen ein lokal gespeichertes Passwort geprüft. Echtes automatisches SSO bräuchte zusätzlich Kerberos/SPNEGO (eigenes SPN + Keytab vom AD-Admin) oder SAML/OIDC über Entra ID -- beides technisch klar unterschiedliche, deutlich aufwändigere Bausteine, die sich später darauf aufsetzen ließen. -- **Zeile 1464** (srv/tesm/app.py): Search+Bind-Muster statt eines direkten UPN-Binds: zuerst mit dem konfigurierten Service-Konto verbinden, den Benutzer per konfigurierbarem Attribut (Standard sAMAccountName, für generisches LDAP z.B. "uid") suchen, dann eine ZWEITE, separate Verbindung mit dessen exakter DN + dem eingegebenen Passwort aufbauen, um es zu prüfen. Das funktioniert unabhängig davon, ob sAMAccountName und der UPN-Präfix übereinstimmen, und damit auch mit generischem (Nicht-AD-)LDAP. - -### `def _ldap_settings` -- **Zeile 1513** (srv/tesm/app.py): Fallback für Benutzer, die zu KEINER konfigurierten AD-Gruppen- Zuordnung passen (siehe ldap_group_mappings/_ldap_resolve_app_groups) -- leer = Systemstandardgruppe. - -### `def _ldap_connect_service` -- **Zeile 1533** (srv/tesm/app.py): tls nur relevant, wenn use_ssl gesetzt ist -- ldap3 ignoriert es sonst ohnehin, aber so bleibt die Absicht im Code eindeutig. - -### `def _attr` -- **Zeile 1633** (srv/tesm/app.py): ACCOUNTDISABLE-Bit, nur bei AD vorhanden -- **Zeile 1641** (srv/tesm/app.py): "E-Mail" meint hier bewusst userPrincipalName (z.B. "user@ad.firma.local"), nicht das separate "mail"-Attribut -- in AD ist der UPN die praxisübliche Login-/Kontaktadresse, "mail" ist oft gar nicht gepflegt (genau wie beim Test-Account, der zu dieser Entscheidung geführt hat) und wird deshalb hier gar nicht erst abgefragt. - -### `def _ldap_group_mappings` -- **Zeile 1791** (srv/tesm/app.py): LEFT JOIN mit deleted_at-Filter: eine inzwischen in den Papierkorb verschobene (oder endgültig gelöschte) Rechtegruppe lässt die Zuordnung sichtbar als "(gelöscht)" statt sie stillschweigend so anzuzeigen, als würde sie noch normal funktionieren. - -### `def _ldap_authenticate` -- **Zeile 1853** (srv/tesm/app.py): Welche App-Gruppen dem Benutzer laut AD-Gruppenmitgliedschaft zustehen (siehe ldap_group_mappings) -- ohne konfigurierte Zuordnung dürfen sich weiterhin alle Domänen-Benutzer anmelden (Standardgruppe greift dann beim Provisionieren). -- **Zeile 1879** (srv/tesm/app.py): Auth - -### `def _assign_ldap_groups` -- **Zeile 1892** (srv/tesm/app.py): Zielgruppe könnte inzwischen in den Papierkorb verschoben oder sogar endgültig gelöscht worden sein -- dann keine (dann sinnlose oder gar auf nichts mehr zeigende) Mitgliedschaft eintragen. - -### `def _ldap_default_group_id` -- **Zeile 1903** (srv/tesm/app.py): Konfigurierte Gruppe könnte inzwischen in den Papierkorb verschoben worden sein -- dann auf den Systemstandard zurückfallen, statt eine (dann möglicherweise sogar endgültig gelöschte) Gruppen-ID stumpf weiterzuverwenden. - -### `def login` -- **Zeile 1924** (srv/tesm/app.py): 1) Exakter Benutzername-Treffer (Groß-/Kleinschreibung wie eingegeben) -- deckt lokale Konten UNVERÄNDERT wie bisher ab, und bereits per LDAP angelegte Konten, wenn der Benutzername konsistent geschrieben wird. deleted_at IS NULL bei allen drei Lookups: ein in den Papierkorb verschobenes lokales Konto darf sich nicht mehr anmelden UND soll als "nicht gefunden" behandelt werden (nicht z.B. mit einer extra Fehlermeldung, die verriete, dass der Name existiert). -- **Zeile 1936** (srv/tesm/app.py): 2) Kein exakter Treffer -- per E-Mail suchen (case-insensitiv). Deckt sowohl lokale Konten mit hinterlegter E-Mail als auch per LDAP angelegte Konten ab (deren E-Mail-Spalte den UPN enthält). Login-Logik umfasst damit genau vier Fälle: lokaler Benutzername, lokale E-Mail, AD sAMAccountName, AD userPrincipalName. -- **Zeile 1948** (srv/tesm/app.py): 3) Immer noch nichts -- unter den PER LDAP angelegten Konten zusätzlich case-insensitiv nach dem Benutzernamen suchen (AD-Benutzernamen sind case-insensitiv; ohne das könnte "JDoe" und "jdoe" sonst zwei verschiedene lokale Schatten-Konten erzeugen). Wirkt sich NIE auf lokale Konten aus. -- **Zeile 1960** (srv/tesm/app.py): Gesperrt (siehe Benutzerverwaltung) -- unabhängig von Passwort/AD-Status. Der einzige zuverlässige Weg, einem AD-Konto den Zugriff über die App wirklich zu entziehen, ohne es in AD selbst zu deaktivieren. -- **Zeile 1969** (srv/tesm/app.py): Passwort wird NIE lokal geprüft (der gespeicherte Hash ist nur ein nie verwendeter Platzhalter) -- bei JEDEM Login live gegen AD verifiziert, damit eine Passwortänderung in AD sofort greift, ohne dass hier etwas synchronisiert werden müsste. -- **Zeile 1975** (srv/tesm/app.py): Name/E-Mail bei jedem Login aus AD auffrischen (können sich dort ändern), und ggf. neu hinzugekommene AD-Gruppen- Zuordnungen zusätzlich zuweisen (nie etwas wegnehmen). -- **Zeile 1999** (srv/tesm/app.py): Kein lokales Konto mit diesem Namen/dieser E-Mail -- bei aktivierter LDAP-Anmeldung gegen AD prüfen (per Benutzername, UPN oder E-Mail, siehe _ldap_find_entry) und beim ersten erfolgreichen Login automatisch ein lokales Konto anlegen. - -### `def logout` -- **Zeile 2044** (srv/tesm/app.py): Landet auf dem (auch ohne Login erreichbaren) Dashboard statt auf /login -- wer sich abmeldet, will i.d.R. trotzdem noch den Online/Offline-Status sehen können, ohne sich extra erneut einloggen zu müssen, nur um zur Übersicht zu gelangen. -- **Zeile 2052** (srv/tesm/app.py): Eigenes Profil / Konto — jeder eingeloggte Benutzer darf Vor-/Nachname, eigenes Passwort und Profilbild selbst ändern (Zahnrad-Symbol neben dem eigenen Namen unten in der Sidebar). Bewusst getrennt vom Admin-"Users"- Bereich, der andere Benutzer verwaltet. Die Navbar-Reihenfolge (siehe save_nav_order()) konfigurieren Admins unter Systemeinstellungen, nicht hier. - -### `def profile` -- **Zeile 2083** (srv/tesm/app.py): Das gespeicherte Passwort ist nur ein nie geprüfter Platzhalter (siehe Login-Handler) -- der bcrypt-Vergleich unten würde ohnehin nie zutreffen, aber mit einer irreführenden "aktuelles Passwort falsch"-Meldung statt einer klaren Ansage. -- **Zeile 2132** (srv/tesm/app.py): Log-Auswertung (Erreichbarkeits-Status je Gerät) - -### `def get_device_status` -- **Zeile 2290** (srv/tesm/app.py): Dashboard - -### `def by_name` -- **Zeile 2319** (srv/tesm/app.py): für Kompatibilität/Zähler - -### `def _restart_check_service` -- **Zeile 2367** (srv/tesm/app.py): z.B. lokale Entwicklungsumgebung ohne systemd - -### `def run_check_now` -- **Zeile 2385** (srv/tesm/app.py): Kurz warten, bis der frisch gestartete Dienst den ersten Durchlauf ins neue Logfile geschrieben hat — dann kann die Antwort den aktuellen Zeitpunkt direkt mitliefern, ohne dass das Frontend dafür einen zweiten Request/Endpunkt braucht (Flask läuft threaded, blockiert also keine anderen Anfragen währenddessen). -- **Zeile 2400** (srv/tesm/app.py): Settings -- **Zeile 2404** (srv/tesm/app.py): System-Netzwerkeinstellungen (IP/DNS/DHCP DIESES Hosts — nicht der Kea-DHCP-*Server* für Client-Geräte weiter unten). Eine falsche Änderung kann die Erreichbarkeit kappen, daher: Backend-Erkennung statt Annahme (unbekannt -> rein lesend), Backup vor jeder Änderung, und ein Sicherheits-Timer (analog "netplan try") der ohne explizite Bestätigung innerhalb von NETWORK_REVERT_SECONDS automatisch zurückrollt. -- **Zeile 2413** (srv/tesm/app.py): {"timer": Timer, "backend":..., "interface":..., "backup": {...}} Eigene netplan-Override-Datei (siehe _apply_network_config) — bewusst nicht die von cloud-init/dem Distributor mitgelieferte Datei, damit ein Rollback nie versehentlich fremden Inhalt überschreibt. - -### `def _detect_network_backend` -- **Zeile 2432** (srv/tesm/app.py): Standard-Ubuntu (Server- wie Cloud-Images) verwaltet sein Netz inzwischen über netplan + systemd-networkd statt NetworkManager oder dhcpcd — mit Abstand der häufigste Fall auf einem "normalen" frisch installierten Ubuntu-Host (live an einer echten Ubuntu-24.04-VM verifiziert, die ihre IP per DHCP bezieht). - -### `def _read_configured_dns` -- **Zeile 2470** (srv/tesm/app.py): Nur die erste Zeile trägt das "Link N (iface):"-Label; bei mehr Servern, als auf eine Zeile passen, folgen reine Fortsetzungszeilen OHNE Label -- ein naives split(":", 1) auf JEDER Zeile würde bei einer IPv6-Fortsetzungszeile (die selbst Doppelpunkte enthält) fälschlich deren Anfang abschneiden (live reproduziert: "2003:fe:7714:..." wurde zu "fe:7714:..."). -- **Zeile 2485** (srv/tesm/app.py): Nur IPv4: diese Seite verwaltet ausschließlich die IPv4-Konfiguration des Hosts (IP-Adresse/Prefix/Gateway sind alles IPv4-Felder) — per IPv6-Router-Advertisement gelernte DNS-Server (die resolvectl ungefragt mit auflistet) gehören nicht zu dieser Konfiguration und wirken hier nur verwirrend/falsch neben den IPv4-Werten. - -### `def _read_network_state` -- **Zeile 2533** (srv/tesm/app.py): Netplan/systemd-networkd markiert eine per DHCP bezogene Adresse im Kernel mit "dynamic" — zuverlässiger als die YAML-Dateien unter /etc/netplan/ zu parsen (mehrere Dateien können sich überlagern). - -### `def _backup_network_config` -- **Zeile 2566** (srv/tesm/app.py): Eigene Override-Datei sichern statt der von cloud-init/dem Distributor mitgelieferten Dateien — None heißt "existierte vorher nicht", dann löscht der Revert sie wieder statt sie leer zu lassen. - -### `def _apply_network_config` -- **Zeile 2638** (srv/tesm/app.py): Ohne diesen Override würde networkd die eigenen DNS-Server mit den per DHCP gelieferten zusammen verwenden, statt sie wie bei den anderen Backends (ipv4.ignore-auto-dns / dhcpcd) vollständig zu ersetzen. -- **Zeile 2645** (srv/tesm/app.py): Bestehende Datei einlesen und nur den Block DIESES Interfaces ersetzen/ergänzen -- ein Host kann mehrere Interfaces haben, jedes über einen eigenen Aufruf dieser Seite konfiguriert. Ein kompletter Neuaufbau des Dokuments (wie ursprünglich) hätte bei jedem Speichern alle ANDEREN, bereits konfigurierten Interfaces aus der Datei geworfen -- deren Konfiguration fiele dadurch auf die Distributor-Datei zurück (i.d.R. DHCP), obwohl an ihnen gar nichts geändert wurde (live reproduziert: zweites Interface eingerichtet, erstes verlor dadurch seine statische IP). -- **Zeile 2662** (srv/tesm/app.py): Eigene, niedrig priorisierte Datei statt der von cloud-init mitgelieferten (z.B. 50-cloud-init.yaml) — netplan wendet Dateien in alphabetischer Reihenfolge an und lässt spätere Werte pro Schlüssel gewinnen, das ist hier der etablierte Weg für eigene Overrides statt die Distributor-Datei zu editieren. - -### `def _revert_network_config` -- **Zeile 2701** (srv/tesm/app.py): Datei existierte vor der Änderung nicht (erste Netplan- Änderung über diese Seite) — dann muss sie beim Rollback auch wieder verschwinden statt leer zurückzubleiben. -- **Zeile 2709** (srv/tesm/app.py): Das Rollback ändert die Netzwerkkonfiguration erneut — falls zwischenzeitlich (z.B. manuell auf der DHCP-Seite) wieder gestartet, aus demselben Grund wie beim ursprünglichen Anwenden vorsorglich stoppen statt mit einer ggf. nicht mehr passenden Range weiterlaufen zu lassen. - -### `def settings` -- **Zeile 2767** (srv/tesm/app.py): nur eine ausstehende Änderung gleichzeitig -- **Zeile 2770** (srv/tesm/app.py): Damit die Seite beim nächsten Laden das zuletzt bearbeitete statt immer nur das alphabetisch erste Interface zeigt -- gerade bei mehreren Interfaces sonst verwirrend, siehe net_interface-Fallback weiter unten. -- **Zeile 2780** (srv/tesm/app.py): Eine vorher passende DHCP-Range kann nach einer IP-/Subnetzänderung plötzlich zum falschen Netz gehören — der Dienst wird deshalb vorsorglich gestoppt statt (ggf. falsch) weiterzulaufen. Bewusst kein automatischer Neustart: die Range muss der Admin aktiv auf der DHCP-Seite prüfen/bestätigen, siehe Kommentar oben am Kea-Block. -- **Zeile 2815** (srv/tesm/app.py): Ohne tzset() würde diese bereits laufende tesm.service- Instanz die neue Zeitzone für datetime.now() (Änderungslog, App-Log) erst nach einem Neustart übernehmen -- glibc cached die Zeitzone sonst ab dem ersten Zugriff im Prozess. poe.sh/date liest sie dagegen ohnehin bei jedem Aufruf frisch, da es ein neuer Prozess pro Durchlauf ist. -- **Zeile 2867** (srv/tesm/app.py): LDAP/Active Directory hat seit dem Umzug auf eine eigene Unterseite (Übersichtlichkeit) eine eigene Route -- siehe settings_ldap(). -- **Zeile 2877** (srv/tesm/app.py): Übersicht ganz oben zeigt JEDES tatsächlich aktive Interface (eigene IPv4-Adresse vorhanden) gleichzeitig, nicht nur das gerade im Formular ausgewählte -- bei mehreren Interfaces sonst leicht der Eindruck, das zweite existiere für die App gar nicht (live so gemeldet: die Übersicht zeigte nach dem Umschalten der Auswahl weiterhin nur die Werte des ersten Interfaces). - -### `def settings_network_state` -- **Zeile 2935** (srv/tesm/app.py): "network" ist ein ipaddress.IPv4Network-Objekt (siehe _detect_interface_network) -- nicht JSON-serialisierbar und vom Frontend hier auch nicht benötigt (nur ip/prefix/gateway/dns/mode). - -### `def settings_import_export` -- **Zeile 2956** (srv/tesm/app.py): DHCP-Reservierungen (Kea DHCP) -- **Zeile 2958** (srv/tesm/app.py): Backend bewusst Kea statt isc-dhcp-server (seit 2022 EOL) — Kea bildet "globaler Wert, pro Client überschreibbar" nativ über Reservation-Options ab. Installation & Dienststeuerung sind eigene, einzeln bestätigte Aktionen statt an "Konfiguration speichern" gekoppelt, da ein unerwarteter DHCP-Server ein Netzsegment stören kann. -- **Zeile 2967** (srv/tesm/app.py): Explizit statt dem eingebauten Kea-Default gesetzt (siehe _render_kea_config) — Anzeige der aktiven Leases (_dhcp_read_leases) liest genau diesen Pfad, damit beide Seiten garantiert übereinstimmen, statt sich auf einen distributionsabhängigen impliziten Default zu verlassen. -- **Zeile 2973** (srv/tesm/app.py): Interface, Range, Gateway und DNS sind seit der Mehrfach-Subnetz- Unterstützung KEINE globalen Einstellungen mehr, sondern je Zeile in der dhcp_subnets-Tabelle gepflegt (siehe _dhcp_subnets/_dhcp_matching_network) — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz haben. Domain, Lease-Zeiten und Ausgabepfad gelten dagegen für den gesamten Kea-Dienst und bleiben deshalb global. - -### `def _dhcp_subnet_matches` -- **Zeile 3000** (srv/tesm/app.py): Auswahl an Kea-Datentypen für eigene Options — bewusst eine kompatible Teilmenge statt der vollen Liste, orientiert an den in der Praxis gebrauchten Fällen (Text, IP, Zahl, Wahrheitswert, Rohdaten/Hex). - -### `def _detect_interface_networks` -- **Zeile 3106** (srv/tesm/app.py): Fallback: irgendeine Default-Route, falls keine speziell an dieses Interface gebunden ist (typisch bei Hosts mit nur einer aktiven Schnittstelle). -- **Zeile 3122** (srv/tesm/app.py): Der Kernel markiert eine per DHCP bezogene Adresse mit "dynamic" — unabhängig vom Backend eine zuverlässige Quelle für den aktuellen DHCP/Statisch-Modus (genutzt von der netplan-Erkennung unten). - -### `def _dhcp_reservation_candidates` -- **Zeile 3239** (srv/tesm/app.py): Case-insensitiv nach MAC dedupliziert (Geräte/manuelle Zeilen können unterschiedlich geschriebene MACs haben) — automatisch gewinnt immer. MAC (upper) -> {mac, ip, name, source, manual_id} -- **Zeile 3265** (srv/tesm/app.py): Kollisionen bei generierten Namen (z.B. zwei Geräte mit gleichem Namen im selben Subnetz) auflösen, statt zwei Reservierungen mit demselben Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen). - -### `def _dhcp_read_leases` -- **Zeile 3312** (srv/tesm/app.py): Kea's memfile-Backend HÄNGT bei jeder Verlängerung eine neue Zeile an, statt die alte zu ersetzen -- dieselbe Adresse steht oft dutzendfach in der Datei, mit jeweils weiter in die Zukunft geschobenem "expire". Da Kea eine Lease schon bei ~50% der Laufzeit erneuert (nicht erst bei Ablauf), sind kurz nach einer Verlängerung sowohl die alte als auch die neue Zeile noch "nicht abgelaufen" -- ohne Deduplizierung nach Adresse würde dieselbe Lease doppelt in der Übersicht auftauchen. -- **Zeile 3321** (srv/tesm/app.py): state: 0 = aktiv/default, 1 = "declined" (Konflikt erkannt), 2 = "expired-reclaimed" (von Kea bereits aufgeräumt) — nur 0 ist eine tatsächlich vergebene, gültige Adresse. -- **Zeile 3330** (srv/tesm/app.py): Abgelaufen, aber von der periodischen Lease-File-Cleanup (lfc) noch nicht aus der CSV entfernt — nicht als aktiv anzeigen. -- **Zeile 3337** (srv/tesm/app.py): bereits eine neuere (oder gleich neue) Zeile für diese IP gesehen -- **Zeile 3346** (srv/tesm/app.py): Absolut UND als Epoch-ms für einen live tickenden Countdown im Browser (siehe initLeaseCountdowns in app.js) — eine reine Absolutzeit beim Seitenaufruf wirkt bei kurzen Lease-Zeiten (Default 600s) schnell "schon abgelaufen", sobald etwas Zeit vergangen ist, bis man sie tatsächlich anschaut. Explizit als UTC ausgewiesen, da Kea intern in UTC-Epoch arbeitet. - -### `def _dhcp_delete_lease` -- **Zeile 3383** (srv/tesm/app.py): lineterminator="\n" ist entscheidend: der csv-Modul-Default ist "\r\n" (RFC 4180), Keas eigener Writer schreibt aber reines "\n" -- ohne diese explizite Angabe interpretiert Kea beim nächsten Start das "\r" als Teil des letzten Spaltennamens ("pool_id\r") und lehnt die ganze Datei mit "invalid column" ab (live reproduziert). -- **Zeile 3392** (srv/tesm/app.py): Diese App läuft als root, öffnet die Datei aber mit "w" -- das legt sie neu an und würde sie root:root statt dem Kea-Dienstnutzer gehören lassen. Kea (i.d.R. eigener Systemnutzer, z.B. "_kea") kann dann zwar noch lesen, aber keine neuen Leases mehr hineinschreiben. Eigentümer/Rechte der Originaldatei deshalb explizit wiederherstellen (live reproduziert). - -### `def _render_kea_config` -- **Zeile 3429** (srv/tesm/app.py): Standard-Optionen (is_standard) bekommen KEIN eigenes option-def — Kea kennt sie bereits nativ, eine Neudefinition würde die eingebaute duplizieren (siehe DHCP_STANDARD_OPTIONS). Nur eigene Options brauchen ein option-def, damit Kea Name/Typ/Code überhaupt kennt. -- **Zeile 3448** (srv/tesm/app.py): Gateway: expliziter Override hat Vorrang, sonst automatisch erkanntes Gateway dieses Hosts — für den Fall, dass Clients einen anderen Router bekommen sollen als den, über den dieser Host für dieses Netz selbst geht. -- **Zeile 3487** (srv/tesm/app.py): Kea nennt den Datei-Pfad des memfile-Backends "name", nicht "lease-file" (mit falschem Schlüssel lehnt Kea die Config mit "unexpected keyword" ab — live so reproduziert und gefixt). -- **Zeile 3496** (srv/tesm/app.py): Eigene Log-Datei statt Keas Distributions-Default (meist Syslog) — damit sie sich zusammen mit den anderen drei App-Logs (Live, Änderungen, App) über dieselbe logrotate-Konfiguration verwalten lässt. Bewusst OHNE maxsize/maxver (Keas eigene, größenbasierte Rotation) — die externe, zeitbasierte logrotate-Rotation (siehe _write_logrotate_config) soll die einzige Quelle für alle vier Logs sein, nicht zwei parallele, unterschiedliche Schemata. "loggers" gehört NACH Live-Test gegen eine echte Kea-Instanz zwingend UNTER "Dhcp4" -- ein Top-Level- "Logging"-Geschwisterobjekt (wie in älterer/anderer Dokumentation zu finden) lehnt Kea 2.4.1 mit einem Syntax-Fehler ab. - -### `def settings_dhcp` -- **Zeile 3605** (srv/tesm/app.py): Nur noch die globalen, Kea-weiten Einstellungen — Interface/ Range/Gateway/DNS werden seit der Mehrfach-Subnetz-Unterstützung separat je Subnetz gepflegt (siehe add/edit/delete_dhcp_subnet). -- **Zeile 3873** (srv/tesm/app.py): Bewusst auch beim Starten nochmal live gegen das aktuelle Netz geprüft (nicht nur beim Speichern/Schreiben) — sonst ließe sich der Dienst mit einer Range starten, die z.B. nach einem Netzwechsel des Hosts seit dem letzten Schreiben gar nicht mehr zu einem echten Subnetz passt. -- **Zeile 3895** (srv/tesm/app.py): Auch deaktivieren, nicht nur stoppen — sonst würde systemd den Dienst nach einem Neustart des Hosts automatisch wieder starten, obwohl der Admin ihn hier bewusst abgeschaltet hat. Damit übersteht ein Host-Neustart genau den zuletzt bewusst gewählten Zustand (an oder aus), statt Kea unabhängig davon immer wieder hochzufahren. -- **Zeile 3924** (srv/tesm/app.py): Aktive Leases direkt aus Kea auslesen (siehe _dhcp_read_leases) und um ein reserved-Flag ergänzen — Clients OHNE eigene Reservierung, die sich einfach eine freie IP aus dem Pool genommen haben, sollen auffallen. -- **Zeile 3932** (srv/tesm/app.py): Für die Anzeige je Subnetz-Zeile (Erkannt/Nicht erkannt + Grund) — als einfaches Dict statt verschachteltem Jinja-selectattr, aus demselben Grund wie option_values.get(...) unten im Template. - -### `def settings_ldap` -- **Zeile 4027** (srv/tesm/app.py): Bind-Konto liegt in der eigenen service_accounts-Tabelle (siehe _ldap_settings()), nicht in settings -- ein neu eingegebenes Konto ERSETZT das alte komplett (purpose ist UNIQUE). Passwort nur überschreiben, wenn tatsächlich ein neues eingegeben wurde; Username kann auch ohne neues Passwort aktualisiert werden (dann bleibt das alte, verschlüsselte Passwort bestehen). -- **Zeile 4059** (srv/tesm/app.py): Prüft die AKTUELL im Formular eingetragenen Werte (auch wenn noch nicht gespeichert) -- gleiches Prinzip wie "Verbindung testen" bei Switchen/Geräten, das ebenfalls die Formularwerte statt gespeicherter Zugangsdaten testet. -- **Zeile 4082** (srv/tesm/app.py): Ohne Bind-Konto kann LDAP nicht funktionieren -- vorsorglich mit deaktivieren, statt eine "aktivierte", aber unbenutzbare Konfiguration stehen zu lassen. - -### `def save_nav_order` -- **Zeile 4149** (srv/tesm/app.py): Unterpunkt-Reihenfolge je Gruppe — ein Feld "nav_child_order_" pro Gruppe mit Kindern, Werte in der gewünschten Reihenfolge. -- **Zeile 4167** (srv/tesm/app.py): Import/Export — für Umzug auf eine neue Umgebung. Wählbare Kategorien (Clients, Switche, Zugangsdaten, Benutzer, Custom-Gruppen, LDAP/AD, DHCP, Logs) werden als EIN mit einer selbstgewählten Passphrase verschlüsseltes JSON-Bundle exportiert — unabhängig vom fernet.key dieser Installation, damit die Datei auf jedem anderen Server wieder eingelesen werden kann, der dieselbe Passphrase kennt. Der Import läuft zweistufig: 1) Datei+Passphrase hochladen -> entschlüsseln, Vorschau zeigt NUR die in der Datei tatsächlich enthaltenen Kategorien samt Anzahl. 2) Aus dieser Vorschau gezielt einzelne Kategorien zum Einspielen auswählen (siehe _pending_imports/import_apply) — eine Vollsicherung muss also nicht komplett eingespielt werden. -- **Zeile 4192** (srv/tesm/app.py): Diese Kategorien betreffen Konten/Rechte/Zugangsdaten für den gesamten Betrieb (können u.a. neue Admin-Konten anlegen oder das LDAP-Bind-Konto überschreiben) — Auswahl bleibt echten Admins vorbehalten, unabhängig vom sonst delegierbaren "Im-/Export"-Recht (gleiches Eskalationsschutz-Prinzip wie bei group_id=="admin" an anderer Stelle im Code). - -### `def _export_devices` -- **Zeile 4207** (srv/tesm/app.py): Papierkorb-Einträge werden nie mitexportiert -- ein Restore/Purge ist eine lokale Aufräum-Entscheidung dieser Instanz, kein Bestand, der auf eine andere Umgebung übertragen werden soll. - -### `def _export_users` -- **Zeile 4229** (srv/tesm/app.py): Nur lokale Konten -- AD/LDAP-Konten werden nie exportiert: Konto und Passwort werden über Windows/Active Directory verwaltet, nicht über diese App, und legen sich beim nächsten Login ohnehin automatisch wieder an (siehe login()). - -### `def _export_dhcp` -- **Zeile 4302** (srv/tesm/app.py): unveränderte Standard-Option -- wird auf dem Ziel automatisch angelegt - -### `def _import_devices` -- **Zeile 4328** (srv/tesm/app.py): WHERE deleted_at IS NULL an der DO-UPDATE-Klausel: ein Import darf ein im Papierkorb liegendes Gerät nicht stillschweigend wiederbeleben -- die MAC bleibt dann einfach reserviert, bis es wiederhergestellt oder endgültig gelöscht wird (siehe Papierkorb-Doku). - -### `def _import_users` -- **Zeile 4378** (srv/tesm/app.py): AD/LDAP-Konten mit gleichem Benutzernamen werden nie überschrieben -- deren Konto/Passwort wird über Active Directory/Windows verwaltet, nicht über einen Import hier. -- **Zeile 4389** (srv/tesm/app.py): Im Papierkorb liegender Benutzername bleibt reserviert -- ein Import darf ihn nicht stillschweigend wiederbeleben (siehe Wartung-Seite: erst wiederherstellen oder endgültig löschen). - -### `def _import_groups` -- **Zeile 4420** (srv/tesm/app.py): Systemgruppen (aktuell nur "Benutzer") nie über Import überschreiben, ebenso wenig im Papierkorb liegende Gruppen -- der Name bleibt dann reserviert, bis wiederhergestellt oder endgültig gelöscht wird. - -### `def _import_ldap` -- **Zeile 4451** (srv/tesm/app.py): set_setting() bewusst NICHT verwendet: das öffnet eine EIGENE sqlite3-Verbindung -- import_apply() ruft mehrere IMPORT_APPLIERS nacheinander auf DERSELBEN, bereits offenen "conn" auf und committet erst ganz am Ende. Ist zu diesem Zeitpunkt (z.B. durch eine vorher im selben Import verarbeitete Kategorie) bereits eine Schreib-Transaktion auf "conn" offen, blockiert set_settings()' eigene Verbindung darauf, bis der SQLite-Standard-Timeout zuschlägt -- "sqlite3.OperationalError: database is locked", live in Produktion reproduziert. Direkt auf der übergebenen "conn" schreiben vermeidet die zweite Verbindung komplett. - -### `def _import_dhcp` -- **Zeile 4485** (srv/tesm/app.py): set_setting() bewusst NICHT verwendet -- siehe ausführlicher Kommentar in _import_ldap() (dieselbe "database is locked"-Falle bei mehreren IMPORT_APPLIERS auf derselben offenen Transaktion). -- **Zeile 4496** (srv/tesm/app.py): Subnetze additiv (nie löschen/ersetzen) -- ein bestehendes, unabhängig vom Ziel-Admin konfiguriertes Subnetz soll ein Import nie entfernen. - -### `def _import_logs` -- **Zeile 4550** (srv/tesm/app.py): Entschlüsselte Import-Vorschau zwischen Upload (import_data) und tatsächlichem Einspielen (import_apply) -- analog _pending_network_revert: In-Memory, token-basiert, TTL-begrenzt, damit die Passphrase nicht ein zweites Mal im Browser landen muss, um gezielt einzelne Kategorien auszuwählen. - -### `def export_data` -- **Zeile 4569** (srv/tesm/app.py): Eigenes, von Import getrenntes Recht (settings_importexport.export, eigene X-Spalte in der Gruppentabelle) -- Export enthält entschlüsselte Passwörter im Klartext, daher bewusst nicht an das generische "Ändern"-Recht gekoppelt. Admins haben es über has_permission() automatisch, für andere Gruppen entscheidet ein Admin gezielt. - -### `def import_data` -- **Zeile 4646** (srv/tesm/app.py): Legacy-Exporte (version 1, vor der Sektionsauswahl) hatten Devices/ Switches/Zugangsdaten direkt unter denselben Schlüsseln wie heute -- Struktur ist identisch, daher ohne Migration weiterverwendbar. - -### `def import_apply` -- **Zeile 4724** (srv/tesm/app.py): Devices - -### `def devices` -- **Zeile 4764** (srv/tesm/app.py): SSH-Anbindung optional — die meisten Geräte sind keine per SSH erreichbaren Hosts, nur wer bewusst Zugangsdaten auswählt/anlegt bekommt sie zugeordnet (siehe Wartungsseite). -- **Zeile 4803** (srv/tesm/app.py): --- Nur Switch-Zuordnung ändern (Modal "Switch ändern") --- -- **Zeile 4826** (srv/tesm/app.py): --- Normales Gerät bearbeiten --- -- **Zeile 4868** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes devices.delete-Recht). -- **Zeile 4879** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- siehe Wartung-Seite für Wiederherstellen/endgültiges Löschen. - -### `def toggle_device` -- **Zeile 4915** (srv/tesm/app.py): Aktivieren/Deaktivieren zählt jetzt zu "Ändern" (devices.edit) statt eines eigenen devices.toggle-Rechts. -- **Zeile 4940** (srv/tesm/app.py): Switches - -### `def delete_switch` -- **Zeile 5077** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes switches.delete-Recht). -- **Zeile 5092** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- siehe Wartung-Seite für Wiederherstellen/endgültiges Löschen. -- **Zeile 5106** (srv/tesm/app.py): Zugangsdaten (Credentials) — wiederverwendbare SSH-Logins für Switche, damit nicht jeder Switch sein eigenes Login braucht. Eigene credentials.*- Rechte, unabhängig von switches.* (bewusst entkoppelt). - -### `def credentials` -- **Zeile 5177** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes credentials.delete-Recht). -- **Zeile 5195** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- siehe Wartung-Seite für Wiederherstellen/endgültiges Löschen. -- **Zeile 5221** (srv/tesm/app.py): Wartung (Clients) — SSH-Bulk-Update + Neustart für Linux-Clients. -- **Zeile 5223** (srv/tesm/app.py): Bewusst komplett getrennt vom PoE-Neustart auf dem Dashboard: der Dashboard-Button (devices.restart) löst IMMER einen PoE-Reset über den zugeordneten Switch aus. Die Aktionen hier laufen IMMER per SSH direkt auf dem Gerät selbst -- unabhängig davon, ob/wie es an einen Switch angeschlossen ist. Job-Status lebt bewusst nur im Prozessspeicher (wie das SSH-Terminal auch keine Sitzung persistiert) -- ein Neustart des tesm.service verwirft laufende Job-Anzeigen, nicht aber die zugrundeliegende SSH-Aktion selbst (die läuft serverseitig auf dem Zielgerät unabhängig vom Anzeigezustand hier weiter). -- **Zeile 5235** (srv/tesm/app.py): mac -> {"action": "update"/"reboot", "status": "running"/"success"/"error", "message": str, "output": str, "started": str, "finished": str|None} - -### `def _maintenance_devices` -- **Zeile 5271** (srv/tesm/app.py): Papierkorb — Clients/Switche/Zugangsdaten/Benutzer (nur lokale Konten)/ Custom-Gruppen landen beim "Löschen" nur noch hier (siehe delete_device/ delete_switch/delete_credential/delete_user/delete_group weiter oben), bis sie wiederhergestellt oder endgültig gelöscht werden -- manuell über die Buttons auf der Wartung-Seite, oder automatisch nach Ablauf von trash_retention_days (siehe Systemeinstellungen). AD/LDAP-Benutzer durchlaufen den Papierkorb NICHT (siehe delete_user) -- sie legen sich bei jedem gültigen AD-Login ohnehin automatisch neu an. - -### `def _purge_device` -- **Zeile 5324** (srv/tesm/app.py): Per-Client-DHCP-Options sind an die MAC gekoppelt (kein FK, siehe dhcp_option_values.device_mac) -- werden hier mit aufgeräumt, statt nach einem endgültigen Löschen verwaist stehen zu bleiben. - -### `def _purge_group` -- **Zeile 5347** (srv/tesm/app.py): AD-Gruppenzuordnungen, die auf genau diese Gruppe zeigten, würden sonst dauerhaft ins Leere laufen (siehe _ldap_group_mappings()-Anzeige "(gelöscht)" für den Fall, dass das trotzdem irgendwo übersehen wird). - -### `def maintenance` -- **Zeile 5564** (srv/tesm/app.py): Abgeschlossene ERFOLGREICHE Aktionen werden bei jedem (erneuten) Laden der Seite zurückgesetzt (Status wieder "Noch keine Aktion") -- ein Erfolg ist nur so lange relevant, wie man ihn noch live über das Polling mitverfolgt; ein späterer Aufruf der Seite soll nicht dauerhaft eine ggf. Stunden alte Erfolgsmeldung zeigen. Fehlgeschlagene Aktionen bleiben dagegen bewusst sichtbar, bis eine neue Aktion sie überschreibt -- ein Fehler soll nicht durch einen einfachen Seiten-Reload versehentlich übersehen werden. - -### `def papierkorb` -- **Zeile 5589** (srv/tesm/app.py): Vor dem Laden der Papierkorb-Listen abgelaufene Einträge endgültig aufräumen -- kein eigener Scheduler nötig (analog dem Import-Vorschau- Token-Aufräumen), die Seite wird ohnehin regelmäßig aufgerufen. - -### `def _maintenance_run_update` -- **Zeile 5649** (srv/tesm/app.py): Zeilen kommen live rein statt erst am Ende komplett -- Grundlage für die automatisch mitscrollende Live-Ausgabe im Frontend. Jede Zeile bekommt ihren eigenen Zeitstempel (wann sie tatsächlich ankam, nicht nur Start/Ende des gesamten Updates). Puffer bewusst auf die letzten _MAINTENANCE_OUTPUT_MAX_LINES begrenzt (ganze Zeilen, kein hartes Zeichen-Limit mehr), damit ein sehr langes Update den Prozessspeicher nicht unbegrenzt wachsen lässt. - -### `def on_line` -- **Zeile 5664** (srv/tesm/app.py): overall_timeout=600: ein echtes "apt upgrade" kann bei vielen ausstehenden Paketen mehrere Minuten dauern -- der kurze Standard-Timeout der interaktiven SSH-Aktionen (25s) wäre hier viel zu knapp; der bleibt für den reinen Verbindungsaufbau bestehen. -- **Zeile 5673** (srv/tesm/app.py): Eindeutiger Abschluss-Marker als letzte Zeile -- vorher endete die Ausgabe einfach mitten im rohen apt-Output, ohne erkennbar zu machen, ob das Update tatsächlich fertig oder nur abgeschnitten war. -- **Zeile 5695** (srv/tesm/app.py): Nach einem ausgelösten Neustart blieb der Job-Status bisher für immer auf "Neustart ausgelöst" stehen -- der eigentlich interessante Zeitpunkt (ist das Gerät wieder erreichbar?) wurde nie erfasst. Gleiche Ping-Invocation wie das bestehende Online/Offline-Monitoring in poe.sh ("ping -c 1 -W 2 "), für konsistentes Verhalten mit dem Dashboard. - -### `def maintenance_reboot` -- **Zeile 5818** (srv/tesm/app.py): Browser-SSH-Terminal — Verbindungstest beim Anlegen/Bearbeiten von Switchen. -- **Zeile 5820** (srv/tesm/app.py): Öffnet eine echte interaktive SSH-Sitzung im Browser, damit Host-Keys beim Erstkontakt bestätigt und Zugangsdaten geprüft werden können, bevor der Switch überhaupt gespeichert ist. Implementiert mit paramiko (reines Python) statt eines lokalen pty + System-`ssh`-Client — das läuft auf jedem Betriebssystem (Windows, Linux, macOS), auf dem die Flask-App läuft, und die SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut, niemals vom Browser/Client aus. Nur für eingeloggte Admins. - -### `def _send_and_close` -- **Zeile 5845** (srv/tesm/app.py): Client ist schon weg — nichts mehr zuzustellen, kein Fehlerfall. - -### `def _terminal_read_line` -- **Zeile 5881** (srv/tesm/app.py): Strg+C - -### `def _authenticate_interactive` -- **Zeile 5955** (srv/tesm/app.py): "password" bewusst VOR "keyboard-interactive" versuchen, auch wenn der Server beides anbietet: viele Switches/APs (u.a. diverse ArubaOS- Varianten) bieten zwar "keyboard-interactive" an, deren Server-seitige Umsetzung ist aber teils nicht robust — z. B. eine erste Runde ganz ohne Prompts (laut RFC 4256 gültig, aber unsichtbar für den Nutzer), gefolgt von sofortigem Scheitern statt einer zweiten Runde mit dem eigentlichen Passwort-Prompt. Das erklärt exakt das beobachtete Symptom: im Terminal erscheint NIE ein Prompt, nur direkt "Zugangs- daten falsch". "password" ist der einfachere, zuverlässiger unterstützte Pfad (Ende-zu-Ende gegen einen echten Host verifiziert) und wird deshalb bevorzugt, wenn der Server ihn überhaupt anbietet. - -### `def handler` -- **Zeile 5975** (srv/tesm/app.py): title/instructions zusätzlich anzeigen: manche Geräte legen den eigentlichen Hinweistext ("Password:") dort statt in prompt_list ab. Leere prompt_text-Strings (ebenfalls bei manchen Geräten üblich) bekommen einen sinnvollen Fallback, damit dem Nutzer nie eine Eingabeaufforderung ganz ohne sichtbaren Text präsentiert wird. - -### `def ssh_terminal` -- **Zeile 6001** (srv/tesm/app.py): Fängt unerwartete Fehler ab und loggt sie, statt sie in flask-sock's Verbindungs-Handling verschwinden zu lassen. - -### `def _ssh_terminal_inner` -- **Zeile 6032** (srv/tesm/app.py): Bewusst die Low-Level-Transport-API statt SSHClient.connect(): eine Passwortabfrage MITTEN im Verbindungsaufbau (nach Host-Key-Bestätigung, vor der eigentlichen Shell) ist mit der High-Level-API nicht möglich, da connect() Host-Key-Prüfung UND Authentifizierung in einem blockierenden Aufruf erledigt. Vorher führte das dazu, dass nie ein Passwort abgefragt wurde und jeder Login mit "No authentication methods available" scheiterte, sobald kein Passwort im initialen Payload mitgeschickt wurde (was nie der Fall war/ist). - -### `def pump_output` -- **Zeile 6140** (srv/tesm/app.py): Wartung: nicht-interaktive SSH-Kommandos (Bulk-Update, Neustart) -- **Zeile 6142** (srv/tesm/app.py): Bewusst getrennt von _ssh_terminal_inner() oben: dort kann bei einem unbekannten Host-Key interaktiv "yes/no" nachgefragt werden (wie ein echtes ssh-CLI). Hier läuft nichts vor einem Menschen ab (Bulk-Aktion über mehrere Geräte per Thread) -- ein unbekannter/nicht vorher bestätigter Host-Key wird deshalb hart abgelehnt (RejectPolicy) statt automatisch akzeptiert (AutoAddPolicy wäre hier ein MITM-Risiko ohne jede Prüfung). Ein Host muss also immer zuerst einmal über "Verbindung testen" (das interaktive Terminal) bestätigt worden sein, bevor Wartungsaktionen für ihn möglich sind -- beide Wege teilen sich dieselbe SSH_KNOWN_HOSTS_PATH. -- **Zeile 6153** (srv/tesm/app.py): Jeder Schritt einzeln benannt (statt nur mit "&&" verkettet), damit nach jedem Schritt ein eigener "Schritt N -> erfolgreich/fehlgeschlagen"-Marker ins Live-Log geschrieben werden kann (siehe _apt_upgrade_command()) -- apt/apt-get statt der literal vorgegebenen "apt"-Aufrufe, weil apt-get eine stabile, für Skripte gedachte Schnittstelle hat (die apt(8)-Manpage warnt ausdrücklich: "the apt command is intended to be pleasant for end users and does not need to be backward compatible" -- also nichts für Automatisierung). - -### `def drain` -- **Zeile 6311** (srv/tesm/app.py): Ein letzter, nicht mit "\n" abgeschlossener Rest zählt noch als Zeile (apt beendet die letzte Ausgabezeile nicht immer mit Zeilenumbruch). - -### `def _run_ssh_reboot` -- **Zeile 6442** (srv/tesm/app.py): Verbindungsabbruch durch den Neustart selbst ist erwartet -- **Zeile 6454** (srv/tesm/app.py): Logs - -### `def get_log` -- **Zeile 6470** (srv/tesm/app.py): Dateiname als Header mitschicken, damit die Anzeige auch nach einem Service-Neustart (neues Logfile, z.B. durch "Jetzt prüfen") ohne Seiten-Reload aktuell bleibt. - -### `def activity_log` -- **Zeile 6562** (srv/tesm/app.py): Manueller PoE-Neustart - -### `def restart_device` -- **Zeile 6583** (srv/tesm/app.py): Ohne zugewiesenen Switch + Port kann poe.sh keinen PoE-Reset auslösen (siehe poe.sh: der SSH-Restart-Block wird dort übersprungen) — dann lieber gar nicht erst einen "Neustart gestartet"-Erfolg vorgaukeln. -- **Zeile 6602** (srv/tesm/app.py): Users - -### `def users` -- **Zeile 6625** (srv/tesm/app.py): Die Gruppen-Auswahl entscheidet auch über die Rolle: Auswahl "admin" macht den Benutzer zum Admin, jede andere Auswahl ist eine normale Gruppe (oder keine). Nur echte Admins dürfen jemanden direkt als Admin anlegen (Eskalationsschutz) — delegierte "Benutzer anlegen"-Rechte reichen dafür nicht. -- **Zeile 6662** (srv/tesm/app.py): Nur Stammdaten + optional Passwort — Gruppe/Rolle wird ausschließlich über "Gruppe zuweisen" geändert (s.u.). -- **Zeile 6665** (srv/tesm/app.py): Admin-Konten dürfen nur von echten Admins bearbeitet werden (u.a. Passwort-Reset) — verhindert, dass ein delegiertes "Benutzer bearbeiten"-Recht zur Übernahme eines Admin-Kontos missbraucht wird. -- **Zeile 6676** (srv/tesm/app.py): AD/LDAP-Konten werden vollständig über AD verwaltet (Name, E-Mail/UPN, Passwort) — hier bearbeiten würde nur mit dem nächsten Login wieder überschrieben werden. Einzig zulässige Aktion für solche Konten ist Sperren/Entsperren und die Gruppenzuweisung (siehe toggle_lock/assign_group). -- **Zeile 6718** (srv/tesm/app.py): Sowohl das Zuweisen ZUR Admin-Gruppe als auch das Ändern der Gruppe eines BESTEHENDEN Admins bleibt echten Admins vorbehalten (Eskalationsschutz) — ein delegiertes "Gruppe zuweisen"-Recht darf niemanden zum Admin machen oder einem Admin die Rechte entziehen. -- **Zeile 6730** (srv/tesm/app.py): Mindestens ein Admin muss bestehen bleiben. -- **Zeile 6755** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes users.delete-Recht). -- **Zeile 6764** (srv/tesm/app.py): Admin-Konten dürfen nur von echten Admins gelöscht werden. -- **Zeile 6770** (srv/tesm/app.py): AD/LDAP-Konten durchlaufen den Papierkorb nicht -- sie sind nur ein Schatten-Eintrag, der sich bei jedem gültigen AD-Login ohnehin automatisch neu anlegt. Ein "wiederherzu- stellender" Papierkorb-Eintrag wäre hier ohne Nutzen. -- **Zeile 6780** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- Mitgliedschaften bleiben dabei bewusst erhalten (siehe Wartung-Seite: Wiederherstellen soll denselben Stand wiederherstellen wie vor dem Löschen). -- **Zeile 6792** (srv/tesm/app.py): Einzige Möglichkeit, einem AD/LDAP-Konto den Zugriff zu entziehen, ohne es in AD selbst zu sperren -- funktioniert aber genauso für lokale Konten. -- **Zeile 6827** (srv/tesm/app.py): Vorab-Zuweisung: ein AD-Benutzer wird gesucht (siehe /users/ldap_search) und hier -- noch VOR seinem ersten Login -- mit einer Gruppe angelegt. Ruft _ldap_lookup erneut auf statt den übermittelten Anzeigenamen zu vertrauen, damit Name/E-Mail garantiert frisch aus AD stammen. - -### `def users_ldap_search` -- **Zeile 6902** (srv/tesm/app.py): Nur LEBENDE Konten ausblenden -- ein im Papierkorb liegender Name soll weiterhin gefunden werden (führt beim Hinzufügen zu einer klaren Fehlermeldung statt stillschweigend zu fehlen, siehe ldap_add_user). -- **Zeile 6914** (srv/tesm/app.py): Gruppen (Rechtesystem) — nur Admins dürfen Gruppen und deren Berechtigungen/Mitgliedschaften verwalten. - -### `def groups` -- **Zeile 6936** (srv/tesm/app.py): Rechte direkt beim Anlegen setzbar (dieselbe Tabelle wie beim Bearbeiten, hier ohne eigenes "_submitted"-Flag, da eine neue Gruppe ohnehin noch keine Rechte hat). -- **Zeile 6966** (srv/tesm/app.py): Systemgruppen (aktuell nur die Standardgruppe 'Benutzer') sind standardmäßig in Name und Rechten fix. Ein Admin kann die Rechte über den expliziten "Freischalten"-Button in der UI trotzdem ändern (unlock_system_group=1) -- der Name bleibt IMMER fix, da DEFAULT_GROUP_NAME an anderer Stelle im Code über den Namen nach dieser Gruppe sucht (Umbenennen würde diese Lookups brechen). Mitgliedschaft (members_submitted) war schon immer frei verwaltbar. -- **Zeile 6988** (srv/tesm/app.py): Die Gruppenkarte (Name + Rechte) und das Mitglieder-Modal sind zwei getrennte Formulare, damit das Speichern des einen nicht versehentlich den Stand des anderen zurücksetzt. Die verstecken *_submitted-Felder markieren, welcher Teil tatsächlich abgeschickt wurde (leere Checkbox-Listen wären sonst nicht von "nichts ausgewählt" zu unterscheiden). -- **Zeile 7025** (srv/tesm/app.py): Bleibt bewusst admin-exklusiv, unabhängig von groups.edit — wer Admin ist/wird, darf nicht über ein delegierbares Recht gesteuert werden können. -- **Zeile 7040** (srv/tesm/app.py): Admins brauchen keine Gruppenrechte mehr (sie dürfen ohnehin alles) — Mitgliedschaften aufräumen, damit sie nicht doppelt in Admin- und z.B. Standardgruppe auftauchen. -- **Zeile 7052** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes groups.delete-Recht). -- **Zeile 7063** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- Rechte/Mitgliedschaften bleiben dabei bewusst erhalten (siehe Wartung-Seite: Wiederherstellen soll denselben Stand wie vorher liefern). -- **Zeile 7106** (srv/tesm/app.py): Virtuelle "Admin"-Gruppe: rein informativ, damit auf einen Blick sichtbar ist, wer alles darf — Mitgliedschaft/Rechte werden weiterhin ausschließlich über den is_admin-Schalter auf der Users-Seite gesteuert, hier gibt es daher bewusst kein Formular. -- **Zeile 7138** (srv/tesm/app.py): threaded=True ist für das SSH-Web-Terminal (WebSocket) notwendig: die Verbindung hält den Thread offen, ohne Threading würde der einzelne Worker der Dev-Umgebung blockieren und WebSocket-Frames verstümmeln. - ---- - -## create_db.py - -### Modulebene -- **Zeile 23** (srv/tesm/create_db.py): Zugangsdaten (wiederverwendbare SSH-Logins, mehrere Switche können sich dieselben teilen; Passwort wird verschlüsselt gespeichert) -- **Zeile 36** (srv/tesm/create_db.py): Switches (Aruba-Switche) — referenzieren ihre Zugangsdaten statt sie direkt zu speichern -- **Zeile 51** (srv/tesm/create_db.py): Devices -- **Zeile 69** (srv/tesm/create_db.py): Benutzer -- **Zeile 86** (srv/tesm/create_db.py): Gruppen (Rechtesystem) — Admins besitzen ohnehin alle Rechte; Gruppen dienen dazu, normalen Benutzern gezielt einzelne Verwaltungsrechte für Devices/Switches zu geben, ohne sie zu Admins zu machen. -- **Zeile 99** (srv/tesm/create_db.py): Welche Berechtigungen eine Gruppe gewährt (siehe app.py: PERMISSIONS) -- **Zeile 109** (srv/tesm/create_db.py): Gruppenmitgliedschaft (ein Benutzer kann in mehreren Gruppen sein; effektive Rechte = Vereinigung aller Gruppen) -- **Zeile 121** (srv/tesm/create_db.py): Settings (z.B. Prüfintervall) -- **Zeile 129** (srv/tesm/create_db.py): DHCP: eigene Options-Definitionen (analog "Vordefinierte Optionen und Werte" bei einem Windows-DHCP-Server) — Code/Name/Typ/Beschreibung. is_standard markiert die vorbefüllten Standard-Optionen (Router/DNS/…) -- die können vom Admin nicht gelöscht werden, nur ihr Wert geleert (die tatsächliche Vorbefüllung übernimmt app.py: _ensure_schema()). -- **Zeile 145** (srv/tesm/create_db.py): DHCP: Werte je Option — device_mac='' bedeutet global (gilt für den ganzen Scope), ein gesetzter device_mac überschreibt den globalen Wert für genau dieses Gerät. Bewusst '' statt NULL für den globalen Fall, da SQLite NULL in UNIQUE-Constraints nicht als gleich behandelt (zwei NULL-Zeilen wären sonst kein Konflikt) — mit '' funktioniert ON CONFLICT zuverlässig für den Upsert des globalen Werts. -- **Zeile 162** (srv/tesm/create_db.py): DHCP: konfigurierte Subnetze (Interface + Range + optionales Gateway/DNS) -- ein Host kann mehrere Subnetze gleichzeitig bedienen, je eines pro Interface/Netz. sort_order steuert die Anzeige-/Verarbeitungsreihenfolge, enabled erlaubt ein Subnetz vorübergehend aus der geschriebenen Konfiguration auszuschließen, ohne es zu löschen. -- **Zeile 180** (srv/tesm/create_db.py): DHCP: manuelle Reservierungen für Geräte OHNE eigenen Client-Datensatz (Geräte mit MAC+IP in der devices-Tabelle bekommen ihre Reservierung automatisch, siehe _dhcp_reservation_candidates in app.py). -- **Zeile 192** (srv/tesm/create_db.py): Service-Accounts (aktuell nur das LDAP-Bind-Konto) -- bewusst NICHT in der users-Tabelle (kein echter, einloggbarer App-Benutzer) und NICHT in der credentials-Tabelle (die ist für Switch-/Geräte-Zugangsdaten, ein anderer Anwendungsfall) -- eine eigene, kleine Tabelle. "purpose" ist UNIQUE, ein neu gespeichertes Konto für denselben Zweck ersetzt das alte. -- **Zeile 206** (srv/tesm/create_db.py): AD-Gruppe -> App-Rechtegruppe: mehrere AD-Gruppen können jeweils einer App-Gruppe zugeordnet werden (additiv, siehe _ldap_resolve_app_groups in app.py) -- passt keine dieser Gruppen, greift die Standardgruppe. -- **Zeile 219** (srv/tesm/create_db.py): Änderungslog (Audit-Log) — wer hat wann was geändert. Bewusst OHNE PoE-Neustarts (die stehen im Live-Log von poe.sh). -- **Zeile 232** (srv/tesm/create_db.py): Standard-Setting: Prüfintervall 5 Minuten (interval = Minuten, check_interval = Sekunden) -- **Zeile 236** (srv/tesm/create_db.py): Standardgruppe 'Benutzer' — Systemgruppe (is_system), Name und Rechte sind über die UI fest und nicht änderbar. Jeder neu angelegte (nicht-admin) Benutzer landet automatisch darin (siehe app.py: users()). Der genaue Rechtesatz muss mit DEFAULT_GROUP_PERMISSIONS in app.py übereinstimmen. - ---- - -## poe.sh - -- **Kopfbereich**: PoE Device Check Script -- prueft Erreichbarkeit von Geraeten, startet PoE-Port bei Ausfall neu, loggt Ereignisse. -- **LOG_DIR/LOGFILE**: Eine einzige, durchgehend fortgeschriebene Datei statt vorher bei jedem (auch taeglich per tesm-check-restart.timer automatisch ausgeloesten) Dienst-Neustart ein neues rpi-.log -- Rotation/Aufbewahrung uebernimmt jetzt zentral logrotate (siehe app.py: _write_logrotate_config, einstellbar unter Systemeinstellungen -> Logs), nicht mehr dieses Skript selbst. Pfad muss mit TESM_LOG_DIR/live.log in app.py uebereinstimmen. -- **SLEEP**: Intervall aus DB abrufen. -- **disable_poe()/enable_poe()**: bewusst OHNE expliziten "timeout { exit 1 }"/"eof { exit 1 }"-Abbruch in den einzelnen expect-Schritten -- ein nicht matchender Schritt laesst expect nach Ablauf von "set timeout 5" einfach zur naechsten Zeile weiterlaufen, statt abzubrechen. Das ist absichtlich so belassen: ein Versuch, hier "echte" Fehlererkennung nachzuruesten (Exit-Code 0 nur bei bestaetigtem Erfolg je Schritt), hat sich live als Regression herausgestellt. Grund: das Login-Prompt-Pattern "-re \".*> $\"" matcht nur ein Operator-Prompt ("switchname>"). Auf ArubaOS-Switches, bei denen der SSH-Account (z.B. "admin") mit Manager-Rechten direkt im privilegierten Modus landet ("switchname#" statt "switchname>", bestaetigt an einem 2930M/"ST-2000-Sued"), matcht das Pattern nie -- mit einem harten Timeout-Abbruch waere das faelschlich als Fehlschlag gewertet worden, obwohl Passwort und Verbindung in Ordnung waren. Diese Funktionen sind daher exakt auf den Stand der urspruenglichen, in Produktion bewaehrten Fassung zurueckgesetzt. -- **check_device()**: Ein Geraete-Check (Ping + ggf. PoE-Neustart) als eigene Funktion, damit sie sowohl vom manuellen Neustart (einzeln, synchron) als auch von der Hauptschleife (parallel im Hintergrund) verwendet werden kann. Nur PoE neu starten, wenn dem Geraet auch tatsaechlich ein Switch zugeordnet ist (switch_ip) -- ein reiner Port-Wert ohne Switch (switch_ip leer) darf keinen SSH-Versuch mit leeren Zugangsdaten ausloesen. -- **Hauptschleife**: Jedes Geraet wird als eigener Hintergrund-Job geprueft, statt strikt nacheinander in einer einzigen Schleife -- bei einer groesseren Geraeteliste (z.B. nach einem Import) haette ein einzelnes bereits ausgefallenes Geraet mit langsamem/nicht erreichbarem Switch (jeder expect-Versuch bis zu ~5s Timeout je Verbindungsschritt) sonst die Pruefung ALLER nachfolgenden Geraete verzoegert. "< <(...)" (Process Substitution) statt einer Pipe, damit die Hintergrund-Jobs Kinder dieser Shell bleiben und "wait" sie unten tatsaechlich erfassen kann (bei "cmd | while ...; done" liefe die Schleife in einer eigenen Subshell, deren Hintergrund-Jobs von aussen nicht mehr sichtbar waeren). Auf alle in diesem Durchlauf gestarteten Geraete-Checks warten, bevor das Intervall abgewartet wird -- sonst koennten sich Durchlaeufe ueberlappen (z.B. mehrere parallele expect-Sitzungen zum selben Switch aus zwei verschiedenen, noch laufenden Zyklen gleichzeitig). - ---- - -## _dashboard_tiles.html - -### Jinja-/HTML-Kommentare -- Live-Update-fähiges Fragment: wird von index.html initial eingebunden UND unverändert von /dashboard/tiles (AJAX) nachgeladen, damit das Dashboard ohne vollen Seiten-Reload aktuell bleibt (siehe dashboard-tiles.js im scripts-Block von index.html). - ---- - -## credentials.html - -### Jinja-/HTML-Kommentare -- Modal: Neue Zugangsdaten -- Modal: Zugangsdaten bearbeiten - ---- - -## devices.html - -### Jinja-/HTML-Kommentare -- Modal: Neues Gerät -- Modal: Bearbeiten -- Modal: Switch ändern -- Modal: SSH-Verbindungstest (echtes Terminal, zum Akzeptieren von Host-Keys und Prüfen der Zugangsdaten, bevor das Gerät gespeichert wird) - -### JavaScript-Kommentare -- Beim Öffnen sicherstellen, dass die "Neue Zugangsdaten"-Felder passend zur aktuellen Auswahl ein-/ausgeblendet sind (relevant v.a. nach vorherigem Umschalten auf "neu" ohne zu speichern). -- SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal) — identisch zum Identisches Muster wie in switches.html. - ---- - -## groups.html - -### Jinja-/HTML-Kommentare -- Rendert einen Bereich (Geräte/Logs/Einstellungen) als kompakte, schmale Spalte: eigene Kopfzeile mit Bereichsname + einem Kästchen (der Kill-Switch, "Bereich sichtbar"), darunter eine kleine Tabelle mit den Unterpunkten (Clients, Switche, ...) als Zeilen und nur den für diesen Bereich tatsächlich genutzten Rechten (group_row_types[group_key]) als Spalten — Logs/Einstellungen zeigen z.B. nur R/W/E, nur Geräte zeigt zusätzlich D (PoE-Neustart über Dashboard, ausschließlich bei Clients). "Ändern" (E) deckt Löschen überall mit ab — es gibt nirgends ein eigenes Löschen-Recht. Zellen ohne passendes Recht für die jeweilige Zeile (z.B. "D" bei Switche/Zugangsdaten) werden als ausgegraute, nicht anklickbare Checkbox dargestellt statt zu fehlen — dadurch bleiben alle Spalten bündig untereinander. readonly=true zeigt nur den aktuellen Stand (Admin/Systemgruppen bzw. fehlendes groups.edit). Die JS-Funktion applyPermissionGating() sperrt Kind-Rechte, solange das Bereich-Lesen (Kill-Switch) nicht gesetzt ist. Mehrere Bereiche stehen per Flexbox nebeneinander (siehe .permission-groups-row), damit die ganze Rechteübersicht einer Gruppe in einer kompakten Zeile Platz hat. -- compact=true (Gruppen-Dropdown auf der Hauptseite) verkleinert Zellen- Padding/Abstände gegenüber dem "Neue Gruppe"-Modal (compact=false, bleibt unverändert) — gleicher Aufbau, nur enger, da im Dropdown mehr Breite zur Verfügung steht als im schmalen Modal und es sonst auseinandergezogen wirkt. -- Virtuelle "Admin"-Gruppe: Rechte sind fix (alles), Mitgliedschaft wird direkt über is_admin gesteuert. Bleibt beim Sortieren immer oben (eigenes ). -- Jede Gruppe (Haupt- + Detail-Zeile) in einem eigenen , damit die Sortierung beide Zeilen gemeinsam verschiebt. -- Modal: Admin-Mitglieder verwalten -- Modals: Mitglieder pro Gruppe verwalten -- Modal: Neue Gruppe — Rechte direkt beim Anlegen setzbar, Mitglieder werden danach über die Gruppentabelle zugeordnet (die Gruppe muss dafür erst existieren). - -### JavaScript-Kommentare -- Filtert ganze -Blöcke (Haupt- + Detail-Zeile je Gruppe gemeinsam, analog zur Sortierung) statt einzelner -- die gepinnte Admin-Gruppe (data-sort-pinned) bleibt dabei immer sichtbar, unabhängig vom Suchbegriff. Freischalten der Systemgruppe "Benutzer": zeigt statt der Nur-Lese-Ansicht das editierbare Formular (inkl. unlock_system_group=1) -- serverseitig erneut geprüft (admin + Flag), das hier ist nur die UI-Bestätigung. Kind-Rechte einer Kategorie sind erst vergebbar, wenn das übergeordnete "Bereich anzeigen"-Recht (Kill-Switch, erste Spalte/L-Zeile) gesetzt ist — spiegelt serverseitig User.has_permission() (PERMISSION_PARENT_GROUP). Bearbeitbare Tabellen bekommen einen Live-Listener, Nur-Lese-Tabellen (Admin/Systemgruppen) bleiben unangetastet, deren Checkboxen sind ohnehin alle disabled. Zusätzlich zum disabled-Attribut (das der Browser nur dezent abblendet) sichtbar ausgrauen, wie gefordert — sonst fällt "Bereich gesperrt" auf den ersten Blick kaum auf. R (erste Spalte/"Lesen") ist je Zeile Voraussetzung für W/E/D/X -- ein Recht ohne R wäre sonst über die UI nicht erreichbar (z.B. Nav-Link/Seite bleibt unsichtbar, obwohl das Kind-Recht technisch gesetzt ist). Abwahl von R nimmt deshalb automatisch auch die anderen Spalten dieser Zeile mit, Anwahl von W/E/D/X wählt automatisch R mit an. - ---- - -## index.html - -### Jinja-/HTML-Kommentare -- Device detail modal -- Schließen bewusst als letztes Element im DOM: modal-footer nutzt justify-content:flex-end, damit landet der zuletzt gerenderte Button ganz rechts — Schließen soll unabhängig davon, welche Aktions-Buttons gerade sichtbar sind, immer rechts stehen. -- Restart confirm modal - -### JavaScript-Kommentare -- Status-Filter über die Stat-Kacheln (Online/Offline/Deaktiviert/ Gesamt) — null = kein Filter aktiv ("Gesamt"). Zusammen mit dem Suchfilter kombiniert in applyFilters(), damit beide gleichzeitig greifen können (z.B. "offline" + Namenssuche). Welche Abschnitte (per data-status) manuell eingeklappt wurden — per Klick auf den Abschnittstitel, unabhängig vom Status-Filter. Bleibt über Live-Updates hinweg erhalten (siehe applyCollapseState()). Kombinierter Filter über alle Abschnitte hinweg: ein Abschnitt wird komplett ausgeblendet, wenn er nicht zum Status-Filter passt ODER keine seiner Kacheln mehr zur Namenssuche passt. Wird nach jedem Live-Update erneut angewendet, damit aktive Filter erhalten bleiben (die alten DOM-Knoten werden beim Austausch verworfen). Aktive Stat-Kachel synchron halten — läuft hier statt nur im Klick-Handler, damit der Zustand auch nach einem Live-Update (frische, neu eingefügte Stat-Kacheln) sofort wieder stimmt. Ein-/Ausgeklappt-Zustand je Abschnitt anhand von collapsedSections anwenden — separat von der Sichtbarkeit oben: "hidden" blendet den ganzen Abschnitt (inkl. Titel) aus, "collapsed" versteckt nur das Kachel-Raster darunter, der Titel bleibt klickbar sichtbar. Stat-Kacheln (Online/Offline/Deaktiviert/Gesamt): Klick filtert das Dashboard auf genau diesen Status; erneuter Klick auf die bereits aktive Kachel (oder auf "Gesamt") hebt den Filter wieder auf. Abschnittstitel (z.B. "Offline"): Klick klappt nur diesen Abschnitt ein/aus, unabhängig vom Status-Filter oben. Klick-Verhalten der Kacheln (Detail-Modal, Neustart/Aktivieren) — wird nach jedem Live-Update erneut auf die frisch eingefügten Kacheln angewendet, da deren alte DOM-Knoten beim Austausch verworfen werden. Ohne zugewiesenen Switch + Port kann poe.sh keinen PoE-Reset auslösen (siehe restart_device()/poe.sh) — der Button wird für diese Geräte komplett ausgeblendet statt nur ausgegraut, damit kein Neustart vorgegaukelt wird, der tatsächlich nichts bewirkt. Live-Update der Dashboard-Kacheln ohne vollen Seiten-Reload: tauscht nur den #dashboard-tiles-Container gegen das AJAX-Partial aus (siehe /dashboard/tiles) und hält dabei Suchfilter, Scroll-Position und Sidebar-Zustand unangetastet. Läuft für an- und abgemeldete Ansicht. + kleiner Puffer, bis poe.sh die neue Zeile tatsächlich geschrieben hat. Globalen Topbar-Timer mit dem tatsächlichen Zeitpunkt dieses Durchlaufs synchronisieren (initCheckTimer() in app.js liest das Attribut bei jedem Tick neu ein). - ---- - -## maintenance.html - -### Jinja-/HTML-Kommentare -- Bewusst KEIN
hier: das säße dann innerhalb des äußeren #updateForm (verschachtelte -Elemente sind ungültiges HTML) -- der Browser hätte das äußere Formular an dieser Stelle vorzeitig geschlossen bzw. dessen Inhalt zerrissen. "Update starten" hat dadurch live IMMER zusätzlich das erste/oberste Gerät mitgeschickt, auch ganz ohne angehakte Checkbox. Das eigentliche für den Neustart steht daher unten AUSSERHALB von #updateForm; der Button hier referenziert es nur über "form=". -- Ein eigenständiges, NICHT verschachteltes je Gerät für den einzelnen "Neustart"-Button (siehe Kommentar oben in der Tabelle) -- jedes referenziert nur genau eine MAC, unabhängig vom Checkbox-Zustand im großen #updateForm. - -### JavaScript-Kommentare -- Checkboxen beim Laden IMMER zurücksetzen: Browser stellen den Checked-Zustand von Formularen nach einem Reload/Redirect (z.B. genau der Redirect nach "Update starten") gerne aus dem eigenen Verlauf wieder her, unabhängig davon, dass das frisch gerenderte HTML gar kein "checked" enthält -- ein einmal angehaktes Gerät (z.B. beim ersten Ausprobieren) blieb dadurch bei jedem weiteren Aufruf der Seite unsichtbar mitausgewählt und lief bei "Update starten" immer zusätzlich mit, ganz gleich was man gerade neu ankreuzt (live genau so reproduziert). Ausgabe-Zeile bleibt ausschließlich über toggleJobOutput() gesteuert, sonst könnte ein zufälliger Text-Treffer darin sie unabhängig vom dazugehörigen Geräte-Namen ein-/ausblenden. "Klebt" am unteren Rand, solange man nicht selbst nach oben gescrollt hat -- reine Update-Ausgabe kann bei vielen Paketen sehr lang werden, ohne das würde man sie sonst manuell nachziehen müssen, um live mitzulesen. Ein kleiner Schwellwert (20px) toleriert, dass "ganz unten" durch Rundungsfehler beim Scrollen selten exakt 0 ist. - ---- - -## settings_dhcp.html - -### Jinja-/HTML-Kommentare -- Modals außerhalb der Tabelle: ein
direkt in ist ungültiges HTML und wird vom Browser aus der Tabelle heraus "foster-parented" — dabei kann die Eltern-Kind-Beziehung zwischen Formular und Feldern zerrissen werden. -- Neu ausgewählte Felder landen hier direkt unter dem Dropdown (siehe poeShowOptionField in app.js), statt irgendwo in der u.U. langen Liste unten sichtbar zu werden. Bereits gesetzte Overrides stehen von Anfang an hier drin. -- Bearbeiten-Modals außerhalb der Karte/Tabelle (siehe Kommentar oben bei den Reservierungen — dieselbe HTML-Validität-Begründung). - ---- - -## switches.html - -### Jinja-/HTML-Kommentare -- Bearbeiten-Modals außerhalb der Tabelle: ein
direkt in ist ungültiges HTML — Browser "foster-parenten" es dann aus der Tabelle heraus und zerreißen dabei mitunter die Eltern-Kind-Beziehung zwischen Formular und Buttons (this.closest('form') lieferte dadurch null, "Verbindung testen" öffnete kein Terminal mehr). -- Modal: Neuer Switch -- Modal: SSH-Verbindungstest (echtes Terminal, zum Akzeptieren von Host-Keys und Prüfen der Zugangsdaten, bevor der Switch gespeichert wird) - -### JavaScript-Kommentare -- Zugangsdaten-Auswahl: "+ Neue Zugangsdaten anlegen" blendet die Felder ein -- Beim Öffnen sicherstellen, dass die "Neue Zugangsdaten"-Felder passend zur aktuellen Auswahl ein-/ausgeblendet sind (relevant v.a. nach vorherigem Umschalten auf "neu" ohne zu speichern). -- SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal) -- Bewusst kein Toast bei "error": manche Browser melden beim Schließen einer WebSocket-Verbindung ein error-Event, obwohl die Sitzung inhaltlich erfolgreich war. Terminal-Inhalt + Status-Pill genügen als Feedback — der Verbindungsstatus wird zuverlässig über onclose gepflegt. Verbindung auch beim Schließen per Klick auf Backdrop / Escape sauber trennen - ---- - -## users.html - -### Jinja-/HTML-Kommentare -- Modal: Neuer Benutzer -- Modal: Benutzer bearbeiten (nur lokale Konten -- AD/LDAP-Konten lassen sich nur sperren/entsperren und einer Gruppe zuweisen, siehe Tabelle) -- Modal: Aus Active Directory hinzufügen (Vorab-Zuweisung vor dem ersten Login des AD-Benutzers) -- Modal: Gruppe zuweisen (inkl. Admin als Auswahl) - ---- - -## logs.html - -### JavaScript-Kommentare -- Neustart-bezogene Zeilen (manueller Neustart, automatischer PoE-Restart bei Ausfall) einheitlich orange markieren. Dateiname mit anzeigen — ändert sich nach einem Service-Neustart (neues Logfile, z.B. durch "Jetzt prüfen") ohne Seiten-Reload. - ---- - -## logs_kea.html - -### JavaScript-Kommentare -- Kea-Zeilenformat: " [logger/pid.tid] MESSAGE_ID text". Grobe Einfärbung nach Schweregrad, ohne die Logik des Live-Logs (online/offline) hier künstlich nachzubilden. - ---- - -## settings.html - -### JavaScript-Kommentare -- Lädt beim Umschalten des Interfaces (Netzwerkeinstellungen) dessen tatsächlichen Ist-Zustand nach -- ohne das würden die Felder weiter die Werte des zuvor angezeigten Interfaces zeigen, obwohl gerade ein anderes ausgewählt wurde (bei mehreren Interfaces leicht zu Verwechslungen geführt, siehe /settings/network_state). diff --git a/README.md b/README.md index f197aa4..eca36c0 100644 --- a/README.md +++ b/README.md @@ -1,1216 +1,176 @@ -# TESM (TimEShepManager) +# TESM -Webbasierte Verwaltung und Monitoring von PoE-Devices und Aruba-Switchen — -funktionales Rebuild von [Aruba-PoE](https://gitea.int.eertmoed.net/WiS/Aruba-PoE) -mit komplett neuem, modernem Frontend und einem granularen Rechtesystem. +Verwaltung von PoE-Geraeten und Aruba-Switchen: Inventar, Zustandsueberwachung, +Portsteuerung, Neustart (PoE, SSH, RPC), DHCP ueber Kea, Dateifreigaben, +Wartung ueber SSH, Protokolle, Papierkorb, verschluesselte Ein- und Ausfuhr und +ein SSH-Terminal im Browser. -**Das Projekt ist ausschließlich für Linux ausgelegt** (Zielsystem: eine -Linux-VM). Entwickelt wird unter WSL2 (Ubuntu) unter Windows — siehe -[Entwicklung unter WSL](#entwicklung-unter-wsl) weiter unten. +Der Lizenzserver liegt in einem eigenen Repository: +[alientim/tesm-license](https://gitea.int.eertmoed.net/alientim/tesm-license). +Beide teilen sich den Kern (`packages/tesm-core`) und das Lizenzprotokoll +(`packages/tesm-licensing`); diese Pakete sind hier mit enthalten, damit sich +das Repository allein installieren laesst. -Die App ermöglicht: +## Zweige -- **Öffentliches Kachel-Dashboard ohne Login** — zeigt nur aktive (nicht - deaktivierte) Geräte, nur Online/Offline/Gesamt-Statistik; volle Verwaltung, - Aktivieren/Deaktivieren und PoE-Neustart bleiben hinter dem Login. Eingeloggt - sind zusätzlich deaktivierte Geräte sichtbar (eigene Kachel-Sektion). Alle - Kacheln sind durchsuchbar und in drei Abschnitten sortiert: Offline, Online, - Deaktiviert — jeweils alphabetisch. Die vier Stat-Kacheln oben (Online/ - Offline/Deaktiviert/Gesamt) sind klickbare Filter — ein Klick blendet alle - anderen Abschnitte aus, „Gesamt“ oder ein erneuter Klick auf die aktive - Kachel hebt den Filter wieder auf. Jeder Abschnitt lässt sich unabhängig - davon per Klick auf seinen Titel ein-/ausklappen (z.B. um eine lange - Offline-Liste vorübergehend zu verstecken, ohne den Filter zu nutzen) — - beide Zustände bleiben über die automatischen Live-Updates hinweg erhalten. - Abmelden führt zurück auf dieses (auch ohne Login einsehbare) Dashboard - statt auf die Login-Seite -- Der Hauptinhaltsbereich nutzt die **volle verfügbare Breite** (kein - Bootstrap-typisches, festes `max-width` mehr) — nur Modals/Dialoge behalten - bewusst ihre kompakte, feste Breite -- Verwaltung von Devices und Switches -- **Zugangsdaten als eigene, wiederverwendbare Entität**: SSH-Logins werden - einmal angelegt und können mehreren Switchen zugeordnet werden; beim - Anlegen eines Switches lassen sich bestehende Zugangsdaten auswählen oder - direkt neue hinterlegen, ohne vorher zu „Zugangsdaten“ wechseln zu müssen -- **Browser-SSH-Terminal beim Anlegen/Bearbeiten eines Switches**, um den - Host-Key zu bestätigen und die Zugangsdaten direkt zu prüfen, bevor der - Switch gespeichert wird — funktioniert auf jedem Betriebssystem, die - SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut -- Manueller PoE-Neustart einzelner Geräte per Klick — ausschließlich über - den zugeordneten Switch (`poe.sh`); der Button erscheint im Detail-Popup - nur, wenn dem Gerät tatsächlich Switch **und** Port zugeordnet sind (sonst - wäre kein PoE-Reset möglich), und ist immer der am weitesten links stehende - Aktions-Button — „Schließen“ steht unabhängig davon immer ganz rechts. Bei - deaktivierten Geräten bietet das Detail-Popup stattdessen direkt eine - Aktivieren-Option -- **SSH-basierte Wartung für Linux-Clients** (eigene Seite „Wartung“, - getrennt vom PoE-Neustart oben): Bulk-Update (`apt update && apt upgrade`) - und Neustart per SSH direkt auf dem Gerät, unabhängig von jeder - Switch-Zuordnung (siehe [Wartung](#wartung-ssh-bulk-update--neustart)) -- **Optionale LDAP/Active-Directory-Anmeldung** mit dem Domänen-Passwort, - zusätzlich zu lokalen Konten — automatische Konto-Anlage beim ersten - Login (siehe [LDAP / Active Directory](#ldap--active-directory-same-sign-on)) -- Einstellbares Prüfintervall (steuert den Hintergrund-Check-Dienst) — - der Countdown „Nächste Prüfung“ wird aus dem tatsächlichen letzten - Log-Eintrag berechnet, nicht bei jedem Seitenaufruf neu gestartet, und - läuft **global in der Topbar auf jeder Seite** (nicht nur im Dashboard) -- Live-Log-Ansicht mit farblicher Hervorhebung (online/offline/Neustart); - Dashboard und Live-Log aktualisieren sich automatisch im Hintergrund - (per AJAX, ohne vollen Seiten-Reload). Ein „Jetzt prüfen"-Icon neben dem - Timer (nur Admins) startet manuell einen sofortigen Prüf-Durchlauf — - der Countdown setzt sich dabei auf den manuell ausgelösten Zeitpunkt zurück -- **Einheitliches Seiten-Layout**: die Topbar zeigt rechts ausschließlich - den „Nächste Prüfung“-Timer; Anlegen-Buttons und eine kurze Beschreibung - sitzen stattdessen direkt über der jeweiligen Tabelle. Die Listen - (Geräte, Switche, Zugangsdaten, Benutzer, Gruppen) sind per Klick auf die - Spaltenüberschrift sortierbar -- **Rechtesystem mit Gruppen**: Admins dürfen immer alles (als Gruppe „Admin“ - überall mit aufgeführt); normalen Benutzern lassen sich über Gruppen - einzelne Verwaltungsrechte gezielt zuweisen, inkl. reiner Ansichtsrechte - (siehe [Rechtesystem](#rechtesystem-gruppen--berechtigungen)) -- Einklappbare Sidebar (Desktop: dauerhaft, über Neuladen hinweg gemerkt; - mobil: temporäres Überlagern) -- **Änderungslog**: jede Anlage/Bearbeitung/Löschung sowie Aktivieren/ - Deaktivieren von Geräten, Switchen, Zugangsdaten, Benutzern und Gruppen - wird protokolliert (wer, wann, was) — einsehbar unter „Logs → Änderungslog“; - Geräte und Switche merken sich zusätzlich direkt am Datensatz, wer sie - zuletzt geändert hat. PoE-Neustarts werden bewusst **nicht** geloggt - (siehe [Änderungslog](#änderungslog)) -- **Verschlüsselter Import/Export**: Geräte, Switche und Zugangsdaten lassen - sich unter „Einstellungen → Im-/Export" als ein mit selbstgewählter - Passphrase verschlüsseltes Bundle exportieren und auf einer neuen Umgebung - wieder importieren (siehe [Import/Export](#importexport)) -- **Eigenes Konto**: das Zahnrad-Symbol neben dem eigenen Namen unten in der - Sidebar führt zu „Mein Konto“ — Vor-/Nachname, eigenes Passwort und - Profilbild pflegen (siehe [Eigenes Konto](#eigenes-konto--profilbild)) -- **Anpassbare Navigation**: Admins legen dort zusätzlich die Reihenfolge der - Sidebar-Hauptpunkte per Auf-/Ab-Buttons fest; jeder Benutzer sieht davon - ohnehin nur, wofür er berechtigt ist — auch pro Unterpunkt innerhalb einer - Gruppe (siehe [Navigation anpassen](#navigation-anpassen)) +| Zweig | Inhalt | +|---|---| +| `main` | dieser Neubau (Version 2.0.0) | +| `SONNET5` | der Stand davor, unveraendert aufbewahrt | -## Was ist neu gegenüber dem Original? +--- -- **Komplett neues Frontend**: eigenes Design-System (kein Bootstrap mehr — - der Hauptinhaltsbereich nutzt bewusst die volle verfügbare Breite statt - eines festen, Bootstrap-typischen `max-width`; nur Modals/Dialoge behalten - ihre kompakte, feste Breite), Logo mittig in der Topbar (auf jeder Seite, - skaliert auf die Balkenhöhe), Sidebar-Navigation (nur für eingeloggte - Nutzer sichtbar), Dark/Light-Umschalter, Karten-Dashboard mit Live-Zählern, - moderne Modals/Toasts statt Browser-`confirm()` und Bootstrap-Alerts. Der - Akzent-Ton (`--accent` in `style.css`) ist exakt das Orange aus dem - WiS-Logo (`#ff7100`, per Pixel-Sampling aus `logo.png` ermittelt), nicht - nur eine ähnliche, frei gewählte Nuance. -- **Dashboard jetzt teilweise öffentlich**: `/` ist ohne Login erreichbar und - zeigt eine reduzierte Lese-Ansicht (Hostname, IP, Status, letzte Prüfung) - mit Login-Button; alle Verwaltungsseiten und der PoE-Neustart bleiben - `@login_required` und zusätzlich rechtebasiert (s.u.). -- **Browser-SSH-Terminal für Switche**, implementiert mit **paramiko** (reines - Python) statt eines lokalen pty + System-`ssh`-Client — läuft dadurch - plattformunabhängig. Die SSH-Verbindung zum Switch wird immer serverseitig - (vom PoE-Server) aufgebaut, nie vom Browser/Client aus. -- **Granulares Rechtesystem mit Gruppen** (neu, siehe unten) statt der - ursprünglichen reinen Admin/User-Unterscheidung. -- **Gleiche Kernlogik**: Datenbankschema (erweitert um Gruppen/Rechte) und - Login sind ansonsten unverändert. Der PoE-Restart-Mechanismus selbst - (`poe.sh` per SSH/`expect` auf die Aruba-Switche) wurde dagegen - überarbeitet — parallele statt sequentielle Geräte-Prüfung und echte - Erfolgskontrolle statt blind angenommenem Erfolg, siehe - [Geräte-Prüfung](#geräte-prüfung-poesh). -- **Aufgeräumtes Backend**: konsistente, konfigurierbare Pfade (`TESM_DB_PATH`, - `POE_SCRIPT`, `TESM_LOG_DIR`, …) statt hart codierter/inkonsistenter Pfade; - Secret-Key und Fernet-Key werden beim ersten Start automatisch generiert - statt im Repo zu liegen. - -> ⚠️ **Sicherheitshinweis zum Original-Repo:** Im Original sind `sqlite.db` -> (Nutzer-Passwort-Hashes, verschlüsselte Switch-Passwörter) und `fernet.key` -> (der zugehörige Schlüssel) mit im Git-Repo eingecheckt — die `.gitignore` -> dort schließt sie über die `!`-Regeln versehentlich wieder ein. In diesem -> Rebuild sind beide Dateien in `.gitignore` ausgeschlossen und werden beim -> ersten Start frisch erzeugt. Empfehlung fürs Original-Repo: beide Dateien -> aus der Git-Historie entfernen und Fernet-Key sowie alle Switch-/User- -> Passwörter rotieren. - -## Rechtesystem (Gruppen & Berechtigungen) - -Admins dürfen immer alles. Zusätzlich gibt es **Gruppen** mit einem -**vollständig granularen, an der Navbar gespiegelten Rechtebaum** — jeder -Bereich der App (nicht nur Devices/Switches) lässt sich einzeln freischalten. -Ein Benutzer kann mehreren Gruppen angehören, die Rechte addieren sich -(Vereinigung, nicht Schnittmenge). - -### Zweistufiger Rechtebaum - -Der Baum hat genau zwei Ebenen, exakt gespiegelt an den drei -Rechte-Bereichen der Gruppen-Seite (Geräte/Logs/Einstellungen) — das ist -**unabhängig** von der Sidebar-Gliederung: „Benutzer“ und „Gruppen“ stehen -in der Sidebar zwar als eigene, flache Top-Level-Punkte (siehe -[Navigation anpassen](#navigation-anpassen)), gehören rechtlich aber -weiterhin zum Bereich „Einstellungen“. Auf der Gruppen-Seite stehen die drei -Bereiche als drei kompakte Spalten nebeneinander, mit einer kurzen Legende -direkt darüber. Jede Spalte ist eine kleine Tabelle: die Kopfzeile enthält -in EINER Zeile sowohl den Bereichsnamen mit seinem Kästchen (dem -Kill-Switch, dicht nebeneinander statt an den Rand gestellt) als auch die -Buchstaben-Spalten **R**(ead/Lesen), **W**(rite/Schreiben, Anlegen), -**E**(dit/Ändern) — nur die Rechte, die dieser Bereich tatsächlich nutzt. -Darunter je eine eingerückte Zeile pro Unterpunkt. **„Ändern“ deckt Löschen -überall mit ab** — es gibt bewusst kein eigenes Löschen-Recht (stattdessen -landet ein gelöschter Eintrag im [Papierkorb](#papierkorb)). Zwei Bereiche -haben eine zusätzliche, bereichsspezifische vierte Spalte: **D** nur bei -Geräte → Clients bedeutet **nicht** Löschen, sondern *PoE-Neustart über das -Dashboard-Popup auslösen* (automatische Neustarts bei Ausfall über `poe.sh` -sind davon komplett unabhängig und laufen immer); **X** nur bei -Einstellungen → Im-/Export bedeutet *Export ausführen* — bewusst getrennt -von E (Import), da die Export-Datei Passwörter im Klartext enthält (siehe -[Import/Export](#importexport)). Checken einer W/E/D/X-Zelle einer Zeile -hakt automatisch auch deren R an, und Abwählen von R wählt automatisch auch -die übrigen Spalten derselben Zeile ab (R ist logisch Voraussetzung für -alles andere). Für Zeilen ohne passendes Recht (z.B. „D“ bei Switche/ -Zugangsdaten/Benutzer/Gruppen, oder „X“ bei allem außer Im-/Export) wird -eine ausgegraute, nicht anklickbare Checkbox angezeigt statt die Zelle -wegzulassen: - -``` -☑ Geräte R W E D ☑ Logs R W E ☑ Einstellungen R W E X - Clients ☑ ☑ ☑ ☑ Live ☑ ☑ ☑ Benutzer ☑ ☑ ☑ ▢ - Switche ☑ ☑ ☑ ▢ Änderungen☑ ☑ ☑ Gruppen ☑ ☑ ☑ ▢ - Zugangsd. ☑ ☑ ☑ ▢ Kea-DHCP ☑ ☑ ☑ Systemeinst. ☑ ▢ ☑ ▢ - Wartung ☑ ▢ ☑ ▢ LDAP ☑ ▢ ☑ ▢ - DHCP ☑ ▢ ☑ ▢ - Im-/Export ☑ ▢ ☑ ☑ -``` - -Das jeweilige „Bereich sichtbar“-Recht (`devices_group.view` / -`logs_group.view` / `settings_group.view`, das Kästchen in der Kopfzeile -einer Spalte) wirkt als **Kill-Switch**: ist es für eine Gruppe nicht -gesetzt, greift kein einziges Recht darunter mehr — selbst wenn z.B. -`devices.view` einzeln noch angehakt ist. So lässt sich ein ganzer Bereich -mit einem Klick sperren, ohne jedes Unterrecht einzeln zurücknehmen zu -müssen (`User.has_permission()` in `app.py`). Auf der Gruppen-Seite sind -die Unterpunkt-Zeilen einer Spalte per JavaScript so lange gesperrt UND -sichtbar ausgegraut (nicht nur das dezente native disabled-Styling), bis -das Kopfzeilen-Kästchen angehakt ist. - -### Systemgruppen - -Zwei Gruppen sind fest und **weder umbenennbar noch in ihren Rechten -änderbar** (auch nicht durch Admins über die UI) — Mitgliedschaft bleibt bei -beiden frei verwaltbar: -- **Admin** — virtuell (kein echter `groups`-Datensatz), intern über den - `is_admin`-Schalter der Benutzer gesteuert, immer alle Rechte. Mindestens - ein Admin muss bestehen bleiben (serverseitig erzwungen). -- **Benutzer** — die Standardgruppe (`is_system`-Flag), der jeder neu - angelegte Nicht-Admin automatisch zugeordnet wird. Fester Rechtesatz: - Geräte-Bereich + Clients/Switche lesen, Logs-Bereich + Live-Log lesen. - -Alle anderen Gruppen sind vom jeweiligen Rechteinhaber (`groups.edit`, das -auch Anlegen und Löschen von Gruppen umfasst) frei konfigurierbar. - -### Eskalationsschutz - -Da jetzt auch **Benutzer-** und **Gruppenverwaltung** delegierbar sind (z.B. -eine Gruppe mit nur `users.edit`, ohne Admin zu sein), gelten zusätzlich zu -den granularen Rechten diese fest einprogrammierten Schranken, unabhängig -davon, was eine Gruppe an Rechten hat: -- Ein Benutzer als Admin anlegen/dazu machen (`group_id=admin` bei - Anlegen/Zuweisen) bleibt echten Admins vorbehalten. -- Ein bestehendes Admin-Konto bearbeiten, löschen oder ihm die Gruppe ändern - bleibt echten Admins vorbehalten. -- Wer Admin ist, wird ausschließlich über „Admin-Mitglieder verwalten“ auf - der Gruppen-Seite gesteuert — bleibt admin-exklusiv, unabhängig von - `groups.edit`. - -Sowohl das Anzeigen jeder Seite als auch jede einzelne Aktion (Buttons, -Formulare) ist an das jeweilige Recht gekoppelt — im Frontend ausgeblendet -**und** im Backend serverseitig durchgesetzt, unabhängig vom Frontend. - -Datenmodell: `groups` (inkl. `is_default`- und `is_system`-Flag), -`group_permissions` (Gruppe → Recht), `user_groups` (Benutzer → Gruppe). -Bestehende Datenbanken werden beim App-Start automatisch migriert -(`_ensure_schema()` in `app.py`): `devices.toggle` → `devices.edit`, -`switches.*` → gespiegelte `credentials.*`, nachträgliches Setzen der neuen -Bereichs-Rechte für bereits vergebene Unterrechte, sowie (spätere Revision) -sämtliche `*.delete`-Rechte → `*.edit` (Löschen wurde nachträglich überall in -Ändern gefaltet). Jede dieser Migrationen läuft **nur einmalig** über einen -eigenen Guard in der `settings`-Tabelle, damit z.B. ein bewusst deaktiviertes -Bereichs-Recht nicht bei jedem Neustart automatisch wieder gesetzt wird. - -## LDAP / Active Directory (Same Sign-On) - -Optionale Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten -— auf der eigenen Seite **Einstellungen → LDAP** (Recht `settings_ldap.view` -zum Ansehen, Speichern erfordert Admin). - -**Wichtige Begriffsklärung, direkt aus einer Nutzerfrage entstanden:** das -ist **"Same Sign-On"**, kein automatisches, promptloses **Single Sign-On**. -Der Benutzer tippt sein AD-Passwort weiterhin auf der normalen Login-Seite -ein — es wird nur bei jedem Login **live** gegen Active Directory geprüft -statt gegen ein lokal gespeichertes Passwort. Eine Passwortänderung in AD -greift dadurch sofort, ohne dass irgendetwas synchronisiert werden müsste. -Echtes, promptloses Single Sign-On (der Browser meldet automatisch den -bereits an Windows angemeldeten Benutzer an) bräuchte zusätzlich: -- **Kerberos/SPNEGO** ("Integrated Windows Authentication"): ein SPN + - Keytab vom AD-Admin, eine per DNS auflösbare Hostname für die App, und - die Browser-Konfiguration der Clients (i.d.R. bereits Standard für die - Intranet-Zone auf domänenbeigetretenen PCs) — **oder** -- **SAML/OIDC über Entra ID (Azure AD)**, falls Microsoft 365/Entra - vorhanden ist — oft der pragmatischere Weg zu echtem SSO als klassisches - Kerberos. - -Beides sind eigenständige, deutlich aufwändigere Ausbaustufen, die sich -später auf die hier beschriebene LDAP-Anmeldung aufsetzen ließen, aber -AD-seitige Vorarbeit (Keytab bzw. App-Registrierung) durch einen -Domain-Admin voraussetzen. - -### Funktionsweise (Search+Bind) - -Beim Login wird zunächst lokal gesucht — per exaktem Benutzernamen, per -E-Mail-Adresse, oder (case-insensitiv, nur für `auth_source='ldap'`-Konten) -per bereits bekanntem AD-Benutzernamen. Ist das gefundene (oder noch nicht -existierende) Konto gesperrt (`is_locked`, siehe unten) oder im Papierkorb, -wird der Login abgelehnt, ohne AD überhaupt zu kontaktieren. Existiert kein -lokales Konto und ist LDAP aktiviert, prüft die App die eingegebene Kennung -(sAMAccountName **oder** UPN, z.B. sowohl `jdoe` als auch -`jdoe@ad.eertmoed.net` funktionieren) und das Passwort per **Search+Bind** -gegen den konfigurierten Server: - -1. Verbindung mit dem konfigurierten **Service-Konto** aufbauen (ein - normales, unprivilegiertes Domänenkonto reicht — es braucht nur - Lesezugriff auf das Verzeichnis, was jedes Domänenkonto in AD - standardmäßig hat). -2. Den Benutzer per konfigurierbarem Attribut suchen (Standard - `sAMAccountName` für Active Directory; für generisches LDAP z.B. `uid`). -3. Eine **zweite, separate** Verbindung mit der gefundenen DN des Benutzers - und dem eingegebenen Passwort aufbauen, um es zu verifizieren — robuster - als ein direktes UPN-Bind (`user@domain`), da es nicht voraussetzt, dass - `sAMAccountName` und der UPN-Präfix übereinstimmen. -4. Zusätzlich wird das `userAccountControl`-Bit `ACCOUNTDISABLE` geprüft - (auch wenn ein echter AD-DC den Bind eines deaktivierten Kontos i.d.R. - ohnehin schon selbst ablehnt) — zusätzliche Absicherung. - -Die **Base-DN** wird standardmäßig automatisch per RootDSE -(`defaultNamingContext`) ermittelt, kann aber manuell überschrieben werden -(z.B. bei mehreren Domänen in einer Gesamtstruktur). - -Bei erfolgreichem erstem Login wird automatisch ein lokales Konto angelegt -(`auth_source='ldap'`, kein Admin). Das gespeicherte Passwort dieses Kontos -ist nur ein **nie geprüfter Platzhalter-Hash** — bei jedem weiteren Login -wird live gegen AD verifiziert, nie gegen die lokale DB. Benutzernamen -werden dabei case-insensitiv abgeglichen (nur für `auth_source='ldap'`- -Konten, wirkt sich nie auf lokale Konten aus), damit z.B. "JDoe" und "jdoe" -nicht zwei verschiedene lokale Konten erzeugen. - -**Gruppen-Zuordnung (AD-Gruppe → App-Gruppe).** Auf der LDAP-Einstellungsseite -lässt sich jeder AD-Gruppe (per DN) genau eine App-Gruppe zuordnen. Ist der -Benutzer beim Login Mitglied mehrerer zugeordneter AD-Gruppen, erhält er -**additiv** die Rechte aller passenden App-Gruppen (dieselbe -Vereinigungslogik wie bei mehrfacher lokaler Gruppenmitgliedschaft). Passt -keine der konfigurierten Zuordnungen, greift ersatzweise eine konfigurierbare -**Standardgruppe** (sonst die System-Standardgruppe „Benutzer“) — es sei denn, -eine der AD-Gruppen ist explizit der internen Admin-Zuordnung zugewiesen, -dann wird das Konto automatisch zum Admin. Die Zuordnung wird bei **jedem** -Login neu ausgewertet, nicht nur beim ersten — Änderungen an -AD-Gruppenmitgliedschaften wirken sich also ohne manuellen Eingriff auf die -nächste Anmeldung aus. - -**Konten sperren.** Ein Admin kann jedes Konto (lokal wie AD) in „Benutzer“ -per Schalter sperren (`is_locked`) — ein gesperrtes Konto kann sich nicht -mehr anmelden, unabhängig davon, ob das AD-Passwort weiterhin gültig ist. -Nützlich, um jemandem ohne Eingriff in AD selbst kurzfristig den Zugriff auf -die App zu entziehen. - -**Vorab-Anlage aus Active Directory** („Benutzer" → „Aus Active Directory -hinzufügen"): eine Live-Suche (Substring, ab 2 Zeichen) im Verzeichnis, -gefiltert um bereits lokal bekannte Benutzernamen. So kann ein Admin einer -Person schon **vor** ihrem ersten Login eine Gruppe zuweisen, statt sich auf -die Standardgruppe zu verlassen — das Konto landet dabei sofort mit -`auth_source='ldap'` und demselben Platzhalter-Passwort-Mechanismus in der -DB, ganz so, als hätte die Person sich bereits einmal angemeldet. - -**Lokale Konten bleiben vollständig erhalten** — der lokale Login-Pfad ist -unverändert, LDAP kommt nur als zusätzlicher Weg für Benutzernamen ohne -lokales Konto dazu. Wichtig als Fallback (Break-Glass-Zugang, falls -AD/LDAP mal nicht erreichbar ist) und für den allerersten -Admin-Bootstrap (`create_admin.py`). - -Für AD-Konten wird die Passwort-Änderung sowohl unter „Mein Konto" als auch -im Admin-Bereich „Benutzer" ausgeblendet bzw. serverseitig ignoriert (ein -kleines "AD"-Symbol markiert sie in der Benutzerliste) — das Passwort wird -ausschließlich in Active Directory verwaltet. - -**Löschen eines AD-Kontos** in „Benutzer" entfernt nur das lokale -Schatten-Konto — **AD-Konten landen dabei bewusst NIE im Papierkorb** -(anders als lokale Konten, siehe [Papierkorb](#papierkorb)), da sie sich bei -jedem gültigen Login ohnehin automatisch wieder anlegen würden und ein -"gelöscht, aber weiter nutzbar"-Zwischenzustand hier keinen Sinn ergibt. -Meldet sich die Person danach erneut mit gültigen AD-Zugangsdaten an, wird -automatisch wieder ein (neues, rechteloses) Konto angelegt. Um jemandem den -Zugriff wirklich zu entziehen, sperrt man das Konto entweder direkt in der -App (siehe oben) oder deaktiviert es in AD selbst (blockiert dann auch den -LDAP-Bind). Aus demselben Grund werden AD-Konten beim -[Export](#importexport) grundsätzlich nie mit exportiert — nur lokale -Konten landen in der Export-Datei. - -**Sicherheitshinweis**: ohne LDAPS/StartTLS wird das Passwort unverschlüsselt -über das Netzwerk übertragen — nur für interne Tests in einem vertrauenswürdigen -Netz geeignet. Vor Produktivbetrieb sollte LDAPS auf dem Domain Controller -eingerichtet und in den Einstellungen aktiviert werden (das Server-Zertifikat -wird dafür noch nicht validiert — reine Transportverschlüsselung). - -Live gegen einen echten Active-Directory-Domain-Controller verifiziert: -automatische Base-DN-Ermittlung, "Verbindung testen", erfolgreicher -Erst-Login mit automatischer Konto-Anlage und korrekter -Standardgruppen-Zuweisung, Ablehnung bei falschem Passwort, erneuter Login -verwendet das bereits angelegte Konto (keine Duplikate), unveränderter -lokaler Admin-Login, sowie dass das Bind-Passwort nie im Klartext im -gerenderten HTML landet. - -## Zugangsdaten (wiederverwendbare SSH-Logins) - -Zugangsdaten (Username + verschlüsseltes Passwort) sind eine eigene Entität -(`credentials`-Tabelle) statt direkt am Switch/Gerät zu hängen — mehrere -Switche/Geräte mit demselben Login teilen sich einen Eintrag. Beim Anlegen/ -Bearbeiten eines Switches **oder Geräts** lässt sich per Dropdown ein -bestehender Eintrag auswählen oder über „+ Neue Zugangsdaten anlegen“ direkt -im selben Formular ein neuer anlegen, ohne vorher zur eigenen -**Zugangsdaten**-Seite wechseln zu müssen. Löschen ist nur möglich, wenn -weder ein Switch noch ein Gerät mehr auf den Eintrag verweist. Bestehende -Datenbanken aus einer älteren Version (Username/Passwort direkt am Switch) -werden beim ersten Start automatisch migriert. - -Jede Zugangsdaten-Zeile hat außerdem eine explizite **Kategorie** (Switch / -Linux-Client / Windows-Client / Router-Sonstiges), vom Admin beim Anlegen -gesetzt — bewusst **nicht** automatisch per Ping/TTL-Heuristik erkannt (zu -unzuverlässig, siehe Diskussion in der Commit-Historie). Aktuell wertet nur -die Kategorie **Linux-Client** etwas aus: nur Geräte mit einer so -kategorisierten Zugangsdaten-Zuordnung erscheinen unter **Wartung** (siehe -unten). Windows/PowerShell-Wartung ist als eigener, separater Schritt -vorgesehen (kein Testsystem dafür verfügbar) — die Kategorie existiert -bereits, die Aktion dahinter noch nicht. - -Sowohl die Switche- als auch die Clients-Tabelle zeigen dieselbe -„Zugangsdaten“-Spalte (Name + Username, oder „— keine —“), damit auf einen -Blick erkennbar ist, welches Gerät bereits per SSH verwaltet werden kann, -ohne extra zur Zugangsdaten-Seite wechseln zu müssen. - -Jeder Switch kann außerdem einen individuellen **SSH-Port** hinterlegen -(Feld „SSH-Port“ beim Anlegen/Bearbeiten) — bleibt er leer, wird überall -automatisch **Port 22** angenommen (`SWITCH_DEFAULT_SSH_PORT` in `app.py`). -Der Port wird konsistent an allen drei Stellen verwendet, an denen die App -sich per SSH mit einem Switch verbindet: dem Web-Terminal (Verbindungstest), -`generate_ips.py` (liefert ihn als eigenes Feld an `poe.sh`) und den -`expect`-Aufrufen in `poe.sh` selbst (`ssh -p ...`) für automatische -wie manuelle PoE-Neustarts. - -## Wartung (SSH-Bulk-Update & -Neustart) - -Eigene Seite unter **Geräte → Wartung** (eigenes Rechtepaar -`maintenance.view`/`maintenance.edit`), komplett getrennt vom PoE-Neustart -auf dem Dashboard: - -- **Dashboard-Neustart** = immer PoE-Reset über den Switch (`poe.sh`), - funktioniert unabhängig davon, ob das Gerät SSH-Zugangsdaten hat. -- **Wartung** = immer eine direkte SSH-Verbindung zum Gerät selbst, - funktioniert unabhängig davon, ob/an welchem Switch es hängt. - -Nur Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie **Linux-Client** -(siehe oben) erscheinen in der Liste. Zwei Aktionen: - -- **Update** (Mehrfachauswahl per Checkbox, ein Klick startet alle - ausgewählten parallel als eigene Hintergrund-Threads): führt serverseitig - `env DEBIAN_FRONTEND=noninteractive apt-get update && env - DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::="--force-confdef" - -o Dpkg::Options::="--force-confold" upgrade` per SSH aus — nicht - interaktiv (kein Terminal, keine Rückfragemöglichkeit), die - `Dpkg::Options`-Flags verhindern, dass ein Paket-Postinst-Skript auf eine - Config-Datei-Rückfrage wartet, die nie kommt und sonst bis zum Timeout - hängen bliebe. -- **Neustart** (pro Gerät): `sudo reboot` per SSH, danach automatisch - weiterverfolgt bis das Gerät wieder online ist — ohne das bliebe der - Status für immer auf „Neustart ausgelöst“ stehen, obwohl der eigentlich - interessante Zeitpunkt (kommt das Gerät zurück?) noch gar nicht erfasst - wäre. Ablauf: das SSH-Neustart-Kommando selbst gilt als erfolgreich - ausgelöst, sobald die Verbindung abbricht (erwartet — die Maschine fährt - herunter, bevor sie noch antworten kann; nur ein Verbindungs-/Auth-Fehler - **vor** dem Kommando ist ein echter Fehler). Danach wartet eine kurze - Schonfrist (15s, damit ein zu früher Ping nicht noch die absterbende alte - Instanz erreicht) und pingt anschließend alle 5s (`ping -c 1 -W 2` — exakt - dieselbe Invocation wie das bestehende Online/Offline-Monitoring in - `poe.sh`), bis eine Antwort kommt oder ein Timeout von 5 Minuten erreicht - ist; erst dann wechselt der Status auf „wieder online“ (Erfolg) bzw. auf - einen Fehler, falls das Gerät nicht rechtzeitig zurückkommt. - -Live-Status pro Gerät (läuft/erfolgreich/fehlgeschlagen inkl. Ausgabe der -letzten Aktion, per Klick ein-/ausblendbar) wird per Polling (`/maintenance/status`, -alle 2s solange etwas läuft, sonst alle 8s) aktualisiert, ohne die Seite neu -zu laden. Der Job-Status lebt bewusst nur im Prozessspeicher (wie beim -SSH-Terminal auch keine Sitzung persistiert wird) — ein Neustart von -`tesm.service` verwirft nur die Anzeige, nicht die auf dem Zielgerät -bereits laufende Aktion selbst. Ein abgeschlossener **erfolgreicher** Job -wird zusätzlich bei jedem (erneuten) Laden der Seite zurückgesetzt (Status -wieder „Noch keine Aktion“) — ein Erfolg ist nur so lange relevant, wie man -ihn noch live mitverfolgt, ein späterer Seitenaufruf soll keine ggf. -stunden-/tagealte Erfolgsmeldung dauerhaft zeigen. Fehlgeschlagene Aktionen -bleiben dagegen bewusst sichtbar, bis eine neue Aktion sie überschreibt, um -nicht durch einen einfachen Reload versehentlich übersehen zu werden. - -**Sicherheitsmodell**: dieselbe `known_hosts`-Datei wie beim -Browser-SSH-Terminal (`SSH_KNOWN_HOSTS_PATH`) — ein Host muss vorher -mindestens einmal interaktiv über „Verbindung testen“ bestätigt worden sein. -Anders als das interaktive Terminal (dort kann bei einem unbekannten -Host-Key wie bei einem echten `ssh`-Client live nachgefragt werden) läuft -hier nichts vor einem Menschen ab — ein unbekannter oder nicht vorher -bestätigter Host-Key wird deshalb per `paramiko.RejectPolicy` hart -abgelehnt statt automatisch akzeptiert, da ohne Rückfragemöglichkeit ein -automatisches Akzeptieren ein MITM-Risiko ohne jede Prüfung wäre. - -Live gegen ein dediziertes Testsystem verifiziert (separat vom gemeinsam -genutzten App-Host, um dort kein echtes `apt upgrade` auszulösen): ein -echtes `apt update && apt upgrade` (inkl. Kernel-/systemd-/netplan-Paketen) -lief nicht-interaktiv vollständig durch (`status: success`, komplette -`apt`-Ausgabe im Job-Verlauf sichtbar); der SSH-Neustart samt -Online-Watchdog wurde zweimal gegen dasselbe Testsystem verifiziert — -jeweils per `uptime -s` bestätigt, dass der Boot-Zeitpunkt exakt zum -Auslöse-Zeitpunkt des jeweiligen Jobs passte (kein False-Positive-Ping -gegen die noch nicht heruntergefahrene alte Instanz), und dass der -Job-Status korrekt von „wartet auf Online“ auf „wieder online“ -umgeschaltet hat. Das Zurücksetzen auf „Noch keine Aktion“ bei einem -erneuten Seitenaufruf wurde ebenfalls live bestätigt (Status vor einem -`GET /maintenance` noch sichtbar, danach wieder leer). - -Windows-Clients/PowerShell-Wartung ist bewusst **noch nicht** umgesetzt -(kein Testsystem verfügbar) — die Kategorie „Windows-Client“ existiert in -den Zugangsdaten bereits als Vorbereitung für einen späteren, separaten -Ausbauschritt. - -## DHCP-Reservierungen & eigene Options (Systemeinstellungen → DHCP) - -Erzeugt aus den vorhandenen Client-Stammdaten (MAC + IP + Name) automatisch -feste DHCP-Reservierungen inkl. eigener/herstellerspezifischer Options, ohne -die Daten ein zweites Mal pflegen zu müssen. Eigenes Rechtepaar -`settings_dhcp.view`/`settings_dhcp.edit`, gespiegelt in der Navbar unter -Einstellungen. - -**Backend: Kea DHCP** (`kea-dhcp4-server`), nicht das ältere -`isc-dhcp-server` — ISC hat isc-dhcp-server 2022 als EOL erklärt, Kea ist -der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client -überschreibbar“ nativ ab (siehe unten). - -- **Status-Erkennung** (rein lesend): `shutil.which("kea-dhcp4")` + - `systemctl is-active`/`is-enabled`. Zeigt Version, Lauf- und - Autostart-Status. -- **Installation & Dienststeuerung**: eigene, jeweils einzeln bestätigte - Buttons — „kea-dhcp4-server installieren“ (`apt-get install -y`), - „Aktivieren & (neu) starten“ (`systemctl enable` + `restart`) und - „Stoppen“ (`systemctl stop` **+ `disable`**, damit ein bewusst gestoppter - Dienst nach einem Host-Neustart nicht trotzdem automatisch wieder - hochfährt — ein Host-Neustart übernimmt so genau den zuletzt bewusst - gewählten Zustand, an oder aus). Bewusst **nicht** automatisch an - „Konfiguration speichern“ oder „In Datei schreiben“ gekoppelt — ein - unerwartet aktiver oder fehlkonfigurierter DHCP-Server kann ein ganzes - Netzsegment stören, jede dieser Aktionen soll ein bewusster, separater - Klick bleiben. -- **Mehrere Subnetze gleichzeitig**: ein Host kann mehrere IPs/Interfaces - mit jeweils eigenem Netz haben, für die alle DHCP angeboten werden soll - (`dhcp_subnets`-Tabelle statt einer einzelnen globalen Range) — jedes - Subnetz hat eigene Interface/Range sowie optional eigenes Gateway/DNS, - generiert wird ein eigener `subnet4`-Block je Subnetz in der Kea-Config. - Domain, Lease-Zeiten und Ausgabepfad bleiben global (Kea-weit gültig). -- **Netzwerk-Kopplung**: Subnet und Netzmaske werden **nie manuell - eingegeben**, sondern bei jeder Anzeige/Generierung live aus der - System-Netzwerkkonfiguration des jeweiligen Interfaces gelesen - (`_detect_interface_network()`/`_detect_interface_networks()`: - `ip -4 addr show dev ` + `ip route show default`) — berücksichtigt - dabei ALLE IPv4-Adressen eines Interfaces, nicht nur die erste (ein - Interface kann mehrere IPs/Subnetze gleichzeitig tragen, z.B. Alias-IPs). - Die generierte Kea-Konfiguration ist dadurch immer an das Netz gekoppelt, - in dem der Host tatsächlich hängt, auch wenn sich dessen IP/Subnet mal - ändert. -- **Range ist Pflicht und muss zu einem echten Netz passen**: kein - vorausgefüllter Default für Range/DNS (nur Platzhaltertext) — ein - Subnetz ohne zu einem tatsächlich vorhandenen Netz passende Range wird - beim Anlegen/Bearbeiten abgelehnt (`_dhcp_matching_network()`), da Kea - ohne eine echte, physisch vorhandene IP im gewünschten Bereich keine - gültige Subnet-CIDR zugeordnet werden kann. Dieselbe Prüfung läuft - zusätzlich bei „In Datei schreiben“ (Subnetze, die inzwischen zu keinem - echten Netz mehr passen — z.B. nach einem Netzwechsel des Hosts — werden - übersprungen und in der Erfolgsmeldung benannt, statt die ganze Aktion - abzubrechen) und bei „Aktivieren & (neu) starten“ (verweigert den Start, - falls kein einziges gültiges Subnetz mehr existiert). Live auf einer - echten Fehlkonfiguration reproduziert und verifiziert. -- **Subnetze einzeln deaktivierbar**: ein Subnetz lässt sich ohne Löschen - pausieren (`enabled`-Flag) — wird dann bei „In Datei schreiben“ und - „Aktivieren & (neu) starten“ komplett ignoriert (kein Fehler, bewusst - kein "ungültig", da das Fehlen des Netzes hier Absicht ist), statt es - jedes Mal neu anlegen zu müssen. -- **DHCP-Options** (`dhcp_option_defs`/`dhcp_option_values`): eine kuratierte - Auswahl an **56 Standard-Optionen** (analog den „Vordefinierten Optionen“ - eines Windows-DHCP-Servers — u.a. NTP-/WINS-/TFTP-/SMTP-Server, Boot-Datei, - Domain-Search, IP-Forwarding) ist automatisch vorbefüllt und **nicht - löschbar** (Backend lehnt es ab, UI zeigt statt eines Löschen-Buttons ein - Schloss-Symbol) — Router/DNS-Server/Domain-Name/Lease-Zeiten sind bewusst - ausgenommen, da dafür bereits eigene, dedizierte Felder existieren - (Subnetze bzw. globale Einstellungen oben); ebenso client-/ - protokollinterne Optionen (message-type, parameter-request-list, - requested-address, ...), die serverseitig nicht sinnvoll setzbar sind. - „host-name“ (12) ist ebenfalls bewusst **nicht** enthalten (war es - zwischenzeitlich, wurde aber wieder entfernt): Kea beantwortet die - Host-Name-Anfrage eines Clients bei einer Reservierung bereits automatisch - über deren eigenes `hostname`-Feld (aus dem Namen der Reservierung - abgeleitet) — ein zusätzlicher `option-data`-Override für dieselbe Option - wäre laut Kea-Dokumentation eine Dopplung und Quelle für Widersprüche, - live an einer echten Reservierung mit beiden gleichzeitig gesetzten - Werten nachvollzogen. Alle verbleibenden Namen sind live gegen eine echte - Kea-2.4.1-Instanz verifiziert (`kea-dhcp4 -t`, jede Option einzeln - getestet). Standard-Optionen bekommen bewusst **kein eigenes - `option-def`** in der generierten Konfiguration — Kea kennt sie bereits - nativ, eine Neudefinition würde die eingebaute duplizieren; nur - zusätzlich angelegte, wirklich eigene/herstellerspezifische Options (z.B. - eine Terminal-Boot-URL) bekommen eines und bleiben frei löschbar. - In der Tabelle sind grundsätzlich nur **tatsächlich genutzte** Options - sichtbar (eigene immer, Standard-Optionen nur mit gesetztem globalen Wert) - — weitere Standard-Optionen kommen über ein Dropdown „+ Standard-Option - hinzufügen“ dazu, statt alle 56 dauerhaft als leere Zeilen anzuzeigen. - Jede Option hat einen **globalen** Wert sowie optional einen **Wert pro - Client** — ein Client-Override überschreibt den globalen Wert - ausschließlich für dieses eine Gerät (technisch: globale Werte landen im - Top-Level `option-data`, Overrides im `option-data` der jeweiligen - Kea-Reservierung, was Kea automatisch pro Client Vorrang geben lässt). Im - Reservierungs-Options-Dialog gilt dasselbe Prinzip: nur Options mit - gesetztem Override sind sichtbar, weitere kommen über ein Dropdown dazu. - Das ausgewählte Feld wird dabei direkt unter das Dropdown verschoben und - fokussiert, statt an seiner ursprünglichen Stelle irgendwo in der u.U. - langen Liste (bis zu 56 mögliche Options) zu erscheinen — sonst lag es - oft außerhalb des sichtbaren Modal-Ausschnitts und wirkte, als wäre nichts - passiert (live genau so reproduziert und behoben). -- **Reservierungen aus zwei Quellen**: automatisch für jedes aktive Gerät mit - gültiger MAC + IP (Client-Stammdaten dieser App) sowie zusätzlich **manuell - anlegbar** („Reservierung hinzufügen“, MAC/IP/Name) — für Geräte, die - nicht als Client gepflegt werden (z.B. ein externes Gerät), sodass ihnen - bewusst und mit voller Kontrolle eine feste IP gegeben werden kann, statt - sie unüberwacht automatisch anzubinden. Automatische Reservierungen können - in dieser Tabelle **nicht gelöscht** werden (Schloss-Symbol statt Button — - sie entstehen/verschwinden mit den Client-Stammdaten), manuelle schon. Bei - einer MAC in beiden Quellen gewinnt immer die automatische. Jede - Reservierung wird ins **passende Subnetz** einsortiert (die IP entscheidet, - zu welchem der ggf. mehreren konfigurierten Subnetze sie gehört); der - Hostname wird aus dem Namen abgeleitet (nur `a-z0-9-`, Kollisionen - innerhalb desselben Subnetzes bekommen automatisch ein `-2`/`-3`-Suffix). - Einträge ohne MAC/IP oder mit einer IP außerhalb aller konfigurierten - Subnetze werden mit Hinweis übersprungen — Kea lehnt eine Reservierung - außerhalb ihres Subnets als Konfigurationsfehler ab (mit einer echten - Kea-Instanz verifiziert, inkl. einer Mischung aus automatischer und - manueller Reservierung im selben Subnetz). -- **Aktive Leases**: eigene Karte, liest die tatsächlich vergebenen Adressen - direkt aus Keas Lease-Datenbank (`kea-leases4.csv`, memfile-Backend — - kein Control-Agent nötig, reiner Lesezugriff auf die Datei). Zeigt damit - auch Clients **ohne eigene Reservierung**, die sich einfach eine freie IP - aus dem Pool genommen haben — deutlich als „Ohne Reservierung“ markiert, - statt nur die (unvollständige) Reservierungsliste zu zeigen. Die - Ablaufzeit wird explizit als **UTC** ausgewiesen (Kea arbeitet intern in - UTC-Epoch) und zusätzlich als live tickender Countdown angezeigt — eine - reine Absolutzeit auf einer nicht selbst aktualisierenden Seite wirkte bei - den kurzen Standard-Lease-Zeiten (600s) schnell „schon abgelaufen“, sobald - etwas Zeit vergangen war, bis man tatsächlich hinschaut (live auf der - Test-VM als genau dieses Missverständnis reproduziert). -- **Schreiben**: „Speichern“ bei den Reservierungen (nur mit - `settings_dhcp.edit`) schreibt die vollständige, generierte Kea-JSON- - Konfiguration an den konfigurierten Ausgabepfad. Wirksam wird sie erst - nach einem Dienst-Neustart über den separaten Button. -- **Ampel im Topbar**: neben dem Prüfintervall-Timer zeigt ein grüner/roter - Punkt, ob der Kea-Dienst läuft — nur sichtbar mit `settings_dhcp.view`. -- Reservierungen mit aktiven eigenen Options zeigen in der Tabelle, welche - Option greift (global oder Client-Override). - -Live gegen eine echte, frisch installierte Kea-Instanz verifiziert: -Installation, generierte Konfiguration besteht `kea-dhcp4 -t`, Dienststart -übernimmt sie fehlerfrei (`DHCP4_CONFIG_COMPLETE`), globaler Options-Wert -sowie Client-spezifischer Override erscheinen korrekt getrennt in der -Konfiguration. - -Die Mehrfach-Subnetz-Unterstützung selbst (Anlegen/Bearbeiten/Löschen samt -Validierung, mehrere generierte `subnet4`-Blöcke mit korrekten Pools/ -Interfaces, Migration einer bestehenden Einzel-Range) ist live auf einem -echten Host verifiziert. Zwei tatsächlich **unabhängige** Netze (z.B. über -zwei physische NICs) standen beim Test nicht zur Verfügung — verifiziert -wurde daher zusätzlich zwei Ranges innerhalb desselben real vorhandenen -Netzes (identischer `subnet`-CIDR, unterschiedliche Pools); ob Kea zwei -`subnet4`-Blöcke mit identischem `subnet`-Wert akzeptiert oder als Fehler -ablehnt, wurde nicht gegen eine echte Kea-Instanz geprüft — bei tatsächlich -unterschiedlichen Netzen (der eigentliche Anwendungsfall) besteht dieses -Risiko nicht. Die Leases-Anzeige wurde vollständig End-to-End im echten -Betrieb bestätigt: ein tatsächlicher, unreservierter Client im Testnetz -(„shellyhtg3-...“) hat sich selbstständig eine IP aus dem Pool geholt und -wurde korrekt mit der „Ohne Reservierung“-Markierung angezeigt. - -## Geräte-Prüfung (`poe.sh`) - -Der Hintergrund-Check-Dienst (`tesm-check.service`, ruft `poe.sh` ohne -Argument auf) prüft in einer Endlosschleife alle Geräte per Ping und -startet bei Ausfall automatisch den PoE-Port neu, über den ein Gerät am -zugeordneten Switch hängt (`expect`, siehe unten). Zwei Korrekturen nach -einem echten Import mit deutlich mehr Geräten (46 statt einer Handvoll -Testgeräte): - -- **Parallele statt sequentielle Prüfung**: vorher wurde jedes Gerät strikt - nacheinander in einer einzigen Schleife geprüft — ein einzelnes bereits - ausgefallenes Gerät mit ebenfalls nicht erreichbarem Switch (jeder - `expect`-Verbindungsschritt bis zu 5s Timeout) hat dadurch die Prüfung - aller nachfolgenden Geräte in der Liste verzögert; bei vielen gleichzeitig - ausgefallenen Geräten konnte ein Prüfdurchlauf so erheblich länger dauern - als das eigentlich konfigurierte Intervall. Jetzt läuft jede - Geräte-Prüfung (Ping + ggf. PoE-Neustart) als eigener Hintergrund-Job - (`&` + `wait` am Ende des Durchlaufs, über Process Substitution statt - einer Pipe, damit `wait` die Jobs auch tatsächlich erfasst) — mehrere - Geräte, auch an unterschiedlichen Switchen, werden dadurch gleichzeitig - geprüft statt einzeln nacheinander. -- **Echte Erfolgskontrolle statt blind angenommenem Erfolg**: `disable_poe()`/ - `enable_poe()` haben vorher unabhängig vom tatsächlichen Ergebnis der - SSH/`expect`-Sitzung immer wie ein Erfolg gewirkt — lief die Verbindung - zum Switch in einen Timeout (Switch nicht erreichbar, falsche - Zugangsdaten, falscher Port), endete das `expect`-Skript trotzdem mit - Exit-Code 0, und `poe.sh` loggte fälschlich „PoE deaktiviert“/„PoE - aktiviert", obwohl am Switch nichts passiert war. Jeder kritische - `expect`-Schritt (Verbindungsaufbau, `configure terminal`-Prompt, - Interface-Prompt, Bestätigung des `power-over-ethernet`-Kommandos) hat - jetzt einen eigenen `timeout`-Zweig mit explizitem `exit 1`; `poe.sh` - wertet diesen Rückgabewert aus und loggt bei einem nicht erreichbaren - oder ablehnenden Switch stattdessen klar „Switch ... nicht erreichbar - oder Kommando abgelehnt — PoE-Deaktivierung/-Reaktivierung - fehlgeschlagen“, ohne einen Neustart vorzugaukeln, der nie stattgefunden - hat. Gilt für die automatische Prüfschleife, den manuellen Neustart - (`poe.sh restart `) und damit auch für den Dashboard-Neustart-Button. - -Live nach einem echten Import (46 Geräte, 8 Switche aus dem Altsystem) -verifiziert: alle 46 Geräte wurden innerhalb von ~2 Sekunden geprüft (statt -sequentiell), und alle 43 als offline erkannten Geräte mit Switch-Zuordnung -zeigten korrekt die neue Fehlermeldung, da die realen Switch-IPs -(192.168.200.x) vom Test-Host aus nicht erreichbar sind — vorher hätte das -fälschlich als 43× „PoE deaktiviert“/„PoE aktiviert“ geloggt. - -## SSH-Login-Banner (dynamisches MOTD) - -Ein SSH-Login auf den TESM-Host selbst zeigt statt der Standard-Ubuntu- -Willkommensmeldung einen kompakten Live-Status der Flotte — Bernstein/Amber -als Akzentfarbe (passend zum Logo), fett hervorgehobene Zahlen: - -``` -TESM · TimEShepManager -überwachen · booten · anbinden (DHCP) · Betriebssystem geben (PXE) - -3 Clients online · 45 down · DHCP: installiert, gestoppt -``` - -Umgesetzt als zwei Teile: -- **`srv/tesm/motd_banner.py`** — liest den Live-Status aus genau denselben - Quellen wie das Web-Dashboard, ohne selbst zu prüfen: `get_device_status()` - parst dieselbe Check-Log-Datei (`live.log`) wie das Dashboard, - `_detect_dhcp_status()`/`_dhcp_read_leases()` lesen Dienststatus bzw. die - Kea-Lease-CSV direkt. Läuft über `venv/bin/python3` (importiert `app.py`, - das Flask u.a. braucht, die nur dort installiert sind — dasselbe Muster - wie `generate_ips.py`). Ein Fehler beim Datenzugriff lässt die jeweilige - Zeile nur einen Platzhaltertext zeigen, statt den Login zu stören. -- **`etc/update-motd.d/50-tesm`** — der eigentliche, ausführbare - Ubuntu-„dynamic MOTD"-Hook (`run-parts /etc/update-motd.d`, von - `pam_motd.so` bei jedem SSH-Login neu ausgeführt und nach - `/run/motd.dynamic` geschrieben). `timeout 3` verhindert, dass ein - hängender DB-/Dateizugriff den Login blockiert. - -**Bewusst kein PXE-Status** in der Live-Zeile: anders als Überwachen/ -Booten/DHCP ist PXE bislang nur Teil der Tagline, keine echte, mit Daten -hinterlegte Funktion der App — ein erfundener Wert wäre keine Live-Daten. - -Die Standard-Ubuntu-MOTD-Skripte (`00-header`, `10-help-text`, -`50-motd-news`, `60-unminimize`, `91-release-upgrade`, -`92-unattended-upgrades`) werden vom Installer per `chmod -x` deaktiviert -(nicht gelöscht — jederzeit reversibel mit `chmod +x`), damit nur noch der -TESM-Banner erscheint. - -## Netzwerkeinstellungen des Hosts (Systemeinstellungen) - -Eigene, klar getrennte Funktion von obigem DHCP-*Server*: hier geht es um -die Netzwerkkonfiguration des Hosts, auf dem TESM selbst läuft -(IP-Adresse, DNS-Server, DHCP-Client vs. statisch, Hostname) — nicht um -Adressen, die er an andere Geräte vergibt. Der Hostname wird separat und -risikolos über `hostnamectl set-hostname` gesetzt (kein Revert-Timer nötig, -da eine Änderung nicht die Erreichbarkeit kappt). - -- **Backend-Erkennung**: prüft, ob NetworkManager oder dhcpcd aktiv ist - (`systemctl is-active`); ist keins von beiden aktiv, aber `systemd-networkd` - aktiv und `netplan` installiert, wird **netplan** angenommen — der Standard - auf einem frisch installierten Ubuntu (Server wie Cloud-Image). Ist keins - der drei erkennbar, bleibt die Seite bewusst **rein lesend** (Status wird - trotzdem angezeigt) — ohne zuverlässige Erkennung des Backends keine - Änderung, um nicht ins Blaue zu raten. -- **Anzeige**: aktuelles Interface, IP/Prefix, Gateway, DNS-Server sowie - Modus (DHCP/Statisch) — Modus wird backend-spezifisch ermittelt - (`nmcli ipv4.method`, ein `static ip_address=`-Eintrag im betreffenden - `interface`-Block von `/etc/dhcpcd.conf`, bzw. bei netplan am `dynamic`-Flag - des Kernels auf der aktuellen Adresse — zuverlässiger als die u.U. mehreren - überlagernden YAML-Dateien unter `/etc/netplan/` selbst zu parsen). - **DNS-Server-Anzeige**: nicht aus `/etc/resolv.conf` gelesen — unter - systemd-resolved (Standard auf aktuellem Ubuntu) steht dort immer nur der - lokale Stub-Resolver `127.0.0.53`, nie die echten Upstream-Server (live - genau so reproduziert). Stattdessen `resolvectl dns `, dessen - mehrzeilige Ausgabe bei vielen Servern korrekt geparst wird (eine - Fortsetzungszeile ohne `Link N (iface):`-Label trägt selbst - Doppelpunkte — eine IPv6-Adresse — ein naives Aufteilen an jedem - Doppelpunkt hätte sie sonst am Anfang abgeschnitten, live genau so - reproduziert und gefixt). Zusätzlich auf **IPv4** gefiltert, da diese - Seite ausschließlich die IPv4-Konfiguration des Hosts verwaltet — von - `resolvectl` ungefragt mitgelieferte, per IPv6-Router-Advertisement - gelernte DNS-Server gehören nicht dazu und wären hier nur irreführend. -- **Änderung**: Interface, Modus (DHCP/Statisch), bei Statisch zusätzlich - IP/Prefix/Gateway, sowie unabhängig davon eigene DNS-Server (funktioniert - in beiden Modi — z.B. IP per DHCP, aber feste DNS-Server). Bei netplan - landet die Änderung in einer eigenen, niedrig priorisierten Override-Datei - (`/etc/netplan/90-tesm.yaml`) statt in der vom Distributor bzw. - cloud-init mitgelieferten — netplan wendet Dateien alphabetisch an, spätere - Werte gewinnen pro Schlüssel, damit bleibt die Original-Datei unangetastet. -- **Sicherheitsnetz** (wie `netplan try`): vor jeder Änderung wird die - vorherige Konfiguration gesichert. Nach dem Anwenden läuft ein - Hintergrund-Timer (Standard 45s) — wird die Änderung nicht **explizit - bestätigt** (nur möglich, wenn die Seite über die neue Konfiguration noch - erreichbar ist), rollt die App automatisch auf die gesicherte - Konfiguration zurück. Verhindert, dass ein Tippfehler bei IP/Gateway den - Host von der Web-UI/SSH abschneidet. -- **DHCP-Server wird vorsorglich gestoppt UND deaktiviert**: eine vorher - passende Range (siehe DHCP-Konfiguration oben) kann nach einer - IP-/Subnetzänderung plötzlich zum falschen Netz gehören. Läuft Kea beim - Anwenden oder beim Auto-Rollback, wird der Dienst deshalb gestoppt und - deaktiviert (nicht automatisch neu gestartet — die Range muss der Admin - aktiv auf der DHCP-Seite prüfen und den Dienst dort bewusst wieder - starten). Das zusätzliche Deaktivieren verhindert, dass ein Host-Neustart - vor dieser Prüfung den Dienst trotzdem automatisch mit der ggf. falschen - Range wieder hochfährt, statt dass die App mit einer möglicherweise - falschen Konfiguration weiterläuft oder blind rät. -- Live gegen eine echte Ubuntu-24.04-VM verifiziert (Standard-Installation, - Netz per DHCP über netplan/systemd-networkd bezogen) — sowohl die - Erkennung/Statusanzeige als auch der komplette Anwenden-Pfad inkl. der - eigenen Override-Datei. NetworkManager und dhcpcd sind zusätzlich per - gemockten `subprocess.run`/Dateizugriffen verifiziert (inkl. eines echten - `dhcpcd.conf`-Rewrites und eines vollständigen Anwenden-dann-Auto- - Rollback-Durchlaufs), da in der WSL-Entwicklungsumgebung keins von beiden - verfügbar ist. - -## Änderungslog - -Jede Anlage, Bearbeitung, Löschung sowie jedes Aktivieren/Deaktivieren von -Geräten, Switchen, Zugangsdaten, Benutzern und Gruppen wird in der Tabelle -`audit_log` protokolliert (Zeitpunkt, Benutzer, Aktion, Ziel, Details) — -einsehbar unter **Logs → Änderungen** (Recht `logs_activity.view`). Zusätzlich merken -sich Geräte und Switche direkt am Datensatz (`last_modified_by`, -`last_modified_at`), wer sie zuletzt geändert hat, damit man das nicht erst -im Log nachschlagen muss. Jeder Eintrag wird zusätzlich in eine eigene Datei -gespiegelt (siehe [Logs & Log-Rotation](#logs--log-rotation)) — die -`audit_log`-Tabelle bleibt dabei die Quelle für diese durchsuchbare/ -sortierbare Seite. - -PoE-Neustarts werden **bewusst nicht** im Änderungslog erfasst — die stehen -bereits im Live-Log von `poe.sh`. Eine Ausnahme ist die Änderung des -Prüfintervalls selbst (eine Einstellungsänderung, kein Neustart), die wird -mitprotokolliert. - -## Logs & Log-Rotation - -Vier getrennte, eigene Log-Dateien statt allem in `/var/log` vermischt oder -nur im journalctl-Output — jede mit derselben, unter **Systemeinstellungen → -Logs** einstellbaren Rotation (Intervall täglich/wöchentlich/monatlich, -Standard wöchentlich; Aufbewahrung als Anzahl Rotationen, Standard 4 ≈ 1 -Monat Historie): - -| Log | Datei | Einsehbar unter | -|-----|-------|------------------| -| Live (Ping-Check/PoE-Neustart) | `/var/log/tesm/live.log` | Logs → Live | -| Änderungen (Audit-Trail) | `/var/log/tesm/changes.log` | Logs → Änderungen (aus der DB) | -| App (Fehler/Warnungen der Flask-App) | `/var/log/tesm/app.log` | nur Datei, keine eigene Seite | -| Kea-DHCP | `/var/log/kea/kea-dhcp4.log` | Logs → Kea-DHCP (Recht `logs_kea.view`) | - -- **Live-Log ist jetzt eine einzige, durchgehend fortgeschriebene Datei** - statt vorher bei jedem (auch täglich per `tesm-check-restart.timer` - automatisch ausgelösten) Dienst-Neustart ein neues `rpi-.log` - — `poe.sh` räumt nicht mehr selbst nach 30 Tagen auf, das übernimmt jetzt - zentral logrotate. Bestehende alte `rpi-*.log`-Dateien werden beim ersten - Start dieser Version einmalig aufgeräumt (Migration). -- **Kea-DHCP-Log liegt bewusst NICHT unter `/var/log/tesm/`**: Keas - AppArmor-Profil (`/etc/apparmor.d/usr.sbin.kea-dhcp4`) erlaubt - Schreibzugriff exklusiv auf den Dateinamen `/var/log/kea/kea-dhcp4.log` - (kein Verzeichnis-Wildcard) — ein anderer Pfad wird mit `DENIED ... mknod - ... .lock` abgelehnt (live so reproduziert). Statt das AppArmor-Profil - eines Systemdienstes aufzuweichen, nutzt die App Keas eigenen, bereits - erlaubten Pfad; die logrotate-Konfiguration deckt trotzdem beide - Verzeichnisse mit derselben Regel ab. -- **`copytruncate` statt Signal-/Neustart-basierter Rotation**: keiner der - drei schreibenden Prozesse (`poe.sh`, Flask, `kea-dhcp4-server`) - implementiert ein Log-Reopen-Signal (SIGHUP o.ä.) — `copytruncate` - funktioniert ohne jede Kooperation des schreibenden Prozesses und läuft - als root, ist also auch von Keas AppArmor-Profil nicht betroffen. -- **App-Log** (`app.log`) fängt Fehler/Warnungen der Flask-App ab (vorher - nur im journalctl-Output von `tesm.service` sichtbar, ohne von der App - selbst kontrollierbare Aufbewahrung) — bewusst nur `WARNING` und höher, - kein Access-/Request-Rauschen. -- **Zeitzone** (Systemeinstellungen, zusammen mit dem Hostnamen in der - Kachel „Host“): setzt die System-Zeitzone per `timedatectl - set-timezone`, damit alle vier Logs sowie das Änderungslog die - tatsächliche lokale Zeit statt UTC zeigen. Wirkt über `time.tzset()` - sofort für die bereits laufende `tesm.service`-Instanz (kein - Neustart nötig) — `poe.sh`/`date` liest die Zeitzone ohnehin bei jedem - Prüf-Durchlauf frisch, da es ein neuer Prozess pro Durchlauf ist. -- **Systemeinstellungen neu sortiert**: Host (Hostname + Zeitzone in einer - Kachel) und Netzwerkeinstellungen stehen als thematisch zusammengehörige - Host-Konfiguration nebeneinander, gefolgt von Logs und Prüfintervall. - -Live verifiziert: alle vier Log-Dateien werden geschrieben, die -logrotate-Konfiguration korrekt generiert und bei einer Änderung der -Einstellung neu geschrieben, ein Zeitzonenwechsel (UTC → Europe/Berlin) -wirkte sofort — die Log-Zeile, die den Wechsel selbst protokolliert, trug -bereits den neuen Zeitstempel. - -## Import/Export - -Unter **Einstellungen → Im-/Export** lassen sich acht Kategorien einzeln -auswählen und als ein JSON-Bundle exportieren: Geräte, Switche, Zugangsdaten -(inkl. entschlüsselter Passwörter), Benutzer, Gruppen, LDAP-Einstellungen -(inkl. Gruppenzuordnungen), DHCP-Konfiguration und Logs. **AD/LDAP- -Benutzerkonten werden dabei nie mitexportiert** — nur lokale Konten landen -in der Datei, siehe [LDAP-Kapitel](#ldap--active-directory-same-sign-on). -Das Bundle wird mit einem aus einer selbstgewählten Passphrase abgeleiteten -Schlüssel verschlüsselt (PBKDF2-HMAC-SHA256 + Fernet) — unabhängig vom -`fernet.key` dieser Installation, damit die Datei auf einer komplett neuen -Umgebung (mit eigenem, neu generiertem `fernet.key`) wieder eingelesen -werden kann. Gedacht für einen Umzug auf einen neuen Server oder ein -selektives Backup einzelner Kategorien. - -**Zweistufiger Import.** Datei hochladen + Passphrase eingeben lädt das -Bundle serverseitig nur kurzzeitig in den Speicher (Token-basiert, läuft -nach einer Weile automatisch ab) und zeigt eine **Vorschau**: nur die -Kategorien, die tatsächlich in der Datei enthalten sind, jeweils mit Anzahl -der Einträge. Erst danach wählt man aus, welche der vorhandenen Kategorien -tatsächlich übernommen werden sollen — ein Import kann also z.B. nur die -Geräte aus einer Datei ziehen, ohne versehentlich auch Benutzer oder -LDAP-Einstellungen zu überschreiben. Abgeglichen wird pro Kategorie nach dem -jeweiligen natürlichen Schlüssel (Zugangsdaten nach Name, Switche nach -Hostname, Geräte nach MAC-Adresse, Benutzer nach Benutzername, Gruppen nach -Name) — bestehende Einträge werden aktualisiert, neue angelegt. Switche -referenzieren ihre Zugangsdaten dabei über den Namen (nicht die interne ID), -damit der Import auch bei abweichender ID-Vergabe auf dem Zielsystem korrekt -verknüpft. - -**Berechtigungen.** Die Im-/Export-Seite hat drei eigene Rechte: **R** -(`settings_importexport.view`) zum Ansehen der Seite (Standardbedeutung wie -überall), **E** (`settings_importexport.edit`) zum Ausführen eines Imports, -und **X** (`settings_importexport.export`) — eine eigene, dedizierte -Berechtigung nur zum Ausführen eines Exports, da die Export-Datei Passwörter -im Klartext enthält (entschlüsselt vor der eigenen Passphrasen- -Verschlüsselung) und deshalb bewusst nicht automatisch an jeden mit -Änderungsrecht gekoppelt ist. Admins haben ohnehin immer alle drei; für -andere Gruppen lassen sich Import und Export unabhängig voneinander vergeben. -Zusätzlich sind die drei Kategorien **Benutzer, Gruppen und LDAP** -unabhängig von R/E/X grundsätzlich Admins vorbehalten (sowohl beim Export -als auch beim Import) — ein Nicht-Admin mit E/X sieht und nutzt dort nur die -übrigen fünf Kategorien. - -## Papierkorb - -Löschen ist für Geräte, Switche, Zugangsdaten, (lokale) Benutzer und -(benutzerdefinierte) Gruppen ein **Soft-Delete** statt eines echten -`DELETE` — der Eintrag bekommt nur einen Zeitstempel (`deleted_at`) und -verschwindet dadurch aus allen normalen Listen, Dropdowns und -Berechtigungsprüfungen, bleibt aber vollständig erhalten und wiederherstellbar. -Erreichbar über einen eigenen Navigationspunkt **„Papierkorb"** direkt -unterhalb von „Wartung" (sichtbar, sobald man für mindestens eine der fünf -Kategorien Leserecht hat) — dort ist jede Kategorie als eigene, permissions- -gegated Kachel mit „Wiederherstellen"- und „Endgültig löschen"-Aktion -aufgeführt. - -**Ausnahmen**: System-/Standardgruppen (aktuell nur „Benutzer") lassen sich -grundsätzlich nicht löschen, landen also auch nie im Papierkorb — unverändert -gegenüber vorher. **AD/LDAP-Benutzerkonten landen ebenfalls nie im -Papierkorb** und werden bei „Löschen" weiterhin sofort endgültig entfernt, da -sie sich bei jedem gültigen Login automatisch neu anlegen (siehe -[LDAP-Kapitel](#ldap--active-directory-same-sign-on)) — ein -Papierkorb-Zwischenzustand wäre hier bedeutungslos. - -**Reservierter Schlüssel, solange etwas im Papierkorb liegt**: MAC-Adresse, -Hostname, Zugangsdaten-Name, Benutzername bzw. Gruppenname eines -gelöschten Eintrags bleiben bewusst reserviert und blockieren die -Neuanlage eines gleichnamigen Eintrags, bis der alte entweder -wiederhergestellt oder endgültig gelöscht (purged) wird — der Fehlertext -beim Anlegen weist entsprechend auf den Papierkorb hin. Kind-Datensätze -(Gruppenmitgliedschaften, Gruppenrechte, DHCP-Werte pro Gerät) werden beim -Soft-Delete bewusst **nicht** mit entfernt, damit eine Wiederherstellung -exakt den vorherigen Zustand zurückbringt — aufgeräumt werden sie erst beim -**endgültigen** Löschen (Purge). - -**Aufbewahrungsfrist**: konfigurierbar unter Systemeinstellungen -(`trash_retention_days`, Standard 30 Tage) — abgelaufene Einträge werden -beim Aufruf der Papierkorb-Seite automatisch endgültig gelöscht (kein -eigener Scheduler nötig), zusätzlich gibt es dort einen Button, um -abgelaufene Einträge sofort manuell zu bereinigen. - -## Eigenes Konto & Profilbild - -Das Zahnrad-Symbol neben dem eigenen Namen unten in der Sidebar führt zu -**„Mein Konto“** (`/account`): Vor-/Nachname ändern, ein Profilbild -hochladen (PNG/JPG/GIF/WEBP) und das eigene Passwort ändern (erfordert -Eingabe des aktuellen Passworts). Das ist bewusst getrennt vom Admin-Bereich -„Benutzer“, der andere Konten verwaltet. Ist ein Profilbild hinterlegt, -erscheint es statt der Initialen sowohl in der Sidebar als auch im -Änderungslog neben dem Benutzernamen. Die Navbar-Reihenfolge (für Admins) -liegt dagegen unter Systemeinstellungen, siehe -[Navigation anpassen](#navigation-anpassen). - -## Warnung bei ungespeicherten Änderungen - -Generischer, app-weiter Mechanismus (kein Opt-in pro Seite nötig): erkennt -automatisch, ob ein Formular mit echten Eingabefeldern (nicht nur -versteckten Aktions-Feldern wie bei Löschen-/Toggle-Buttons) seit dem Laden -der Seite verändert wurde, und fragt beim Verlassen (Klick auf einen Link, -z.B. in der Navigation) per eigenem **Ja/Nein-Dialog**, ob zuerst gespeichert -werden soll, statt Änderungen stillschweigend zu verwerfen: - -- **„Ja, speichern“**: sendet das betroffene Formular ganz normal ab (echter - POST, kein AJAX-Nachbau) und merkt sich das eigentliche Navigationsziel in - `sessionStorage`, um nach dem Speichern automatisch dorthin - weiterzuleiten — der Link muss dafür nicht ein zweites Mal angeklickt - werden. -- **„Nein, verwerfen“**: verwirft die Änderungen und wechselt direkt zur - Zielseite. -- Klickt man stattdessen auf das eigene „Speichern“/„Anlegen“ der Seite - direkt (ohne über einen Link zu einer anderen Seite zu gehen), passiert - **nichts zusätzlich** — kein Dialog, kein Browser-Popup, es wird einfach - ganz normal gespeichert. -- Für Browser-eigene Navigation (Tab schließen, Neuladen, Adresszeile) gibt - es zusätzlich `beforeunload` — dort erlaubt der Browser aus - Sicherheitsgründen nur eine generische Warnung ohne eigene Buttons, kein - Ja/Nein/Speichern-Dialog. Sie wird bewusst unterdrückt, sobald die Seite - ohnehin schon bewusst verlassen wird (eigenes Speichern oder der - Ja/Nein-Dialog) — sie soll ausschließlich bei tatsächlich ungefragter - Navigation erscheinen, nicht zusätzlich bei einem ganz normalen Speichern - (live als genau dieses Doppel-Popup reproduziert und behoben). -- Ausnahmen über `data-no-unsaved-guard` auf Formular oder Link (z.B. das - Login-Formular — Zugangsdaten eintippen ist keine "Einstellung"). - -Live per Playwright auf mehreren Seiten (Systemeinstellungen, DHCP-Options, -Gruppen anlegen) verifiziert: eigener Dialog erscheint nur bei tatsächlich -ungespeicherten Änderungen, „Ja“ speichert und leitet danach automatisch -zum ursprünglichen Ziel weiter, „Nein“ verwirft und wechselt direkt, ein -normales Speichern über den Seiten-eigenen Button zeigt weder eigenen noch -Browser-Dialog. - -Nebenbei wurde ein generisches Cache-Busting für `app.js`/`style.css` -ergänzt (`asset_url()`-Helper, hängt die Änderungszeit der Datei als -`?v=...` an) — vorher konnte ein bereits gecachtes, veraltetes `app.js` im -Browser nach einem Update scheinbar "nicht funktionierende" neue Funktionen -verursachen, obwohl der Code auf dem Server längst aktuell war. - -## Navigation anpassen - -Die Sidebar besteht aus zwei aufklappbaren Gruppen und mehreren flachen -Top-Level-Punkten dazwischen: - -- **Geräte** (Gruppe) — Clients (die eigentlichen Devices), Switche, - Zugangsdaten, Wartung (SSH-Bulk-Update/-Neustart), **Papierkorb** - (direkt unterhalb von Wartung, siehe [Papierkorb](#papierkorb)) -- **Benutzer**, **Gruppen** — eigene, flache Top-Level-Punkte direkt nach - „Geräte“ (bewusst nicht in einer Untergruppe versteckt) -- **Einstellungen** (Gruppe) — Systemeinstellungen (Host/Prüfintervall/ - Navbar-Reihenfolge/Papierkorb-Aufbewahrung), LDAP, DHCP, Im-/Export -- **Logs** (Gruppe) — Live, Änderungen, Kea-DHCP - -Die Reihenfolge der Top-Level-Punkte **und** der Unterpunkte innerhalb jeder -Gruppe ist für Admins unter **Systemeinstellungen** (eigene, nicht -einklappbare Karte „Navbar-Reihenfolge") per Auf-/Ab-Buttons konfigurierbar -(gespeichert als JSON unter `settings.nav_order` bzw. -`settings.nav_child_order`) und gilt global für alle Benutzer. Unabhängig -von der Reihenfolge sieht jeder Benutzer ohnehin nur, wofür er berechtigt -ist — sowohl auf Gruppenebene als auch pro Unterpunkt: hat ein Benutzer z.B. -nur `devices.view`, erscheint die Gruppe „Geräte“ nur mit dem Unterpunkt -„Clients“ (ohne Switche/Zugangsdaten/Wartung/Papierkorb); eine Gruppe -verschwindet komplett, wenn kein Unterpunkt sichtbar ist. - -## Entwicklung unter WSL - -Da das Projekt ausschließlich für Linux gebaut ist, empfiehlt sich die -Entwicklung direkt unter WSL2 (Ubuntu), auch wenn der Editor unter Windows -läuft — das Projektverzeichnis liegt dabei ganz normal unter -`C:\...\aruba-poe-modern` und wird aus WSL über `/mnt/c/...` erreicht: +## Installation auf einem Server ```bash -wsl --install -d Ubuntu # einmalig, falls noch nicht vorhanden -wsl -d Ubuntu -sudo apt update && sudo apt install -y python3 python3-venv python3-pip sqlite3 expect openssh-client -cd /mnt/c/Users//aruba-poe-modern/srv/tesm -python3 -m venv venv -./venv/bin/pip install -r requirements.txt -./venv/bin/python create_db.py -./venv/bin/python create_admin.py -./venv/bin/python app.py # http://localhost:5000 — via WSL2-Portweiterleitung - # auch aus dem Windows-Browser erreichbar +sudo bash deploy/install.sh --app tesm --https +sudo -u tesm tesm-admin create-admin ``` -**Hinweis zu `tesm-check.service`:** Der „Speichern & Service neustarten“-Button -unter Systemeinstellungen sowie das „Jetzt prüfen“-Icon funktionieren nur, -wenn `tesm-check.service` tatsächlich als echter systemd-Service läuft (viele -aktuelle WSL2-Installationen unterstützen systemd, siehe `wsl.conf` -`[boot] systemd=true`). Für lokale Tests reicht es, `etc/systemd/system/ -tesm-check.service` sowie `usr/local/bin/custom/poe.sh` per Symlink an ihre -Produktivpfade (`/etc/systemd/system/`, `/usr/local/bin/custom/`) zu legen -und `/srv/tesm` auf den Checkout zu verlinken, dann `systemctl -daemon-reload && systemctl enable --now tesm-check.service`. Ohne systemd -(z.B. `systemctl` nicht vorhanden) scheitert der Neustart-Aufruf lautlos — -das Intervall in der DB wird trotzdem aktualisiert, greift dann aber erst -nach einem manuellen Neustart des Prozesses. +Danach `https:///`. Ohne `--domain` entsteht ein selbstsigniertes +Zertifikat; der Browser warnt einmal, die Verbindung ist verschluesselt. -## Lokal starten (Entwicklung) +Mit oeffentlichem Namen und Let's Encrypt stattdessen: ```bash -cd srv/tesm -python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt - -python create_db.py # legt sqlite.db + Tabellen an -python create_admin.py # legt ersten Admin-Benutzer an - -python app.py # http://localhost:5000 +sudo bash deploy/install.sh --app tesm --https --domain tesm.firma.de --acme-email it@firma.de ``` -## Produktiv-Deployment (auf der Ziel-VM, App landet unter `/srv/tesm`) +Zielzustand: **Port 443 mit TLS, Port 80 leitet dorthin um** -- ausser dem +ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern. -Das Repo wird bewusst **nicht** direkt nach `/srv/tesm` geklont — -`install.sh` kopiert (`rsync`) den Inhalt von `srv/tesm/` aus dem Repo -gezielt dorthin und lässt Quell-Checkout und Zielverzeichnis damit sauber -getrennt (kein doppeltes `/srv/tesm/srv/tesm/…`): +Sitzt ein eigener Reverse Proxy davor, der TLS beendet, laeuft TESM ohne +`--https` schlicht auf Port 80. + +Eine zweite Installation zum Testen stoert die laufende nicht: ```bash -sudo git clone /opt/aruba-poe-modern-src -cd /opt/aruba-poe-modern-src -sudo bash install.sh -sudo /srv/tesm/venv/bin/python3 /srv/tesm/create_admin.py +sudo bash deploy/install.sh --app tesm --instance test --port 5100 --http-port 8080 ``` -Nach der Installation liegt die laufende App vollständig unter -**`/srv/tesm`** (so wie es `tesm.service`, `tesm-check.service` und -`POE_SCRIPT`/`TESM_DB_PATH` als Default erwarten) — `/opt/aruba-poe-modern-src` -wird nur als Checkout-Quelle für `install.sh` gebraucht und kann danach -liegen bleiben (für spätere Updates: `git pull` dort, dann `install.sh` -erneut ausführen) oder gelöscht werden. +Jede Instanz bekommt eigene Datenbank, eigene Schluessel, eigene nginx-Site und +ein eigenes Sitzungscookie. -`install.sh` installiert Systempakete, richtet die venv ein, initialisiert die -Datenbank (falls nicht vorhanden), kopiert `poe.sh` nach -`/usr/local/bin/custom/`, registriert die systemd-Units -(`tesm.service`, `tesm-check.service`, `tesm-check-restart.timer`) und -konfiguriert nginx als Reverse-Proxy auf Port 80. - -### Relevante Umgebungsvariablen (optional) - -| Variable | Zweck | Default | -|-----------------------|------------------------------------------|---------------------------------------| -| `TESM_DB_PATH` | Pfad zur SQLite-DB | `/sqlite.db` | -| `POE_SCRIPT` | Pfad zum PoE-Restart-Skript | `/usr/local/bin/custom/poe.sh` | -| `TESM_LOG_DIR` | Verzeichnis für `live.log`/`changes.log`/`app.log` | `/var/log/tesm` | -| `TESM_FERNET_KEY` | Pfad zum Fernet-Schlüssel | `/fernet.key` (auto-erzeugt)| -| `TESM_KNOWN_HOSTS` | Pfad zur SSH-known_hosts-Datei des Terminals| `/known_hosts` (auto-erzeugt)| -| `TESM_SECRET_KEY` | Flask Secret Key (überschreibt Datei) | zufällig, in `secret.key` gespeichert| -| `PORT`, `FLASK_DEBUG` | Flask-Serverport / Debug-Modus | `5000` / aus | - -### Frische Neuinstallation aus einem Release-Paket (`update.sh`) - -Für einen Host, dessen `/srv/tesm` bislang ad-hoc gepflegt wurde (z.B. per -manuellem Datei-Upload statt über `install.sh`), für das ursprüngliche, -noch ältere reine Bash-Setup ganz ohne Datenbank/Web-UI (`rpi-check.service` -+ statische `ips.list`), oder um einen sauberen Stand aus einem Gitea- -Release herzustellen, gibt es zusätzlich zum `git pull` + `install.sh`- -Update oben `update.sh` — es lädt ein fertig gepacktes **Gitea-Release** -direkt per HTTP herunter, **kein `git clone`/Git auf dem Zielsystem nötig**: +### Aktualisieren ```bash -sudo ./update.sh -# oder eine bestimmte Version statt "latest": -sudo TESM_RELEASE_TAG=v1.0.0 ./update.sh -# für ein privates Repo mit Zugriffstoken: -sudo TESM_GITEA_USER=alientim TESM_GITEA_TOKEN= ./update.sh +sudo bash deploy/update.sh --app tesm ``` -Das Skript erkennt und ersetzt dabei jede der drei historischen Ausbaustufen -dieses Hosts, egal welche gerade aktiv ist: +Das laedt ein Release, entpackt es und ruft dessen `install.sh` auf. Vor jedem +Update entsteht ein vollstaendiges Backup -- unabhaengig davon, ob sich das +Schema geaendert hat -- und danach laeuft ein Health-Check mit automatischem +Rollback. -1. das ursprüngliche, reine Bash-Setup aus dem separaten Repo - [WiS/Aruba-PoE](https://gitea.int.eertmoed.net/WiS/Aruba-PoE) - (`rpi-check.service`/`rpi-check-restart.*`, statische `ips.list`, keine - Datenbank/Web-UI), -2. die spätere Flask-App „PoE Manager" unter `/srv/poe_manager` - (`poe_web.service`, der Vorgänger von TESM in diesem Repo vor der - Umbenennung), -3. eine bereits während der Entwicklung ad-hoc gepflegte TESM-Instanz unter - `/srv/tesm`. +--- -Es stoppt/deaktiviert alle davon vorhandenen Dienste, verschiebt jedes -vorhandene `/srv/tesm` bzw. `/srv/poe_manager` **ohne es zu löschen** nach -`-backup-` (bleibt inkl. `sqlite.db` als -Rückfalloption und Datenquelle erhalten), lädt dann das Release-Paket -(`tesm-.tar.gz`, standardmäßig `latest`) von den Gitea-Releases dieses -Repos herunter und führt darin `install.sh` aus. - -**Ergebnis ist eine komplett frische Installation** — neue, leere Datenbank, -keine Geräte/Switche/Zugangsdaten/Benutzer/Einstellungen aus der alten -Instanz. Vor dem Ausführen deshalb unbedingt unter **Einstellungen → -Im-/Export** ein Backup der alten Instanz exportieren; danach entweder alles -manuell neu einrichten oder dieses Backup in die neue Installation -importieren (siehe [Import/Export](#importexport)). Das Skript weist sowohl -vor dem Start als auch am Ende explizit darauf hin. - -Ein neues Release paketieren (als Maintainer, nicht auf dem Zielsystem): +## Release bauen ```bash -git archive --format=tar.gz --prefix=tesm/ -o tesm-vX.Y.Z.tar.gz vX.Y.Z -# als Release-Asset zum passenden Tag "vX.Y.Z" UND zum beweglichen Tag -# "latest" (zeigt immer auf den neuesten Stand) auf Gitea hochladen. +git tag -a v2.0.0 -m "TESM 2.0.0" && git push origin v2.0.0 +bash deploy/release.sh v2.0.0 ``` -## Browser-SSH-Terminal (Switch-Verbindungstest) +Heraus kommt `tesm-v2.0.0.tar.gz` samt SHA-256. Der Name kommt aus dem +Repository, nicht aus einer festen Zeichenkette. -„Verbindung testen“ im Switch-Modal öffnet eine WebSocket-Verbindung zu -`/ws/ssh_terminal`. Serverseitig baut **paramiko** eine echte SSH-Sitzung -zum Switch auf (inkl. interaktiver Host-Key-Bestätigung beim Erstkontakt, -danach dauerhaft in `known_hosts` gemerkt) und reicht eine interaktive Shell -1:1 an ein xterm.js-Terminal im Browser durch. Die Zugangsdaten aus dem -Formular werden direkt für die SSH-Authentifizierung verwendet (nicht als -Tastatureingabe in die Shell getippt) — Erfolg/Fehlschlag der Anmeldung wird -sofort und eindeutig im Terminal angezeigt. Ein Klick auf „Passwort -einfügen“ sendet den aktuell im Formular eingetragenen Wert zusätzlich als -Tastatureingabe (z.B. für ein `sudo`-Passwort in der geöffneten Sitzung). +--- -Voraussetzungen: -- Läuft auf **jedem Betriebssystem** (paramiko ist reines Python) — die - SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut, nie vom - Browser/Client. -- Nur für eingeloggte **Admins** erreichbar. -- Der Flask-Entwicklungsserver muss mit `threaded=True` laufen (ist bereits - so konfiguriert) — für einen echten Produktivbetrieb empfiehlt sich - ohnehin ein WSGI-Server mit WebSocket-Unterstützung (z.B. Gunicorn mit - `gevent`- oder `eventlet`-Worker) statt des eingebauten Dev-Servers. -- **nginx muss den WebSocket-Upgrade für `/ws/` durchreichen** (`Upgrade`/ - `Connection`-Header, siehe `etc/nginx/sites-available/tesm`) — ohne - das lehnt Flask den Handshake mit `400 Bad Request` ab und das Terminal - bleibt leer auf „Getrennt“ hängen, obwohl derselbe Aufruf direkt gegen - Port 5000 (an nginx vorbei) funktioniert. War auf einer frisch per - `install.sh` aufgesetzten Ubuntu-VM live reproduzierbar, da alle bisherigen - Terminal-Tests ausschließlich direkt gegen Flask liefen, nie durch nginx. -- Da `tesm.service` (wie im Original) als `root` läuft, kann über dieses - Terminal im Prinzip jeder erreichbare Host per SSH kontaktiert werden — - bewusst so gewählt, da Admins über die App ohnehin schon vollen Zugriff auf - alle hinterlegten Switch-Zugangsdaten haben. Für eine strengere Trennung - empfiehlt es sich, `tesm.service` künftig unter einem eigenen, - unprivilegierten Systembenutzer laufen zu lassen (im Original wie auch hier - aktuell `root`, um Änderungen am produktiven Setup nicht zu vergrößern). - -## Projektstruktur +## Entwicklung +```bash +python -m venv .venv && . .venv/bin/activate +pip install -e packages/tesm-licensing -e packages/tesm-core -e apps/tesm +pip install pytest +pytest tests -q ``` -srv/tesm/ Flask-App (Backend + Templates + Static-Assets) -usr/local/bin/custom/ poe.sh — Ping-Check & PoE-Restart per SSH (expect) -etc/systemd/system/ Service-/Timer-Units (Web-App + Check-Dienst) -etc/nginx/ Reverse-Proxy-Konfiguration -install.sh Produktiv-Installer + +Ohne gesetzte Umgebungsvariablen legt die Anwendung ihre Daten unter +`instance/tesm/` im Arbeitsverzeichnis an -- kein Zugriff auf `/srv` oder +`/var/log` noetig. + +```bash +TESM_DEBUG=1 flask --app tesm run ``` + +Im Debug-Modus wird `COOKIE_SECURE` abgeschaltet, sonst kaeme das +Sitzungscookie ueber `http://127.0.0.1` nie an. + +--- + +## Verwaltung von der Kommandozeile + +```bash +sudo -u tesm tesm-admin status # Zustand der Installation +sudo -u tesm tesm-admin create-admin # Konto anlegen oder zuruecksetzen +sudo -u tesm tesm-admin check-now # Ueberwachungsdurchlauf sofort +sudo -u tesm tesm-admin housekeeping # Sitzungen und Protokolle aufraeumen +sudo -u tesm tesm-admin web-setup --https # nginx und TLS einrichten +``` + +### Umzug aus dem alten TESM + +Ein Export des Vorgaengers laesst sich uebersetzen und dann ueber den normalen +Import einlesen -- Clients, Switche, Zugangsdaten und die DHCP-Konfiguration: + +```bash +tesm-admin convert-legacy tesm_export_20260813_195455.json --dry-run +``` + +`--dry-run` zeigt nur den Bericht. Ohne die Option entsteht eine Datei im +neuen Format, die unter *Verwaltung -> Sicherung* eingelesen wird. Der Bericht +nennt ausdruecklich, was **nicht** uebersetzt werden kann (lokale Konten, Rechte, +Verzeichnisdienst, Webserver) -- das ist von Hand nachzuziehen. + +Die Passphrase gehoert an die verdeckte Eingabeaufforderung. `--passphrase` +existiert nur fuer Automatisierung: es schreibt sie in Shell-Historie und +Prozessliste. + +--- + +## Dokumentation + +| | | +|---|---| +| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Aufbau, Schnitte und die Gruende dafuer | +| [docs/SICHERHEIT.md](docs/SICHERHEIT.md) | Anmeldung, Sitzungen, CSRF, Geheimnisse, Rechtetrennung | +| [docs/BETRIEB.md](docs/BETRIEB.md) | Installation, Update, HTTPS, Sicherung, Fehlersuche | + +--- + +## Tests + +```bash +pytest tests -q +``` + +Dazu ein Skript, das **auf dem Zielhost gegen die laufende Installation** +spricht -- ueber nginx, gunicorn und echtes TLS. Es wird nicht mitinstalliert +(`install.sh` laesst `tests/` aus); bei Bedarf dorthin kopieren. Jeder Lauf legt +eigene Datensaetze an, ist also beliebig oft wiederholbar: + +```bash +python3 tests/e2e_smoke.py https://host admin 'Passwort' +``` + +--- + +## Was diese Fassung anders macht + +Ein Neubau. Die Fehler des Vorgaengers sind bekannt; an den Stellen, an denen +sie entstanden sind, steht jetzt ein Kommentar, der erklaert, warum es hier +anders geloest ist. Die wichtigsten: + +* **Kein root fuer die Anwendung.** Ein einziger allowlisted Helfer mit + Argumentpruefung statt eines Webprozesses als `root`. +* **CSRF-Schutz ueberhaupt.** Der Vorgaenger hatte keinen. +* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet. +* **Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.** Ein + falscher `alias`-Pfad ueberlebte im Vorgaenger jahrelang jedes Update. +* **Kein Hostname im Lizenz-Fingerabdruck.** Umbenennen brach die Bindung. +* **Zwei Installationen stoeren sich nicht** -- eigener `SITE_KEY` fuer + nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie. +* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock. +* **Jede Konfiguration wird vor dem Uebernehmen geprueft** -- nginx wie Kea. + Eine fehlerhafte Datei faellt beim Schreiben auf, nicht erst beim naechsten + Start des Dienstes. diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml new file mode 100644 index 0000000..b639514 --- /dev/null +++ b/apps/tesm/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "tesm" +version = "2.0.0" +description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" +requires-python = ">=3.11" +dependencies = [ + "tesm-core>=2.0.0", + "tesm-licensing>=2.0.0", + "paramiko>=3.5", + "PyYAML>=6.0", + "ldap3>=2.9", + "Flask-Sock>=0.7", +] + +[project.optional-dependencies] +server = ["gunicorn>=23.0"] + +[project.scripts] +tesm-admin = "tesm.cli:main" +tesm-monitor = "tesm.worker:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +tesm = ["templates/**/*.html", "static/**/*"] diff --git a/apps/tesm/src/tesm/__init__.py b/apps/tesm/src/tesm/__init__.py new file mode 100644 index 0000000..53cedb7 --- /dev/null +++ b/apps/tesm/src/tesm/__init__.py @@ -0,0 +1,296 @@ +"""TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen. + +Diese Datei ist die Anwendungsfabrik. Sie tut drei Dinge und nicht mehr: +Konfiguration laden, das gemeinsame Geruest aus ``tesm_core`` aufbauen, die +eigenen Blueprints anmelden. Der Vorgaenger hatte an dieser Stelle eine +``app.py`` mit mehreren tausend Zeilen -- Routen, SQL, SSH, DHCP-Erzeugung und +Templates in einer Datei. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from flask import Flask, current_app +from tesm_core.app_factory import create_base_app +from tesm_core.config import load_core_config +from tesm_core.extension import core +from tesm_core.views import trash as trash_views + +from .navigation import NAVIGATION +from .permissions import PERMISSIONS, ROLE_PRESETS +from .schema import TESM_MIGRATIONS +from .settings_spec import TESM_SETTINGS + +__version__ = "2.0.0" + +APP_KEY = "tesm" +APP_NAME = "TESM" +APP_SHORT = "TE" +APP_EDITION = "Geraeteverwaltung" +#: Funktionszeile unter der Wortmarke auf der Anmeldeseite. Uebernommen vom +#: Vorgaenger, wo sie Teil des Hintergrundmotivs war -- sie sagt in einer Zeile, +#: was die Anwendung tut, und das gehoert auf die erste Seite. +AUTH_TAGLINE = ( + "UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · " + "BETRIEBSSYSTEM GEBEN (PXE)" +) +ENV_PREFIX = "TESM_" + +logger = logging.getLogger(__name__) + + +def create_app(**overrides: Any) -> Flask: + config = load_core_config( + app_key=APP_KEY, app_name=APP_NAME, env_prefix=ENV_PREFIX + ) + + app = create_base_app( + config=config, + permissions=PERMISSIONS, + nav=NAVIGATION, + role_presets=ROLE_PRESETS, + app_migrations={"tesm": TESM_MIGRATIONS}, + settings_specs=TESM_SETTINGS, + import_name="tesm", + app_short=APP_SHORT, + app_edition=APP_EDITION, + auth_tagline=AUTH_TAGLINE, + app_version=__version__, + enable_license=True, + ) + + _register_blueprints(app) + _register_registry(app) + _register_trash_providers(app) + _startup_tasks(app) + + if overrides: + app.config.update(overrides) + return app + + +def _register_blueprints(app: Flask) -> None: + from .blueprints import ( + backup, + credentials, + dashboard, + devices, + dhcp, + fileshare, + license as license_bp, + logs, + maintenance, + settings, + switches, + hostkeys, + terminal, + ) + + app.register_blueprint(dashboard.bp) + app.register_blueprint(devices.bp) + app.register_blueprint(switches.bp) + app.register_blueprint(credentials.bp) + app.register_blueprint(hostkeys.bp) + app.register_blueprint(maintenance.bp) + app.register_blueprint(dhcp.bp) + app.register_blueprint(fileshare.bp) + app.register_blueprint(logs.bp) # tesmlogs: Kea + Neustarts + app.register_blueprint(settings.bp) + app.register_blueprint(license_bp.bp) + app.register_blueprint(backup.bp) + terminal.init_app(app) + + # Die Statusuebersicht ist die Startseite; ``index`` bleibt als Endpunkt + # bestehen, weil der Kern (Weiterleitungen, Fehlerseiten) darauf verweist. + app.add_url_rule("/", "index", dashboard.index_view) + + +def _register_registry(app: Flask) -> None: + """Haengt app-spezifische Bausteine in den Kern ein.""" + from .services import inventory, monitor + + extension = core(app) + extension.registry["reencrypt_secrets"] = inventory.reencrypt_secrets + # Angaben fuer die nginx-Vorlage im Kern. + extension.registry["upstream"] = extension.config.bind + extension.registry["static_root"] = str(extension.config.base_dir / "static") + extension.registry["websocket_path"] = "/ws/" + extension.registry["public_dashboard_url"] = "/" + extension.registry["live_log"] = monitor.LiveLog(extension.config.log_dir / "live.log") + extension.registry["status_pill_builder"] = _status_pills + extension.login_hooks.append(_mount_shares_after_login) + extension.logout_hooks.append(_release_shares_on_logout) + + +def _mount_shares_after_login(conn: Any, user: Any, secret: str, group_dns: tuple) -> None: + """Bindet nach der Anmeldung die Freigaben dieses Benutzers ein. + + Ohne diesen Haken blieb der ganze Freigabe-Teil Theorie: Zuordnungen liessen + sich anlegen, es entstand aber nie eine Einbindung, und weil der Menuepunkt + an vorhandenen Einbindungen haengt, war die Seite nie zu sehen. + """ + from flask import session + + from .services import fileshare + + extension = core() + if extension.license is not None and not extension.license.module_active("fileshare"): + return + + with extension.database.transaction(conn): + messages = fileshare.mount_for_login( + conn, + user_id=user.id, + session_id=getattr(session, "sid", ""), + username=user.username, + password=secret, + group_dns=group_dns, + ) + for message in messages: + current_app.logger.info("Dateifreigabe fuer %s -- %s", user.username, message) + + +def _release_shares_on_logout(conn: Any, user: Any) -> None: + """Haengt die Freigaben dieses Benutzers beim Abmelden wieder aus. + + Eingebunden wird mit dem Konto des Benutzers. Bliebe die Einbindung nach + dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann + die Zeitgrenze greift. Das ist zu lange. + """ + from .services import fileshare + + extension = core() + with extension.database.transaction(conn): + released = fileshare.release_all_for_user(conn, user.id) + if released: + current_app.logger.info( + "Dateifreigaben fuer %s geloest: %d", user.username, released + ) + + +def _status_pills(user: Any) -> list[dict[str, str]]: + """Zusaetzliche Anzeigen in der Topbar.""" + if not user.is_authenticated: + return [] + from tesm_core.extension import db + + from .services import kea, monitor + + extension = core() + pills: list[dict[str, str]] = [] + + counts = monitor.dashboard_counts(db()) + if counts["offline"]: + pills.append( + { + "label": f"{counts['offline']} offline", + "css_class": "status-pill--danger", + "dot": "1", + "dot_class": "status-pill__dot--pulse", + "title": "Geraete, die beim letzten Durchlauf nicht geantwortet haben.", + } + ) + + if user.has_any(("dhcp.view", "dhcp.edit")) and extension.license is not None: + if extension.license.module_active("dhcp") and extension.sysops is not None: + state = extension.sysops.service_state(kea.SERVICE_UNIT) + if state["installed"] == "ja": + active = state["active"] == "active" + pills.append( + { + "label": "DHCP aktiv" if active else "DHCP gestoppt", + "css_class": "" if active else "status-pill--warning", + "dot": "1", + "dot_class": "", + "title": f"kea-dhcp4-server: {state['active']}, {state['enabled']}", + } + ) + return pills + + +def _register_trash_providers(app: Flask) -> None: + from .services import inventory + + with app.app_context(): + trash_views.register( + trash_views.TrashProvider( + key="devices", + label="Clients", + icon="cpu", + list_fn=lambda conn: inventory.trash_entries( + conn, "devices", label_column="name", detail_columns=("mac", "ip") + ), + restore_fn=lambda conn, ident: inventory.restore_row( + conn, "devices", int(ident), unique_column="mac" + ), + purge_fn=lambda conn, ident: inventory.purge_row(conn, "devices", int(ident)), + hint="Die MAC-Adresse bleibt reserviert, solange das Geraet hier liegt.", + ) + ) + trash_views.register( + trash_views.TrashProvider( + key="switches", + label="Switche", + icon="switch", + list_fn=lambda conn: inventory.trash_entries( + conn, "switches", label_column="hostname", detail_columns=("ip", "model") + ), + restore_fn=lambda conn, ident: inventory.restore_row( + conn, "switches", int(ident), unique_column="hostname" + ), + purge_fn=lambda conn, ident: inventory.purge_row(conn, "switches", int(ident)), + ) + ) + trash_views.register( + trash_views.TrashProvider( + key="credentials", + label="Zugangsdaten", + icon="key", + list_fn=lambda conn: inventory.trash_entries( + conn, "credentials", label_column="name", detail_columns=("username", "category") + ), + restore_fn=lambda conn, ident: inventory.restore_row( + conn, "credentials", int(ident), unique_column="name" + ), + purge_fn=lambda conn, ident: inventory.purge_row(conn, "credentials", int(ident)), + hint="Endgueltiges Loeschen entfernt auch das gespeicherte Passwort.", + ) + ) + + +def _startup_tasks(app: Flask) -> None: + """Einmalige Aufgaben beim Start des echten Webprozesses. + + Der Vorgaenger fuehrte solche Aufgaben bei **jedem** Modul-Import aus -- ein + Hilfsskript, das ``app.py`` importierte, raeumte dadurch die + Datei-Einbindungen fremder, aktiver Sitzungen ab. Darum laufen sie hier nur, + wenn die systemd-Unit den Prozess ausdruecklich als Webprozess markiert hat. + """ + extension = core(app) + if not extension.config.web_process: + logger.info( + "Startaufgaben uebersprungen -- dieser Prozess ist nicht als Webprozess markiert." + ) + return + + from .services import maintenance + + with extension.database.session() as conn, extension.database.transaction(conn): + orphaned = maintenance.mark_orphaned_jobs(conn) + if orphaned: + logger.warning("%s unterbrochene Wartungsauftraege als unklar markiert", orphaned) + + if extension.license is not None: + import socket + + extension.license.start_heartbeat(hostname=socket.gethostname()) + + +# Fuer ``gunicorn tesm:app`` +def _lazy_app() -> Flask: # pragma: no cover - nur vom WSGI-Server benutzt + return create_app() + + +app = None # wird von wsgi.py gesetzt diff --git a/apps/tesm/src/tesm/blueprints/__init__.py b/apps/tesm/src/tesm/blueprints/__init__.py new file mode 100644 index 0000000..9d2b258 --- /dev/null +++ b/apps/tesm/src/tesm/blueprints/__init__.py @@ -0,0 +1 @@ +"""Blueprints von TESM.""" diff --git a/apps/tesm/src/tesm/blueprints/backup.py b/apps/tesm/src/tesm/blueprints/backup.py new file mode 100644 index 0000000..5c6ffc0 --- /dev/null +++ b/apps/tesm/src/tesm/blueprints/backup.py @@ -0,0 +1,236 @@ +"""Sicherung und Uebertragung: verschluesselter Export, zweistufiger Import.""" + +from __future__ import annotations + +from datetime import datetime + +from flask import Blueprint, Response, flash, redirect, render_template, request, session, url_for +from tesm_core import audit +from tesm_core.auth.models import current_user, permission_required, reauth_required +from tesm_core.auth.service import client_ip +from tesm_core.extension import core, db, transaction +from tesm_core.licensing_client import LicenseError + +from ..services import transfer + +bp = Blueprint("backup", __name__, url_prefix="/sicherung") + +MAX_IMPORT_BYTES = 64 * 1024 * 1024 +PREVIEW_SESSION_KEY = "_import_token" + + +def _audit(action: str, detail: str = "", severity: str = "info") -> None: + with transaction() as conn: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action=action, + detail=detail, + ip=client_ip(), + severity=severity, + ) + + +def _allowed_categories() -> list[transfer.Category]: + allowed: list[transfer.Category] = [] + for category in transfer.CATEGORIES: + if category.admin_only and not current_user.is_admin: + continue + if category.sensitive and not current_user.has("backup.secrets"): + continue + allowed.append(category) + return allowed + + +@bp.get("/") +@permission_required("backup.view", "backup.export", "backup.execute", mode="any") +def overview(): + extension = core() + conn = db() + preview = None + token = session.get(PREVIEW_SESSION_KEY, "") + if token: + try: + payload = transfer.load_preview(conn, token, actor=current_user.username) + preview = {"token": token, "summary": transfer.summarize(payload)} + except transfer.TransferError: + session.pop(PREVIEW_SESSION_KEY, None) + + license_hint = "" + if extension.license is not None: + try: + extension.license.require("export") + except LicenseError as exc: + license_hint = str(exc) + + return render_template( + "tesm/backup.html", + categories=_allowed_categories(), + all_categories=transfer.CATEGORIES, + preview=preview, + min_passphrase=transfer.MIN_PASSPHRASE_LENGTH, + preview_ttl=transfer.PREVIEW_TTL_MINUTES, + license_hint=license_hint, + can_secrets=current_user.has("backup.secrets"), + ) + + +@bp.post("/export") +@permission_required("backup.export") +@reauth_required +def export(): + extension = core() + conn = db() + if extension.license is not None: + try: + extension.license.require("export") + except LicenseError as exc: + flash(str(exc), "danger") + return redirect(url_for("backup.overview")) + + allowed = {category.key for category in _allowed_categories()} + chosen = [key for key in request.form.getlist("categories") if key in allowed] + if not chosen: + flash("Es wurde keine Kategorie ausgewaehlt.", "warning") + return redirect(url_for("backup.overview")) + + include_secrets = ( + request.form.get("include_secrets") == "1" and current_user.has("backup.secrets") + ) + passphrase = request.form.get("passphrase") or "" + repeat = request.form.get("passphrase_repeat") or "" + if passphrase != repeat: + flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger") + return redirect(url_for("backup.overview")) + + try: + payload = transfer.build_export( + conn, extension.keystore, categories=chosen, include_secrets=include_secrets + ) + envelope = transfer.seal(payload, passphrase) + except transfer.TransferError as exc: + flash(str(exc), "danger") + return redirect(url_for("backup.overview")) + + _audit( + "backup.exported", + f"Kategorien: {', '.join(chosen)}" + (" (mit Geheimnissen)" if include_secrets else ""), + severity="warning" if include_secrets else "notice", + ) + stamp = datetime.now().strftime("%Y%m%d-%H%M") + return Response( + envelope.encode("utf-8"), + mimetype="application/json", + headers={ + "Content-Disposition": f'attachment; filename="tesm-sicherung-{stamp}.json"', + "Cache-Control": "no-store", + }, + ) + + +@bp.post("/import/vorschau") +@permission_required("backup.execute") +@reauth_required +def import_preview(): + conn = db() + upload = request.files.get("backup_file") + if upload is None or not upload.filename: + flash("Keine Datei ausgewaehlt.", "danger") + return redirect(url_for("backup.overview")) + raw = upload.stream.read(MAX_IMPORT_BYTES + 1) + if len(raw) > MAX_IMPORT_BYTES: + flash("Die Datei ist zu gross.", "danger") + return redirect(url_for("backup.overview")) + + try: + payload, header = transfer.unseal(raw, request.form.get("passphrase") or "") + except transfer.TransferError as exc: + flash(str(exc), "danger") + return redirect(url_for("backup.overview")) + + with transaction(): + token = transfer.store_preview(conn, payload, actor=current_user.username) + session[PREVIEW_SESSION_KEY] = token + _audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice") + flash( + f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die " + "Uebernahme zu bestaetigen.", + "success", + ) + return redirect(url_for("backup.overview")) + + +@bp.post("/import/uebernehmen") +@permission_required("backup.execute") +@reauth_required +def import_apply(): + extension = core() + conn = db() + token = session.get(PREVIEW_SESSION_KEY, "") + if not token: + flash("Es liegt keine geoeffnete Sicherung vor.", "danger") + return redirect(url_for("backup.overview")) + + allowed = {category.key for category in _allowed_categories()} + chosen = [key for key in request.form.getlist("categories") if key in allowed] + if not chosen: + flash("Es wurde keine Kategorie ausgewaehlt.", "warning") + return redirect(url_for("backup.overview")) + + try: + payload = transfer.load_preview(conn, token, actor=current_user.username) + except transfer.TransferError as exc: + session.pop(PREVIEW_SESSION_KEY, None) + flash(str(exc), "danger") + return redirect(url_for("backup.overview")) + + # Wichtig: alles in **einer** Transaktion auf **einer** Verbindung. Der + # Vorgaenger oeffnete hier ueber set_setting() eine zweite Verbindung und lief + # dadurch in "database is locked". + with transaction(): + report = transfer.apply_import( + conn, + extension.keystore, + payload, + categories=chosen, + set_setting=lambda key, value: extension.settings.set( + conn, key, value, actor=current_user.username + ), + ) + transfer.drop_preview(conn, token) + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="backup.imported", + detail="; ".join( + f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, " + f"{counts['skipped']} uebersprungen" + for key, counts in report.items() + ), + ip=client_ip(), + severity="warning", + ) + session.pop(PREVIEW_SESSION_KEY, None) + + lines = [ + f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, " + f"{counts['updated']} aktualisiert" + + (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "") + for key, counts in report.items() + if key in transfer.CATEGORY_BY_KEY + ] + flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success") + return redirect(url_for("backup.overview")) + + +@bp.post("/import/verwerfen") +@permission_required("backup.execute") +def import_discard(): + token = session.pop(PREVIEW_SESSION_KEY, "") + if token: + with transaction() as conn: + transfer.drop_preview(conn, token) + flash("Zwischenstand verworfen.", "info") + return redirect(url_for("backup.overview")) diff --git a/apps/tesm/src/tesm/blueprints/credentials.py b/apps/tesm/src/tesm/blueprints/credentials.py new file mode 100644 index 0000000..3b18f44 --- /dev/null +++ b/apps/tesm/src/tesm/blueprints/credentials.py @@ -0,0 +1,143 @@ +"""Zugangsdaten: pflegen und -- mit eigenem Recht -- im Klartext ansehen.""" + +from __future__ import annotations + +from typing import Any + +from flask import Blueprint, abort, flash, redirect, render_template, request, url_for +from tesm_core import audit +from tesm_core.auth.models import current_user, permission_required, reauth_required +from tesm_core.auth.service import client_ip +from tesm_core.extension import core, db, transaction + +from ..services import inventory + +bp = Blueprint("credentials", __name__, url_prefix="/zugangsdaten") + + +def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action=action, + target=target, + detail=detail, + ip=client_ip(), + severity=severity, + ) + + +@bp.get("/") +@permission_required("credentials.view") +def overview(): + conn = db() + return render_template( + "tesm/credentials.html", + credentials=inventory.list_credentials(conn), + categories=inventory.CREDENTIAL_CATEGORIES, + ) + + +@bp.post("/neu") +@permission_required("credentials.create") +def create(): + try: + with transaction() as conn: + inventory.create_credential( + conn, + core().keystore, + name=request.form.get("name", ""), + username=request.form.get("username", ""), + secret=request.form.get("secret", ""), + category=request.form.get("category", "switch"), + description=request.form.get("description", ""), + actor=current_user.username, + ) + _audit(conn, "credential.created", request.form.get("name", ""), severity="notice") + flash("Zugangsdaten angelegt.", "success") + except inventory.ValidationError as exc: + flash(str(exc), "danger") + return redirect(url_for("credentials.overview")) + + +@bp.post("/") +@permission_required("credentials.edit") +def update(credential_id: int): + try: + with transaction() as conn: + changes = inventory.update_credential( + conn, + core().keystore, + credential_id, + name=request.form.get("name", ""), + username=request.form.get("username", ""), + secret=request.form.get("secret") or None, + category=request.form.get("category", "switch"), + description=request.form.get("description", ""), + actor=current_user.username, + ) + if changes: + _audit( + conn, + "credential.updated", + request.form.get("name", str(credential_id)), + "; ".join(changes), + severity="notice", + ) + flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") + except inventory.ValidationError as exc: + flash(str(exc), "danger") + return redirect(url_for("credentials.overview")) + + +@bp.post("//loeschen") +@permission_required("credentials.delete") +def delete(credential_id: int): + try: + with transaction() as conn: + entry = next( + (c for c in inventory.list_credentials(conn) if c.id == credential_id), None + ) + if entry is None: + abort(404) + inventory.delete_credential(conn, credential_id) + _audit(conn, "credential.deleted", entry.name, severity="warning") + flash("Zugangsdaten in den Papierkorb verschoben.", "success") + except inventory.ValidationError as exc: + flash(str(exc), "danger") + return redirect(url_for("credentials.overview")) + + +@bp.post("//anzeigen") +@permission_required("credentials.secrets") +@reauth_required +def reveal(credential_id: int): + """Zeigt ein gespeichertes Passwort im Klartext. + + Drei Huerden, bewusst: eigenes Recht (``credentials.secrets``), frische + Passwortbestaetigung und ein Eintrag im Aenderungsprotokoll mit Zeitpunkt, + Benutzer und IP. Der Vorgaenger kannte keine dieser Huerden -- wer + Zugangsdaten bearbeiten durfte, konnte sie exportieren und damit lesen. + """ + conn = db() + entry = next((c for c in inventory.list_credentials(conn) if c.id == credential_id), None) + if entry is None: + abort(404) + try: + username, secret = inventory.credential_secret(conn, core().keystore, credential_id) + except inventory.ValidationError as exc: + flash(str(exc), "danger") + return redirect(url_for("credentials.overview")) + + with transaction(): + _audit( + conn, + "credential.secret_revealed", + entry.name, + "Klartextanzeige", + severity="warning", + ) + return render_template( + "tesm/credential_reveal.html", entry=entry, username=username, secret=secret + ) diff --git a/apps/tesm/src/tesm/blueprints/dashboard.py b/apps/tesm/src/tesm/blueprints/dashboard.py new file mode 100644 index 0000000..8306118 --- /dev/null +++ b/apps/tesm/src/tesm/blueprints/dashboard.py @@ -0,0 +1,148 @@ +"""Statusuebersicht -- die Startseite. + +Bewusst auch ohne Anmeldung erreichbar (abschaltbar ueber die Einstellung +``dashboard_public``): sie ist ein Informationsboard fuer jeden im Netz, damit +niemand sich anmelden muss, um zu sehen, ob ein Geraet laeuft. + +Ohne Anmeldung zeigt sie **nur** Name und Erreichbarkeit aktiver Geraete. Keine +IP-Adressen, keine MAC, keine Switch-/Portzuordnung, keine Zaehler zu +deaktivierten Geraeten -- der Vorgaenger gab hier deutlich mehr heraus, als ein +oeffentliches Board hergeben sollte. +""" + +from __future__ import annotations + +from typing import Any + +from flask import Blueprint, abort, render_template, request +from tesm_core.extension import core, db + +from ..services import monitor + +bp = Blueprint("dashboard", __name__) + + +def _maintenance_active() -> bool: + from tesm_core.extension import core + + manager = core().license + return manager is None or manager.module_active("maintenance") + + +def _public_allowed() -> bool: + return bool(core().settings.get(db(), "dashboard_public")) + + +def _tiles(*, authenticated: bool) -> dict[str, Any]: + from tesm_core.auth.models import current_user + + from ..services import inventory, restart + + conn = db() + counts = monitor.dashboard_counts(conn) + rows = inventory.list_devices(conn, only_active=not authenticated) + + detailed = authenticated and current_user.has("devices.view") + tiles: list[dict[str, Any]] = [] + for row in rows: + entry: dict[str, Any] = { + "id": int(row["id"]), + "name": str(row["name"]), + "state": "disabled" if not row["is_active"] else str(row["state"]), + "last_change_at": row["last_change_at"], + "category": str(row["category"] or ""), + "location": str(row["location"] or ""), + } + if detailed: + entry.update( + { + "ip": str(row["ip"]), + "mac": str(row["mac"]), + "switch": str(row["switch_hostname"] or ""), + "port": str(row["port"] or ""), + "latency_ms": row["latency_ms"], + "last_check_at": row["last_check_at"], + "restart_count": row["restart_count"] or 0, + "credential_category": str(row["credential_category"] or ""), + # Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC + # neu startbar sein. Die Kachel zeigt den bevorzugten Weg. + "restart_methods": restart.available( + row, maintenance_active=_maintenance_active() + ), + } + ) + tiles.append(entry) + + sections = [ + { + # Vor "nicht erreichbar": ein Geraet, das gerade neu startet, ist + # erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst + # wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall. + "key": "restarting", + "label": "Neustart laeuft", + "kind": "warning", + "tiles": [tile for tile in tiles if tile["state"] == "restarting"], + }, + { + "key": "offline", + "label": "Nicht erreichbar", + "kind": "danger", + "tiles": [tile for tile in tiles if tile["state"] == "offline"], + }, + { + "key": "online", + "label": "Erreichbar", + "kind": "ok", + "tiles": [tile for tile in tiles if tile["state"] == "online"], + }, + { + "key": "unknown", + "label": "Noch nicht geprueft", + "kind": "warning", + "tiles": [tile for tile in tiles if tile["state"] == "unknown"], + }, + ] + if authenticated: + sections.append( + { + "key": "disabled", + "label": "Deaktiviert", + "kind": "muted", + "tiles": [tile for tile in tiles if tile["state"] == "disabled"], + } + ) + + return { + "counts": counts, + "sections": sections, + "detailed": detailed, + "interval": int(core().settings.get(conn, "monitor_interval_seconds") or 300), + } + + +@bp.get("/uebersicht") +def index(): + from tesm_core.auth.models import current_user + + if not current_user.is_authenticated and not _public_allowed(): + from flask import redirect, url_for + + return redirect(url_for("auth.login")) + data = _tiles(authenticated=bool(current_user.is_authenticated)) + return render_template("tesm/dashboard.html", **data) + + +def index_view(): + """Wird als Endpunkt ``index`` registriert (Startseite).""" + return index() + + +@bp.get("/uebersicht/kacheln") +def tiles(): + """Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment.""" + from tesm_core.auth.models import current_user + + if not current_user.is_authenticated and not _public_allowed(): + abort(404) + data = _tiles(authenticated=bool(current_user.is_authenticated)) + return render_template("tesm/_dashboard_tiles.html", **data) diff --git a/apps/tesm/src/tesm/blueprints/devices.py b/apps/tesm/src/tesm/blueprints/devices.py new file mode 100644 index 0000000..8463472 --- /dev/null +++ b/apps/tesm/src/tesm/blueprints/devices.py @@ -0,0 +1,428 @@ +"""Clients: Bestand pflegen, Neustart ausloesen, SSH-Host-Schluessel freigeben. + +Die Host-Schluessel-Freigabe gab es zuerst nur fuer Switche. Fuer Clients +fehlte sie -- mit der Folge, dass die Wartung sich weigerte ("Schluessel +unbekannt") und es keine Stelle gab, an der man ihn haette ansehen und +freigeben koennen. Eine Sackgasse, aus der man nur mit einem Shell-Zugang +auf den Server herauskam. +""" + +from __future__ import annotations + +from typing import Any + +from flask import Blueprint, abort, flash, redirect, render_template, request, url_for +from tesm_core import audit +from tesm_core.auth.models import current_user, permission_required, reauth_required +from tesm_core.auth.service import client_ip +from tesm_core.db import Database +from tesm_core.extension import core, db, transaction + +from ..services import inventory, monitor, restart, ssh + +bp = Blueprint("devices", __name__, url_prefix="/clients") + + +def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action=action, + target=target, + detail=detail, + ip=client_ip(), + severity=severity, + ) + + +def _maintenance_active() -> bool: + manager = core().license + return manager is None or manager.module_active("maintenance") + + +def _form_values() -> dict[str, Any]: + return { + "name": request.form.get("name", ""), + "mac": request.form.get("mac", ""), + "ip": request.form.get("ip", ""), + "switch_id": request.form.get("switch_id") or None, + "port": request.form.get("port", ""), + "credential_id": request.form.get("credential_id") or None, + "ssh_port": request.form.get("ssh_port") or 22, + "category": request.form.get("category", ""), + "location": request.form.get("location", ""), + "notes": request.form.get("notes", ""), + "is_active": request.form.get("is_active") == "1", + "auto_restart": request.form.get("auto_restart") == "1", + "actor": current_user.username, + } + + +@bp.get("/") +@permission_required("devices.view") +def overview(): + conn = db() + devices = inventory.list_devices(conn) + maintenance_active = _maintenance_active() + return render_template( + "tesm/devices.html", + devices=devices, + restart_methods={ + int(device["id"]): restart.available( + device, maintenance_active=maintenance_active + ) + for device in devices + }, + switches=inventory.list_switches(conn), + category_labels=dict(inventory.CREDENTIAL_CATEGORIES), + credential_categories=inventory.CREDENTIAL_CATEGORIES, + category_effects=inventory.CATEGORY_EFFECTS, + credentials=inventory.list_credentials(conn), + categories=inventory.DEVICE_CATEGORIES, + counts=monitor.dashboard_counts(conn), + ) + + +def _credential_from_form(conn: Any) -> int | None: + """Legt bei Bedarf neue Zugangsdaten an und gibt deren Kennung zurueck. + + ``None`` heisst: nichts anzulegen, das Formular hat eine vorhandene Auswahl + (oder gar keine). Fehler werden als ``ValidationError`` durchgereicht, + damit das Geraet dann ebenfalls nicht entsteht -- ein Geraet ohne die + beabsichtigten Zugangsdaten waere eine stille Halbausfuehrung. + """ + if request.form.get("credential_id") != "__neu__": + return None + if not current_user.has("credentials.create"): + raise inventory.ValidationError( + "Zum Anlegen neuer Zugangsdaten fehlt die Berechtigung." + ) + name = (request.form.get("new_credential_name") or "").strip() + if not name: + raise inventory.ValidationError("Fuer neue Zugangsdaten wird eine Bezeichnung gebraucht.") + return inventory.create_credential( + conn, + core().keystore, + name=name, + username=(request.form.get("new_credential_username") or "").strip(), + secret=request.form.get("new_credential_secret") or "", + category=request.form.get("new_credential_category", "linux"), + description="Beim Anlegen eines Geraets erfasst.", + actor=current_user.username, + ) + + +@bp.post("/neu") +@permission_required("devices.create") +def create(): + try: + with transaction() as conn: + values = _form_values() + new_credential = _credential_from_form(conn) + if new_credential is not None: + values["credential_id"] = new_credential + device_id = inventory.create_device(conn, **values) + _audit( + conn, + "device.created", + request.form.get("name", ""), + f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}", + severity="notice", + ) + flash("Geraet angelegt.", "success") + return redirect(url_for("devices.overview", highlight=device_id)) + except inventory.ValidationError as exc: + flash(str(exc), "danger") + return redirect(url_for("devices.overview")) + + +@bp.post("/") +@permission_required("devices.edit") +def update(device_id: int): + try: + with transaction() as conn: + values = _form_values() + new_credential = _credential_from_form(conn) + if new_credential is not None: + values["credential_id"] = new_credential + changes = inventory.update_device(conn, device_id, **values) + if changes: + _audit( + conn, + "device.updated", + request.form.get("name", str(device_id)), + "; ".join(changes), + severity="notice", + ) + flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") + except inventory.ValidationError as exc: + flash(str(exc), "danger") + return redirect(url_for("devices.overview")) + + +@bp.post("//aktiv") +@permission_required("devices.edit") +def toggle_active(device_id: int): + active = request.form.get("active") == "1" + with transaction() as conn: + device = inventory.get_device(conn, device_id) + if device is None: + abort(404) + inventory.set_device_active(conn, device_id, active) + _audit( + conn, + "device.activated" if active else "device.deactivated", + str(device["name"]), + severity="notice", + ) + flash("Geraet aktiviert." if active else "Geraet deaktiviert.", "success") + return redirect(request.referrer or url_for("devices.overview")) + + +@bp.post("//loeschen") +@permission_required("devices.delete") +def delete(device_id: int): + with transaction() as conn: + device = inventory.get_device(conn, device_id) + if device is None: + abort(404) + inventory.delete_device(conn, device_id) + _audit(conn, "device.deleted", str(device["name"]), severity="warning") + flash("Geraet in den Papierkorb verschoben.", "success") + return redirect(url_for("devices.overview")) + + +@bp.post("//neustart") +@permission_required("devices.execute") +def restart_device(device_id: int): + """Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde. + + Der PoE-Neustart ist bewusst **nicht** lizenzpflichtig: ein Kunde ohne + gueltige Lizenz soll ein haengendes Geraet weiter von Hand neu starten + koennen. Nur der *automatische* Neustart bei Ausfall und die Wege ueber + SSH bzw. RPC gehoeren zum Modul Wartung. + """ + extension = core() + conn = db() + device = inventory.get_device(conn, device_id) + if device is None or device["deleted_at"]: + abort(404) + + maintenance_active = _maintenance_active() + method = restart.resolve( + dict(device), + (request.form.get("method") or "").strip(), + maintenance_active=maintenance_active, + ) + if method is None: + options = restart.available(dict(device), maintenance_active=maintenance_active) + if options: + flash( + "Der gewaehlte Weg steht fuer dieses Geraet nicht zur Verfuegung. " + "Moeglich waere: " + ", ".join(entry.label for entry in options), + "warning", + ) + else: + flash( + f"Fuer {device['name']} ist kein Neustart moeglich: es fehlen Switch und Port " + "fuer den PoE-Weg und passende Zugangsdaten fuer SSH oder RPC.", + "warning", + ) + return redirect(request.referrer or url_for("devices.overview")) + + if method.is_poe: + return _restart_over_poe(extension, conn, device) + return _restart_as_job(extension, conn, device, method) + + +def _restart_over_poe(extension: Any, conn: Any, device: Any): # type: ignore[no-untyped-def] + """Sofort und synchron -- der Portzyklus dauert nur Sekunden.""" + live = extension.registry.get("live_log") + try: + outcome = monitor.restart_device_poe( + conn, + dict(device), + keystore=extension.keystore, + trigger="manuell", + actor=current_user.username, + live=live, + database=extension.database, + ) + with transaction(): + _audit( + conn, + "device.poe_restart", + str(device["name"]), + outcome["message"], + severity="notice" if outcome["ok"] else "warning", + ) + except inventory.ValidationError as exc: + flash(str(exc), "danger") + return redirect(request.referrer or url_for("devices.overview")) + + flash( + f"PoE-Neustart fuer {device['name']} durchgefuehrt." + if outcome["ok"] + else f"PoE-Neustart fehlgeschlagen: {outcome['message']}", + "success" if outcome["ok"] else "danger", + ) + return redirect(request.referrer or url_for("devices.overview")) + + +def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # type: ignore[no-untyped-def] + """Ueber SSH bzw. RPC -- als Auftrag, denn das Warten dauert Minuten.""" + from ..services import maintenance as maintenance_service + + try: + username, password = inventory.credential_secret( + conn, extension.keystore, int(device["credential_id"]) + ) + except inventory.ValidationError as exc: + flash(f"{device['name']}: {exc}", "danger") + return redirect(request.referrer or url_for("devices.overview")) + + with transaction(): + handle = maintenance_service.create_job( + conn, + kind=maintenance_service.JOB_KIND_REBOOT, + subject=str(device["name"]), + actor=current_user.username, + message="Wartet auf Start", + ) + _audit( + conn, + "device.restart", + str(device["name"]), + f"{method.label}, Auftrag {handle.id}", + severity="notice", + ) + + maintenance_service.start_background( + maintenance_service.run_reboot, + database=extension.database, + job_id=handle.id, + device=dict(device), + username=username, + password=password, + wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300), + actor=current_user.username, + ) + flash( + f"{method.label} fuer {device['name']} gestartet. Der Verlauf steht unter Wartung.", + "success", + ) + return redirect(request.referrer or url_for("devices.overview")) + + +@bp.get("/") +@permission_required("devices.view") +def detail(device_id: int): + conn = db() + device = inventory.get_device(conn, device_id) + if device is None: + abort(404) + events = Database.all( + conn, + "SELECT * FROM restart_events WHERE device_id=? ORDER BY ts DESC LIMIT 50", + (device_id,), + ) + host_keys = ssh.all_known_keys(conn, str(device["ip"]), int(device["ssh_port"] or 22)) + extension = core() + return render_template( + "tesm/device_detail.html", + device=device, + restart_methods=restart.available( + device, maintenance_active=_maintenance_active() + ), + events=events, + switches=inventory.list_switches(conn), + credentials=inventory.list_credentials(conn), + categories=inventory.DEVICE_CATEGORIES, + host_keys=host_keys, + method_labels=monitor.METHOD_LABELS, + terminal_enabled=bool(extension.settings.get(conn, "terminal_enabled")), + ) + + +# --------------------------------------------------------------------------- # +# SSH-Host-Schluessel +# --------------------------------------------------------------------------- # + + +@bp.post("//hostkey/pruefen") +@permission_required("devices.edit") +def probe_host_key(device_id: int): + """Liest den Host-Schluessel des Clients, ohne sich anzumelden. + + Ausdruecklich getrennt vom Freigeben: erst ansehen und vergleichen, dann + -- in einem zweiten, bestaetigten Schritt -- freigeben. + """ + conn = db() + device = inventory.get_device(conn, device_id) + if device is None: + abort(404) + try: + record = ssh.probe_host_key(str(device["ip"]), int(device["ssh_port"] or 22)) + except ssh.SshError as exc: + flash(str(exc), "danger") + return redirect(url_for("devices.detail", device_id=device_id)) + + level, message = ssh.describe_probe(conn, record) + flash(message, level) + return redirect( + url_for( + "devices.detail", + device_id=device_id, + probed_type=record.key_type, + probed_fingerprint=record.fingerprint, + ) + ) + + +@bp.post("//hostkey/freigeben") +@permission_required("devices.edit") +@reauth_required +def trust_host_key(device_id: int): + conn = db() + device = inventory.get_device(conn, device_id) + if device is None: + abort(404) + key_type = (request.form.get("key_type") or "").strip() + fingerprint = (request.form.get("fingerprint") or "").strip() + if not key_type or not ssh.valid_fingerprint(fingerprint): + flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger") + return redirect(url_for("devices.detail", device_id=device_id)) + + with transaction(): + ssh.trust_key( + conn, + host=str(device["ip"]), + port=int(device["ssh_port"] or 22), + key_type=key_type, + fingerprint=fingerprint, + actor=current_user.username, + ) + _audit( + conn, + "device.host_key_trusted", + str(device["name"]), + f"{key_type} {fingerprint}", + severity="notice", + ) + flash("Host-Schluessel freigegeben.", "success") + return redirect(url_for("devices.detail", device_id=device_id)) + + +@bp.post("//hostkey/verwerfen") +@permission_required("devices.edit") +def forget_host_key(device_id: int): + conn = db() + device = inventory.get_device(conn, device_id) + if device is None: + abort(404) + with transaction(): + ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22)) + _audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning") + flash("Gespeicherte Host-Schluessel entfernt.", "info") + return redirect(url_for("devices.detail", device_id=device_id)) diff --git a/apps/tesm/src/tesm/blueprints/dhcp.py b/apps/tesm/src/tesm/blueprints/dhcp.py new file mode 100644 index 0000000..abf86a3 --- /dev/null +++ b/apps/tesm/src/tesm/blueprints/dhcp.py @@ -0,0 +1,519 @@ +"""DHCP-Server (Kea): Subnetze, Optionen, Reservierungen, Dienstzustand. + +Lizenzpflichtiges Vollmodul -- ohne Lizenz verschwindet der Punkt aus der +Navigation **und** jede Route hier antwortet mit 404. Ein direkter URL-Aufruf +kommt damit genauso wenig durch wie ein fehlendes Menue. + +Dienstaktionen (installieren, starten, stoppen) sind bewusst **nicht** an +"Speichern" gekoppelt: wer eine Konfiguration ablegt, will nicht zwangsweise +einen Dienst starten. +""" + +from __future__ import annotations + +from typing import Any + +from flask import Blueprint, abort, flash, redirect, render_template, request, url_for +from tesm_core import audit +from tesm_core.auth.models import current_user, permission_required +from tesm_core.auth.service import client_ip +from tesm_core.db import Database +from tesm_core.extension import core, db, transaction + +from ..services import kea + +bp = Blueprint("dhcp", __name__, url_prefix="/dhcp") + + +@bp.before_request +def _require_module() -> None: + manager = core().license + if manager is not None and not manager.module_active("dhcp"): + abort(404) + + +def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None: + with transaction() as conn: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action=action, + target=target, + detail=detail, + ip=client_ip(), + severity=severity, + ) + + +def _settings_values() -> dict[str, Any]: + return core().settings.get_many( + db(), ["dhcp_domain", "dhcp_lease_default", "dhcp_lease_max", "dhcp_output_path"] + ) + + +@bp.get("/") +@permission_required("dhcp.view", "dhcp.edit", "dhcp.execute", mode="any") +def overview(): + extension = core() + conn = db() + sysops = extension.sysops + networks = kea.detect_networks(sysops) if sysops else [] + values = _settings_values() + config, warnings = kea.render_config(conn, networks=networks, settings=values) + + # Nur Adressen in eingerichteten Bereichen: alles andere waere eine + # Reservierung, die Kea nie ausliefert. + skipped: list[dict[str, str]] = [] + reservations = kea.collect_reservations( + conn, only_networks=kea.configured_networks(conn, networks), skipped=skipped + ) + leases = kea.read_leases(sysops) if sysops else [] + # Zustand aus den laufenden Leases -- wie im Windows-DHCP. + reservations = kea.annotate_reservations(reservations, leases) + reserved_ips = {str(entry["ip"]) for entry in reservations} + + return render_template( + "tesm/dhcp.html", + subnets=Database.all(conn, "SELECT * FROM dhcp_subnets ORDER BY sort_order, id"), + options=Database.all( + conn, + "SELECT o.*, v.value, v.id AS value_id FROM dhcp_options o " + "LEFT JOIN dhcp_option_values v ON v.option_id = o.id AND v.device_id IS NULL " + "ORDER BY o.code", + ), + option_types=kea.OPTION_TYPES, + device_option_values=_device_option_values(conn), + reservations=reservations, + skipped_reservations=skipped, + manual_reservations=Database.all(conn, "SELECT * FROM dhcp_reservations ORDER BY ip"), + leases=leases, + reserved_ips=reserved_ips, + networks=networks, + default_gateway=kea.default_gateway(sysops) if sysops else "", + service=kea.service_state(sysops) if sysops else {}, + values=values, + preview=kea.config_to_text(config), + warnings=warnings, + lease_file=kea.LEASE_FILE, + sysops_available=bool(sysops and sysops.available), + ) + + +# --------------------------------------------------------------------------- # +# Einstellungen und Subnetze +# --------------------------------------------------------------------------- # + + +@bp.post("/einstellungen") +@permission_required("dhcp.edit") +def save_settings(): + extension = core() + conn = db() + values = { + key: request.form.get(key) + for key in ("dhcp_domain", "dhcp_lease_default", "dhcp_lease_max", "dhcp_output_path") + if key in request.form + } + with transaction(): + changed = extension.settings.set_many(conn, values, actor=current_user.username) + _audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice") + flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.", "success" if changed else "info") + return _write_and_redirect() + + +@bp.post("/subnetz") +@permission_required("dhcp.edit") +def add_subnet(): + extension = core() + conn = db() + sysops = extension.sysops + networks = kea.detect_networks(sysops) if sysops else [] + interface = (request.form.get("interface") or "").strip() + start = (request.form.get("range_start") or "").strip() + end = (request.form.get("range_end") or "").strip() + + problem = kea.validate_subnet(networks, interface, start, end) + if problem: + flash(problem, "danger") + return redirect(url_for("dhcp.overview")) + + with transaction(): + conn.execute( + "INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, comment, " + "enabled, sort_order, created_at, updated_at) " + "VALUES (?,?,?,?,?,?,1,?,datetime('now'),datetime('now'))", + ( + interface, + start, + end, + (request.form.get("gateway") or "").strip(), + (request.form.get("dns") or "").strip(), + (request.form.get("comment") or "").strip(), + int(request.form.get("sort_order") or 0), + ), + ) + _audit("dhcp.subnet_added", f"{interface} {start}-{end}", severity="notice") + flash("Subnetz angelegt.", "success") + return _write_and_redirect() + + +@bp.post("/subnetz/") +@permission_required("dhcp.edit") +def update_subnet(subnet_id: int): + extension = core() + conn = db() + sysops = extension.sysops + networks = kea.detect_networks(sysops) if sysops else [] + interface = (request.form.get("interface") or "").strip() + start = (request.form.get("range_start") or "").strip() + end = (request.form.get("range_end") or "").strip() + problem = kea.validate_subnet(networks, interface, start, end) + if problem: + flash(problem, "danger") + return redirect(url_for("dhcp.overview")) + + with transaction(): + conn.execute( + "UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, " + "comment=?, enabled=?, sort_order=?, updated_at=datetime('now') WHERE id=?", + ( + interface, + start, + end, + (request.form.get("gateway") or "").strip(), + (request.form.get("dns") or "").strip(), + (request.form.get("comment") or "").strip(), + 1 if request.form.get("enabled") == "1" else 0, + int(request.form.get("sort_order") or 0), + subnet_id, + ), + ) + _audit("dhcp.subnet_updated", str(subnet_id), severity="notice") + flash("Subnetz gespeichert.", "success") + return _write_and_redirect() + + +@bp.post("/subnetz//loeschen") +@permission_required("dhcp.edit") +def delete_subnet(subnet_id: int): + with transaction() as conn: + conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,)) + _audit("dhcp.subnet_deleted", str(subnet_id), severity="warning") + flash("Subnetz entfernt.", "success") + return _write_and_redirect() + + +# --------------------------------------------------------------------------- # +# Optionen +# --------------------------------------------------------------------------- # + + +@bp.post("/option/wert") +@permission_required("dhcp.edit") +def set_option_value(): + conn = db() + option_id = int(request.form.get("option_id") or 0) + value = (request.form.get("value") or "").strip() + option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,)) + if option is None: + abort(404) + with transaction(): + if value: + conn.execute( + "INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,NULL,?) " + "ON CONFLICT(option_id) WHERE device_id IS NULL DO UPDATE SET value=excluded.value", + (option_id, value), + ) + else: + conn.execute( + "DELETE FROM dhcp_option_values WHERE option_id=? AND device_id IS NULL", + (option_id,), + ) + _audit("dhcp.option_set", str(option["name"]), value or "(geleert)") + flash(f"Option {option['name']} gespeichert.", "success") + return _write_and_redirect() + + +def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ignore[no-untyped-def] + """Je-Geraet gesetzte Optionen, nach Geraet gruppiert -- fuer die Anzeige.""" + gruppen: dict[int, list[dict[str, Any]]] = {} + for row in Database.all( + conn, + "SELECT v.id, v.device_id, v.value, o.code, o.name, o.id AS option_id " + "FROM dhcp_option_values v JOIN dhcp_options o ON o.id = v.option_id " + "WHERE v.device_id IS NOT NULL ORDER BY o.code", + ): + gruppen.setdefault(int(row["device_id"]), []).append(dict(row)) + return gruppen + + +@bp.post("/option/geraet") +@permission_required("dhcp.edit") +def set_device_option_value(): + """Setzt eine Option fuer ein einzelnes Geraet. + + Der Wert landet in der Reservierung dieses Geraets, nicht global. Genau + dafuer war ``dhcp_option_values.device_id`` von Anfang an gedacht -- der + Erzeuger hat es immer schon ausgewertet, nur liess sich der Wert nirgends + eingeben. + """ + conn = db() + option_id = int(request.form.get("option_id") or 0) + device_id = int(request.form.get("device_id") or 0) + value = (request.form.get("value") or "").strip() + + option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,)) + device = Database.one( + conn, "SELECT id, name FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,) + ) + if option is None or device is None: + abort(404) + + with transaction(): + if value: + conn.execute( + "INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,?,?) " + "ON CONFLICT(option_id, device_id) WHERE device_id IS NOT NULL " + "DO UPDATE SET value=excluded.value", + (option_id, device_id, value), + ) + else: + conn.execute( + "DELETE FROM dhcp_option_values WHERE option_id=? AND device_id=?", + (option_id, device_id), + ) + _audit( + "dhcp.device_option_set", + f"{device['name']} / {option['name']}", + value or "(geleert)", + ) + flash( + f"Option {option['name']} fuer {device['name']} " + f"{'gespeichert' if value else 'entfernt'}.", + "success", + ) + return _write_and_redirect() + + +@bp.post("/option/neu") +@permission_required("dhcp.edit") +def add_custom_option(): + conn = db() + try: + code = int(request.form.get("code") or 0) + except ValueError: + flash("Der Optionscode muss eine Zahl sein.", "danger") + return redirect(url_for("dhcp.overview")) + name = (request.form.get("name") or "").strip() + option_type = (request.form.get("type") or "").strip() + if not 1 <= code <= 254: + flash("Der Optionscode muss zwischen 1 und 254 liegen.", "danger") + return redirect(url_for("dhcp.overview")) + if not name or option_type not in kea.OPTION_TYPES: + flash("Name und Typ sind erforderlich.", "danger") + return redirect(url_for("dhcp.overview")) + if Database.one(conn, "SELECT 1 FROM dhcp_options WHERE code=? OR name=?", (code, name)): + flash("Code oder Name existiert bereits.", "danger") + return redirect(url_for("dhcp.overview")) + with transaction(): + conn.execute( + "INSERT INTO dhcp_options (code, name, type, description, is_standard) " + "VALUES (?,?,?,?,0)", + (code, name, option_type, (request.form.get("description") or "").strip()), + ) + _audit("dhcp.option_created", name, severity="notice") + flash("Eigene Option angelegt.", "success") + return redirect(url_for("dhcp.overview")) + + +@bp.post("/option//loeschen") +@permission_required("dhcp.edit") +def delete_custom_option(option_id: int): + conn = db() + option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,)) + if option is None: + abort(404) + if int(option["is_standard"] or 0): + flash( + "Standardoptionen koennen nicht geloescht werden -- nur ihr Wert lasst sich leeren.", + "danger", + ) + return redirect(url_for("dhcp.overview")) + with transaction(): + conn.execute("DELETE FROM dhcp_options WHERE id=?", (option_id,)) + _audit("dhcp.option_deleted", str(option["name"]), severity="warning") + flash("Option entfernt.", "success") + return _write_and_redirect() + + +# --------------------------------------------------------------------------- # +# Reservierungen +# --------------------------------------------------------------------------- # + + +@bp.post("/reservierung") +@permission_required("dhcp.edit") +def add_reservation(): + conn = db() + mac = kea.normalize_mac(request.form.get("mac", "")) + ip = (request.form.get("ip") or "").strip() + if not mac: + flash("Die MAC-Adresse ist ungueltig.", "danger") + return redirect(url_for("dhcp.overview")) + + # Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie + # aus. Sie anzunehmen und stillschweigend zu verwerfen waere die + # schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen. + if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"): + flash( + "Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an -- " + "ohne eines wird keine Reservierung ausgeliefert.", + "danger", + ) + return redirect(url_for("dhcp.overview")) + + sysops = core().sysops + if sysops is None or not sysops.available: + # Ohne Systemhelfer ist das Netz des Hosts nicht feststellbar. Dann hier + # nicht blockieren: sonst waere in einer eingeschraenkten Umgebung gar + # keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft + # bleibt. + flash( + "Der Bereich konnte nicht geprueft werden -- ohne Systemhelfer ist das Netz " + "dieses Servers nicht feststellbar.", + "warning", + ) + else: + netze = kea.configured_networks(conn, kea.detect_networks(sysops)) + if netze and not kea.in_configured_network(ip, netze): + bereiche = ", ".join(str(netz) for netz in netze) + flash( + f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich " + f"({bereiche}). Die Reservierung waere ohne Wirkung.", + "danger", + ) + return redirect(url_for("dhcp.overview")) + + with transaction(): + conn.execute( + "INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) " + "VALUES (?,?,?,?,datetime('now')) ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, " + "hostname=excluded.hostname, comment=excluded.comment", + ( + mac, + ip, + (request.form.get("hostname") or "").strip(), + (request.form.get("comment") or "").strip(), + ), + ) + _audit("dhcp.reservation_saved", f"{mac} -> {ip}", severity="notice") + flash("Reservierung gespeichert.", "success") + return _write_and_redirect() + + +@bp.post("/reservierung//loeschen") +@permission_required("dhcp.edit") +def delete_reservation(reservation_id: int): + with transaction() as conn: + conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,)) + _audit("dhcp.reservation_deleted", str(reservation_id), severity="notice") + flash("Reservierung entfernt.", "success") + return _write_and_redirect() + + +# --------------------------------------------------------------------------- # +# Datei schreiben und Dienst steuern +# --------------------------------------------------------------------------- # + + +def _write_config() -> dict[str, Any]: + """Schreibt die Konfiguration und startet den Dienst neu, falls er laeuft. + + Ein gestoppter Dienst wird **nicht** automatisch gestartet -- das bleibt eine + ausdrueckliche Entscheidung. + """ + extension = core() + sysops = extension.sysops + conn = db() + if sysops is None or not sysops.available: + return {"ok": False, "message": "Systemhelfer nicht verfuegbar."} + + networks = kea.detect_networks(sysops) + config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values()) + if not config["Dhcp4"]["subnet4"]: + return { + "ok": False, + "message": "Es ist kein gueltiges Subnetz konfiguriert -- die Datei wurde nicht geschrieben.", + "warnings": warnings, + } + result = sysops.write_kea_config(kea.config_to_text(config)) + if not result.ok: + return {"ok": False, "message": result.message, "warnings": warnings} + + state = sysops.service_state(kea.SERVICE_UNIT) + restarted = False + if state["active"] == "active": + restart = sysops.service("restart", kea.SERVICE_UNIT) + restarted = restart.ok + if not restart.ok: + return { + "ok": False, + "message": f"Datei geschrieben, Neustart fehlgeschlagen: {restart.message}", + "warnings": warnings, + } + return { + "ok": True, + "message": "Konfiguration geschrieben." + (" Dienst neu gestartet." if restarted else ""), + "warnings": warnings, + } + + +def _write_and_redirect(): + result = _write_config() + for warning in result.get("warnings", []): + flash(warning, "warning") + if not result["ok"] and result.get("message"): + flash(result["message"], "warning") + elif result["ok"]: + flash(result["message"], "info") + return redirect(url_for("dhcp.overview")) + + +@bp.post("/schreiben") +@permission_required("dhcp.edit") +def write_config(): + return _write_and_redirect() + + +@bp.post("/dienst/") +@permission_required("dhcp.execute") +def service_action(action: str): + allowed = {"install", "start", "stop"} + if action not in allowed: + abort(404) + extension = core() + sysops = extension.sysops + if sysops is None or not sysops.available: + flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") + return redirect(url_for("dhcp.overview")) + + if action == "install": + result = sysops.apt_install(kea.PACKAGE_NAME) + message = "Kea installiert." if result.ok else f"Installation fehlgeschlagen: {result.message}" + elif action == "start": + enable = sysops.service("enable", kea.SERVICE_UNIT) + result = sysops.service("start", kea.SERVICE_UNIT) + message = ( + "Dienst aktiviert und gestartet." + if result.ok and enable.ok + else f"Start fehlgeschlagen: {result.message or enable.message}" + ) + else: + sysops.service("disable", kea.SERVICE_UNIT) + result = sysops.service("stop", kea.SERVICE_UNIT) + message = "Dienst gestoppt und deaktiviert." if result.ok else f"Stoppen fehlgeschlagen: {result.message}" + + _audit(f"dhcp.service_{action}", detail=message, severity="notice" if result.ok else "warning") + flash(message, "success" if result.ok else "danger") + return redirect(url_for("dhcp.overview")) diff --git a/apps/tesm/src/tesm/blueprints/fileshare.py b/apps/tesm/src/tesm/blueprints/fileshare.py new file mode 100644 index 0000000..81d9bf8 --- /dev/null +++ b/apps/tesm/src/tesm/blueprints/fileshare.py @@ -0,0 +1,265 @@ +"""Dateifreigaben: Blaettern, Herunterladen, Hochladen, Verwalten.""" + +from __future__ import annotations + +import io + +from flask import ( + Blueprint, + Response, + abort, + flash, + redirect, + render_template, + request, + send_file, + url_for, +) +from tesm_core import audit +from tesm_core.auth.models import current_user, permission_required +from tesm_core.auth.service import client_ip +from tesm_core.extension import core, db, transaction + +from ..services import fileshare as service + +bp = Blueprint("fileshare", __name__, url_prefix="/dateifreigaben") + + +@bp.before_request +def _require_module() -> None: + manager = core().license + if manager is not None and not manager.module_active("fileshare"): + abort(404) + + +def _audit(action: str, target: str, detail: str = "", severity: str = "info") -> None: + with transaction() as conn: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action=action, + target=target, + detail=detail, + ip=client_ip(), + severity=severity, + ) + + +def _mount_or_404(token: str) -> service.Mount: + mounts = service.active_mounts(db(), current_user.id) + for mount in mounts: + if mount.token == token: + return mount + abort(404) + + +@bp.get("/") +@permission_required("fileshare.view") +def browse(): + conn = db() + mounts = service.active_mounts(conn, current_user.id) + token = request.args.get("freigabe") or (mounts[0].token if mounts else "") + relative = request.args.get("pfad") or "" + listing = None + error = "" + if token: + try: + mount = _mount_or_404(token) + listing = service.list_directory(mount, relative) + except service.FileshareError as exc: + error = str(exc) + return render_template( + "tesm/fileshare.html", + mounts=mounts, + token=token, + listing=listing, + error=error, + max_upload_mb=int(core().settings.get(conn, "fileshare_upload_max_mb") or 512), + ) + + +@bp.get("//herunterladen") +@permission_required("fileshare.view") +def download(token: str): + mount = _mount_or_404(token) + relative = request.args.get("pfad") or "" + try: + path = service.resolve_path(mount, relative) + except service.FileshareError as exc: + abort(400, description=str(exc)) + if not path.is_file(): + abort(404) + _audit("fileshare.downloaded", f"{mount.label}:{relative}") + return send_file( + path, + as_attachment=True, + download_name=path.name, + mimetype=service.download_mime(path.name), + ) + + +@bp.get("//vorschau") +@permission_required("fileshare.view") +def preview(token: str): + mount = _mount_or_404(token) + relative = request.args.get("pfad") or "" + try: + path = service.resolve_path(mount, relative) + mimetype = service.preview_mime(path.name) + except service.FileshareError as exc: + abort(415, description=str(exc)) + if not path.is_file(): + abort(404) + # ``Content-Disposition: inline`` mit engem MIME-Typ; die CSP der Anwendung + # verhindert zusaetzlich, dass eingebettetes Skript ausgefuehrt wuerde. + return Response( + path.read_bytes(), + mimetype=mimetype, + headers={ + "Content-Disposition": f'inline; filename="{path.name}"', + "X-Content-Type-Options": "nosniff", + }, + ) + + +@bp.post("//hochladen") +@permission_required("fileshare.create") +def upload(token: str): + mount = _mount_or_404(token) + relative = request.form.get("pfad") or "" + files = request.files.getlist("files") + if not files: + flash("Keine Datei ausgewaehlt.", "danger") + return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) + + limit = int(core().settings.get(db(), "fileshare_upload_max_mb") or 512) * 1024 * 1024 + stored = 0 + try: + target_dir = service.resolve_path(mount, relative) + for upload_file in files: + if not upload_file.filename: + continue + name = service.safe_filename(upload_file.filename) + data = upload_file.stream.read(limit + 1) + if len(data) > limit: + flash(f"{name}: zu gross (Grenze {limit // 1024 // 1024} MB).", "danger") + continue + (target_dir / name).write_bytes(data) + stored += 1 + except service.FileshareError as exc: + flash(str(exc), "danger") + return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) + + if stored: + _audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)") + flash(f"{stored} Datei(en) hochgeladen.", "success") + return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) + + +@bp.post("//ordner") +@permission_required("fileshare.create") +def make_directory(token: str): + mount = _mount_or_404(token) + relative = request.form.get("pfad") or "" + try: + service.make_directory(mount, relative, request.form.get("name", "")) + except (service.FileshareError, OSError) as exc: + flash(str(exc), "danger") + return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) + _audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}") + flash("Ordner angelegt.", "success") + return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) + + +@bp.post("//umbenennen") +@permission_required("fileshare.edit") +def rename(token: str): + mount = _mount_or_404(token) + relative = request.form.get("pfad") or "" + parent = request.form.get("parent") or "" + try: + service.rename_entry(mount, relative, request.form.get("name", "")) + except (service.FileshareError, OSError) as exc: + flash(str(exc), "danger") + return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) + _audit("fileshare.renamed", f"{mount.label}:{relative}") + flash("Umbenannt.", "success") + return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) + + +@bp.post("//loeschen") +@permission_required("fileshare.delete") +def delete(token: str): + mount = _mount_or_404(token) + parent = request.form.get("parent") or "" + removed = 0 + for relative in request.form.getlist("pfade"): + try: + kind = service.delete_entry(mount, relative) + _audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning") + removed += 1 + except (service.FileshareError, OSError) as exc: + flash(str(exc), "danger") + if removed: + flash(f"{removed} Eintrag/Eintraege geloescht.", "success") + return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) + + +@bp.post("//freigeben") +@permission_required("fileshare.view") +def release(token: str): + mount = _mount_or_404(token) + with transaction() as conn: + service.release_mount(conn, mount.id) + _audit("fileshare.released", mount.label) + flash("Einbindung geloest.", "success") + return redirect(url_for("fileshare.browse")) + + +# --------------------------------------------------------------------------- # +# Verwaltung der Zuordnungen (Einstellungen) +# --------------------------------------------------------------------------- # + + +@bp.get("/zuordnungen") +@permission_required("fileshare.edit") +def mappings(): + from tesm_core.auth import directory + + conn = db() + return render_template( + "tesm/fileshare_mappings.html", + mappings=service.list_mappings(conn), + directory_enabled=directory.is_enabled(conn), + mount_root=str(core().settings.get(conn, "fileshare_mount_root")), + ) + + +@bp.post("/zuordnungen") +@permission_required("fileshare.edit") +def mapping_add(): + try: + with transaction() as conn: + service.add_mapping( + conn, + ad_group_dn=request.form.get("ad_group_dn", ""), + ad_group_name=request.form.get("ad_group_name", ""), + label=request.form.get("label", ""), + unc=request.form.get("unc", ""), + ) + _audit("fileshare.mapping_added", request.form.get("label", ""), severity="notice") + flash("Zuordnung gespeichert.", "success") + except service.FileshareError as exc: + flash(str(exc), "danger") + return redirect(url_for("fileshare.mappings")) + + +@bp.post("/zuordnungen//loeschen") +@permission_required("fileshare.edit") +def mapping_delete(mapping_id: int): + with transaction() as conn: + service.delete_mapping(conn, mapping_id) + _audit("fileshare.mapping_deleted", str(mapping_id), severity="notice") + flash("Zuordnung entfernt.", "success") + return redirect(url_for("fileshare.mappings")) diff --git a/apps/tesm/src/tesm/blueprints/hostkeys.py b/apps/tesm/src/tesm/blueprints/hostkeys.py new file mode 100644 index 0000000..0fd07cb --- /dev/null +++ b/apps/tesm/src/tesm/blueprints/hostkeys.py @@ -0,0 +1,249 @@ +"""SSH-Host-Schluessel -- alle Ziele auf einer Seite. + +Die Freigabe einzelner Schluessel gibt es auch auf den Detailseiten von +Clients und Switchen. Diese Seite beantwortet die andere Frage, die im Betrieb +haeufiger ist: *wo fehlt noch eine Freigabe?* Sie vorher zu beantworten hiess, +jede Detailseite einzeln aufzurufen. + +Gezeigt werden beide Richtungen: + +* Jedes Ziel (Client oder Switch) mit einer SSH-Adresse -- mit oder ohne + Freigabe. +* Freigegebene Schluessel, zu denen es kein Ziel mehr gibt. Die sind kein + Fehler, aber sie sollten auffallen: ein Geraet wurde geloescht oder hat eine + neue IP, und die alte Freigabe steht noch. + +Die Rechte bleiben an den Objekten: wer Clients bearbeiten darf, gibt +Client-Schluessel frei; fuer Switche gilt ``switches.edit``. Diese Seite +buendelt die Ansicht, nicht die Berechtigung. +""" + +from __future__ import annotations + +from typing import Any + +from flask import Blueprint, abort, flash, redirect, render_template, request, url_for +from tesm_core import audit +from tesm_core.auth.models import current_user, permission_required, reauth_required +from tesm_core.auth.service import client_ip +from tesm_core.db import Database +from tesm_core.extension import core, db, transaction + +from ..services import inventory, ssh + +bp = Blueprint("hostkeys", __name__, url_prefix="/hostschluessel") + +KIND_DEVICE = "client" +KIND_SWITCH = "switch" + +#: Welches Recht welche Art von Ziel freigibt. +EDIT_RIGHT = {KIND_DEVICE: "devices.edit", KIND_SWITCH: "switches.edit"} + + +def _audit(action: str, target: str, detail: str = "", severity: str = "info") -> None: + with transaction() as conn: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action=action, + target=target, + detail=detail, + ip=client_ip(), + severity=severity, + ) + + +def _targets(conn: Any) -> list[dict[str, Any]]: + """Alle Ziele mit SSH-Adresse, jeweils mit ihren freigegebenen Schluesseln.""" + rows: list[dict[str, Any]] = [] + for switch in inventory.list_switches(conn): + rows.append( + { + "kind": KIND_SWITCH, + "id": int(switch["id"]), + "label": str(switch["hostname"]), + "host": str(switch["ip"]), + "port": int(switch["ssh_port"] or 22), + "detail_endpoint": "switches.detail", + "credential": switch["credential_name"] if "credential_name" in switch.keys() + else "", + } + ) + for device in inventory.list_devices(conn): + # Windows-Geraete werden nicht ueber SSH bedient -- sie haetten hier + # ewig den Zustand "Freigabe offen", ohne dass es je etwas freizugeben + # gaebe. + if not inventory.is_ssh_managed(device["credential_category"]): + continue + rows.append( + { + "kind": KIND_DEVICE, + "id": int(device["id"]), + "label": str(device["name"]), + "host": str(device["ip"]), + "port": int(device["ssh_port"] or 22), + "detail_endpoint": "devices.detail", + "credential": device["credential_name"] or "", + } + ) + for row in rows: + # Nicht "keys" nennen: Jinja loest ``row.keys`` auf die dict-Methode + # auf, nicht auf den Eintrag -- die Vorlage bekaeme dann eine Funktion. + found = ssh.all_known_keys(conn, row["host"], row["port"]) + row["host_keys"] = found + row["trusted"] = bool(found) + rows.sort(key=lambda item: (item["trusted"], item["label"].lower())) + return rows + + +def _orphans(conn: Any, targets: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Freigaben, zu denen kein Ziel mehr passt.""" + used = {(row["host"], row["port"]) for row in targets} + return [ + dict(row) + for row in Database.all( + conn, + "SELECT host, port, key_type, fingerprint, added_at, added_by " + "FROM switch_host_keys ORDER BY host, port, key_type", + ) + if (str(row["host"]), int(row["port"])) not in used + ] + + +@bp.get("/") +@permission_required("devices.view", "switches.view", mode="any") +def overview(): + conn = db() + targets = _targets(conn) + return render_template( + "tesm/hostkeys.html", + targets=targets, + orphans=_orphans(conn, targets), + open_count=sum(1 for row in targets if not row["trusted"]), + edit_right=EDIT_RIGHT, + probed_host=request.args.get("probed_host", ""), + probed_port=request.args.get("probed_port", ""), + probed_type=request.args.get("probed_type", ""), + probed_fingerprint=request.args.get("probed_fingerprint", ""), + ) + + +def _load(conn: Any, kind: str, target_id: int) -> dict[str, Any] | None: + for row in _targets(conn): + if row["kind"] == kind and row["id"] == target_id: + return row + return None + + +def _require(kind: str) -> None: + right = EDIT_RIGHT.get(kind) + if right is None or not current_user.has(right): + abort(403) + + +@bp.post("///pruefen") +@permission_required("devices.view", "switches.view", mode="any") +def probe(kind: str, target_id: int): + conn = db() + _require(kind) + target = _load(conn, kind, target_id) + if target is None: + abort(404) + try: + record = ssh.probe_host_key(target["host"], target["port"]) + except ssh.SshError as exc: + flash(str(exc), "danger") + return redirect(url_for("hostkeys.overview")) + + level, message = ssh.describe_probe(conn, record) + flash(f"{target['label']}: {message}", level) + return redirect( + url_for( + "hostkeys.overview", + probed_host=record.host, + probed_port=record.port, + probed_type=record.key_type, + probed_fingerprint=record.fingerprint, + ) + ) + + +@bp.post("///freigeben") +@permission_required("devices.view", "switches.view", mode="any") +@reauth_required +def trust(kind: str, target_id: int): + conn = db() + _require(kind) + target = _load(conn, kind, target_id) + if target is None: + abort(404) + key_type = (request.form.get("key_type") or "").strip() + fingerprint = (request.form.get("fingerprint") or "").strip() + if not key_type or not ssh.valid_fingerprint(fingerprint): + flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger") + return redirect(url_for("hostkeys.overview")) + + with transaction(): + ssh.trust_key( + conn, + host=target["host"], + port=target["port"], + key_type=key_type, + fingerprint=fingerprint, + actor=current_user.username, + ) + _audit( + f"{'switch' if kind == KIND_SWITCH else 'device'}.host_key_trusted", + target["label"], + f"{key_type} {fingerprint}", + severity="notice", + ) + flash(f"Host-Schluessel von {target['label']} freigegeben.", "success") + return redirect(url_for("hostkeys.overview")) + + +@bp.post("///verwerfen") +@permission_required("devices.view", "switches.view", mode="any") +def forget(kind: str, target_id: int): + conn = db() + _require(kind) + target = _load(conn, kind, target_id) + if target is None: + abort(404) + with transaction(): + ssh.forget_key(conn, target["host"], target["port"]) + _audit( + f"{'switch' if kind == KIND_SWITCH else 'device'}.host_key_forgotten", + target["label"], + severity="warning", + ) + flash(f"Gespeicherte Schluessel von {target['label']} entfernt.", "info") + return redirect(url_for("hostkeys.overview")) + + +@bp.post("/verwaist/verwerfen") +@permission_required("devices.edit", "switches.edit", mode="any") +def forget_orphan(): + """Entfernt eine Freigabe, zu der es kein Ziel mehr gibt.""" + host = (request.form.get("host") or "").strip() + try: + port = int(request.form.get("port") or 0) + except ValueError: + port = 0 + if not host or not 1 <= port <= 65535: + flash("Ungueltiges Ziel.", "danger") + return redirect(url_for("hostkeys.overview")) + + conn = db() + if (host, port) in {(row["host"], row["port"]) for row in _targets(conn)}: + # Ein Ziel, das es noch gibt, wird ueber seine eigene Zeile verworfen -- + # sonst umginge dieser Weg die Rechtepruefung je Objektart. + flash("Zu diesem Eintrag gibt es ein Ziel -- bitte dort verwerfen.", "warning") + return redirect(url_for("hostkeys.overview")) + + with transaction(): + ssh.forget_key(conn, host, port) + _audit("host_key.orphan_forgotten", f"{host}:{port}", severity="warning") + flash(f"Verwaiste Freigabe fuer {host}:{port} entfernt.", "info") + return redirect(url_for("hostkeys.overview")) diff --git a/apps/tesm/src/tesm/blueprints/license.py b/apps/tesm/src/tesm/blueprints/license.py new file mode 100644 index 0000000..e7fb108 --- /dev/null +++ b/apps/tesm/src/tesm/blueprints/license.py @@ -0,0 +1,250 @@ +"""Lizenzverwaltung auf Kundenseite. + +Diese Seite bleibt **immer** erreichbar -- auch ohne gueltige Lizenz. Sonst +liesse sich eine neue Lizenz nie einspielen (Henne-Ei). +""" + +from __future__ import annotations + +import socket + +from flask import Blueprint, flash, redirect, render_template, request, url_for +from tesm_core import audit +from tesm_core.auth.models import ( + admin_required, + current_user, + permission_required, + reauth_required, +) +from tesm_core.auth.service import client_ip +from tesm_core.db import Database +from tesm_core.extension import core, db, transaction +from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError + +import tesm_licensing as lic + +bp = Blueprint("license", __name__, url_prefix="/lizenz") + +MAX_UPLOAD_BYTES = 256 * 1024 + + +def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None: + with transaction() as conn: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action=action, + target=target, + detail=detail, + ip=client_ip(), + severity=severity, + ) + + +@bp.get("/") +@permission_required("settings_license.view") +def overview(): + extension = core() + conn = db() + manager = extension.license + state = manager.reload(conn) if manager else None + pending = manager._pending(conn) if manager else {} # noqa: SLF001 - eigenes Paket + return render_template( + "tesm/license.html", + state=state, + pending=pending, + server_certificate=extension.settings.get(conn, "license_server_ca") or "", + modules=lic.ALL_MODULES, + module_labels=lic.MODULE_LABELS, + type_labels=lic.LICENSE_TYPE_LABELS, + role_labels=lic.ROLE_LABELS, + features=LICENSED_FEATURES, + trusted_keys=Database.all( + conn, "SELECT * FROM trusted_issuer_keys ORDER BY added_at DESC" + ), + fingerprint=lic.system_fingerprint(), + hostname=socket.gethostname(), + grace_days=lic.GRACE_PERIOD_DAYS, + ) + + +@bp.post("/einspielen") +@permission_required("settings_license.edit") +def upload(): + extension = core() + manager = extension.license + if manager is None: + flash("Diese Installation nutzt keine Lizenzierung.", "info") + return redirect(url_for("license.overview")) + + upload_file = request.files.get("license_file") + if upload_file is None or not upload_file.filename: + flash("Keine Datei ausgewaehlt.", "danger") + return redirect(url_for("license.overview")) + raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1) + if len(raw) > MAX_UPLOAD_BYTES: + flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger") + return redirect(url_for("license.overview")) + + allow_new_issuer = request.form.get("allow_new_issuer") == "1" and current_user.is_admin + try: + with transaction() as conn: + state = manager.import_bundle( + conn, raw, actor=current_user.username, allow_new_issuer=allow_new_issuer + ) + except LicenseError as exc: + flash(str(exc), "danger") + return redirect(url_for("license.overview")) + + flash( + "Lizenz eingespielt. Bitte jetzt aktivieren, damit die Funktionen freigeschaltet werden.", + "success", + ) + if state.status and state.status.role != "customer": + flash( + f"Achtung: diese Lizenz hat die Rolle {state.status.role!r}. " + "In einer TESM-Kundeninstanz ist das ungewoehnlich.", + "warning", + ) + return redirect(url_for("license.overview")) + + +@bp.post("/aktivieren") +@permission_required("settings_license.edit") +def activate(): + return _handshake("activate") + + +@bp.post("/deaktivieren") +@permission_required("settings_license.edit") +@reauth_required +def deactivate(): + """Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann. + + Verlangt eine frische Passwortbestaetigung: eine versehentliche + Deaktivierung schaltet lizenzpflichtige Funktionen ab. + """ + return _handshake("deactivate") + + +@bp.post("/heartbeat") +@permission_required("settings_license.edit") +def heartbeat(): + return _handshake("heartbeat") + + +def _handshake(action: str): + extension = core() + manager = extension.license + if manager is None: + flash("Diese Installation nutzt keine Lizenzierung.", "info") + return redirect(url_for("license.overview")) + try: + with transaction() as conn: + result = manager.handshake( + conn, action, actor=current_user.username, hostname=socket.gethostname() + ) + except LicenseError as exc: + flash(str(exc), "danger") + return redirect(url_for("license.overview")) + + if result["ok"]: + flash(result["message"], "success") + elif result["transport"] == "offline": + flash(result["message"], "warning") + else: + flash(result["message"], "danger") + return redirect(url_for("license.overview")) + + +@bp.post("/offline-antwort") +@permission_required("settings_license.edit") +def offline_response(): + extension = core() + manager = extension.license + if manager is None: + return redirect(url_for("license.overview")) + try: + with transaction() as conn: + result = manager.submit_offline_response( + conn, request.form.get("code", ""), actor=current_user.username + ) + except LicenseError as exc: + flash(str(exc), "danger") + return redirect(url_for("license.overview")) + flash(result["message"], "success" if result["ok"] else "danger") + return redirect(url_for("license.overview")) + + +@bp.post("/serverzertifikat") +@permission_required("settings_license.edit") +def server_certificate(): + """Hinterlegt das Zertifikat des Lizenzservers. + + Ein Lizenzserver im eigenen Netz hat meist ein selbstsigniertes + Zertifikat. Ohne diesen Anker scheitert jeder Online-Handshake an der + Pruefung und faellt auf den Offline-Weg zurueck. Die Pruefung deshalb + abzuschalten waere der falsche Weg -- hier wird sie stattdessen auf genau + dieses Zertifikat gerichtet. + """ + extension = core() + conn = db() + pem = (request.form.get("certificate") or "").strip() + if pem and "BEGIN CERTIFICATE" not in pem: + flash( + "Das sieht nicht nach einem Zertifikat aus. Erwartet wird PEM, beginnend mit " + "'-----BEGIN CERTIFICATE-----'.", + "danger", + ) + return redirect(url_for("license.overview")) + if pem: + # Lieber jetzt scheitern als beim naechsten Handshake. + try: + extension.license._tls_context(pem) # noqa: SLF001 - eigenes Paket + except ValueError as exc: + flash(str(exc), "danger") + return redirect(url_for("license.overview")) + + with transaction(): + extension.settings.set(conn, "license_server_ca", pem, actor=current_user.username) + _audit( + "license.server_certificate_set" if pem else "license.server_certificate_cleared", + detail="PEM hinterlegt" if pem else "entfernt", + severity="notice", + ) + flash( + "Zertifikat hinterlegt. Der naechste Versuch laeuft wieder ueber den Online-Weg." + if pem + else "Zertifikat entfernt -- es gelten wieder die Systemanker.", + "success", + ) + return redirect(url_for("license.overview")) + + +@bp.post("/aussteller//entfernen") +@admin_required +@reauth_required +def remove_trusted_key(key_id: str): + """Entfernt einen gepinnten Aussteller-Schluessel. + + Nur echte Administratoren, nur mit frischer Bestaetigung: das ist die + Vertrauensgrundlage der Lizenzpruefung. + """ + with transaction() as conn: + conn.execute("DELETE FROM trusted_issuer_keys WHERE key_id=?", (key_id,)) + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="license.issuer_unpinned", + target=key_id, + ip=client_ip(), + severity="warning", + ) + manager = core().license + if manager is not None: + with core().database.session() as conn: + manager.reload(conn) + flash("Aussteller-Schluessel entfernt.", "info") + return redirect(url_for("license.overview")) diff --git a/apps/tesm/src/tesm/blueprints/logs.py b/apps/tesm/src/tesm/blueprints/logs.py new file mode 100644 index 0000000..ac296e6 --- /dev/null +++ b/apps/tesm/src/tesm/blueprints/logs.py @@ -0,0 +1,69 @@ +"""TESM-eigene Protokollansichten: Kea-DHCP und Neustarts. + +Die generischen Ansichten (laufend, Verlauf, Aenderungen) liegen im Kern -- +sie sind in beiden Anwendungen identisch. +""" + +from __future__ import annotations + +from flask import Blueprint, abort, render_template, request +from tesm_core.auth.models import permission_required +from tesm_core.db import Database +from tesm_core.extension import core, db + +from ..services import kea, monitor + +bp = Blueprint("tesmlogs", __name__, url_prefix="/protokolle") + +MAX_TAIL_LINES = 2000 + + +@bp.get("/kea") +@permission_required("logs_dhcp.view") +def dhcp(): + extension = core() + if extension.license is not None and not extension.license.module_active("dhcp"): + abort(404) + sysops = extension.sysops + lines: list[str] = [] + error = "" + if sysops is not None and sysops.available: + result = sysops.read_privileged_file(kea.LOG_PATH) + if result.ok: + lines = result.stdout.splitlines()[-MAX_TAIL_LINES:] + else: + error = result.message + else: + error = "Systemhelfer nicht verfuegbar." + return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error) + + +@bp.get("/neustarts") +@permission_required("devices.view") +def restarts(): + """Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung. + + Frueher hiess diese Seite "PoE-Vorgaenge" und zeigte nur die eine Haelfte. + Ein Geraet, das die Wartung per ``reboot`` neu gestartet hatte, fehlte in + der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht, + der wissen will, warum ein Geraet weg war. + """ + method = (request.args.get("methode") or "").strip() + if method and method not in monitor.METHOD_LABELS: + method = "" + sql = ( + "SELECT e.*, d.name AS device_name FROM restart_events e " + "LEFT JOIN devices d ON d.id = e.device_id " + ) + params: tuple[object, ...] = () + if method: + sql += "WHERE e.method = ? " + params = (method,) + sql += "ORDER BY e.ts DESC LIMIT 300" + return render_template( + "tesm/logs_restarts.html", + rows=Database.all(db(), sql, params), + method=method, + methods=tuple(monitor.METHOD_LABELS.items()), + method_labels=monitor.METHOD_LABELS, + ) diff --git a/apps/tesm/src/tesm/blueprints/maintenance.py b/apps/tesm/src/tesm/blueprints/maintenance.py new file mode 100644 index 0000000..e9f2fb2 --- /dev/null +++ b/apps/tesm/src/tesm/blueprints/maintenance.py @@ -0,0 +1,276 @@ +"""Wartung: Paketaktualisierung und Neustart ueber SSH. + +Lizenzpflichtiges Vollmodul. Es erscheinen Geraete, deren Zugangsdaten die +Kategorie ``linux`` oder ``windows`` tragen: + +* **Linux** -- Paketaktualisierung (apt) und Neustart. +* **Windows** -- nur Neustart, ueber ``shutdown.exe``. Das Geraet braucht dafuer + den OpenSSH-Server. Paketaktualisierung bleibt bewusst aus: dafuer braeuchte + es PSWindowsUpdate oder WSUS, und beides darf man nicht voraussetzen. + +Die Kategorie setzt der Administrator ausdruecklich, sie wird bewusst nicht per +Ping/TTL erraten (das war im Vorgaenger schon so und ist richtig: eine falsch +geratene Kategorie schickt ein ``reboot`` an das falsche Geraet). +""" + +from __future__ import annotations + +from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for +from tesm_core import audit +from tesm_core.auth.models import current_user, permission_required +from tesm_core.auth.service import client_ip +from tesm_core.db import Database +from tesm_core.extension import core, db, transaction + +from ..services import inventory, maintenance as service, ssh, windowsops + +bp = Blueprint("maintenance", __name__, url_prefix="/wartung") + + +@bp.before_request +def _require_module() -> None: + manager = core().license + if manager is not None and not manager.module_active("maintenance"): + abort(404) + + +def _eligible_devices(conn) -> list[dict]: # type: ignore[no-untyped-def] + """Geraete, auf denen die Wartung ueberhaupt arbeiten kann. + + Zusaetzlich zur Auswahl steht am Ergebnis, ob der SSH-Host-Schluessel schon + freigegeben ist. Ohne diese Angabe lief man in eine Sackgasse: die Wartung + brach mit "Schluessel unbekannt" ab, und auf der Seite war nicht zu sehen, + woran es lag oder wo man es beheben kann. + """ + rows = _query_devices(conn) + for row in rows: + row["ssh_managed"] = inventory.is_ssh_managed(row["category"]) + # Windows braucht keinen Host-Schluessel -- dort waere "offen" eine + # Warnung, die sich nie abstellen laesst. + row["host_key_ready"] = ( + ssh.is_trusted(conn, str(row["ip"]), int(row["ssh_port"] or 22)) + if row["ssh_managed"] + else True + ) + return rows + + +def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def] + return Database.all( + conn, + """ + SELECT d.id, d.name, d.ip, d.ssh_port, c.name AS credential_name, c.id AS credential_id, + c.category AS category, + COALESCE(st.state,'unknown') AS state + FROM devices d + JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL + AND c.category IN ({placeholders}) + LEFT JOIN device_status st ON st.device_id = d.id + WHERE d.deleted_at IS NULL AND d.is_active = 1 + ORDER BY d.name COLLATE NOCASE + """.format(placeholders=",".join("?" * len(inventory.MAINTENANCE_CATEGORIES))), + inventory.MAINTENANCE_CATEGORIES, + ) + + +@bp.get("/") +@permission_required("maintenance.view", "maintenance.execute", mode="any") +def overview(): + conn = db() + return render_template( + "tesm/maintenance.html", + devices=_eligible_devices(conn), + jobs=service.list_jobs(conn), + category_labels=dict(inventory.CREDENTIAL_CATEGORIES), + category_effects=inventory.CATEGORY_EFFECTS, + update_category=inventory.MAINTENANCE_CATEGORY, + terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), + windows_tool_missing=_windows_tool_missing(conn), + windows_package=windowsops.REQUIRED_PACKAGE, + ) + + +def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def] + """Fehlt ``net``, obwohl Windows-Geraete gepflegt werden? + + Nur dann ist der Hinweis angebracht. Auf einem Server ohne Windows-Geraete + waere er Laerm, und das Paket waere unnoetig installiert. + """ + if windowsops.available(): + return False + return any( + str(device.get("category") or "") == inventory.NON_SSH_CATEGORY + for device in _eligible_devices(conn) + ) + + +@bp.post("/paket-nachinstallieren") +@permission_required("maintenance.execute") +def install_windows_tool(): + """Installiert das Paket, das der Fehlermeldung nach fehlt. + + Ohne diesen Weg endete der Neustart eines Windows-Geraetes in einer + Sackgasse: die Meldung nannte das fehlende Paket, aus der Oberflaeche liess + es sich aber nicht beschaffen. + """ + sysops = core().sysops + if sysops is None or not sysops.available: + flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") + return redirect(url_for("maintenance.overview")) + + result = sysops.apt_install(windowsops.REQUIRED_PACKAGE) + if result.ok: + flash(f"{windowsops.REQUIRED_PACKAGE} installiert.", "success") + else: + flash(f"Installation fehlgeschlagen: {result.message}", "danger") + with transaction() as conn: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="maintenance.package_installed", + target=windowsops.REQUIRED_PACKAGE, + detail="" if result.ok else result.message, + ip=client_ip(), + severity="notice" if result.ok else "warning", + ) + return redirect(url_for("maintenance.overview")) + + +@bp.get("/status") +@permission_required("maintenance.view", "maintenance.execute", mode="any") +def status(): + """Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher.""" + conn = db() + jobs = service.list_jobs(conn, limit=30) + running = any(job["state"] in {"pending", "running"} for job in jobs) + return jsonify( + { + "running": running, + "poll_ms": 2000 if running else 10000, + "jobs": [ + { + "id": job["id"], + "kind": job["kind"], + "subject": job["subject"], + "state": job["state"], + "progress": job["progress"], + "message": job["message"], + "updated_at": job["updated_at"], + } + for job in jobs + ], + } + ) + + +@bp.get("/auftrag/") +@permission_required("maintenance.view", "maintenance.execute", mode="any") +def job_detail(job_id: str): + conn = db() + job = service.get_job(conn, job_id) + if job is None: + abort(404) + return render_template("tesm/maintenance_job.html", job=job) + + +@bp.get("/auftrag//ausgabe") +@permission_required("maintenance.view", "maintenance.execute", mode="any") +def job_output(job_id: str): + conn = db() + job = service.get_job(conn, job_id) + if job is None: + abort(404) + return render_template("tesm/_maintenance_output.html", job=job) + + +def _start(kind: str) -> None: + extension = core() + conn = db() + device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()] + if not device_ids: + flash("Es wurde kein Geraet ausgewaehlt.", "warning") + return + + eligible = {int(row["id"]): row for row in _eligible_devices(conn)} + started = 0 + for device_id in device_ids: + device = eligible.get(device_id) + if device is None: + continue + # Vor dem Anlegen pruefen, nicht danach: sonst bliebe ein Auftrag + # zurueck, der nie gestartet wird und ewig auf "wartet" steht. + if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux": + flash( + f"{device['name']}: Paketaktualisierung gibt es nur fuer Linux-Clients. " + "Windows-Geraete lassen sich hier nur neu starten.", + "warning", + ) + continue + + try: + username, password = inventory.credential_secret( + conn, extension.keystore, int(device["credential_id"]) + ) + except inventory.ValidationError as exc: + flash(f"{device['name']}: {exc}", "danger") + continue + + with transaction(): + handle = service.create_job( + conn, + kind=kind, + subject=str(device["name"]), + actor=current_user.username, + message="Wartet auf Start", + ) + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action=kind, + target=str(device["name"]), + detail=f"Auftrag {handle.id}", + ip=client_ip(), + severity="notice", + ) + + if kind == service.JOB_KIND_UPDATE: + service.start_background( + service.run_update, + database=extension.database, + job_id=handle.id, + device=dict(device), + username=username, + password=password, + timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900), + ) + else: + service.start_background( + service.run_reboot, + database=extension.database, + job_id=handle.id, + device=dict(device), + username=username, + password=password, + wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300), + actor=current_user.username, + ) + started += 1 + + if started: + flash(f"{started} Auftrag/Auftraege gestartet.", "success") + + +@bp.post("/aktualisieren") +@permission_required("maintenance.execute") +def start_update(): + _start(service.JOB_KIND_UPDATE) + return redirect(url_for("maintenance.overview")) + + +@bp.post("/neustarten") +@permission_required("maintenance.execute") +def start_reboot(): + _start(service.JOB_KIND_REBOOT) + return redirect(url_for("maintenance.overview")) diff --git a/apps/tesm/src/tesm/blueprints/settings.py b/apps/tesm/src/tesm/blueprints/settings.py new file mode 100644 index 0000000..0baaad9 --- /dev/null +++ b/apps/tesm/src/tesm/blueprints/settings.py @@ -0,0 +1,235 @@ +"""Systemeinstellungen von TESM. + +Netzwerk, Webserver/TLS und Verzeichnisdienst liegen im Kern +(``tesm_core.views.hostsettings``) -- sie sind in beiden Anwendungen identisch. +Hier steht nur, was TESM eigen ist: Ueberwachung, Konsole, Protokolle, Host. +""" + +from __future__ import annotations + +import threading +import time +from typing import Any + +from flask import Blueprint, flash, redirect, render_template, request, url_for +from tesm_core import audit +from tesm_core.auth.models import current_user, permission_required +from tesm_core.auth.service import client_ip +from tesm_core.extension import core, db, transaction +from tesm_core.licensing_client import LicenseError + +bp = Blueprint("settings", __name__, url_prefix="/einstellungen") + + +def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None: + with transaction() as conn: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action=action, + target=target, + detail=detail, + ip=client_ip(), + severity=severity, + ) + + +def _license_hint(feature: str) -> str: + manager = core().license + if manager is None: + return "" + try: + manager.require(feature) + except LicenseError as exc: + return str(exc) + return "" + + +def _current_timezone() -> str: + sysops = core().sysops + if sysops is None: + return "" + return sysops.read_command( + ["timedatectl", "show", "--property=Timezone", "--value"] + ).stdout.strip() + + +def _timezone_choices() -> list[str]: + try: + from zoneinfo import available_timezones + + return sorted(available_timezones()) + except Exception: # noqa: BLE001 + return ["Europe/Berlin", "UTC"] + + +@bp.get("/system") +@permission_required("settings_system.view") +def system(): + extension = core() + conn = db() + sysops = extension.sysops + return render_template( + "tesm/settings_system.html", + values=extension.settings.get_many( + conn, + [ + "monitor_interval_seconds", + "monitor_failures_before_restart", + "monitor_auto_restart", + "dashboard_public", + "terminal_enabled", + "terminal_idle_timeout", + "trash_retention_days", + "log_rotate_interval", + "log_rotate_keep", + ], + ), + specs={spec.key: spec for spec in extension.settings.specs()}, + hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""), + timezone=_current_timezone(), + timezones=_timezone_choices(), + nav_items=extension.nav.items, + nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(), + license_hint=_license_hint("monitor_interval"), + sysops_available=bool(sysops and sysops.available), + ) + + +@bp.post("/system") +@permission_required("settings_system.edit") +def system_save(): + extension = core() + conn = db() + form = request.form + + values: dict[str, Any] = {} + for key in ( + "monitor_failures_before_restart", + "monitor_auto_restart", + "dashboard_public", + "terminal_enabled", + "terminal_idle_timeout", + "trash_retention_days", + "log_rotate_interval", + "log_rotate_keep", + ): + spec = extension.settings.spec(key) + if spec.kind == "bool": + # Nur beruecksichtigen, wenn das Formular diesen Bereich ueberhaupt + # enthaelt -- sonst wuerde ein Teilformular fremde Schalter loeschen. + if f"{key}__present" in form or key in form: + values[key] = form.get(key) == "1" + elif key in form: + values[key] = form.get(key) + + interval_changed = False + if "monitor_interval_seconds" in form: + problem = _license_hint("monitor_interval") + current = str(extension.settings.get(conn, "monitor_interval_seconds") or 300) + wanted = str(form.get("monitor_interval_seconds")) + if current != wanted: + if problem: + flash(problem, "danger") + else: + values["monitor_interval_seconds"] = wanted + interval_changed = True + + with transaction(): + changed = extension.settings.set_many(conn, values, actor=current_user.username) + if changed: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="settings.system_saved", + detail=", ".join(changed), + ip=client_ip(), + severity="notice", + ) + if changed: + message = "Einstellungen gespeichert." + if interval_changed: + message += " Das Pruefintervall greift beim naechsten Durchlauf." + flash(message, "success") + else: + flash("Keine Aenderungen.", "info") + return redirect(url_for("settings.system")) + + +@bp.post("/system/hostname") +@permission_required("settings_system.execute") +def set_hostname(): + extension = core() + sysops = extension.sysops + hostname = (request.form.get("hostname") or "").strip() + if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum(): + flash("Der Hostname ist ungueltig.", "danger") + return redirect(url_for("settings.system")) + if sysops is None or not sysops.available: + flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") + return redirect(url_for("settings.system")) + result = sysops.set_hostname(hostname) + if result.ok: + with transaction() as conn: + extension.settings.set(conn, "hostname", hostname, actor=current_user.username) + _audit("settings.hostname_changed", hostname, severity="notice") + flash(f"Hostname auf {hostname} gesetzt.", "success") + else: + flash(f"Hostname konnte nicht gesetzt werden: {result.message}", "danger") + return redirect(url_for("settings.system")) + + +@bp.post("/system/zeitzone") +@permission_required("settings_system.execute") +def set_timezone(): + extension = core() + sysops = extension.sysops + timezone = (request.form.get("timezone") or "").strip() + if timezone not in _timezone_choices(): + flash("Unbekannte Zeitzone.", "danger") + return redirect(url_for("settings.system")) + if sysops is None or not sysops.available: + flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") + return redirect(url_for("settings.system")) + result = sysops.set_timezone(timezone) + if result.ok: + # Sofort auf den laufenden Prozess wirken lassen -- sonst zeigt die + # Oberflaeche bis zum naechsten Neustart die alte Zeit. + try: + time.tzset() # type: ignore[attr-defined] + except AttributeError: # pragma: no cover - Windows + pass + with transaction() as conn: + extension.settings.set(conn, "timezone", timezone, actor=current_user.username) + _audit("settings.timezone_changed", timezone, severity="notice") + flash(f"Zeitzone auf {timezone} gesetzt.", "success") + else: + flash(f"Zeitzone konnte nicht gesetzt werden: {result.message}", "danger") + return redirect(url_for("settings.system")) + + +@bp.post("/system/pruefung-jetzt") +@permission_required("settings_system.execute") +def run_check_now(): + """Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund.""" + extension = core() + + def worker() -> None: + from ..services import inventory, monitor + + allowed = extension.license is None or extension.license.feature_allowed( + "auto_poe_restart" + ) + monitor.run_sweep( + database=extension.database, + live=extension.registry["live_log"], + keystore=extension.keystore, + license_allows_auto_restart=allowed, + ) + + threading.Thread(target=worker, name="manual-sweep", daemon=True).start() + _audit("monitor.manual_sweep") + flash("Durchlauf gestartet. Das Ergebnis erscheint im laufenden Protokoll.", "success") + return redirect(request.referrer or url_for("settings.system")) diff --git a/apps/tesm/src/tesm/blueprints/switches.py b/apps/tesm/src/tesm/blueprints/switches.py new file mode 100644 index 0000000..a7cf8cf --- /dev/null +++ b/apps/tesm/src/tesm/blueprints/switches.py @@ -0,0 +1,292 @@ +"""Switche: Bestand pflegen, Host-Schluessel freigeben, Zustand abfragen.""" + +from __future__ import annotations + +from typing import Any + +from flask import Blueprint, abort, flash, redirect, render_template, request, url_for +from tesm_core import audit +from tesm_core.auth.models import ( + current_user, + permission_required, + reauth_required, +) +from tesm_core.auth.service import client_ip +from tesm_core.db import Database +from tesm_core.extension import core, db, transaction + +from ..services import inventory, ssh, switchcli + +bp = Blueprint("switches", __name__, url_prefix="/switche") + + +def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action=action, + target=target, + detail=detail, + ip=client_ip(), + severity=severity, + ) + + +def _form_values() -> dict[str, Any]: + return { + "hostname": request.form.get("hostname", ""), + "ip": request.form.get("ip", ""), + "ssh_port": request.form.get("ssh_port") or 22, + "credential_id": request.form.get("credential_id") or None, + "model": request.form.get("model", ""), + "location": request.form.get("location", ""), + "notes": request.form.get("notes", ""), + "actor": current_user.username, + } + + +@bp.get("/") +@permission_required("switches.view") +def overview(): + conn = db() + return render_template( + "tesm/switches.html", + switches=inventory.list_switches(conn), + credentials=inventory.list_credentials(conn), + host_keys=Database.all(conn, "SELECT * FROM switch_host_keys ORDER BY host"), + terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), + ) + + +@bp.post("/neu") +@permission_required("switches.create") +def create(): + try: + with transaction() as conn: + switch_id = inventory.create_switch(conn, **_form_values()) + _audit( + conn, + "switch.created", + request.form.get("hostname", ""), + f"IP {request.form.get('ip')}", + severity="notice", + ) + flash("Switch angelegt. Der Host-Schluessel muss vor dem ersten Zugriff freigegeben werden.", "success") + return redirect(url_for("switches.detail", switch_id=switch_id)) + except inventory.ValidationError as exc: + flash(str(exc), "danger") + return redirect(url_for("switches.overview")) + + +@bp.post("/") +@permission_required("switches.edit") +def update(switch_id: int): + try: + with transaction() as conn: + changes = inventory.update_switch(conn, switch_id, **_form_values()) + if changes: + _audit( + conn, + "switch.updated", + request.form.get("hostname", str(switch_id)), + "; ".join(changes), + severity="notice", + ) + flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") + except inventory.ValidationError as exc: + flash(str(exc), "danger") + return redirect(request.referrer or url_for("switches.overview")) + + +@bp.post("//loeschen") +@permission_required("switches.delete") +def delete(switch_id: int): + try: + with transaction() as conn: + switch = inventory.get_switch(conn, switch_id) + if switch is None: + abort(404) + inventory.delete_switch(conn, switch_id) + _audit(conn, "switch.deleted", str(switch["hostname"]), severity="warning") + flash("Switch in den Papierkorb verschoben.", "success") + except inventory.ValidationError as exc: + flash(str(exc), "danger") + return redirect(url_for("switches.overview")) + + +@bp.get("/") +@permission_required("switches.view") +def detail(switch_id: int): + conn = db() + switch = inventory.get_switch(conn, switch_id) + if switch is None: + abort(404) + host_key = ssh.known_key(conn, str(switch["ip"]), int(switch["ssh_port"]), "ssh-ed25519") + all_keys = Database.all( + conn, + "SELECT * FROM switch_host_keys WHERE host=? AND port=? ORDER BY key_type", + (str(switch["ip"]), int(switch["ssh_port"])), + ) + devices = Database.all( + conn, + "SELECT d.id, d.name, d.port, d.ip, COALESCE(st.state,'unknown') AS state " + "FROM devices d LEFT JOIN device_status st ON st.device_id = d.id " + "WHERE d.switch_id=? AND d.deleted_at IS NULL ORDER BY d.port, d.name", + (switch_id,), + ) + return render_template( + "tesm/switch_detail.html", + switch=switch, + host_key=host_key, + host_keys=all_keys, + devices=devices, + credentials=inventory.list_credentials(conn), + safe_commands=switchcli.SAFE_COMMANDS, + terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), + ) + + +# --------------------------------------------------------------------------- # +# Host-Schluessel +# --------------------------------------------------------------------------- # + + +@bp.post("//hostkey/pruefen") +@permission_required("switches.edit") +def probe_host_key(switch_id: int): + conn = db() + switch = inventory.get_switch(conn, switch_id) + if switch is None: + abort(404) + try: + record = ssh.probe_host_key(str(switch["ip"]), int(switch["ssh_port"])) + except ssh.SshError as exc: + flash(str(exc), "danger") + return redirect(url_for("switches.detail", switch_id=switch_id)) + + level, message = ssh.describe_probe(conn, record) + flash(message, level) + return redirect( + url_for( + "switches.detail", + switch_id=switch_id, + probed_type=record.key_type, + probed_fingerprint=record.fingerprint, + ) + ) + + +@bp.post("//hostkey/freigeben") +@permission_required("switches.edit") +@reauth_required +def trust_host_key(switch_id: int): + """Gibt einen Host-Schluessel ausdruecklich frei. + + Nur ein Mensch mit frischer Passwortbestaetigung kann das -- kein + Hintergrundprozess und kein Automatismus. + """ + conn = db() + switch = inventory.get_switch(conn, switch_id) + if switch is None: + abort(404) + key_type = (request.form.get("key_type") or "").strip() + fingerprint = (request.form.get("fingerprint") or "").strip() + if not key_type or not ssh.valid_fingerprint(fingerprint): + flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger") + return redirect(url_for("switches.detail", switch_id=switch_id)) + + with transaction(): + ssh.trust_key( + conn, + host=str(switch["ip"]), + port=int(switch["ssh_port"]), + key_type=key_type, + fingerprint=fingerprint, + actor=current_user.username, + ) + _audit( + conn, + "switch.host_key_trusted", + str(switch["hostname"]), + f"{key_type} {fingerprint}", + severity="notice", + ) + flash("Host-Schluessel freigegeben.", "success") + return redirect(url_for("switches.detail", switch_id=switch_id)) + + +@bp.post("//hostkey/verwerfen") +@permission_required("switches.edit") +def forget_host_key(switch_id: int): + conn = db() + switch = inventory.get_switch(conn, switch_id) + if switch is None: + abort(404) + with transaction(): + ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"])) + _audit(conn, "switch.host_key_forgotten", str(switch["hostname"]), severity="warning") + flash("Gespeicherte Host-Schluessel entfernt.", "info") + return redirect(url_for("switches.detail", switch_id=switch_id)) + + +# --------------------------------------------------------------------------- # +# Kommandos +# --------------------------------------------------------------------------- # + + +@bp.post("//kommando") +@permission_required("switches.execute") +@reauth_required +def run_command(switch_id: int): + """Fuehrt ein Kommando aus der Positivliste aus. + + Der schnelle, sichere Weg fuer den Alltag: kein interaktives Terminal, nur + lesende Kommandos aus einer festen Liste. Wer wirklich konfigurieren muss, + nutzt das Terminal -- das ebenfalls Recht und Bestaetigung verlangt und + zusaetzlich vollstaendig mitgeschrieben wird. + """ + extension = core() + conn = db() + switch = inventory.get_switch(conn, switch_id) + if switch is None: + abort(404) + command = (request.form.get("command") or "").strip() + allowed = {entry[0] for entry in switchcli.SAFE_COMMANDS} + if command not in allowed: + flash("Dieses Kommando steht nicht auf der Positivliste.", "danger") + return redirect(url_for("switches.detail", switch_id=switch_id)) + if not switch["credential_id"]: + flash("Fuer diesen Switch sind keine Zugangsdaten hinterlegt.", "danger") + return redirect(url_for("switches.detail", switch_id=switch_id)) + + try: + username, password = inventory.credential_secret( + conn, extension.keystore, int(switch["credential_id"]) + ) + with switchcli.SwitchCli( + conn, + host=str(switch["ip"]), + port=int(switch["ssh_port"]), + username=username, + password=password, + ) as cli: + output = cli.send(command, tolerate_error=True) + except (ssh.SshError, inventory.ValidationError) as exc: + flash(str(exc), "danger") + return redirect(url_for("switches.detail", switch_id=switch_id)) + + with transaction(): + _audit( + conn, + "switch.command", + str(switch["hostname"]), + command, + severity="notice", + ) + conn.execute( + "UPDATE switches SET last_seen_at=datetime('now') WHERE id=?", (switch_id,) + ) + return render_template( + "tesm/switch_command.html", switch=switch, command=command, output=output + ) diff --git a/apps/tesm/src/tesm/blueprints/terminal.py b/apps/tesm/src/tesm/blueprints/terminal.py new file mode 100644 index 0000000..929a4a2 --- /dev/null +++ b/apps/tesm/src/tesm/blueprints/terminal.py @@ -0,0 +1,397 @@ +"""Konsole im Browser -- fuer Switche und fuer Clients, zeilenorientiert. + +Bewusste Entscheidung gegen ein vollstaendiges Terminal-Emulat: der Vorgaenger +lieferte dafuer xterm.js als Fremdcode im Repo mit (mehrere hundert Kilobyte, +eigene Aktualisierungspflicht) und brauchte einen dauerhaft blockierten +Worker-Thread pro Sitzung. Eine Zeile hinein, eine Antwort heraus deckt beide +Faelle ab -- die ArubaOS-Kommandozeile ist ohnehin zeilenorientiert, und auf +einem Linux-Client ist eine eigene ``exec``-Sitzung je Kommando sogar +robuster als eine geteilte Shell. + +Zwei Ziele, ein Ablauf: + +* **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber + :class:`~tesm.services.switchcli.SwitchCli`. +* **Client** -- ``maintenance.execute`` und das lizenzpflichtige Modul + ``maintenance``. Ein Kommando je ``exec``-Kanal ueber + :class:`~tesm.services.shellcli.ShellSession`. + +Die Rechte sind absichtlich verschieden: ``devices.execute`` erlaubt heute +einen PoE-Neustart. Wer ein Geraet neu starten darf, soll damit nicht +automatisch eine Shell darauf bekommen -- das waere genau die stille +Rechteausweitung, die dieses Projekt an anderer Stelle ausschliesst. + +Gemeinsame Sicherheitsmerkmale: + +* Frische Passwortbestaetigung, zusaetzlich zum Recht. +* Abschaltbar ueber die Einstellung ``terminal_enabled``. +* Der Host-Schluessel muss vorher freigegeben sein -- die Konsole akzeptiert + keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier + interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite. +* Jede Sitzung wird vollstaendig mitgeschrieben (Datei unter + ``/terminal/``) und im Aenderungsprotokoll vermerkt. +* Leerlauf-Zeitlimit trennt die Verbindung. +""" + +from __future__ import annotations + +import json +import logging +import re +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +from flask import Blueprint, abort, render_template +from tesm_core import audit +from tesm_core.auth.models import current_user, permission_required, reauth_required +from tesm_core.auth.service import client_ip, reauth_is_fresh +from tesm_core.extension import core, db + +from ..services import inventory, shellcli, ssh, switchcli + +logger = logging.getLogger(__name__) + +bp = Blueprint("terminal", __name__) + +MAX_LINE_LENGTH = 400 + +KIND_SWITCH = "switch" +KIND_DEVICE = "device" + +#: Vorschlaege fuer die Auswahlliste auf einem Linux-Client. Reine +#: Lesekommandos -- die Liste ist eine Bequemlichkeit, keine Schranke: das +#: Eingabefeld nimmt jedes Kommando an. Eine Positivliste waere hier +#: Sicherheitstheater, denn wer eine Shell hat, hat eine Shell. +CLIENT_COMMANDS: tuple[tuple[str, str], ...] = ( + ("uptime", "Laufzeit und Last"), + ("df -h", "Belegung der Dateisysteme"), + ("free -h", "Arbeitsspeicher"), + ("ip -brief address", "Netzwerkadressen"), + ("systemctl --failed", "Fehlgeschlagene Dienste"), + ("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"), + ("apt list --upgradable 2>/dev/null", "Verfuegbare Aktualisierungen"), + ("uname -a", "Kernel und Architektur"), +) + + +@dataclass(frozen=True, slots=True) +class Target: + """Alles, was die Konsole ueber ihr Gegenueber wissen muss.""" + + kind: str + id: int + label: str + host: str + port: int + credential_id: int + back_endpoint: str + back_label: str + + @property + def is_switch(self) -> bool: + return self.kind == KIND_SWITCH + + @property + def audit_prefix(self) -> str: + return "switch" if self.is_switch else "device" + + +def _load_target(conn: Any, kind: str, target_id: int) -> Target | None: + if kind == KIND_SWITCH: + row = inventory.get_switch(conn, target_id) + if row is None: + return None + return Target( + kind=KIND_SWITCH, + id=int(row["id"]), + label=str(row["hostname"]), + host=str(row["ip"]), + port=int(row["ssh_port"] or 22), + credential_id=int(row["credential_id"] or 0), + back_endpoint="switches.detail", + back_label="Switche", + ) + row = inventory.get_device(conn, target_id) + if row is None or row["deleted_at"]: + return None + return Target( + kind=KIND_DEVICE, + id=int(row["id"]), + label=str(row["name"]), + host=str(row["ip"]), + port=int(row["ssh_port"] or 22), + credential_id=int(row["credential_id"] or 0), + back_endpoint="devices.detail", + back_label="Clients", + ) + + +def _client_module_active() -> bool: + manager = core().license + return manager is None or manager.module_active("maintenance") + + +def _render_console(target: Target): # type: ignore[no-untyped-def] + extension = core() + conn = db() + if not extension.settings.get(conn, "terminal_enabled"): + abort(404) + if not target.credential_id: + abort(409, description="Fuer dieses Ziel sind keine Zugangsdaten hinterlegt.") + + return render_template( + "tesm/console.html", + target=target, + host_key_ready=ssh.is_trusted(conn, target.host, target.port), + presets=switchcli.SAFE_COMMANDS if target.is_switch else CLIENT_COMMANDS, + idle_timeout=int(extension.settings.get(conn, "terminal_idle_timeout") or 300), + websocket_path=f"/ws/{target.kind}/{target.id}", + ) + + +@bp.get("/switche//konsole") +@permission_required("switches.execute") +@reauth_required +def switch_console(switch_id: int): + target = _load_target(db(), KIND_SWITCH, switch_id) + if target is None: + abort(404) + return _render_console(target) + + +@bp.get("/clients//konsole") +@permission_required("maintenance.execute") +@reauth_required +def device_console(device_id: int): + if not _client_module_active(): + abort(404) + target = _load_target(db(), KIND_DEVICE, device_id) + if target is None: + abort(404) + return _render_console(target) + + +def init_app(app) -> None: # type: ignore[no-untyped-def] + """Meldet die HTTP-Routen und -- falls verfuegbar -- die WebSockets an.""" + app.register_blueprint(bp) + + try: + from flask_sock import Sock + except ImportError: # pragma: no cover - optionale Abhaengigkeit + logger.info("flask-sock ist nicht installiert -- die Konsole bleibt deaktiviert.") + return + + sock = Sock(app) + app.config.setdefault("SOCK_SERVER_OPTIONS", {"ping_interval": 25}) + + @sock.route("/ws/switch/") + def switch_socket(ws, target_id: int): # type: ignore[no-untyped-def] + _handle_socket(ws, KIND_SWITCH, target_id) + + @sock.route("/ws/device/") + def device_socket(ws, target_id: int): # type: ignore[no-untyped-def] + _handle_socket(ws, KIND_DEVICE, target_id) + + +def _send(ws, kind: str, text: str) -> None: # type: ignore[no-untyped-def] + ws.send(json.dumps({"type": kind, "data": text})) + + +def _transcript_name(label: str) -> str: + """Dateiname aus dem Ziel -- ohne alles, was einen Pfad ergeben koennte.""" + return re.sub(r"[^A-Za-z0-9._-]", "_", label)[:60] or "unbenannt" + + +def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-untyped-def] + extension = core() + conn = db() + + # -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein + # WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich. + required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute" + if not current_user.is_authenticated or not current_user.has(required): + _send(ws, "error", "Keine Berechtigung fuer diese Konsole.") + return + if not reauth_is_fresh(): + _send(ws, "error", "Die Passwortbestaetigung ist abgelaufen. Bitte Seite neu laden.") + return + if not extension.settings.get(conn, "terminal_enabled"): + _send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.") + return + if kind == KIND_DEVICE and not _client_module_active(): + _send(ws, "error", "Die Client-Konsole gehoert zum Modul Wartung -- es ist nicht aktiv.") + return + + target = _load_target(conn, kind, target_id) + if target is None or not target.credential_id: + _send(ws, "error", "Ziel oder Zugangsdaten nicht gefunden.") + return + + idle_timeout = int(extension.settings.get(conn, "terminal_idle_timeout") or 300) + transcript_dir = extension.config.log_dir / "terminal" + transcript_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + transcript_path = ( + transcript_dir + / f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log" + ) + + try: + username, password = inventory.credential_secret( + conn, extension.keystore, target.credential_id + ) + except inventory.ValidationError as exc: + _send(ws, "error", str(exc)) + return + + with extension.database.transaction(conn): + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action=f"{target.audit_prefix}.console_opened", + target=target.label, + detail=f"Mitschrift: {transcript_path.name}", + ip=client_ip(), + severity="warning", + ) + + commands = 0 + try: + with transcript_path.open("a", encoding="utf-8", newline="\n") as transcript: + transcript.write( + f"# Sitzung {stamp} von {current_user.username} auf {target.label} " + f"({target.host}:{target.port})\n" + ) + if target.is_switch: + commands = _run_switch_session( + ws, conn, target, username, password, transcript, idle_timeout + ) + else: + commands = _run_client_session( + ws, conn, target, username, password, transcript, idle_timeout + ) + except ssh.UnknownHostKey as exc: + page = "Switch-Seite" if target.is_switch else "Client-Seite" + _send( + ws, + "error", + f"{exc} Die Konsole akzeptiert keine unbekannten Schluessel -- bitte auf der " + f"{page} ausdruecklich freigeben.", + ) + except ssh.SshError as exc: + _send(ws, "error", str(exc)) + except Exception as exc: # noqa: BLE001 + logger.exception("Konsolensitzung abgebrochen") + _send(ws, "error", f"Unerwarteter Fehler: {exc}") + finally: + with extension.database.session() as fresh, extension.database.transaction(fresh): + audit.log( + fresh, + actor=current_user.username, + actor_id=current_user.id, + action=f"{target.audit_prefix}.console_closed", + target=target.label, + detail=f"{commands} Kommandos, Mitschrift {transcript_path.name}", + ip=client_ip(), + severity="notice", + ) + + +def _read_command(ws, idle_timeout: int) -> str | None: # type: ignore[no-untyped-def] + """Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll.""" + raw = ws.receive(timeout=idle_timeout) + if raw is None: + _send(ws, "info", "Verbindung wegen Untaetigkeit getrennt.") + return None + try: + message = json.loads(raw) + except ValueError: + return "" + command = str(message.get("command") or "").strip() + if not command: + return "" + if len(command) > MAX_LINE_LENGTH: + _send(ws, "error", "Die Zeile ist zu lang.") + return "" + if command.lower() in {"exit", "quit", "logout"}: + _send(ws, "info", "Sitzung beendet.") + return None + return command + + +def _run_switch_session( # type: ignore[no-untyped-def] + ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int +) -> int: + commands = 0 + with switchcli.SwitchCli( + conn, host=target.host, port=target.port, username=username, password=password + ) as cli: + _send(ws, "ready", f"Verbunden mit {target.label} ({target.host}).") + _send(ws, "output", cli.full_transcript) + while True: + command = _read_command(ws, idle_timeout) + if command is None: + break + if not command: + continue + transcript.write(f"$ {command}\n") + started = time.monotonic() + try: + output = cli.send(command, tolerate_error=True) + except ssh.SshError as exc: + _send(ws, "error", str(exc)) + transcript.write(f"! {exc}\n") + break + duration = int((time.monotonic() - started) * 1000) + transcript.write(output + "\n") + transcript.flush() + commands += 1 + _send(ws, "output", output) + _send(ws, "done", f"{duration} ms") + return commands + + +def _run_client_session( # type: ignore[no-untyped-def] + ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int +) -> int: + commands = 0 + with shellcli.ShellSession( + conn, host=target.host, port=target.port, username=username, password=password + ) as session: + _send(ws, "ready", f"Verbunden mit {target.label} ({target.host}) als {username}.") + banner = session.banner() + if banner: + _send(ws, "output", banner) + transcript.write(banner + "\n") + while True: + command = _read_command(ws, idle_timeout) + if command is None: + break + if not command: + continue + transcript.write(f"$ {command}\n") + try: + result = session.run(command) + except ssh.SshError as exc: + _send(ws, "error", str(exc)) + transcript.write(f"! {exc}\n") + break + transcript.write(result["output"] + "\n") + transcript.flush() + commands += 1 + if result["output"]: + _send(ws, "output", result["output"]) + # Der Rueckgabewert ist auf einem Client die eigentliche Antwort: + # ein Kommando ohne Ausgabe kann trotzdem fehlgeschlagen sein. + if result["status"] == 0: + _send(ws, "done", f"{result['duration_ms']} ms") + else: + _send( + ws, + "error", + f"Rueckgabewert {result['status']} nach {result['duration_ms']} ms.", + ) + return commands diff --git a/apps/tesm/src/tesm/cli.py b/apps/tesm/src/tesm/cli.py new file mode 100644 index 0000000..73911bc --- /dev/null +++ b/apps/tesm/src/tesm/cli.py @@ -0,0 +1,303 @@ +"""Kommandozeilenwerkzeuge: Administrator anlegen, Zustand pruefen, Wartung. + +Ersetzt ``create_db.py``/``create_admin.py`` des Vorgaengers. Die Datenbank wird +nicht mehr von einem eigenen Skript erzeugt -- das Schema entsteht beim ersten +Start durch die Migrationen. +""" + +from __future__ import annotations + +import argparse +import getpass +import json +import sys +from typing import Any + +from tesm_core import audit +from tesm_core.cli_web import add_web_setup, run_web_setup +from tesm_core.db import Database +from tesm_core.extension import core +from tesm_core.rbac import service as rbac +from tesm_core.security import passwords + +from . import create_app + + +def _app_and_core() -> tuple[Any, Any]: + app = create_app() + return app, core(app) + + +def cmd_create_admin(args: argparse.Namespace) -> int: + app, extension = _app_and_core() + username = args.username or input("Benutzername: ").strip() + if not username: + print("Kein Benutzername angegeben.", file=sys.stderr) + return 2 + + password = args.password + if not password: + password = getpass.getpass("Passwort: ") + if password != getpass.getpass("Passwort wiederholen: "): + print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr) + return 2 + + problems = extension.password_policy.check(password, username=username) + if problems and not args.force: + for problem in problems: + print(f"- {problem}", file=sys.stderr) + print("Mit --force trotzdem setzen.", file=sys.stderr) + return 2 + + with app.app_context(), extension.database.session() as conn: + existing = rbac.find_user_by_username(conn, username, include_deleted=True) + with extension.database.transaction(conn): + if existing is not None: + conn.execute( + "UPDATE users SET password_hash=?, is_admin=1, is_locked=0, deleted_at=NULL, " + "must_change_password=0, updated_at=datetime('now') WHERE id=?", + (passwords.hash_password(password), existing["id"]), + ) + action = "aktualisiert" + user_id = int(existing["id"]) + else: + cursor = conn.execute( + "INSERT INTO users (username, password_hash, is_admin, auth_source, " + "created_at, updated_at, password_changed_at) " + "VALUES (?,?,1,'local',datetime('now'),datetime('now'),datetime('now'))", + (username, passwords.hash_password(password)), + ) + user_id = int(cursor.lastrowid or 0) + action = "angelegt" + audit.log( + conn, + actor="cli", + actor_id=user_id, + action="user.admin_created", + target=username, + detail=f"ueber die Kommandozeile {action}", + severity="warning", + ) + print(f"Administrator {username!r} {action}.") + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + app, extension = _app_and_core() + with app.app_context(), extension.database.session() as conn: + chain = audit.verify_chain(conn, limit=5000) + counts = { + "benutzer": Database.value( + conn, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL", default=0 + ), + "administratoren": rbac.count_active_admins(conn), + "gruppen": Database.value( + conn, "SELECT COUNT(*) FROM groups WHERE deleted_at IS NULL", default=0 + ), + "geraete": Database.value( + conn, "SELECT COUNT(*) FROM devices WHERE deleted_at IS NULL", default=0 + ), + "switche": Database.value( + conn, "SELECT COUNT(*) FROM switches WHERE deleted_at IS NULL", default=0 + ), + "protokollzeilen": audit.count(conn), + } + license_state = None + if extension.license is not None: + state = extension.license.reload(conn) + license_state = { + "vorhanden": state.present, + "gueltig": state.valid, + "aktiviert": state.activated, + "rolle": state.role, + "module": sorted(state.modules), + "fehler": state.error, + } + + payload = { + "datenbank": str(extension.config.db_path), + "protokolle": str(extension.config.log_dir), + "migrationen": extension.migration_status(), + "audit_kette": chain, + "bestand": counts, + "lizenz": license_state, + "systemhelfer": bool(extension.sysops and extension.sysops.available), + } + if args.json: + print(json.dumps(payload, indent=2, ensure_ascii=False, default=str)) + return 0 + + print(f"Datenbank : {payload['datenbank']}") + print(f"Protokolle : {payload['protokolle']}") + print(f"Systemhelfer : {'verfuegbar' if payload['systemhelfer'] else 'nicht verfuegbar'}") + print( + "Audit-Kette : " + + ("unversehrt" if chain["ok"] else f"GEBROCHEN bei Eintrag {chain['broken_at']}") + + f" ({chain['checked']} geprueft)" + ) + for key, value in counts.items(): + print(f"{key.capitalize():<15}: {value}") + if license_state: + print( + "Lizenz : " + + ("aktiv" if license_state["aktiviert"] and license_state["gueltig"] else "nicht aktiv") + + f", Rolle {license_state['rolle']}, Module {', '.join(license_state['module']) or '-'}" + ) + if license_state["fehler"]: + print(f"Lizenzfehler : {license_state['fehler']}") + if not counts["administratoren"]: + print("\nWARNUNG: es existiert kein entsperrtes Administratorkonto.", file=sys.stderr) + return 1 + return 0 + + +def cmd_housekeeping(_args: argparse.Namespace) -> int: + app, extension = _app_and_core() + from tesm_core.security import ratelimit + from tesm_core.security import sessions as session_store + + with app.app_context(), extension.database.session() as conn: + with extension.database.transaction(conn): + sessions = session_store.prune(conn) + events = ratelimit.prune(conn) + archived = audit.archive_old_rows(conn, extension.audit_archive_dir) + print( + f"{sessions} Sitzungen entfernt, {events} Ratenereignisse entfernt, " + f"{len(archived)} Protokolltage archiviert." + ) + return 0 + + +def cmd_check_now(_args: argparse.Namespace) -> int: + app, extension = _app_and_core() + from .services import inventory, monitor + + with app.app_context(): + allowed = extension.license is None or extension.license.feature_allowed( + "auto_poe_restart" + ) + report = monitor.run_sweep( + database=extension.database, + live=extension.registry["live_log"], + keystore=extension.keystore, + license_allows_auto_restart=allowed, + ) + print( + f"{report.checked} geprueft, {report.online} online, {report.offline} offline, " + f"{report.restarts_triggered} Neustarts ({report.restarts_ok} erfolgreich), " + f"{report.duration_ms} ms" + ) + return 0 + + +def cmd_web_setup(args: argparse.Namespace) -> int: + app, extension = _app_and_core() + return run_web_setup(app, extension, args) + + +def cmd_convert_legacy(args: argparse.Namespace) -> int: + """Uebersetzt einen Export des alten TESM in das neue Format. + + Bewusst ohne Datenbankzugriff: heraus kommt eine Datei, die ueber den + vorhandenen Import eingelesen wird. So sieht man vorher, was ankommt, und + es gibt nur einen Schreibpfad in die Datenbank. + """ + from getpass import getpass + from pathlib import Path + + from .services import legacy_transfer as legacy + from .services import transfer + + quelle = Path(args.datei) + if not quelle.is_file(): + print(f"Datei nicht gefunden: {quelle}") + return 2 + ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json") + if ziel.exists() and not args.force: + print(f"{ziel} existiert bereits. Mit --force ueberschreiben.") + return 2 + + alt_pass = args.passphrase or getpass("Passphrase der alten Datei: ") + try: + rohdaten = legacy.open_legacy(quelle.read_bytes(), alt_pass) + nutzlast, bericht = legacy.convert(rohdaten) + except legacy.LegacyTransferError as exc: + print(f"Fehlgeschlagen: {exc}") + return 1 + + print(bericht.as_text()) + if args.dry_run: + print() + print("--dry-run: keine Datei geschrieben.") + return 0 + + neu_pass = args.neue_passphrase or getpass("Passphrase fuer die neue Datei: ") + try: + ziel.write_text(transfer.seal(nutzlast, neu_pass), encoding="utf-8", newline="\n") + except Exception as exc: # noqa: BLE001 - Passphrase zu kurz, Pfad nicht schreibbar + print(f"Schreiben fehlgeschlagen: {exc}") + return 1 + print() + print(f"Geschrieben: {ziel}") + print("Einlesen unter Verwaltung -> Sicherung mit derselben Passphrase.") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="tesm-admin", description="Verwaltungswerkzeuge fuer TESM" + ) + sub = parser.add_subparsers(dest="command", required=True) + + admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen") + admin.add_argument("--username") + admin.add_argument("--password", help="Nur fuer automatisierte Installationen") + admin.add_argument("--force", action="store_true", help="Passwortregeln ignorieren") + admin.set_defaults(func=cmd_create_admin) + + status = sub.add_parser("status", help="Zustand der Installation ausgeben") + status.add_argument("--json", action="store_true") + status.set_defaults(func=cmd_status) + + house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufraeumen") + house.set_defaults(func=cmd_housekeeping) + + check = sub.add_parser("check-now", help="Einen Ueberwachungsdurchlauf sofort ausfuehren") + check.set_defaults(func=cmd_check_now) + + legacy = sub.add_parser( + "convert-legacy", + help="Export des alten TESM in das neue Format uebersetzen", + description=( + "Liest eine Exportdatei des Vorgaengers und schreibt eine Datei im " + "Format dieser Anwendung. Uebersetzt werden Clients, Switche, " + "Zugangsdaten und die DHCP-Konfiguration." + ), + ) + legacy.add_argument("datei", help="Exportdatei des alten TESM") + legacy.add_argument("--ausgabe", help="Zieldatei (Vorgabe: -neu.json)") + legacy.add_argument( + "--passphrase", help="Passphrase der alten Datei (sonst wird gefragt)" + ) + legacy.add_argument( + "--neue-passphrase", dest="neue_passphrase", help="Passphrase der neuen Datei" + ) + legacy.add_argument( + "--dry-run", action="store_true", help="Nur den Bericht zeigen, nichts schreiben" + ) + legacy.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben") + legacy.set_defaults(func=cmd_convert_legacy) + + add_web_setup(sub, cmd_web_setup) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/apps/tesm/src/tesm/navigation.py b/apps/tesm/src/tesm/navigation.py new file mode 100644 index 0000000..bdd61f6 --- /dev/null +++ b/apps/tesm/src/tesm/navigation.py @@ -0,0 +1,245 @@ +"""Navigationsbaum von TESM. + +Sichtbarkeit steht am Eintrag selbst -- nicht in einer zentralen if/elif-Kette +wie im Vorgaenger. Lizenzpflichtige Vollmodule verschwinden vollstaendig aus der +Navigation; die zugehoerigen Routen pruefen dasselbe zusaetzlich serverseitig +(ein direkter Aufruf per URL wird ebenso abgewiesen). +""" + +from __future__ import annotations + +from typing import Any + +from tesm_core.extension import core +from tesm_core.nav import NavItem, NavTree + + +def _module(name: str): + """Bedingung: Vollmodul lizenziert?""" + + def check(_user: Any) -> bool: + manager = core().license + return manager is None or manager.module_active(name) + + return check + + +def _has_fileshare_mounts(user: Any) -> bool: + """Dateifreigaben erscheinen, wenn es etwas zu sehen oder zu tun gibt. + + Zu sehen: eingebundene Freigaben dieses Benutzers. Zu tun: die Verwaltung + der Zuordnungen. Der zweite Fall fehlte -- damit war die einzige Seite, auf + der Zuordnungen entstehen, nur ueber die Adresszeile erreichbar, und wer sie + nicht kannte, hielt die Funktion fuer nicht vorhanden. + """ + if not user.is_authenticated: + return False + if not _module("fileshare")(user): + return False + if user.has("fileshare.edit"): + return True + from .services import fileshare + + return fileshare.has_mounts(user.id) + + +NAVIGATION = NavTree( + [ + NavItem(key="index", label="Statusuebersicht", icon="grid", endpoint="dashboard.index"), + NavItem( + key="monitoring", + label="Ueberwachung", + icon="cpu", + children=( + NavItem( + key="devices", + label="Clients", + icon="cpu", + endpoint="devices.overview", + permissions=("devices.view",), + ), + NavItem( + key="switches", + label="Switche", + icon="switch", + endpoint="switches.overview", + permissions=("switches.view",), + ), + NavItem( + key="credentials", + label="Zugangsdaten", + icon="key", + endpoint="credentials.overview", + permissions=("credentials.view",), + ), + NavItem( + key="hostkeys", + label="Host-Schluessel", + icon="shield", + endpoint="hostkeys.overview", + permissions=("devices.view", "switches.view"), + ), + NavItem( + key="maintenance", + label="Wartung", + icon="wrench", + endpoint="maintenance.overview", + permissions=("maintenance.view", "maintenance.execute"), + condition=_module("maintenance"), + ), + ), + ), + NavItem( + key="dhcp", + label="DHCP-Server", + icon="network", + endpoint="dhcp.overview", + permissions=("dhcp.view", "dhcp.edit", "dhcp.execute"), + condition=_module("dhcp"), + ), + NavItem( + key="fileshare", + label="Dateifreigaben", + icon="folder", + condition=_has_fileshare_mounts, + children=( + NavItem( + key="fileshare_browse", + label="Meine Freigaben", + icon="folder", + endpoint="fileshare.browse", + permissions=("fileshare.view",), + ), + NavItem( + key="fileshare_mappings", + label="Zuordnungen", + icon="link", + endpoint="fileshare.mappings", + permissions=("fileshare.edit",), + ), + ), + ), + NavItem( + key="observability", + label="Protokolle", + icon="terminal", + children=( + NavItem( + key="logs_live", + label="Laufend", + icon="terminal", + endpoint="logs.live", + permissions=("logs_live.view",), + ), + NavItem( + key="logs_history", + label="Verlauf", + icon="clock", + endpoint="logs.history", + permissions=("logs_history.view", "logs_history.export"), + ), + NavItem( + key="logs_audit", + label="Aenderungen", + icon="history", + endpoint="logs.audit_view", + permissions=("logs_audit.view", "logs_audit.export"), + ), + NavItem( + key="logs_dhcp", + label="Kea-DHCP", + icon="network", + endpoint="tesmlogs.dhcp", + permissions=("logs_dhcp.view",), + condition=_module("dhcp"), + ), + NavItem( + key="restarts", + label="Neustarts", + icon="power", + endpoint="tesmlogs.restarts", + permissions=("devices.view",), + ), + ), + ), + NavItem( + key="administration", + label="Verwaltung", + icon="sliders", + children=( + NavItem( + key="users", + label="Benutzer", + icon="users", + endpoint="admin.users", + permissions=("users.view",), + ), + NavItem( + key="groups", + label="Gruppen und Rechte", + icon="shield-users", + endpoint="admin.groups", + permissions=("groups.view",), + ), + NavItem( + key="settings_system", + label="System", + icon="sliders", + endpoint="settings.system", + permissions=("settings_system.view",), + ), + NavItem( + key="settings_network", + label="Netzwerk", + icon="network", + endpoint="hostsettings.network", + permissions=("settings_network.view",), + ), + NavItem( + key="settings_web", + label="Webserver und TLS", + icon="server", + endpoint="hostsettings.web", + permissions=("settings_web.view",), + ), + NavItem( + key="settings_directory", + label="Verzeichnisdienst", + icon="users", + endpoint="hostsettings.directory_settings", + permissions=("settings_directory.view",), + ), + NavItem( + key="settings_license", + label="Lizenz", + icon="key", + endpoint="license.overview", + # Bewusst immer erreichbar, auch ohne gueltige Lizenz -- + # sonst liesse sich eine neue Lizenz nie einspielen. + permissions=("settings_license.view",), + ), + NavItem( + key="backup", + label="Sicherung", + icon="transfer", + endpoint="backup.overview", + permissions=("backup.view", "backup.export", "backup.execute"), + ), + NavItem( + key="trash", + label="Papierkorb", + icon="trash", + endpoint="trash.overview", + permissions=("trash.view",), + ), + NavItem( + key="diagnostics", + label="Diagnose", + icon="info", + endpoint="diagnostics.overview", + permissions=("diagnostics.view",), + ), + ), + ), + ] +) diff --git a/apps/tesm/src/tesm/permissions.py b/apps/tesm/src/tesm/permissions.py new file mode 100644 index 0000000..c9a1cc7 --- /dev/null +++ b/apps/tesm/src/tesm/permissions.py @@ -0,0 +1,222 @@ +"""Rechtematrix von TESM. + +Bewusste Aenderungen gegenueber dem Vorgaenger (dort: R/W/E/D/X mit +bereichsabhaengiger Bedeutung): + +* **Loeschen ist ein eigenes Recht.** Vorher deckte "Aendern" das Loeschen mit + ab -- wer Geraete pflegen sollte, durfte sie zwangslaeufig auch entfernen. +* **Jede Spalte bedeutet ueberall dasselbe.** "Ausfuehren" ist immer "eine + Aktion ausloesen", nicht mal Neustart und mal Export. +* **Geheimnisse sind ein eigenes Recht.** Wer Zugangsdaten *pflegen* darf, muss + sie nicht im Klartext *sehen* duerfen. Zusaetzlich verlangt jede Anzeige eine + frische Passwortbestaetigung. +* **Jede Einstellungsseite hat ihr eigenes Rechtepaar** -- im Vorgaenger lief + die Lizenzseite ueber ``settings_system`` mit, was sich nicht getrennt + vergeben liess. +""" + +from __future__ import annotations + +from tesm_core.rbac.common import ( + LOGS_HISTORY, + LOGS_LIVE, + administration_area, + observability_area, +) +from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset + +A = Action + +DEVICES = Resource( + key="devices", + label="Clients", + actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), + hint="Ueberwachte PoE-Endgeraete", + action_hints={ + A.EXECUTE: "PoE-Neustart eines Geraets manuell ausloesen. Der automatische " + "Neustart bei Ausfall ist davon unabhaengig und laeuft immer.", + A.DELETE: "Geraet in den Papierkorb legen.", + }, +) + +SWITCHES = Resource( + key="switches", + label="Switche", + actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), + hint="Aruba-Switche und ihre Erreichbarkeit", + action_hints={ + A.EXECUTE: "Browser-Terminal oeffnen und Kommandos auf dem Switch ausfuehren. " + "Verlangt zusaetzlich eine frische Passwortbestaetigung; jede Sitzung wird " + "vollstaendig protokolliert.", + }, +) + +CREDENTIALS = Resource( + key="credentials", + label="Zugangsdaten", + actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS), + hint="Wiederverwendbare SSH-Logins", + action_hints={ + A.VIEW: "Namen und Benutzer sehen -- nicht das Passwort.", + A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische " + "Passwortbestaetigung und wird protokolliert.", + }, +) + +MAINTENANCE = Resource( + key="maintenance", + label="Wartung", + actions=(A.VIEW, A.EXECUTE), + hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)", + action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."}, +) + +DHCP = Resource( + key="dhcp", + label="DHCP-Server", + actions=(A.VIEW, A.EDIT, A.EXECUTE), + hint="Kea-DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)", + action_hints={ + A.EDIT: "Konfiguration aendern und schreiben.", + A.EXECUTE: "Dienst installieren, starten oder stoppen.", + }, +) + +FILESHARE = Resource( + key="fileshare", + label="Dateifreigaben", + actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), + hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)", + action_hints={ + A.CREATE: "Ordner anlegen und Dateien hochladen.", + A.EDIT: "Umbenennen und verschieben.", + }, +) + +LOGS_DHCP = Resource( + key="logs_dhcp", + label="Kea-DHCP-Protokoll", + actions=(A.VIEW,), +) + +MONITORING_AREA = Area( + key="monitoring", + label="Ueberwachung", + icon="cpu", + hint="Geraete, Switche, Zugangsdaten und Wartung. Ohne diesen Zugang bleibt der " + "gesamte Bereich verborgen.", + resources=(DEVICES, SWITCHES, CREDENTIALS, MAINTENANCE), +) + +NETWORK_AREA = Area( + key="network", + label="Netzwerkdienste", + icon="network", + resources=(DHCP,), +) + +FILES_AREA = Area( + key="files", + label="Dateifreigaben", + icon="folder", + resources=(FILESHARE,), +) + +PERMISSIONS = PermissionTree( + [ + MONITORING_AREA, + NETWORK_AREA, + FILES_AREA, + observability_area(LOGS_DHCP), + administration_area(), + ] +) + + +ROLE_PRESETS: tuple[RolePreset, ...] = ( + RolePreset( + key="viewer", + name="Benutzer", + description="Standardgruppe: Geraete und Switche ansehen, laufendes Protokoll lesen.", + grants=( + ("devices", (A.VIEW,)), + ("switches", (A.VIEW,)), + ("logs_live", (A.VIEW,)), + ), + is_default=True, + ), + RolePreset( + key="operations", + name="Betrieb", + description="Geraete und Switche pflegen, Neustarts ausloesen, Wartung fahren.", + grants=( + ("devices", "*"), + ("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)), + ("credentials", (A.VIEW,)), + ("maintenance", "*"), + ("logs_live", (A.VIEW,)), + ("logs_history", (A.VIEW,)), + ("trash", (A.VIEW, A.EDIT)), + ), + ), + RolePreset( + key="support", + name="Support", + description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart ausloesen.", + grants=( + ("devices", (A.VIEW, A.EXECUTE)), + ("switches", (A.VIEW,)), + ("logs_live", (A.VIEW,)), + ), + ), + RolePreset( + key="network", + name="Netzwerk", + description="DHCP und Host-Netzwerkeinstellungen.", + grants=( + ("dhcp", "*"), + ("settings_network", "*"), + ("logs_dhcp", (A.VIEW,)), + ("devices", (A.VIEW,)), + ("switches", (A.VIEW,)), + ), + ), + RolePreset( + key="auditor", + name="Revision", + description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.", + grants=( + ("devices", (A.VIEW,)), + ("switches", (A.VIEW,)), + ("credentials", (A.VIEW,)), + ("dhcp", (A.VIEW,)), + ("users", (A.VIEW,)), + ("groups", (A.VIEW,)), + ("logs_live", (A.VIEW,)), + ("logs_history", (A.VIEW, A.EXPORT)), + ("logs_audit", (A.VIEW, A.EXPORT)), + ("diagnostics", (A.VIEW,)), + ("trash", (A.VIEW,)), + ), + ), + RolePreset( + key="administration", + name="Administration", + description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.", + grants=( + ("users", "*"), + ("groups", "*"), + ("settings_system", "*"), + ("settings_license", "*"), + ("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)), + ("settings_web", "*"), + ("settings_network", "*"), + ("backup", (A.VIEW, A.EXECUTE, A.EXPORT)), + ("trash", "*"), + ("diagnostics", (A.VIEW,)), + ("logs_audit", (A.VIEW,)), + ), + ), +) + +__all__ = ["PERMISSIONS", "ROLE_PRESETS", "LOGS_DHCP"] diff --git a/apps/tesm/src/tesm/schema.py b/apps/tesm/src/tesm/schema.py new file mode 100644 index 0000000..24c0c32 --- /dev/null +++ b/apps/tesm/src/tesm/schema.py @@ -0,0 +1,257 @@ +"""Migrationen der Komponente ``tesm``. + +Frische Zeitlinie ab Version 1 -- kein Nachbau der 35 gewachsenen +``ALTER TABLE``-Schritte des Vorgaengers. Inhaltliche Aenderungen am Datenmodell: + +* **Kuenstliche Schluessel.** Switche hatten den Hostnamen als Primaerschluessel, + Geraete die MAC. Ein Umbenennen bzw. ein Tausch der Netzwerkkarte zog damit + jede Referenz nach sich. Jetzt: ``id`` als Schluessel, Hostname/MAC bleiben + eindeutig -- aber nur unter den nicht geloeschten Zeilen (Teilindex), damit ein + Eintrag im Papierkorb den Namen nicht auf Dauer blockiert. +* **Status getrennt von Stammdaten.** Erreichbarkeit, Fehlerzaehler und + Neustart-Historie liegen in ``device_status``/``restart_events``, nicht in der + Geraetetabelle. Der Ueberwachungsprozess schreibt damit ausschliesslich in + Tabellen, die niemand von Hand pflegt. +* **Nachvollziehbare Neustarts.** Der Vorgaenger protokollierte sie + ausdruecklich *nicht* im Aenderungslog, nur in einer Textdatei. Jetzt gibt es + ``restart_events`` -- mit Ergebnis, Dauer, Ausloeser und **Methode**: ein + Neustart ueber den PoE-Port des Switches und einer per ``reboot`` ueber SSH + sind fuer den Betrieb dasselbe Ereignis und gehoeren in dieselbe Liste. +* **Host-Schluessel des Switches** werden gespeichert und geprueft + (``switch_host_keys``), statt sie im Terminal blind zu akzeptieren. +""" + +from __future__ import annotations + +from tesm_core.migrations import Migration + +V1 = """ +-- Wiederverwendbare Zugangsdaten. Das Passwort liegt AES-GCM-verschluesselt vor, +-- gebunden an "credentials.secret:" als AAD. +CREATE TABLE IF NOT EXISTS credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL COLLATE NOCASE, + username TEXT NOT NULL DEFAULT '', + secret_encrypted TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT 'switch', + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by TEXT NOT NULL DEFAULT '', + deleted_at TEXT +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_credentials_name_live + ON credentials(name) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS switches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname TEXT NOT NULL COLLATE NOCASE, + ip TEXT NOT NULL, + ssh_port INTEGER NOT NULL DEFAULT 22, + credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL, + model TEXT NOT NULL DEFAULT '', + location TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + last_seen_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by TEXT NOT NULL DEFAULT '', + deleted_at TEXT +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_switches_hostname_live + ON switches(hostname) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mac TEXT NOT NULL COLLATE NOCASE, + ip TEXT NOT NULL, + name TEXT NOT NULL, + switch_id INTEGER REFERENCES switches(id) ON DELETE SET NULL, + port TEXT NOT NULL DEFAULT '', + is_active INTEGER NOT NULL DEFAULT 1, + credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL, + ssh_port INTEGER NOT NULL DEFAULT 22, + category TEXT NOT NULL DEFAULT '', + location TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + auto_restart INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by TEXT NOT NULL DEFAULT '', + deleted_at TEXT +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_live + ON devices(mac) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_devices_switch ON devices(switch_id); +CREATE INDEX IF NOT EXISTS idx_devices_active ON devices(is_active, deleted_at); + +-- Laufender Zustand der Ueberwachung. Getrennt von den Stammdaten, damit der +-- Ueberwachungsprozess nie in vom Menschen gepflegte Spalten schreibt. +CREATE TABLE IF NOT EXISTS device_status ( + device_id INTEGER PRIMARY KEY REFERENCES devices(id) ON DELETE CASCADE, + state TEXT NOT NULL DEFAULT 'unknown', + latency_ms REAL, + fail_count INTEGER NOT NULL DEFAULT 0, + last_check_at TEXT, + last_ok_at TEXT, + last_change_at TEXT, + last_restart_at TEXT, + restart_count INTEGER NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '' +); + +-- Jeder PoE-Vorgang, automatisch oder manuell, mit Ergebnis. +CREATE TABLE IF NOT EXISTS poe_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE, + ts TEXT NOT NULL, + trigger TEXT NOT NULL DEFAULT 'auto', + actor TEXT NOT NULL DEFAULT 'system', + action TEXT NOT NULL, + result TEXT NOT NULL, + duration_ms INTEGER, + detail TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_poe_events_device ON poe_events(device_id, ts); +CREATE INDEX IF NOT EXISTS idx_poe_events_ts ON poe_events(ts); + +-- Gespeicherte Host-Schluessel der Switche. Ein Wechsel wird gemeldet, statt +-- stillschweigend akzeptiert zu werden. +CREATE TABLE IF NOT EXISTS switch_host_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + key_type TEXT NOT NULL, + fingerprint TEXT NOT NULL, + added_at TEXT NOT NULL, + added_by TEXT NOT NULL DEFAULT '', + UNIQUE(host, port, key_type) +); + +-- DHCP (Kea) +CREATE TABLE IF NOT EXISTS dhcp_subnets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + interface TEXT NOT NULL, + range_start TEXT NOT NULL, + range_end TEXT NOT NULL, + gateway TEXT NOT NULL DEFAULT '', + dns TEXT NOT NULL DEFAULT '', + comment TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS dhcp_options ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code INTEGER NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + is_standard INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS dhcp_option_values ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + option_id INTEGER NOT NULL REFERENCES dhcp_options(id) ON DELETE CASCADE, + device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE, + value TEXT NOT NULL +); +-- Ein globaler Wert je Option (device_id IS NULL) und hoechstens ein Wert je +-- Option und Geraet. Zwei Teilindizes, weil NULL in SQLite in einem UNIQUE +-- nicht mit sich selbst kollidiert. +CREATE UNIQUE INDEX IF NOT EXISTS idx_dhcp_value_global + ON dhcp_option_values(option_id) WHERE device_id IS NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_dhcp_value_device + ON dhcp_option_values(option_id, device_id) WHERE device_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS dhcp_reservations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mac TEXT NOT NULL UNIQUE COLLATE NOCASE, + ip TEXT NOT NULL, + hostname TEXT NOT NULL DEFAULT '', + comment TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); + +-- Dateifreigaben: Zuordnung Verzeichnisgruppe -> UNC-Pfad. +CREATE TABLE IF NOT EXISTS fileshare_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ad_group_dn TEXT NOT NULL, + ad_group_name TEXT NOT NULL, + label TEXT NOT NULL, + unc TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(ad_group_dn, unc) +); + +-- Aktive Mounts je Sitzung. Der Sweep-Prozess raeumt hierueber auf, statt das +-- Dateisystem zu durchsuchen. +CREATE TABLE IF NOT EXISTS fileshare_mounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT NOT NULL UNIQUE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + session_id TEXT NOT NULL DEFAULT '', + label TEXT NOT NULL, + unc TEXT NOT NULL, + mount_path TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + released_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_fileshare_mounts_user ON fileshare_mounts(user_id, released_at); +""" + + +def seed_dhcp_options(conn) -> None: # type: ignore[no-untyped-def] + """Legt den kuratierten Katalog der DHCP-Standardoptionen an. + + 56 Eintraege, gegen eine echte Kea-Instanz verifiziert. Bewusst *nicht* + enthalten: + + * Optionen mit eigenen Feldern in der Oberflaeche: Router (3), DNS (6), + Domain-Name (15), Lease-Zeiten (51/58/59). + * ``host-name`` (12): Kea beantwortet die Anfrage bereits ueber das + ``hostname``-Feld der Reservierung. Ein zusaetzlicher Override ist laut + Kea-Dokumentation eine Widerspruchsquelle -- der Vorgaenger hatte den + Eintrag zeitweise drin und musste ihn per Migration wieder entfernen. + * Client- und protokollinterne Optionen. + """ + from .services.kea import STANDARD_OPTIONS + + conn.executemany( + "INSERT INTO dhcp_options (code, name, type, description, is_standard) " + "VALUES (?,?,?,?,1) ON CONFLICT(code) DO NOTHING", + STANDARD_OPTIONS, + ) + + +# Version 3 ----------------------------------------------------------------- +# +# ``poe_events`` hiess so, weil es anfangs nur PoE-Neustarts gab. Inzwischen +# startet auch die Wartung Geraete neu -- per ``reboot`` ueber SSH. Beides ist +# aus Betriebssicht dasselbe Ereignis ("dieses Geraet wurde neu gestartet"), +# und beides gehoert in dieselbe Liste. Ein Name, der nur die halbe Wahrheit +# sagt, faellt spaetestens dem naechsten auf die Fuesse, der die Datenbank +# liest. +# +# Die Spalte ``method`` haelt fest, *wie* neu gestartet wurde. Bestehende +# Zeilen sind ausnahmslos PoE-Vorgaenge -- daher der Vorgabewert. +V3 = """ +ALTER TABLE poe_events RENAME TO restart_events; +ALTER TABLE restart_events ADD COLUMN method TEXT NOT NULL DEFAULT 'poe'; + +DROP INDEX IF EXISTS idx_poe_events_device; +DROP INDEX IF EXISTS idx_poe_events_ts; +CREATE INDEX IF NOT EXISTS idx_restart_events_device ON restart_events(device_id, ts); +CREATE INDEX IF NOT EXISTS idx_restart_events_ts ON restart_events(ts); +CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, ts); +""" + + +TESM_MIGRATIONS: tuple[Migration, ...] = ( + Migration(version=1, name="baseline", sql=V1), + Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), + Migration(version=3, name="restart-events", sql=V3), +) diff --git a/apps/tesm/src/tesm/services/__init__.py b/apps/tesm/src/tesm/services/__init__.py new file mode 100644 index 0000000..c4353a3 --- /dev/null +++ b/apps/tesm/src/tesm/services/__init__.py @@ -0,0 +1 @@ +"""Fachdienste von TESM -- Netzwerk, SSH, DHCP, Dateifreigaben, Wartung, Sicherung.""" diff --git a/apps/tesm/src/tesm/services/fileshare.py b/apps/tesm/src/tesm/services/fileshare.py new file mode 100644 index 0000000..9232dc4 --- /dev/null +++ b/apps/tesm/src/tesm/services/fileshare.py @@ -0,0 +1,443 @@ +"""Dateifreigaben: SMB-Einbindungen und sicheres Blaettern im Dateibaum. + +Einbindungen entstehen ausschliesslich nach einer erfolgreichen Anmeldung gegen +den Verzeichnisdienst -- lokale Konten haben hier grundsaetzlich nichts zu +sehen. *Welche* Freigaben sichtbar sind, entscheidet allein die Gruppen- +mitgliedschaft im Verzeichnis; das Recht ``fileshare.view`` entscheidet, ob +ueberhaupt eingebunden wird. + +Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger: + +* **Das Passwort geht nie ueber die Umgebung.** Der Vorgaenger uebergab es per + ``PASSWD``-Variable an ``mount.cifs`` -- sichtbar in ``/proc//environ`` + fuer denselben Benutzer und fuer root. Hier schreibt der privilegierte Helfer + es in eine Datei mit Modus 0600, die unmittelbar danach geloescht wird. +* **Pfadpruefung mit ``Path.resolve()``.** Jeder Zugriff wird gegen das + Wurzelverzeichnis der Sitzung geprueft; Symlinks koennen nicht herausfuehren. +* **Einbindungen stehen in der Datenbank.** Der Aufraeumprozess liest sie dort, + statt das Dateisystem zu durchsuchen -- und ein Neustart des Dienstes verliert + nicht mehr die Information, was noch eingebunden ist. +""" + +from __future__ import annotations + +import logging +import mimetypes +import os +import re +import secrets +import shutil +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path, PurePosixPath +from typing import Any, Iterable + +from tesm_core.db import Database +from tesm_core.extension import core + +logger = logging.getLogger(__name__) + +_UNC_HINT = ( + "Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schraegstriche " + "gehen auch: //server/freigabe." +) + +UNC_RE = re.compile(r"^\\\\[A-Za-z0-9._-]+\\[^\\/:*?\"<>|]+(\\[^\\/:*?\"<>|]+)*$") + +#: Endungen, die inline angezeigt werden duerfen -- alles andere nur als Download. +#: ``.doc`` fehlt bewusst: alte Word-Dateien koennen Makros tragen und werden von +#: Browsern uneinheitlich behandelt. +PREVIEW_MIME: dict[str, str] = { + ".pdf": "application/pdf", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".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", + ".xml": "text/plain; charset=utf-8", +} +#: SVG ist bewusst *nicht* in der Vorschau: es kann Skript enthalten und wuerde +#: im Ursprung der Anwendung ausgefuehrt. Download bleibt moeglich. + + +class FileshareError(Exception): + """Fehler mit Klartextmeldung fuer die Oberflaeche.""" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(value: datetime) -> str: + return value.isoformat(timespec="seconds") + + +def validate_unc(value: str) -> str: + """Prueft und vereinheitlicht einen Freigabepfad. + + Beide Schreibweisen sind erlaubt und bedeuten dasselbe:: + + \\\\dc2022\\daten + //dc2022/daten + + Gespeichert wird immer die Backslash-Form; der Helfer dreht sie fuer + ``mount`` wieder um. Ueberzaehlige Trenner und ein Trenner am Ende werden + entfernt, statt den Pfad abzulehnen: genau so sieht ein Pfad aus, den + jemand aus dem Explorer oder aus einem Browser kopiert hat, und eine + Ablehnung dafuer waere Schikane ohne Gewinn. + """ + text = (value or "").strip().replace("/", "\\") + # Der doppelte Trenner am Anfang macht den Pfad zur Freigabe. Ein einfacher + # waere ein absoluter Pfad auf diesem Rechner -- etwas voellig anderes. + if not text.startswith("\\\\"): + raise FileshareError(_UNC_HINT) + + teile = [teil for teil in text.split("\\") if teil] + if len(teile) < 2: + raise FileshareError(_UNC_HINT) + + text = "\\\\" + "\\".join(teile) + if not UNC_RE.match(text): + raise FileshareError(_UNC_HINT) + return text + + +# --------------------------------------------------------------------------- # +# Zuordnungen +# --------------------------------------------------------------------------- # + + +def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]: + return Database.all( + conn, "SELECT * FROM fileshare_mappings ORDER BY label COLLATE NOCASE" + ) + + +def add_mapping( + conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str +) -> int: + clean_unc = validate_unc(unc) + clean_label = (label or "").strip() + if not clean_label: + raise FileshareError("Der Anzeigename darf nicht leer sein.") + if not (ad_group_dn or "").strip(): + raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.") + cursor = conn.execute( + "INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) " + "VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING", + (ad_group_dn.strip(), (ad_group_name or "").strip(), clean_label, clean_unc, _iso(_now())), + ) + if not cursor.rowcount: + raise FileshareError("Diese Zuordnung existiert bereits.") + return int(cursor.lastrowid or 0) + + +def delete_mapping(conn: sqlite3.Connection, mapping_id: int) -> None: + conn.execute("DELETE FROM fileshare_mappings WHERE id=?", (mapping_id,)) + + +def shares_for_groups( + conn: sqlite3.Connection, group_dns: Iterable[str] +) -> list[dict[str, Any]]: + """Freigaben, auf die diese Verzeichnisgruppen Anspruch geben. + + Es gibt bewusst keinen zweiten Weg: eine Freigabe wird mit dem Konto des + angemeldeten Verzeichnisbenutzers eingebunden. Ein lokales TESM-Konto hat + kein Passwort, das ein Fileserver akzeptieren wuerde -- eine Zuordnung an + eine lokale Gruppe waere deshalb eine Zuordnung, die nie zu einer + Einbindung fuehrt. + """ + lowered = {dn.strip().lower() for dn in group_dns if dn and dn.strip()} + if not lowered: + return [] + return [ + row + for row in list_mappings(conn) + if str(row["ad_group_dn"] or "").lower() in lowered + ] + + +# --------------------------------------------------------------------------- # +# Einbindungen +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class Mount: + id: int + token: str + label: str + unc: str + path: Path + expires_at: str + + +def mount_root() -> Path: + from tesm_core.extension import db + + return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares")) + + +def active_mounts(conn: sqlite3.Connection, user_id: int) -> list[Mount]: + rows = Database.all( + conn, + "SELECT * FROM fileshare_mounts WHERE user_id=? AND released_at IS NULL " + "AND expires_at > ? ORDER BY label COLLATE NOCASE", + (user_id, _iso(_now())), + ) + return [ + Mount( + id=int(row["id"]), + token=str(row["token"]), + label=str(row["label"]), + unc=str(row["unc"]), + path=Path(str(row["mount_path"])), + expires_at=str(row["expires_at"]), + ) + for row in rows + ] + + +def has_mounts(user_id: int) -> bool: + from tesm_core.extension import db + + return bool(active_mounts(db(), user_id)) + + +def mount_for_login( + conn: sqlite3.Connection, + *, + user_id: int, + session_id: str, + username: str, + password: str, + group_dns: Iterable[str] = (), + domain: str = "", +) -> list[str]: + """Bindet alle Freigaben ein, auf die die Verzeichnisgruppen Anspruch geben. + + Rueckgabe: Liste der Meldungen fuer das Protokoll. Fehler einzelner + Freigaben brechen die Anmeldung **nicht** ab -- der Benutzer soll sich + anmelden koennen, auch wenn ein Fileserver gerade nicht erreichbar ist. + """ + extension = core() + sysops = extension.sysops + if sysops is None or not sysops.available: + return ["Systemhelfer nicht verfuegbar -- keine Einbindungen."] + + dns = [dn for dn in group_dns if dn and dn.strip()] + shares = shares_for_groups(conn, dns) + if not shares: + # Stillschweigen war hier der eigentliche Fehler: ob keine Gruppe ankam + # oder keine passte, liess sich von aussen nicht unterscheiden -- und + # ein Merkmal, das lautlos nichts tut, ist nicht zu diagnostizieren. + bekannt = len(list_mappings(conn)) + if bekannt: + logger.info( + "Keine Freigabe passend: %d Verzeichnisgruppe(n) der Anmeldung gegen " + "%d Zuordnung(en). Gruppen: %s", + len(dns), + bekannt, + ", ".join(dns) or "keine", + ) + return [] + + # Ohne Passwort kaeme eine Gast-Einbindung zustande, die still danebengreift: + # eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei + # der das Passwort im zweiten Schritt nicht mehr vorliegt. + if not password: + return ["Kein Passwort fuer den Fileserver verfuegbar -- keine Einbindungen."] + + hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12) + root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares")) + messages: list[str] = [] + + for share in shares: + token = secrets.token_urlsafe(16) + target = root / token + result = sysops.mount_cifs( + str(share["unc"]), str(target), username, password, domain=domain + ) + if not result.ok: + messages.append(f"{share['label']}: Einbindung fehlgeschlagen ({result.message})") + continue + conn.execute( + "INSERT INTO fileshare_mounts (token, user_id, session_id, label, unc, mount_path, " + "created_at, expires_at) VALUES (?,?,?,?,?,?,?,?)", + ( + token, + user_id, + session_id, + str(share["label"]), + str(share["unc"]), + str(target), + _iso(_now()), + _iso(_now() + timedelta(hours=hours)), + ), + ) + messages.append(f"{share['label']}: eingebunden") + return messages + + +def release_mount(conn: sqlite3.Connection, mount_id: int) -> None: + row = Database.one(conn, "SELECT * FROM fileshare_mounts WHERE id=?", (mount_id,)) + if row is None: + return + sysops = core().sysops + if sysops is not None and sysops.available: + sysops.umount(str(row["mount_path"])) + conn.execute( + "UPDATE fileshare_mounts SET released_at=? WHERE id=?", (_iso(_now()), mount_id) + ) + + +def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int: + count = 0 + for mount in active_mounts(conn, user_id): + release_mount(conn, mount.id) + count += 1 + return count + + +def sweep_expired(database: Any) -> int: + """Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess. + + Bewusst datenbankgetrieben: der Vorgaenger durchsuchte beim Start das + Dateisystem und raeumte dabei auch Einbindungen *anderer, aktiver* Sitzungen + ab, sobald ein Hilfsskript das Modul importierte. + """ + released = 0 + with database.session() as conn: + rows = Database.all( + conn, + "SELECT id FROM fileshare_mounts WHERE released_at IS NULL AND expires_at <= ?", + (_iso(_now()),), + ) + if not rows: + return 0 + with database.transaction(conn): + for row in rows: + release_mount(conn, int(row["id"])) + released += 1 + return released + + +# --------------------------------------------------------------------------- # +# Dateibaum +# --------------------------------------------------------------------------- # + + +def resolve_path(mount: Mount, relative: str) -> Path: + """Loest einen relativen Pfad innerhalb einer Einbindung auf. + + Wirft, sobald das Ergebnis das Wurzelverzeichnis verlaesst -- auch ueber + Symlinks, weil ``resolve()`` diesen folgt. + """ + candidate = PurePosixPath((relative or "").strip().lstrip("/")) + if any(part == ".." for part in candidate.parts): + raise FileshareError("Ungueltiger Pfad.") + root = mount.path.resolve() + target = (root / str(candidate)).resolve() + if target != root and root not in target.parents: + raise FileshareError("Der Pfad liegt ausserhalb der Freigabe.") + return target + + +def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]: + path = resolve_path(mount, relative) + if not path.is_dir(): + raise FileshareError("Dieses Verzeichnis existiert nicht.") + directories: list[dict[str, Any]] = [] + files: list[dict[str, Any]] = [] + try: + for entry in sorted(path.iterdir(), key=lambda item: item.name.lower()): + try: + stat = entry.stat() + except OSError: + continue + record = { + "name": entry.name, + "relative": str(PurePosixPath(relative or "") / entry.name).lstrip("/"), + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat( + timespec="seconds" + ), + } + if entry.is_dir(): + directories.append(record) + else: + record["previewable"] = entry.suffix.lower() in PREVIEW_MIME + files.append(record) + except OSError as exc: + raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc + + parts = [part for part in PurePosixPath(relative or "").parts] + breadcrumb = [] + walked = PurePosixPath("") + for part in parts: + walked = walked / part + breadcrumb.append({"name": part, "relative": str(walked)}) + return { + "directories": directories, + "files": files, + "breadcrumb": breadcrumb, + "relative": relative or "", + } + + +def preview_mime(name: str) -> str: + suffix = Path(name).suffix.lower() + if suffix in PREVIEW_MIME: + return PREVIEW_MIME[suffix] + raise FileshareError("Fuer diesen Dateityp gibt es keine Vorschau -- bitte herunterladen.") + + +def download_mime(name: str) -> str: + guessed, _ = mimetypes.guess_type(name) + return guessed or "application/octet-stream" + + +def safe_filename(name: str) -> str: + """Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen.""" + cleaned = os.path.basename((name or "").strip().replace("\\", "/")) + if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."): + raise FileshareError("Ungueltiger Dateiname.") + if len(cleaned) > 200: + raise FileshareError("Der Dateiname ist zu lang.") + return cleaned + + +def delete_entry(mount: Mount, relative: str) -> str: + path = resolve_path(mount, relative) + if path == mount.path.resolve(): + raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht geloescht werden.") + if path.is_dir(): + shutil.rmtree(path) + return "Verzeichnis" + path.unlink() + return "Datei" + + +def make_directory(mount: Mount, relative: str, name: str) -> Path: + parent = resolve_path(mount, relative) + target = parent / safe_filename(name) + if target.exists(): + raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.") + target.mkdir() + return target + + +def rename_entry(mount: Mount, relative: str, new_name: str) -> Path: + path = resolve_path(mount, relative) + target = path.parent / safe_filename(new_name) + if target.exists(): + raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.") + path.rename(target) + return target diff --git a/apps/tesm/src/tesm/services/inventory.py b/apps/tesm/src/tesm/services/inventory.py new file mode 100644 index 0000000..f88bdc1 --- /dev/null +++ b/apps/tesm/src/tesm/services/inventory.py @@ -0,0 +1,764 @@ +"""Bestandsdaten: Zugangsdaten, Switche, Geraete. + +Zugangsdaten sind -- wie im Vorgaenger -- eine eigene, wiederverwendbare +Entitaet und stehen nicht als Benutzer/Passwort-Paar an jedem Switch. Neu ist, +wie das Geheimnis liegt: AES-256-GCM mit rotierbarem Schluessel und dem +Verwendungszweck als zugeordnete Daten (``credentials.secret:``). Damit ist +ein Chiffrat nicht an anderer Stelle einspielbar. +""" + +from __future__ import annotations + +import ipaddress +import re +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Iterable + +from tesm_core.db import Database +from tesm_core.keystore import DecryptionError, Keystore + +MAC_RE = re.compile(r"^[0-9a-f]{2}(:[0-9a-f]{2}){5}$") +PORT_RE = re.compile(r"^[A-Za-z0-9/.:-]{1,24}$") +HOSTNAME_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9._-]{0,62})$") + +CREDENTIAL_CATEGORIES: tuple[tuple[str, str], ...] = ( + ("switch", "Switch"), + ("linux", "Linux-Client"), + ("windows", "Windows-Client"), + ("router", "Router/Sonstiges"), +) + +#: Diese Kategorien erscheinen auf der Wartungsseite. Der Weg dorthin ist +#: verschieden: Linux ueber SSH, Windows ueber RPC. +MAINTENANCE_CATEGORIES: tuple[str, ...] = ("linux", "windows") + +#: Rueckwaertskompatibler Name -- die Vorgabe, wenn nur eine gemeint ist. +MAINTENANCE_CATEGORY = "linux" + +#: Diese Kategorie wird **nicht** ueber SSH bedient. Windows-Geraete haben in +#: aller Regel keinen SSH-Dienst und damit auch keinen Host-Schluessel, den +#: jemand freigeben koennte. Alles, was mit Host-Schluesseln zu tun hat, laesst +#: sie deshalb aus -- sonst stuenden sie fuer immer auf "Freigabe offen". +NON_SSH_CATEGORY = "windows" + + +def is_ssh_managed(category: str | None) -> bool: + """Wird ein Geraet dieser Kategorie ueber SSH bedient?""" + return str(category or "") != NON_SSH_CATEGORY + + +#: Was eine Kategorie tatsaechlich bewirkt. Steht in der Oberflaeche, damit +#: niemand eine Auswahl trifft, die stillschweigend folgenlos bleibt: genau das +#: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung. +CATEGORY_EFFECTS: dict[str, str] = { + "switch": "Anmeldung an Switchen: PoE schalten, Konsole, Statusabfragen.", + "linux": "Wartung ueber SSH: Paketaktualisierung (apt) und Neustart. Konsole.", + "windows": ( + "Neustart ueber RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schluessel. Das " + "Geraet braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht " + "das Recht, aus der Ferne herunterzufahren. Paketaktualisierung gibt es hier nicht." + ), + "router": "Nur Dokumentation -- diese Kategorie loest keine Aktion aus.", +} + +DEVICE_CATEGORIES: tuple[tuple[str, str], ...] = ( + ("", "nicht angegeben"), + ("kamera", "Kamera"), + ("accesspoint", "Access Point"), + ("telefon", "Telefon"), + ("sensor", "Sensor"), + ("rechner", "Rechner"), + ("sonstiges", "Sonstiges"), +) + + +class ValidationError(Exception): + """Eingabefehler mit Klartextmeldung.""" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def aad_for_credential(credential_id: int) -> str: + return f"credentials.secret:{credential_id}" + + +# --------------------------------------------------------------------------- # +# Validierung +# --------------------------------------------------------------------------- # + + +def normalize_mac(value: str) -> str: + cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "") + if len(cleaned) != 12: + raise ValidationError( + "Die MAC-Adresse muss zwoelf hexadezimale Zeichen enthalten " + "(z. B. aa:bb:cc:dd:ee:ff)." + ) + return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower() + + +def validate_ip(value: str, *, field: str = "IP-Adresse") -> str: + text = (value or "").strip() + try: + ipaddress.ip_address(text) + except ValueError as exc: + raise ValidationError(f"{field}: {text!r} ist keine gueltige IP-Adresse.") from exc + return text + + +def validate_port_name(value: str) -> str: + text = (value or "").strip() + if not text: + return "" + if not PORT_RE.match(text): + raise ValidationError( + "Die Portbezeichnung enthaelt unerlaubte Zeichen (erlaubt: Buchstaben, " + "Ziffern, / . : -)." + ) + return text + + +def validate_hostname(value: str) -> str: + text = (value or "").strip() + if not HOSTNAME_RE.match(text): + raise ValidationError("Der Hostname enthaelt unerlaubte Zeichen oder ist leer.") + return text + + +def validate_ssh_port(value: Any) -> int: + try: + port = int(value or 22) + except (TypeError, ValueError) as exc: + raise ValidationError("Der SSH-Port muss eine Zahl sein.") from exc + if not 1 <= port <= 65535: + raise ValidationError("Der SSH-Port muss zwischen 1 und 65535 liegen.") + return port + + +# --------------------------------------------------------------------------- # +# Zugangsdaten +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class Credential: + id: int + name: str + username: str + category: str + description: str + has_secret: bool + in_use: int + + +def list_credentials( + conn: sqlite3.Connection, *, include_deleted: bool = False +) -> list[Credential]: + where = "" if include_deleted else "WHERE c.deleted_at IS NULL" + rows = Database.all( + conn, + f""" + SELECT c.*, + (SELECT COUNT(*) FROM switches s + WHERE s.credential_id = c.id AND s.deleted_at IS NULL) + + (SELECT COUNT(*) FROM devices d + WHERE d.credential_id = c.id AND d.deleted_at IS NULL) AS in_use + FROM credentials c {where} + ORDER BY c.name COLLATE NOCASE + """, + ) + return [ + Credential( + id=int(row["id"]), + name=str(row["name"]), + username=str(row["username"] or ""), + category=str(row["category"] or "switch"), + description=str(row["description"] or ""), + has_secret=bool(row["secret_encrypted"]), + in_use=int(row["in_use"] or 0), + ) + for row in rows + ] + + +def create_credential( + conn: sqlite3.Connection, + keystore: Keystore, + *, + name: str, + username: str, + secret: str, + category: str, + description: str = "", + actor: str = "", +) -> int: + clean_name = (name or "").strip() + if not clean_name: + raise ValidationError("Der Name darf nicht leer sein.") + if category not in dict(CREDENTIAL_CATEGORIES): + raise ValidationError("Unbekannte Kategorie.") + existing = Database.one( + conn, "SELECT id, deleted_at FROM credentials WHERE name=? COLLATE NOCASE", (clean_name,) + ) + if existing is not None: + if existing["deleted_at"]: + raise ValidationError( + f"Zugangsdaten namens {clean_name!r} liegen im Papierkorb. Stellen Sie sie " + "wieder her oder loeschen Sie sie endgueltig." + ) + raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.") + + cursor = conn.execute( + "INSERT INTO credentials (name, username, secret_encrypted, category, description, " + "created_at, updated_at, updated_by) VALUES (?,?,'',?,?,?,?,?)", + (clean_name, (username or "").strip(), category, (description or "").strip(), _now(), _now(), actor), + ) + credential_id = int(cursor.lastrowid or 0) + if secret: + conn.execute( + "UPDATE credentials SET secret_encrypted=? WHERE id=?", + (keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id), + ) + return credential_id + + +def update_credential( + conn: sqlite3.Connection, + keystore: Keystore, + credential_id: int, + *, + name: str, + username: str, + secret: str | None, + category: str, + description: str = "", + actor: str = "", +) -> list[str]: + row = Database.one( + conn, "SELECT * FROM credentials WHERE id=? AND deleted_at IS NULL", (credential_id,) + ) + if row is None: + raise ValidationError("Diese Zugangsdaten existieren nicht (mehr).") + clean_name = (name or "").strip() + if not clean_name: + raise ValidationError("Der Name darf nicht leer sein.") + clash = Database.one( + conn, + "SELECT id FROM credentials WHERE name=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL", + (clean_name, credential_id), + ) + if clash is not None: + raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.") + + changes: list[str] = [] + if clean_name != str(row["name"]): + changes.append(f"Name: {row['name']} -> {clean_name}") + if (username or "").strip() != str(row["username"] or ""): + changes.append("Benutzer geaendert") + if category != str(row["category"]): + changes.append(f"Kategorie: {row['category']} -> {category}") + if (description or "").strip() != str(row["description"] or ""): + changes.append("Beschreibung geaendert") + + conn.execute( + "UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, " + "updated_by=? WHERE id=?", + (clean_name, (username or "").strip(), category, (description or "").strip(), _now(), actor, credential_id), + ) + if secret: + conn.execute( + "UPDATE credentials SET secret_encrypted=? WHERE id=?", + (keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id), + ) + changes.append("Passwort geaendert") + return changes + + +def credential_secret( + conn: sqlite3.Connection, keystore: Keystore, credential_id: int +) -> tuple[str, str]: + """``(benutzer, passwort)`` -- entschluesselt. Nur fuer den Verbindungsaufbau.""" + row = Database.one( + conn, + "SELECT username, secret_encrypted FROM credentials WHERE id=? AND deleted_at IS NULL", + (credential_id,), + ) + if row is None: + raise ValidationError("Die hinterlegten Zugangsdaten existieren nicht mehr.") + encrypted = str(row["secret_encrypted"] or "") + if not encrypted: + return str(row["username"] or ""), "" + try: + secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id))) + except DecryptionError as exc: + raise ValidationError( + "Das gespeicherte Passwort konnte nicht entschluesselt werden. Wurde der " + "Datenschluessel ausgetauscht? Bitte die Zugangsdaten neu eingeben." + ) from exc + return str(row["username"] or ""), secret + + +def delete_credential(conn: sqlite3.Connection, credential_id: int) -> None: + row = Database.one( + conn, + """ + SELECT + (SELECT COUNT(*) FROM switches WHERE credential_id=? AND deleted_at IS NULL) AS switches, + (SELECT COUNT(*) FROM devices WHERE credential_id=? AND deleted_at IS NULL) AS devices + """, + (credential_id, credential_id), + ) or {} + if int(row.get("switches") or 0) or int(row.get("devices") or 0): + raise ValidationError( + f"Diese Zugangsdaten werden noch von {row.get('switches')} Switch(en) und " + f"{row.get('devices')} Geraet(en) verwendet." + ) + conn.execute("UPDATE credentials SET deleted_at=? WHERE id=?", (_now(), credential_id)) + + +def reencrypt_secrets(conn: sqlite3.Connection, keystore: Keystore) -> int: + """Verschluesselt alle Geheimnisse unter dem aktuellen Schluessel neu. + + Wird von der Schluesselrotation auf der Diagnoseseite aufgerufen. + """ + migrated = 0 + for row in Database.all( + conn, "SELECT id, secret_encrypted FROM credentials WHERE secret_encrypted <> ''" + ): + token = str(row["secret_encrypted"]) + if not keystore.needs_reencrypt(token): + continue + aad = aad_for_credential(int(row["id"])) + try: + plain = keystore.decrypt(token, aad=aad) + except DecryptionError: + continue + conn.execute( + "UPDATE credentials SET secret_encrypted=? WHERE id=?", + (keystore.encrypt(plain, aad=aad), row["id"]), + ) + migrated += 1 + + # Bind-Passwort des Verzeichnisdienstes mitnehmen. + from tesm_core.auth.directory import AAD_LDAP_BIND, SERVICE_PURPOSE + + row = Database.one( + conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,) + ) + if row and str(row["password_encrypted"] or ""): + token = str(row["password_encrypted"]) + if keystore.needs_reencrypt(token): + try: + plain = keystore.decrypt(token, aad=AAD_LDAP_BIND) + except DecryptionError: + plain = "" + if plain: + conn.execute( + "UPDATE service_accounts SET password_encrypted=? WHERE purpose=?", + (keystore.encrypt(plain, aad=AAD_LDAP_BIND), SERVICE_PURPOSE), + ) + migrated += 1 + return migrated + + +# --------------------------------------------------------------------------- # +# Switche +# --------------------------------------------------------------------------- # + + +def list_switches(conn: sqlite3.Connection, *, include_deleted: bool = False) -> list[dict[str, Any]]: + where = "" if include_deleted else "WHERE s.deleted_at IS NULL" + return Database.all( + conn, + f""" + SELECT s.*, c.name AS credential_name, c.username AS credential_username, + (SELECT COUNT(*) FROM devices d + WHERE d.switch_id = s.id AND d.deleted_at IS NULL) AS device_count, + (SELECT COUNT(*) FROM switch_host_keys k + WHERE k.host = s.ip AND k.port = s.ssh_port) AS host_key_known + FROM switches s + LEFT JOIN credentials c ON c.id = s.credential_id AND c.deleted_at IS NULL + {where} + ORDER BY s.hostname COLLATE NOCASE + """, + ) + + +def get_switch(conn: sqlite3.Connection, switch_id: int) -> dict[str, Any] | None: + return Database.one( + conn, + "SELECT s.*, c.name AS credential_name FROM switches s " + "LEFT JOIN credentials c ON c.id = s.credential_id " + "WHERE s.id=? AND s.deleted_at IS NULL", + (switch_id,), + ) + + +def create_switch( + conn: sqlite3.Connection, + *, + hostname: str, + ip: str, + ssh_port: Any, + credential_id: Any, + model: str = "", + location: str = "", + notes: str = "", + actor: str = "", +) -> int: + clean_host = validate_hostname(hostname) + clean_ip = validate_ip(ip) + port = validate_ssh_port(ssh_port) + existing = Database.one( + conn, "SELECT id, deleted_at FROM switches WHERE hostname=? COLLATE NOCASE", (clean_host,) + ) + if existing is not None: + if existing["deleted_at"]: + raise ValidationError( + f"Ein Switch namens {clean_host!r} liegt im Papierkorb." + ) + raise ValidationError(f"Ein Switch namens {clean_host!r} existiert bereits.") + + cursor = conn.execute( + "INSERT INTO switches (hostname, ip, ssh_port, credential_id, model, location, notes, " + "created_at, updated_at, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?)", + ( + clean_host, + clean_ip, + port, + int(credential_id) if credential_id else None, + (model or "").strip(), + (location or "").strip(), + (notes or "").strip(), + _now(), + _now(), + actor, + ), + ) + return int(cursor.lastrowid or 0) + + +def update_switch( + conn: sqlite3.Connection, + switch_id: int, + *, + hostname: str, + ip: str, + ssh_port: Any, + credential_id: Any, + model: str = "", + location: str = "", + notes: str = "", + actor: str = "", +) -> list[str]: + row = get_switch(conn, switch_id) + if row is None: + raise ValidationError("Dieser Switch existiert nicht (mehr).") + clean_host = validate_hostname(hostname) + clean_ip = validate_ip(ip) + port = validate_ssh_port(ssh_port) + clash = Database.one( + conn, + "SELECT id FROM switches WHERE hostname=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL", + (clean_host, switch_id), + ) + if clash is not None: + raise ValidationError(f"Ein Switch namens {clean_host!r} existiert bereits.") + + changes: list[str] = [] + for label, before, after in ( + ("Hostname", str(row["hostname"]), clean_host), + ("IP", str(row["ip"]), clean_ip), + ("SSH-Port", str(row["ssh_port"]), str(port)), + ("Zugangsdaten", str(row["credential_id"] or ""), str(credential_id or "")), + ("Modell", str(row["model"] or ""), (model or "").strip()), + ("Standort", str(row["location"] or ""), (location or "").strip()), + ): + if before != after: + changes.append(f"{label}: {before or '-'} -> {after or '-'}") + + conn.execute( + "UPDATE switches SET hostname=?, ip=?, ssh_port=?, credential_id=?, model=?, location=?, " + "notes=?, updated_at=?, updated_by=? WHERE id=?", + ( + clean_host, + clean_ip, + port, + int(credential_id) if credential_id else None, + (model or "").strip(), + (location or "").strip(), + (notes or "").strip(), + _now(), + actor, + switch_id, + ), + ) + return changes + + +def delete_switch(conn: sqlite3.Connection, switch_id: int) -> None: + count = Database.value( + conn, + "SELECT COUNT(*) FROM devices WHERE switch_id=? AND deleted_at IS NULL", + (switch_id,), + default=0, + ) + if int(count or 0): + raise ValidationError( + f"Diesem Switch sind noch {count} Geraet(e) zugeordnet. Bitte zuerst umhaengen." + ) + conn.execute("UPDATE switches SET deleted_at=? WHERE id=?", (_now(), switch_id)) + + +# --------------------------------------------------------------------------- # +# Geraete +# --------------------------------------------------------------------------- # + + +def list_devices( + conn: sqlite3.Connection, + *, + include_deleted: bool = False, + only_active: bool = False, +) -> list[dict[str, Any]]: + clauses = [] if include_deleted else ["d.deleted_at IS NULL"] + if only_active: + clauses.append("d.is_active = 1") + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + return Database.all( + conn, + f""" + SELECT d.*, s.hostname AS switch_hostname, s.ip AS switch_ip, + s.ssh_port AS switch_ssh_port, s.credential_id AS switch_credential_id, + c.name AS credential_name, c.category AS credential_category, + -- Wie bei den Switchen: der Zustand der Host-Schluessel-Freigabe + -- gehoert in die Liste. Ob eine Wartung ueberhaupt laufen kann, + -- soll man sehen, ohne jede Detailseite zu oeffnen. + (SELECT COUNT(*) FROM switch_host_keys k + WHERE k.host = d.ip AND k.port = d.ssh_port) AS host_key_known, + COALESCE(st.state,'unknown') AS state, st.latency_ms, st.last_check_at, + st.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count, + st.fail_count + FROM devices d + LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL + LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL + LEFT JOIN device_status st ON st.device_id = d.id + {where} + ORDER BY d.name COLLATE NOCASE + """, + ) + + +def get_device(conn: sqlite3.Connection, device_id: int) -> dict[str, Any] | None: + rows = [ + row + for row in list_devices(conn, include_deleted=True) + if int(row["id"]) == int(device_id) + ] + return rows[0] if rows else None + + +def create_device( + conn: sqlite3.Connection, + *, + name: str, + mac: str, + ip: str, + switch_id: Any, + port: str, + credential_id: Any, + ssh_port: Any, + category: str = "", + location: str = "", + notes: str = "", + is_active: bool = True, + auto_restart: bool = True, + actor: str = "", +) -> int: + clean_name = (name or "").strip() + if not clean_name: + raise ValidationError("Der Name darf nicht leer sein.") + clean_mac = normalize_mac(mac) + clean_ip = validate_ip(ip) + clean_port = validate_port_name(port) + existing = Database.one( + conn, "SELECT id, deleted_at FROM devices WHERE mac=? COLLATE NOCASE", (clean_mac,) + ) + if existing is not None: + if existing["deleted_at"]: + raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} liegt im Papierkorb.") + raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.") + + if switch_id and not clean_port: + raise ValidationError( + "Zu einem zugeordneten Switch gehoert eine Portbezeichnung -- sonst ist kein " + "PoE-Neustart moeglich." + ) + + cursor = conn.execute( + "INSERT INTO devices (mac, ip, name, switch_id, port, is_active, credential_id, ssh_port, " + "category, location, notes, auto_restart, created_at, updated_at, updated_by) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + clean_mac, + clean_ip, + clean_name, + int(switch_id) if switch_id else None, + clean_port, + int(bool(is_active)), + int(credential_id) if credential_id else None, + validate_ssh_port(ssh_port), + (category or "").strip(), + (location or "").strip(), + (notes or "").strip(), + int(bool(auto_restart)), + _now(), + _now(), + actor, + ), + ) + device_id = int(cursor.lastrowid or 0) + conn.execute( + "INSERT INTO device_status (device_id, state, last_change_at) VALUES (?,'unknown',?)", + (device_id, _now()), + ) + return device_id + + +def update_device( + conn: sqlite3.Connection, + device_id: int, + *, + name: str, + mac: str, + ip: str, + switch_id: Any, + port: str, + credential_id: Any, + ssh_port: Any, + category: str = "", + location: str = "", + notes: str = "", + is_active: bool = True, + auto_restart: bool = True, + actor: str = "", +) -> list[str]: + row = Database.one( + conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,) + ) + if row is None: + raise ValidationError("Dieses Geraet existiert nicht (mehr).") + clean_name = (name or "").strip() + if not clean_name: + raise ValidationError("Der Name darf nicht leer sein.") + clean_mac = normalize_mac(mac) + clean_ip = validate_ip(ip) + clean_port = validate_port_name(port) + clash = Database.one( + conn, + "SELECT id FROM devices WHERE mac=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL", + (clean_mac, device_id), + ) + if clash is not None: + raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.") + if switch_id and not clean_port: + raise ValidationError("Zu einem zugeordneten Switch gehoert eine Portbezeichnung.") + + changes: list[str] = [] + for label, before, after in ( + ("Name", str(row["name"]), clean_name), + ("MAC", str(row["mac"]), clean_mac), + ("IP", str(row["ip"]), clean_ip), + ("Switch", str(row["switch_id"] or ""), str(switch_id or "")), + ("Port", str(row["port"] or ""), clean_port), + ("Aktiv", str(bool(row["is_active"])), str(bool(is_active))), + ("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))), + ): + if before != after: + changes.append(f"{label}: {before or '-'} -> {after or '-'}") + + conn.execute( + "UPDATE devices SET mac=?, ip=?, name=?, switch_id=?, port=?, is_active=?, " + "credential_id=?, ssh_port=?, category=?, location=?, notes=?, auto_restart=?, " + "updated_at=?, updated_by=? WHERE id=?", + ( + clean_mac, + clean_ip, + clean_name, + int(switch_id) if switch_id else None, + clean_port, + int(bool(is_active)), + int(credential_id) if credential_id else None, + validate_ssh_port(ssh_port), + (category or "").strip(), + (location or "").strip(), + (notes or "").strip(), + int(bool(auto_restart)), + _now(), + actor, + device_id, + ), + ) + return changes + + +def set_device_active(conn: sqlite3.Connection, device_id: int, active: bool) -> None: + conn.execute( + "UPDATE devices SET is_active=?, updated_at=? WHERE id=? AND deleted_at IS NULL", + (int(bool(active)), _now(), device_id), + ) + + +def delete_device(conn: sqlite3.Connection, device_id: int) -> None: + conn.execute("UPDATE devices SET deleted_at=? WHERE id=?", (_now(), device_id)) + + +def restore_row(conn: sqlite3.Connection, table: str, row_id: int, *, unique_column: str) -> None: + """Wiederherstellung mit Konfliktpruefung auf dem eindeutigen Feld.""" + if table not in {"devices", "switches", "credentials"}: + raise ValidationError("Unbekannte Ressource.") + row = Database.one(conn, f"SELECT {unique_column} FROM {table} WHERE id=?", (row_id,)) + if row is None: + raise ValidationError("Dieser Eintrag existiert nicht.") + value = row[unique_column] + clash = Database.one( + conn, + f"SELECT id FROM {table} WHERE {unique_column}=? COLLATE NOCASE AND id<>? " + "AND deleted_at IS NULL", + (value, row_id), + ) + if clash is not None: + raise ValidationError( + f"Es existiert bereits ein aktiver Eintrag mit {unique_column} = {value!r}." + ) + conn.execute(f"UPDATE {table} SET deleted_at=NULL WHERE id=?", (row_id,)) + + +def purge_row(conn: sqlite3.Connection, table: str, row_id: int) -> None: + if table not in {"devices", "switches", "credentials"}: + raise ValidationError("Unbekannte Ressource.") + conn.execute(f"DELETE FROM {table} WHERE id=?", (row_id,)) + + +def trash_entries( + conn: sqlite3.Connection, table: str, *, label_column: str, detail_columns: Iterable[str] +) -> list[dict[str, Any]]: + columns = ", ".join([label_column, *detail_columns]) + rows = Database.all( + conn, + f"SELECT id, {columns}, deleted_at FROM {table} " + "WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC", + ) + return [ + { + "id": str(row["id"]), + "label": str(row[label_column]), + "detail": " / ".join(str(row[column] or "-") for column in detail_columns), + "deleted_at": row["deleted_at"], + } + for row in rows + ] diff --git a/apps/tesm/src/tesm/services/kea.py b/apps/tesm/src/tesm/services/kea.py new file mode 100644 index 0000000..760d284 --- /dev/null +++ b/apps/tesm/src/tesm/services/kea.py @@ -0,0 +1,661 @@ +"""Kea-DHCP: Optionskatalog, Konfigurationserzeugung, Lease-Auswertung. + +Backend ist ``kea-dhcp4-server`` (isc-dhcp-server ist seit 2022 abgekuendigt). + +Die im Vorgaengerprojekt teuer erarbeiteten Kea-Eigenheiten sind hier von +Anfang an beruecksichtigt und im Code vermerkt: + +* Der Schluessel der Lease-Datei heisst ``name``, nicht ``lease-file``. +* ``loggers`` gehoert **unter** ``Dhcp4``, nicht daneben -- sonst Syntaxfehler. +* Standardoptionen bekommen **kein** eigenes ``option-def`` (Kea kennt sie + nativ); nur eigene Optionen werden definiert. +* Die Lease-CSV wird mit ``\\n`` geschrieben; Pythons csv-Default ``\\r\\n`` + laesst Kea die Datei mit "invalid column" ablehnen. +* Das Kea-Protokoll darf nicht unter dem Protokollverzeichnis der Anwendung + liegen: das AppArmor-Profil von Kea erlaubt ausschliesslich den fest + einprogrammierten Pfad ``/var/log/kea/kea-dhcp4.log``. +""" + +from __future__ import annotations + +import ipaddress +import json +import logging +import re +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Iterable, Sequence + +from tesm_core.db import Database +from tesm_core.sysops import SysOps + +logger = logging.getLogger(__name__) + +SERVICE_UNIT = "kea-dhcp4-server" +PACKAGE_NAME = "kea-dhcp4-server" +LEASE_FILE = "/var/lib/kea/kea-leases4.csv" +LOG_PATH = "/var/log/kea/kea-dhcp4.log" +DEFAULT_CONFIG_PATH = "/etc/kea/kea-dhcp4.conf" + +#: ``(code, name, type, description)`` -- kuratierte Standardoptionen. +STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = ( + (4, "time-servers", "ipv4-address", "Zeitserver (RFC 868)"), + (7, "log-servers", "ipv4-address", "Log-Server (MIT-LCS UDP)"), + (8, "cookie-servers", "ipv4-address", "Cookie-Server"), + (9, "lpr-servers", "ipv4-address", "LPR-Druckserver"), + (10, "impress-servers", "ipv4-address", "Impress-Server"), + (11, "resource-location-servers", "ipv4-address", "Resource Location Server"), + (13, "boot-size", "uint16", "Boot-Image-Groesse (Bloecke)"), + (14, "merit-dump", "string", "Crash-Dump-Datei"), + (16, "swap-server", "ipv4-address", "Swap-Server"), + (17, "root-path", "string", "Root-Pfad (NFS)"), + (18, "extensions-path", "string", "Extensions-Pfad (BOOTP)"), + (19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"), + (20, "non-local-source-routing", "boolean", "Non-Local Source Routing"), + (21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"), + (22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgroesse"), + (23, "default-ip-ttl", "uint8", "Standard-TTL fuer IP"), + (24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"), + (25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"), + (26, "interface-mtu", "uint16", "MTU des Interfaces"), + (27, "all-subnets-local", "boolean", "Alle Subnetze lokal erreichbar"), + (28, "broadcast-address", "ipv4-address", "Broadcast-Adresse"), + (29, "perform-mask-discovery", "boolean", "Subnetzmasken-Discovery"), + (30, "mask-supplier", "boolean", "Subnetzmasken-Anbieter"), + (31, "router-discovery", "boolean", "Router Discovery (RFC 1256)"), + (32, "router-solicitation-address", "ipv4-address", "Router-Solicitation-Adresse"), + (33, "static-routes", "string", "Statische Routen (Ziel,Gateway,...)"), + (34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"), + (35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"), + (36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"), + (37, "default-tcp-ttl", "uint8", "Standard-TTL fuer TCP"), + (38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"), + (40, "nis-domain", "string", "NIS-Domain"), + (41, "nis-servers", "ipv4-address", "NIS-Server"), + (42, "ntp-servers", "ipv4-address", "NTP-Server"), + (44, "netbios-name-servers", "ipv4-address", "WINS/NBNS-Server"), + (45, "netbios-dd-server", "ipv4-address", "NetBIOS Datagram Distribution Server"), + (46, "netbios-node-type", "uint8", "WINS/NBT-Knotentyp"), + (47, "netbios-scope", "string", "NetBIOS-Scope-ID"), + (48, "font-servers", "ipv4-address", "X-Window-Font-Server"), + (49, "x-display-manager", "ipv4-address", "X-Window Display Manager"), + (60, "vendor-class-identifier", "string", "Vendor Class Identifier"), + (64, "nisplus-domain-name", "string", "NIS+-Domain"), + (65, "nisplus-servers", "ipv4-address", "NIS+-Server"), + (66, "tftp-server-name", "string", "Boot-Server (TFTP-Hostname)"), + (67, "boot-file-name", "string", "Bootdatei-Name"), + (68, "mobile-ip-home-agent", "ipv4-address", "Mobile-IP-Home-Agent"), + (69, "smtp-server", "ipv4-address", "SMTP-Server"), + (70, "pop-server", "ipv4-address", "POP3-Server"), + (71, "nntp-server", "ipv4-address", "NNTP-Server"), + (72, "www-server", "ipv4-address", "WWW-Server"), + (73, "finger-server", "ipv4-address", "Finger-Server"), + (74, "irc-server", "ipv4-address", "IRC-Server"), + (75, "streettalk-server", "ipv4-address", "StreetTalk-Server"), + (76, "streettalk-directory-assistance-server", "ipv4-address", "StreetTalk Directory Assistance Server"), + (118, "subnet-selection", "ipv4-address", "Subnet Selection (RFC 3011)"), + (119, "domain-search", "string", "DNS-Suchliste (Domain Search)"), +) + +OPTION_TYPES = ( + "ipv4-address", + "string", + "boolean", + "uint8", + "uint16", + "uint32", + "int8", + "int16", + "int32", +) + +_HOSTNAME_CLEAN = re.compile(r"[^a-z0-9-]+") + + +# --------------------------------------------------------------------------- # +# Netzwerkerkennung +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class InterfaceNetwork: + interface: str + cidr: str + + @property + def network(self) -> ipaddress.IPv4Network: + return ipaddress.ip_network(self.cidr, strict=False) # type: ignore[return-value] + + @property + def address(self) -> str: + return self.cidr.split("/")[0] + + +def detect_networks(sysops: SysOps) -> list[InterfaceNetwork]: + """Liest alle IPv4-Adressen inkl. Alias-Adressen aus ``ip -4 addr show``. + + Subnetz und Maske werden nie von Hand eingegeben -- sie kommen immer vom + laufenden System. Das war schon im Vorgaenger die richtige Entscheidung. + """ + result = sysops.read_command(["ip", "-4", "-o", "addr", "show", "scope", "global"]) + if not result.ok: + return [] + networks: list[InterfaceNetwork] = [] + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) < 4 or parts[2] != "inet": + continue + networks.append(InterfaceNetwork(interface=parts[1], cidr=parts[3])) + return networks + + +def default_gateway(sysops: SysOps) -> str: + result = sysops.read_command(["ip", "route", "show", "default"]) + if not result.ok: + return "" + for line in result.stdout.splitlines(): + parts = line.split() + if "via" in parts: + return parts[parts.index("via") + 1] + return "" + + + +def _now() -> datetime: + return datetime.now(timezone.utc) + +def matching_network( + networks: Iterable[InterfaceNetwork], interface: str, range_start: str, range_end: str +) -> InterfaceNetwork | None: + """Findet das Netz, in das ein Adressbereich tatsaechlich passt.""" + try: + start = ipaddress.ip_address(range_start) + end = ipaddress.ip_address(range_end) + except ValueError: + return None + if int(end) < int(start): + return None + for candidate in networks: + if candidate.interface != interface: + continue + network = candidate.network + if start in network and end in network: + return candidate + return None + + +def validate_subnet( + networks: Iterable[InterfaceNetwork], interface: str, range_start: str, range_end: str +) -> str: + """Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung.""" + if not interface: + return "Es ist kein Interface ausgewaehlt." + try: + start = ipaddress.ip_address(range_start) + end = ipaddress.ip_address(range_end) + except ValueError: + return "Start- und Endadresse muessen gueltige IPv4-Adressen sein." + if int(end) < int(start): + return "Die Endadresse liegt vor der Startadresse." + if matching_network(networks, interface, range_start, range_end) is None: + return ( + f"Der Bereich {range_start}-{range_end} passt in kein Netz von {interface}. " + "Subnetz und Maske werden vom laufenden System gelesen." + ) + return "" + + +# --------------------------------------------------------------------------- # +# Reservierungen +# --------------------------------------------------------------------------- # + + +def normalize_mac(value: str) -> str: + """Vereinheitlicht MAC-Adressen auf ``aa:bb:cc:dd:ee:ff``.""" + cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "") + if len(cleaned) != 12: + return "" + return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower() + + +def hostname_from_name(name: str, taken: set[str]) -> str: + """Leitet einen DNS-taugliche Hostnamen ab und loest Kollisionen mit -2/-3.""" + base = _HOSTNAME_CLEAN.sub("-", (name or "").lower()).strip("-") or "client" + base = base[:60] + candidate = base + suffix = 2 + while candidate in taken: + candidate = f"{base}-{suffix}" + suffix += 1 + taken.add(candidate) + return candidate + + +def configured_networks( + conn: sqlite3.Connection, networks: Iterable[InterfaceNetwork] +) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]: + """Die Netze, fuer die tatsaechlich ein Subnetz eingerichtet ist. + + Nur diese landen in der Konfiguration. Eine Reservierung ausserhalb wird von + Kea nie ausgeliefert -- sie in der Oberflaeche trotzdem zu zeigen, hat + Reservierungen versprochen, die es nicht gab. + """ + treffer = [] + for subnet in Database.all( + conn, "SELECT * FROM dhcp_subnets WHERE enabled=1 ORDER BY sort_order, id" + ): + match = matching_network( + networks, + str(subnet["interface"]), + str(subnet["range_start"]), + str(subnet["range_end"]), + ) + if match is not None: + treffer.append(match.network) + return treffer + + +def in_configured_network( + ip: str, networks: Iterable[ipaddress.IPv4Network | ipaddress.IPv6Network] +) -> bool: + try: + adresse = ipaddress.ip_address(ip) + except ValueError: + return False + return any(adresse in netz for netz in networks) + + +def collect_reservations( + conn: sqlite3.Connection, + *, + only_networks: Iterable[ipaddress.IPv4Network | ipaddress.IPv6Network] | None = None, + skipped: list[dict[str, str]] | None = None, +) -> list[dict[str, Any]]: + """Automatische Reservierungen aus dem Geraetebestand plus manuelle Eintraege. + + Bei einer MAC-Kollision gewinnt die automatische Reservierung -- ein + ueberwachtes Geraet ist die verlaesslichere Quelle. + + ``only_networks`` grenzt auf Adressen in eingerichteten Bereichen ein. Ohne + diese Eingrenzung entstanden Eintraege fuer Adressen, fuer die es kein + Subnetz gibt: in der Liste sichtbar, in der Kea-Konfiguration nie + vorhanden. Was dabei wegfaellt, wird in ``skipped`` gesammelt, damit die + Oberflaeche es benennen kann statt es zu verschweigen. + """ + grenzen = list(only_networks) if only_networks is not None else None + taken: set[str] = set() + reservations: list[dict[str, Any]] = [] + seen_macs: set[str] = set() + + for row in Database.all( + conn, + "SELECT id, mac, ip, name FROM devices " + "WHERE deleted_at IS NULL AND is_active=1 AND ip <> '' ORDER BY name COLLATE NOCASE", + ): + mac = normalize_mac(str(row["mac"])) + if not mac or mac in seen_macs: + continue + try: + ipaddress.ip_address(str(row["ip"])) + except ValueError: + continue + if grenzen is not None and not in_configured_network(str(row["ip"]), grenzen): + if skipped is not None: + skipped.append({"name": str(row["name"]), "ip": str(row["ip"]), "source": "auto"}) + continue + seen_macs.add(mac) + reservations.append( + { + "device_id": int(row["id"]), + "mac": mac, + "ip": str(row["ip"]), + "hostname": hostname_from_name(str(row["name"]), taken), + "source": "auto", + "name": str(row["name"]), + } + ) + + for row in Database.all( + conn, "SELECT id, mac, ip, hostname, comment FROM dhcp_reservations ORDER BY ip" + ): + mac = normalize_mac(str(row["mac"])) + if not mac or mac in seen_macs: + continue + if grenzen is not None and not in_configured_network(str(row["ip"]), grenzen): + if skipped is not None: + skipped.append( + { + "name": str(row["hostname"] or row["comment"] or mac), + "ip": str(row["ip"]), + "source": "manuell", + } + ) + continue + seen_macs.add(mac) + reservations.append( + { + "device_id": None, + "reservation_id": int(row["id"]), + "mac": mac, + "ip": str(row["ip"]), + "hostname": hostname_from_name( + str(row["hostname"] or row["comment"] or mac.replace(":", "")), taken + ), + "source": "manuell", + "name": str(row["comment"] or ""), + } + ) + return reservations + + +# --------------------------------------------------------------------------- # +# Konfiguration +# --------------------------------------------------------------------------- # + + +def render_config( + conn: sqlite3.Connection, + *, + networks: list[InterfaceNetwork], + settings: dict[str, Any], +) -> tuple[dict[str, Any], list[str]]: + """Erzeugt die Kea-Konfiguration. Rueckgabe: ``(config, warnungen)``.""" + warnings: list[str] = [] + + subnets = Database.all( + conn, + "SELECT * FROM dhcp_subnets WHERE enabled=1 ORDER BY sort_order, id", + ) + reservations = collect_reservations(conn) + option_rows = Database.all(conn, "SELECT * FROM dhcp_options ORDER BY code") + options_by_id = {int(row["id"]): row for row in option_rows} + + global_values: dict[int, str] = {} + device_values: dict[int, dict[int, str]] = {} + for row in Database.all(conn, "SELECT * FROM dhcp_option_values"): + option_id = int(row["option_id"]) + value = str(row["value"] or "").strip() + if not value: + continue + if row["device_id"] is None: + global_values[option_id] = value + else: + device_values.setdefault(int(row["device_id"]), {})[option_id] = value + + def option_data(values: dict[int, str]) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + for option_id, value in sorted(values.items()): + option = options_by_id.get(option_id) + if option is None: + continue + entries.append({"name": str(option["name"]), "data": value}) + return entries + + subnet_entries: list[dict[str, Any]] = [] + interfaces: set[str] = set() + for index, subnet in enumerate(subnets, start=1): + interface = str(subnet["interface"]) + match = matching_network( + networks, interface, str(subnet["range_start"]), str(subnet["range_end"]) + ) + if match is None: + # Nicht passende Subnetze werden uebersprungen, nicht die ganze + # Aktion abgebrochen -- eine geaenderte Host-IP soll nicht dazu + # fuehren, dass gar keine Konfiguration mehr geschrieben wird. + warnings.append( + f"Subnetz {subnet['range_start']}-{subnet['range_end']} auf {interface} " + "passt in kein aktuelles Netz und wurde uebersprungen." + ) + continue + interfaces.add(interface) + network = match.network + + subnet_options: list[dict[str, Any]] = [] + if str(subnet["gateway"] or "").strip(): + subnet_options.append({"name": "routers", "data": str(subnet["gateway"]).strip()}) + if str(subnet["dns"] or "").strip(): + subnet_options.append( + {"name": "domain-name-servers", "data": str(subnet["dns"]).strip()} + ) + + subnet_reservations = [] + for reservation in reservations: + if ipaddress.ip_address(reservation["ip"]) not in network: + continue + entry: dict[str, Any] = { + "hw-address": reservation["mac"], + "ip-address": reservation["ip"], + "hostname": reservation["hostname"], + } + overrides = device_values.get(reservation.get("device_id") or -1, {}) + if overrides: + entry["option-data"] = option_data(overrides) + subnet_reservations.append(entry) + + subnet_entries.append( + { + "id": index, + "subnet": str(network), + "pools": [{"pool": f"{subnet['range_start']} - {subnet['range_end']}"}], + **({"option-data": subnet_options} if subnet_options else {}), + "reservations": subnet_reservations, + } + ) + + if not subnet_entries: + warnings.append("Es ist kein gueltiges Subnetz konfiguriert -- Kea wuerde nicht starten.") + + global_option_data = option_data(global_values) + domain = str(settings.get("dhcp_domain") or "").strip() + if domain: + global_option_data.insert(0, {"name": "domain-name", "data": domain}) + + # Eigene (nicht-Standard-) Optionen brauchen eine Definition, sonst + # verweigert Kea den Start: "definition for the option 'dhcp4.' does + # not exist". Deklariert werden **alle** eigenen Optionen, nicht nur die + # mit globalem Wert -- ein Wert kann auch nur in einer einzelnen + # Reservierung stehen, und genau das hat den Dienst umgebracht: die + # Definition fehlte, weil hier nur die globalen Werte geprueft wurden. + # Eine ungenutzte Definition ist fuer Kea harmlos; eine fehlende nicht. + custom_defs = [ + { + "name": str(row["name"]), + "code": int(row["code"]), + "type": str(row["type"]), + "space": "dhcp4", + } + for row in option_rows + if not int(row["is_standard"] or 0) + ] + + config = { + "Dhcp4": { + "interfaces-config": {"interfaces": sorted(interfaces)}, + "lease-database": { + "type": "memfile", + "lfc-interval": 3600, + # Schluessel heisst "name" -- nicht "lease-file". + "name": LEASE_FILE, + }, + "valid-lifetime": int(settings.get("dhcp_lease_default") or 600), + "max-valid-lifetime": int(settings.get("dhcp_lease_max") or 7200), + **({"option-def": custom_defs} if custom_defs else {}), + **({"option-data": global_option_data} if global_option_data else {}), + "subnet4": subnet_entries, + # loggers gehoert UNTER Dhcp4 -- daneben ist es ein Syntaxfehler. + "loggers": [ + { + "name": "kea-dhcp4", + "output_options": [{"output": LOG_PATH, "pattern": "%D{%Y-%m-%d %H:%M:%S} %-5p %m\n"}], + "severity": "INFO", + "debuglevel": 0, + } + ], + } + } + return config, warnings + + +def config_to_text(config: dict[str, Any]) -> str: + return json.dumps(config, indent=2, ensure_ascii=False) + "\n" + + +# --------------------------------------------------------------------------- # +# Leases +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class Lease: + address: str + mac: str + hostname: str + expires_at: int + state: str + + @property + def is_active(self) -> bool: + return self.state == "0" + + @property + def is_expired(self) -> bool: + return self.expires_at > 0 and self.expires_at <= int(_now().timestamp()) + + @property + def expires_text(self) -> str: + """Ablauf als Datum und Uhrzeit in der Zeitzone des Servers. + + Kea schreibt einen Unix-Zeitstempel in die Lease-Datei. Der stand hier + vorher unveraendert in der Tabelle -- eine zehnstellige Zahl, aus der + niemand ablesen kann, ob eine Lease in zehn Minuten oder erst morgen + ablaeuft. + """ + if self.expires_at <= 0: + return "unbegrenzt" + return datetime.fromtimestamp(self.expires_at).strftime("%d.%m.%Y %H:%M") + + @property + def remaining_text(self) -> str: + """Restlaufzeit in Worten -- die eigentlich interessierende Angabe.""" + if self.expires_at <= 0: + return "" + sekunden = self.expires_at - int(_now().timestamp()) + if sekunden <= 0: + return "abgelaufen" + if sekunden < 60: + return "unter einer Minute" + if sekunden < 3600: + return f"in {sekunden // 60} min" + if sekunden < 86400: + stunden, rest = divmod(sekunden, 3600) + return f"in {stunden} h {rest // 60} min" if rest >= 60 else f"in {stunden} h" + tage, rest = divmod(sekunden, 86400) + return f"in {tage} Tg {rest // 3600} h" if rest >= 3600 else f"in {tage} Tg" + + +#: Zustand einer Reservierung -- wie im Windows-DHCP, wo eine Reservierung +#: "aktiv" oder "inaktiv" ist, je nachdem ob der Client sie gerade benutzt. +#: Ohne diese Angabe sieht eine nie abgeholte Reservierung genauso aus wie eine +#: benutzte, und ein Geraet, das noch auf einer alten Adresse haengt, faellt +#: ueberhaupt nicht auf. +RESERVATION_STATES: dict[str, tuple[str, str]] = { + "aktiv": ("online", "Das Geraet benutzt diese Adresse gerade."), + "abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen -- Geraet derzeit nicht da."), + "andere_adresse": ( + "warning", + "Das Geraet haelt noch eine Lease auf einer anderen Adresse. Es uebernimmt die " + "Reservierung erst nach Ablauf oder Neuanforderung.", + ), + "unbenutzt": ("", "Fuer dieses Geraet liegt keine Lease vor."), +} + + +def annotate_reservations( + reservations: Sequence[dict[str, Any]], leases: Sequence[Lease] +) -> list[dict[str, Any]]: + """Ergaenzt jede Reservierung um ihren Zustand aus den laufenden Leases. + + Reine Funktion ueber zwei Listen -- damit pruefbar, ohne Kea zu starten. + """ + nach_mac: dict[str, Lease] = {} + for lease in leases: + mac = normalize_mac(lease.mac) + if not mac: + continue + vorhanden = nach_mac.get(mac) + # Bei mehreren Leases je MAC gewinnt die, die spaeter ablaeuft. + if vorhanden is None or lease.expires_at > vorhanden.expires_at: + nach_mac[mac] = lease + + ergebnis: list[dict[str, Any]] = [] + for reservation in reservations: + eintrag = dict(reservation) + lease = nach_mac.get(normalize_mac(str(reservation["mac"]))) + if lease is None: + zustand = "unbenutzt" + elif lease.address != str(reservation["ip"]): + zustand = "andere_adresse" + elif lease.is_expired or not lease.is_active: + zustand = "abgelaufen" + else: + zustand = "aktiv" + eintrag["state"] = zustand + eintrag["state_hint"] = RESERVATION_STATES[zustand][1] + eintrag["state_style"] = RESERVATION_STATES[zustand][0] + eintrag["lease"] = lease + ergebnis.append(eintrag) + return ergebnis + + +def read_leases(sysops: SysOps, *, path: str = LEASE_FILE) -> list[Lease]: + """Liest die memfile-Lease-Datei. + + Die Datei haengt bei jeder Verlaengerung neue Zeilen an; es gewinnt die + letzte Zeile je Adresse. Gelesen wird ueber den privilegierten Helfer, weil + ``/var/lib/kea`` dem Kea-Dienstbenutzer gehoert. + """ + result = sysops.read_privileged_file(path) + if not result.ok: + return [] + lines = result.stdout.splitlines() + if not lines: + return [] + header = [name.strip().strip('"') for name in lines[0].split(",")] + try: + index = { + "address": header.index("address"), + "hwaddr": header.index("hwaddr"), + "expire": header.index("expire"), + "state": header.index("state"), + "hostname": header.index("hostname"), + } + except ValueError: + logger.warning("Kea-Lease-Datei hat unerwartete Spalten: %s", header) + return [] + + latest: dict[str, Lease] = {} + for line in lines[1:]: + fields = [field.strip().strip('"') for field in line.split(",")] + if len(fields) < len(header): + continue + try: + lease = Lease( + address=fields[index["address"]], + mac=normalize_mac(fields[index["hwaddr"]]), + hostname=fields[index["hostname"]], + expires_at=int(fields[index["expire"]] or 0), + state=fields[index["state"]] or "0", + ) + except (ValueError, IndexError): + continue + latest[lease.address] = lease + return sorted(latest.values(), key=lambda entry: tuple(int(p) for p in entry.address.split(".") if p.isdigit())) + + +def service_state(sysops: SysOps) -> dict[str, str]: + state = sysops.service_state(SERVICE_UNIT) + import shutil + + state["binary"] = "vorhanden" if shutil.which("kea-dhcp4") else "fehlt" + return state diff --git a/apps/tesm/src/tesm/services/legacy_transfer.py b/apps/tesm/src/tesm/services/legacy_transfer.py new file mode 100644 index 0000000..a990eda --- /dev/null +++ b/apps/tesm/src/tesm/services/legacy_transfer.py @@ -0,0 +1,417 @@ +"""Uebersetzt einen Export des alten TESM in das Format dieser Anwendung. + +Der Vorgaenger (``gitea:alientim/tesm``) schreibt seine Exportdatei so:: + + {"app": "tesm", "version": 2, "exported_at": "...", + "salt": "", "payload": ""} + +Der Schluessel entsteht aus Passphrase und Salt per PBKDF2-HMAC-SHA256 mit +390000 Runden, die Nutzlast ist ein Fernet-Token ueber JSON. Das neue Format +verwendet Argon2id und AES-GCM und bindet seinen Kopf als AAD ein. Die +Umschlaege sind also nicht ineinander ueberfuehrbar, wohl aber ihr Inhalt. + +Absichtlich ein **Uebersetzer** und kein zweiter Importweg: die Daten werden +in das neue Format geschrieben und dann vom vorhandenen, geprueften Import +eingelesen. Ein eigener Schreibpfad fuer Altdaten waere eine zweite Stelle, an +der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die, +die niemand pflegt. + +Uebersetzt werden Clients, Switche, Zugangsdaten und die DHCP-Konfiguration. +Was in der Datei sonst noch steht, wird nicht stillschweigend verworfen, +sondern im Bericht benannt -- damit sichtbar bleibt, was von Hand nachzuziehen +ist. +""" + +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass, field +from typing import Any + +#: Runden der alten Schluesselableitung. Steht so im Vorgaenger und darf sich +#: nicht aendern -- sonst laesst sich keine alte Datei mehr oeffnen. +LEGACY_KDF_ITERATIONS = 390000 +LEGACY_APP_TAG = "tesm" + +#: Diese Abschnitte werden uebersetzt. +SUPPORTED = ("credentials", "switches", "devices", "dhcp") + +#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt. +KNOWN_UNSUPPORTED: dict[str, str] = { + "users": ( + "Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung " + "verwendet Argon2id. Ein Hash laesst sich nicht umrechnen -- das ist sein " + "Zweck. Konten neu anlegen." + ), + "groups": "Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.", + "ldap": ( + "Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von " + "Hand uebertragen." + ), + "nginx": "Webserver: Domain, Ports und Zertifikat von Hand uebertragen.", + "logs": "Auditlog: bleibt bewusst beim alten System.", + "known_hosts": ( + "SSH-Host-Schluessel: werden hier je Geraet mit Fingerprint gefuehrt und " + "ueber die Seite 'Host-Schluessel' freigegeben." + ), +} + +#: Kategorien der Zugangsdaten -- in beiden Anwendungen dieselben vier Werte. +VALID_CATEGORIES = ("switch", "linux", "windows", "router") +FALLBACK_CATEGORY = "switch" + +#: DHCP-Einstellungen, die in beiden Anwendungen gleich heissen. +DHCP_SETTING_KEYS = ("dhcp_domain", "dhcp_lease_default", "dhcp_lease_max", "dhcp_output_path") + + +class LegacyTransferError(Exception): + """Fehler mit Klartextmeldung fuer Oberflaeche und Kommandozeile.""" + + +@dataclass +class Report: + """Was uebernommen wurde -- und was nicht, mit Grund.""" + + counts: dict[str, int] = field(default_factory=dict) + notes: list[str] = field(default_factory=list) + + def took(self, category: str, amount: int = 1) -> None: + if amount: + self.counts[category] = self.counts.get(category, 0) + amount + + def note(self, text: str) -> None: + if text not in self.notes: + self.notes.append(text) + + def as_text(self) -> str: + zeilen = ["Uebersetzt:"] + zeilen += [f" {key}: {value}" for key, value in sorted(self.counts.items())] or [ + " nichts" + ] + if self.notes: + zeilen.append("") + zeilen.append("Zu beachten:") + zeilen += [f" - {note}" for note in self.notes] + return "\n".join(zeilen) + + +# --------------------------------------------------------------------------- # +# Alten Umschlag oeffnen +# --------------------------------------------------------------------------- # + + +def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]: + """Entschluesselt eine Exportdatei des Vorgaengers.""" + from cryptography.fernet import Fernet, InvalidToken + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + + text = raw.decode("utf-8") if isinstance(raw, bytes) else raw + try: + envelope = json.loads(text) + except ValueError as exc: + raise LegacyTransferError("Die Datei ist kein JSON.") from exc + if not isinstance(envelope, dict): + raise LegacyTransferError("Die Datei enthaelt kein Objekt.") + if str(envelope.get("app") or "") != LEGACY_APP_TAG: + raise LegacyTransferError( + f"Diese Datei gehoert zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, " + "nicht zu TESM." + ) + for schluessel in ("salt", "payload"): + if not envelope.get(schluessel): + raise LegacyTransferError(f"Im Umschlag fehlt {schluessel!r}.") + + try: + salt = base64.urlsafe_b64decode(str(envelope["salt"])) + except (ValueError, TypeError) as exc: + raise LegacyTransferError("Das Salt ist unlesbar.") from exc + + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), length=32, salt=salt, iterations=LEGACY_KDF_ITERATIONS + ) + key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8"))) + try: + klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8")) + except (InvalidToken, ValueError) as exc: + raise LegacyTransferError( + "Entschluesselung fehlgeschlagen -- falsche Passphrase oder beschaedigte Datei." + ) from exc + + try: + payload = json.loads(klartext.decode("utf-8")) + except ValueError as exc: + raise LegacyTransferError("Die entschluesselte Nutzlast ist kein JSON.") from exc + if not isinstance(payload, dict): + raise LegacyTransferError("Die Nutzlast enthaelt kein Objekt.") + return payload + + +# --------------------------------------------------------------------------- # +# Uebersetzen +# --------------------------------------------------------------------------- # + + +def _liste(value: Any) -> list[dict[str, Any]]: + return [entry for entry in value if isinstance(entry, dict)] if isinstance(value, list) else [] + + +def _category(value: Any, report: Report, subject: str) -> str: + kategorie = str(value or "").strip().lower() + if kategorie in VALID_CATEGORIES: + return kategorie + if kategorie: + report.note( + f"Unbekannte Kategorie {kategorie!r} bei {subject} auf {FALLBACK_CATEGORY!r} gesetzt." + ) + return FALLBACK_CATEGORY + + +def _credentials(items: Any, report: Report) -> list[dict[str, Any]]: + ergebnis: list[dict[str, Any]] = [] + ohne_passwort = 0 + for entry in _liste(items): + name = str(entry.get("name") or "").strip() + if not name: + report.note("Zugangsdaten ohne Namen uebersprungen.") + continue + umgesetzt: dict[str, Any] = { + "name": name, + "username": str(entry.get("username") or ""), + "category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"), + # Der Vorgaenger kennt kein Beschreibungsfeld. + "description": "aus altem TESM uebernommen", + } + # Der alte Export entschluesselt das Passwort und legt es im Klartext in + # die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import + # verschluesselt es der Schluesselspeicher sofort wieder. + if entry.get("password"): + umgesetzt["secret"] = str(entry["password"]) + else: + ohne_passwort += 1 + ergebnis.append(umgesetzt) + if ohne_passwort: + report.note( + f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei -- " + "sie muessen nachgetragen werden." + ) + report.took("credentials", len(ergebnis)) + return ergebnis + + +def _switches(items: Any, report: Report) -> list[dict[str, Any]]: + ergebnis: list[dict[str, Any]] = [] + for entry in _liste(items): + hostname = str(entry.get("hostname") or "").strip() + if not hostname: + report.note("Switch ohne Hostnamen uebersprungen.") + continue + ergebnis.append( + { + "hostname": hostname, + "ip": str(entry.get("ip") or ""), + "ssh_port": int(entry.get("ssh_port") or 22), + "credential_name": entry.get("credential_name"), + # Diese Felder gibt es im Vorgaenger nicht. + "model": "", + "location": "", + "notes": "", + } + ) + report.took("switches", len(ergebnis)) + return ergebnis + + +def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> list[dict[str, Any]]: + # Der Vorgaenger kennt keine Geraetekategorie. Sie steuert hier, was mit + # einem Geraet moeglich ist (Linux ueber SSH, Windows ueber RPC), und laesst + # sich aus den zugeordneten Zugangsdaten ableiten. Das ist besser als leer: + # ohne Kategorie erscheint das Geraet auf der Wartungsseite gar nicht. + kategorie_je_zugang = {str(c["name"]): str(c["category"]) for c in credentials} + ergebnis: list[dict[str, Any]] = [] + abgeleitet = 0 + for entry in _liste(items): + mac = str(entry.get("mac") or "").strip() + if not mac: + report.note("Client ohne MAC-Adresse uebersprungen.") + continue + zugang = entry.get("credential_name") + kategorie = kategorie_je_zugang.get(str(zugang or ""), "") + if kategorie: + abgeleitet += 1 + ergebnis.append( + { + "mac": mac, + "ip": str(entry.get("ip") or ""), + "name": str(entry.get("name") or mac), + "port": str(entry.get("port") or ""), + "is_active": int(bool(entry.get("is_active", 1))), + "ssh_port": int(entry.get("ssh_port") or 22), + "switch_hostname": entry.get("switch_hostname"), + "credential_name": zugang, + "category": kategorie, + "location": "", + "notes": "", + # Im Vorgaenger war der automatische Neustart nicht abschaltbar. + "auto_restart": 1, + } + ) + if abgeleitet: + report.note( + f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten " + "abgeleitet -- der Vorgaenger kennt kein solches Feld. Bitte pruefen." + ) + report.took("devices", len(ergebnis)) + return ergebnis + + +def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]: + """DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``.""" + if not isinstance(data, dict): + return {}, {} + + subnets = [ + { + "interface": str(entry.get("interface") or ""), + "range_start": str(entry.get("range_start") or ""), + "range_end": str(entry.get("range_end") or ""), + "gateway": str(entry.get("gateway") or ""), + "dns": str(entry.get("dns") or ""), + # Der Vorgaenger hat kein Kommentarfeld am Subnetz. + "comment": "", + "enabled": int(bool(entry.get("enabled", 1))), + "sort_order": int(entry.get("sort_order") or 0), + } + for entry in _liste(data.get("subnets")) + ] + + # Der Vorgaenger fuehrt am Eintrag ein Feld "name"; hier heisst es + # "hostname", weil genau das im Kea-Eintrag landet. + reservations = [] + for entry in _liste(data.get("reservations")): + mac = str(entry.get("mac") or "").strip() + if not mac: + report.note("Manuelle Reservierung ohne MAC-Adresse uebersprungen.") + continue + reservations.append( + { + "mac": mac, + "ip": str(entry.get("ip") or ""), + "hostname": str(entry.get("name") or ""), + "comment": "aus altem TESM uebernommen", + } + ) + + # Der Vorgaenger schachtelt die Werte unter der Definition, hier steht je + # Wert eine Zeile mit den Feldern der Definition daneben. + options: list[dict[str, Any]] = [] + ohne_wert = 0 + for definition in _liste(data.get("options")): + gemeinsam = { + "code": int(definition.get("code") or 0), + "name": str(definition.get("name") or ""), + "type": str(definition.get("type") or "string"), + "description": str(definition.get("description") or ""), + "is_standard": int(bool(definition.get("is_standard"))), + } + if not gemeinsam["code"] or not gemeinsam["name"]: + report.note("DHCP-Option ohne Code oder Namen uebersprungen.") + continue + werte = _liste(definition.get("values")) + if not werte: + ohne_wert += 1 + continue + for wert in werte: + options.append( + { + **gemeinsam, + "value": str(wert.get("value") or ""), + # Leerer Wert = globale Geltung; sonst die MAC des Geraets. + "device_mac": str(wert.get("device_mac") or "") or None, + } + ) + if ohne_wert: + report.note( + f"{ohne_wert} eigene DHCP-Option(en) ohne Wert uebersprungen -- ohne Wert " + "gibt es nichts zu senden. Bei Bedarf von Hand anlegen." + ) + + dhcp: dict[str, Any] = {} + if subnets: + dhcp["subnets"] = subnets + if reservations: + dhcp["reservations"] = reservations + if options: + dhcp["options"] = options + + report.took("dhcp-Subnetze", len(subnets)) + report.took("dhcp-Reservierungen", len(reservations)) + report.took("dhcp-Optionswerte", len(options)) + + alt_settings = data.get("settings") if isinstance(data.get("settings"), dict) else {} + settings = { + key: str(alt_settings[key]) + for key in DHCP_SETTING_KEYS + if alt_settings.get(key) not in (None, "") + } + report.took("Einstellungen", len(settings)) + return dhcp, settings + + +def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]: + """Baut aus der alten Nutzlast eine Nutzlast im neuen Format.""" + report = Report() + payload: dict[str, Any] = {} + + credentials = _credentials(legacy.get("credentials"), report) + if credentials: + payload["credentials"] = credentials + + switches = _switches(legacy.get("switches"), report) + if switches: + payload["switches"] = switches + + devices = _devices(legacy.get("devices"), credentials, report) + if devices: + payload["devices"] = devices + + dhcp, settings = _dhcp(legacy.get("dhcp"), report) + if dhcp: + payload["dhcp"] = dhcp + if settings: + # Nur die vier DHCP-Schluessel, nichts sonst: alles andere heisst hier + # anders oder bedeutet etwas anderes. + payload["settings"] = settings + + # Zugeordnete Zugangsdaten, die nicht mitexportiert wurden, laufen beim + # Import ins Leere -- er loest sie ueber den Namen auf und setzt sonst NULL. + # Lieber vorher sagen als hinterher suchen. + bekannt = {str(c["name"]) for c in credentials} + fehlend = sorted( + { + str(entry["credential_name"]) + for entry in (*switches, *devices) + if entry.get("credential_name") and str(entry["credential_name"]) not in bekannt + } + ) + if fehlend: + report.note( + "Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben " + f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die " + "Kategorie 'Zugangsdaten' mitwaehlen." + ) + + for schluessel, grund in KNOWN_UNSUPPORTED.items(): + if legacy.get(schluessel): + report.note(grund) + unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED)) + if unbekannt: + report.note(f"Unbekannte Abschnitte uebersprungen: {', '.join(unbekannt)}.") + + if not payload: + raise LegacyTransferError( + "Die Datei enthaelt keine Clients, Switche, Zugangsdaten oder DHCP-Daten. " + "Beim Export im alten TESM diese Kategorien mitwaehlen." + ) + return payload, report diff --git a/apps/tesm/src/tesm/services/maintenance.py b/apps/tesm/src/tesm/services/maintenance.py new file mode 100644 index 0000000..c38d5a5 --- /dev/null +++ b/apps/tesm/src/tesm/services/maintenance.py @@ -0,0 +1,713 @@ +"""Wartung: Paketaktualisierung und Neustart, als nachvollziehbare Auftraege. + +Klar getrennt vom PoE-Neustart: das Dashboard schaltet immer den Switch-Port, +die Wartung spricht immer direkt mit dem Geraet. + +Der Weg dorthin haengt am Betriebssystem: + +* **Linux** -- SSH. Host-Schluessel muss freigegeben sein, ``sudo`` bekommt bei + Bedarf das hinterlegte Passwort ueber stdin. +* **Windows** -- RPC ueber :mod:`tesm.services.windowsops`. Kein SSH, kein + Host-Schluessel; nur Neustart, keine Paketaktualisierung. + +Aenderung gegenueber dem Vorgaenger: Auftragszustand lag dort ausschliesslich im +Prozessspeicher. Ein Dienst-Neustart verlor die Anzeige komplett -- inklusive der +Information, ob ueberhaupt etwas gelaufen war. Hier liegt jeder Auftrag in der +Tabelle ``jobs``: ueberlebt Neustarts, ist im Verlauf einsehbar und kann nach +einem Abbruch als "unklar beendet" erkannt werden. + +Host-Schluessel werden hier **immer** streng geprueft. Bei einem Hintergrundlauf +sieht niemand zu; ein automatisches Akzeptieren waere ein Einfallstor. +""" + +from __future__ import annotations + +import logging +import re +import secrets +import sqlite3 +import threading +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Callable + +from tesm_core import audit +from tesm_core.db import Database + +from . import windowsops +from .monitor import METHOD_SSH, record_restart, tcp_reachable +from .restart import METHOD_RPC +from .ssh import SshError, open_transport + +logger = logging.getLogger(__name__) + +JOB_KIND_UPDATE = "maintenance.update" +JOB_KIND_REBOOT = "maintenance.reboot" + +MAX_OUTPUT_LINES = 400 +CONNECT_TIMEOUT = 25 +REBOOT_GRACE_SECONDS = 15 +REBOOT_POLL_SECONDS = 5 + +#: Die Wartungsschritte. ``{sudo}`` wird zur Laufzeit eingesetzt -- je nachdem, +#: ob das Zielgeraet fuer ``sudo`` ein Passwort verlangt. +UPDATE_COMMANDS: tuple[tuple[str, str], ...] = ( + ( + "Paketlisten aktualisieren", + "env DEBIAN_FRONTEND=noninteractive {sudo} apt-get update", + ), + ( + "Pakete aktualisieren", + "env DEBIAN_FRONTEND=noninteractive {sudo} apt-get -y " + '-o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade', + ), + ("Aufraeumen", "env DEBIAN_FRONTEND=noninteractive {sudo} apt-get -y autoremove"), +) + +REBOOT_COMMAND = "{sudo} reboot" + +#: Windows wird **nicht** ueber SSH bedient -- siehe :mod:`tesm.services.windowsops`. +#: Diese Geraete haben in aller Regel keinen SSH-Dienst und damit auch keinen +#: Host-Schluessel, den jemand freigeben koennte. +WINDOWS_CATEGORY = "windows" + +#: Woran ein Windows-Geraet als "wieder da" erkannt wird: Port 445 (SMB/RPC) -- +#: derselbe Dienst, ueber den der Neustart ausgeloest wurde. +WINDOWS_PROBE_PORT = 445 + +#: ``-S`` liest das Passwort von stdin, ``-p ''`` unterdrueckt die Aufforderung +#: (sie landete sonst in der Ausgabe), ``-k`` verwirft einen zwischen- +#: gespeicherten sudo-Zeitstempel, damit der Ablauf reproduzierbar bleibt. +SUDO_WITH_PASSWORD = "sudo -S -k -p ''" +SUDO_PASSWORDLESS = "sudo -n" + +COMPLETION_MARKER = "### TESM-WARTUNG-ENDE ###" + +#: Fortschrittsgeschwaetz von dpkg. Ohne Terminal kann dpkg die Zeile nicht +#: ueberschreiben und schreibt stattdessen jede Stufe einzeln -- zwanzig Zeilen +#: "(Reading database ... 45%)" je Paket. Das blaeht das Protokoll so weit auf, +#: dass die eigentlichen Meldungen darin untergehen und die Zeilenbegrenzung +#: alles Interessante hinausschiebt. +NOISE_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"^\(?Reading database \.\.\.\s*\d+%\)?\s*$"), + re.compile(r"^\(?Reading database \.\.\.\s*$"), + re.compile(r"^Progress: \[\s*\d+%\]"), + re.compile(r"^\s*$"), +) + + +def is_noise(line: str) -> bool: + """Reine Fortschrittsanzeige ohne Informationsgehalt? + + Die Abschlusszeile mit der Dateizahl bleibt erhalten -- sie sagt etwas aus. + """ + return any(pattern.match(line) for pattern in NOISE_PATTERNS) + + +#: Fehlerbilder, die auf dem Zielgeraet liegen und dort behoben werden muessen. +#: Die rohe Meldung sagt zwar die Wahrheit, aber nicht, was zu tun ist. +HINTS: tuple[tuple[str, str], ...] = ( + ( + "incorrect password attempt", + "Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Fuer die " + "SSH-Anmeldung hat es gereicht -- 'sudo' auf diesem Geraet verlangt also ein " + "anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.", + ), + ( + "is not in the sudoers file", + "Das Wartungskonto darf auf diesem Geraet kein 'sudo' ausfuehren. Auf dem " + "Zielgeraet eintragen, z. B. in /etc/sudoers.d/tesm-wartung: " + "' ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem " + "NOPASSWD: entfaellt die Passwortabfrage ganz.", + ), + ( + "a password is required", + "'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geraets ist keines " + "hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein -- TESM reicht " + "es ueber die bereits verschluesselte SSH-Verbindung an 'sudo' weiter.", + ), + ( + "no tty present", + "Das Wartungskonto verlangt fuer 'sudo' ein Terminal. In der sudoers-Datei des " + "Zielgeraets 'Defaults requiretty' entfernen oder fuer dieses Konto abschalten.", + ), + ( + "command not found", + "Das Kommando gibt es auf dem Zielgeraet nicht. Die Wartung erwartet ein " + "Debian- oder Ubuntu-System mit apt-get.", + ), + ( + "Access is denied", + "Das Konto darf auf diesem Windows-Geraet keinen Neustart ausloesen. Es muss " + "Mitglied der lokalen Administratoren sein.", + ), + ( + "is not recognized as an internal or external command", + "Das Kommando gibt es auf diesem Geraet nicht. Ist bei den Zugangsdaten die " + "richtige Kategorie gewaehlt (Linux-Client bzw. Windows-Client)?", + ), + ( + "Could not get lock", + "Ein anderer Paketvorgang laeuft bereits auf dem Zielgeraet (etwa " + "unattended-upgrades). Spaeter erneut versuchen.", + ), +) + + +def hint_for(output: str) -> str: + """Sucht zu einer Fehlerausgabe den passenden Hinweis -- oder gibt "" zurueck.""" + for needle, hint in HINTS: + if needle in output: + return hint + return "" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +@dataclass(slots=True) +class JobHandle: + id: str + kind: str + subject: str + + +def create_job( + conn: sqlite3.Connection, *, kind: str, subject: str, actor: str, message: str = "" +) -> JobHandle: + job_id = secrets.token_urlsafe(12) + conn.execute( + "INSERT INTO jobs (id, kind, subject, state, progress, message, output, started_by, " + "created_at, updated_at) VALUES (?,?,?,'pending',0,?,'',?,?,?)", + (job_id, kind, subject, message, actor, _now(), _now()), + ) + return JobHandle(id=job_id, kind=kind, subject=subject) + + +def update_job( + database: Any, + job_id: str, + *, + state: str | None = None, + progress: int | None = None, + message: str | None = None, + append_output: str = "", + finished: bool = False, +) -> None: + with database.session() as conn, database.transaction(conn): + fields: list[str] = ["updated_at=?"] + params: list[Any] = [_now()] + if state is not None: + fields.append("state=?") + params.append(state) + if progress is not None: + fields.append("progress=?") + params.append(max(0, min(100, progress))) + if message is not None: + fields.append("message=?") + params.append(message[:500]) + if finished: + fields.append("finished_at=?") + params.append(_now()) + if append_output: + current = str( + Database.value(conn, "SELECT output FROM jobs WHERE id=?", (job_id,), default="") + or "" + ) + combined = current + append_output + lines = combined.splitlines() + trimmed = "\n".join(lines[-MAX_OUTPUT_LINES:]) + # splitlines() wirft ein abschliessendes Zeilenende weg. Ohne diese + # Zeile klebte jede neue Ausgabe an der vorherigen, und das ganze + # Protokoll stand am Ende auf einer einzigen Zeile. + if combined.endswith("\n"): + trimmed += "\n" + fields.append("output=?") + params.append(trimmed) + params.append(job_id) + conn.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE id=?", tuple(params)) + + +def list_jobs( + conn: sqlite3.Connection, *, kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT), limit: int = 60 +) -> list[dict[str, Any]]: + placeholders = ",".join("?" * len(kinds)) + return Database.all( + conn, + f"SELECT * FROM jobs WHERE kind IN ({placeholders}) ORDER BY created_at DESC LIMIT ?", + (*kinds, limit), + ) + + +def get_job(conn: sqlite3.Connection, job_id: str) -> dict[str, Any] | None: + return Database.one(conn, "SELECT * FROM jobs WHERE id=?", (job_id,)) + + +def mark_orphaned_jobs(conn: sqlite3.Connection) -> int: + """Auftraege, die beim Dienststart noch als "laufend" gelten, ehrlich kennzeichnen. + + Ein abgebrochener Webprozess weiss nicht, was auf dem Zielgeraet passiert + ist -- also wird der Auftrag als "unklar" markiert statt als erfolgreich + oder fehlgeschlagen. + """ + cursor = conn.execute( + "UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? " + "WHERE state IN ('pending','running')", + ( + "Der Dienst wurde waehrend des Laufs neu gestartet. Was auf dem Zielgeraet " + "passiert ist, laesst sich von hier nicht mehr feststellen.", + _now(), + _now(), + ), + ) + return cursor.rowcount or 0 + + +# --------------------------------------------------------------------------- # +# Ausfuehrung +# --------------------------------------------------------------------------- # + + +def _run_remote_command( + transport: Any, + command: str, + *, + timeout: int, + on_line: Callable[[str], None], + stdin_text: str = "", +) -> int: + """Fuehrt ein Kommando aus und liefert dessen Rueckgabewert. + + ``stdin_text`` geht ueber den Kanal an das Kommando -- so und nur so bekommt + ``sudo -S`` sein Passwort. Auf der Kommandozeile stuende es auf dem + Zielgeraet in ``ps``, in der Umgebung in ``/proc//environ``. + """ + channel = transport.open_session() + channel.settimeout(timeout) + channel.set_combine_stderr(True) + channel.exec_command(command) + if stdin_text: + try: + channel.sendall(stdin_text.encode("utf-8")) + channel.shutdown_write() + except Exception as exc: # noqa: BLE001 + on_line(f"[Eingabe konnte nicht uebergeben werden: {exc}]") + deadline = time.monotonic() + timeout + pending = b"" + while True: + if channel.recv_ready(): + chunk = channel.recv(32768) + if not chunk: + break + pending += chunk + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + on_line(line.decode("utf-8", errors="replace")) + elif channel.exit_status_ready(): + break + elif time.monotonic() > deadline: + on_line("[Zeitlimit ueberschritten -- Verbindung wird getrennt]") + channel.close() + return 124 + else: + time.sleep(0.1) + if pending: + on_line(pending.decode("utf-8", errors="replace")) + status = channel.recv_exit_status() + channel.close() + return int(status) + + +def _sudo_mode( + transport: Any, password: str, on_line: Callable[[str], None] +) -> tuple[str, str]: + """Bestimmt, wie ``sudo`` auf diesem Geraet aufzurufen ist. + + Rueckgabe: ``(praefix, stdin)`` -- entweder ``sudo -n`` ohne Eingabe oder + ``sudo -S`` mit dem Passwort auf stdin. + + Die Reihenfolge ist Absicht: geht es ohne Passwort (sudoers-Regel mit + NOPASSWD), verlaesst das Passwort den Server gar nicht erst. Erst wenn + ``sudo`` eines verlangt, wird das in den Zugangsdaten hinterlegte + weitergereicht -- ueber stdin der bereits verschluesselten Verbindung. + """ + swallowed: list[str] = [] + try: + status = _run_remote_command( + transport, f"{SUDO_PASSWORDLESS} true", timeout=20, on_line=swallowed.append + ) + except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort + status = 1 + if status == 0: + on_line("sudo ist ohne Passwort moeglich.") + return SUDO_PASSWORDLESS, "" + if not password: + on_line( + "sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt." + ) + return SUDO_PASSWORDLESS, "" + on_line("sudo verlangt ein Passwort -- es wird aus den Zugangsdaten uebergeben.") + return SUDO_WITH_PASSWORD, password + "\n" + + +def run_update( + database: Any, + *, + job_id: str, + device: dict[str, Any], + username: str, + password: str, + timeout: int, +) -> None: + """Fuehrt die Paketaktualisierung aus. Laeuft in einem eigenen Thread.""" + name = str(device.get("name") or device.get("ip")) + + def emit(line: str) -> None: + stamp = datetime.now().strftime("%H:%M:%S") + update_job(database, job_id, append_output=f"{stamp} {line}\n") + + update_job(database, job_id, state="running", progress=5, message="Verbindung wird aufgebaut") + try: + with database.session() as conn: + transport = open_transport( + conn, + host=str(device["ip"]), + port=int(device.get("ssh_port") or 22), + username=username, + password=password, + ) + except SshError as exc: + emit(f"FEHLER: {exc}") + update_job(database, job_id, state="failed", message=str(exc), finished=True) + return + + failed = False + problem = "" + sudo, sudo_stdin = _sudo_mode(transport, password, emit) + try: + total = len(UPDATE_COMMANDS) + for index, (label, template) in enumerate(UPDATE_COMMANDS, start=1): + update_job( + database, + job_id, + progress=int(5 + (index - 1) / total * 90), + message=f"Schritt {index}/{total}: {label}", + ) + emit(f"--- {label} ---") + collected: list[str] = [] + + def capture(line: str) -> None: + # Fuer die Fehlersuche wird alles gesammelt, ins Protokoll + # kommt nur, was etwas aussagt. + collected.append(line) + if not is_noise(line): + emit(line) + + command = template.format(sudo=sudo) + status = _run_remote_command( + transport, command, timeout=timeout, on_line=capture, stdin_text=sudo_stdin + ) + if status != 0: + failed = True + emit(f"Schritt {label!r} endete mit Rueckgabewert {status}.") + problem = hint_for("\n".join(collected)) + if problem: + emit("") + emit(f"Hinweis: {problem}") + break + finally: + try: + transport.close() + except Exception: # noqa: BLE001 + pass + + emit(COMPLETION_MARKER) + update_job( + database, + job_id, + state="failed" if failed else "succeeded", + progress=100, + message=( + (problem or "Ein Schritt ist fehlgeschlagen.") + if failed + else "Aktualisierung abgeschlossen." + )[:500], + finished=True, + ) + with database.session() as conn, database.transaction(conn): + audit.log( + conn, + actor="system", + action="maintenance.update_finished", + target=name, + detail="fehlgeschlagen" if failed else "erfolgreich", + severity="warning" if failed else "notice", + ) + + +def _reboot_windows( + database: Any, + *, + job_id: str, + device: dict[str, Any], + username: str, + password: str, + actor: str, + emit: Callable[[str], None], + elapsed: Callable[[], int], +) -> bool: + """Neustart eines Windows-Geraets ueber RPC. ``True`` = weiter warten. + + Getrennt gehalten, weil hier fast nichts gemeinsam ist: keine + SSH-Verbindung, kein Host-Schluessel, kein sudo. Nur das Warten auf die + Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer. + """ + host = str(device["ip"]) + emit(f"Windows-Geraet -- Neustart ueber RPC an {host} (Port {WINDOWS_PROBE_PORT}).") + result = windowsops.reboot(host, username, password) + + for line in result.output.splitlines(): + if not is_noise(line): + emit(line) + if result.ok: + return True + + detail = result.hint or result.output or "Der Neustart wurde abgewiesen." + if result.hint: + emit("") + emit(f"Hinweis: {result.hint}") + update_job(database, job_id, state="failed", progress=100, message=detail[:500], finished=True) + _log_restart( + database, + device, + result="error", + actor=actor, + duration_ms=elapsed(), + detail=detail, + method=METHOD_RPC, + ) + return False + + +def run_reboot( + database: Any, + *, + job_id: str, + device: dict[str, Any], + username: str, + password: str, + wait_seconds: int, + actor: str = "system", +) -> None: + """Loest einen Neustart aus und wartet auf die Rueckkehr. + + Der Verbindungsabbruch direkt nach dem Kommando ist der **erwartete** Fall + und gilt als Erfolg. Nur ein Fehler *vor* dem Kommando (Anmeldung, + Erreichbarkeit) ist ein echter Fehlschlag. + """ + name = str(device.get("name") or device.get("ip")) + started = time.monotonic() + + def emit(line: str) -> None: + stamp = datetime.now().strftime("%H:%M:%S") + update_job(database, job_id, append_output=f"{stamp} {line}\n") + + def elapsed() -> int: + return int((time.monotonic() - started) * 1000) + + host = str(device["ip"]) + is_windows = str(device.get("category") or "") == WINDOWS_CATEGORY + # Woran erkennt man, dass das Geraet zurueck ist? Am selben Dienst, ueber + # den der Neustart lief: SSH bei Linux, SMB/RPC bei Windows. + probe_port = WINDOWS_PROBE_PORT if is_windows else int(device.get("ssh_port") or 22) + + update_job(database, job_id, state="running", progress=5, message="Verbindung wird aufgebaut") + + collected: list[str] = [] + + def capture(line: str) -> None: + collected.append(line) + if not is_noise(line): + emit(line) + + if is_windows: + # Windows wird nicht ueber SSH bedient -- kein Transport, kein + # Host-Schluessel, kein sudo. + if not _reboot_windows( + database, + job_id=job_id, + device=device, + username=username, + password=password, + actor=actor, + emit=emit, + elapsed=elapsed, + ): + return + else: + try: + with database.session() as conn: + transport = open_transport( + conn, + host=host, + port=int(device.get("ssh_port") or 22), + username=username, + password=password, + ) + except SshError as exc: + emit(f"FEHLER: {exc}") + update_job(database, job_id, state="failed", message=str(exc), finished=True) + _log_restart( + database, + device, + result="error", + actor=actor, + duration_ms=elapsed(), + detail=str(exc), + ) + return + + sudo, sudo_stdin = _sudo_mode(transport, password, emit) + command = REBOOT_COMMAND.format(sudo=sudo) + try: + emit(f"Neustart wird ausgeloest ({command})") + try: + _run_remote_command( + transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin + ) + except Exception as exc: # noqa: BLE001 - Abbruch ist hier normal + emit(f"Verbindung getrennt ({exc}) -- das ist beim Neustart zu erwarten.") + finally: + try: + transport.close() + except Exception: # noqa: BLE001 + pass + + # Scheitert schon das Kommando, geht das Geraet gar nicht erst herunter. + # Dann waere die Wartezeit reine Zeitverschwendung -- und die Meldung + # "nicht zurueckgekehrt" waere irrefuehrend: es war nie weg. + problem = hint_for("\n".join(collected)) + if problem: + emit("") + emit(f"Hinweis: {problem}") + update_job( + database, + job_id, + state="failed", + progress=100, + message=problem[:500], + finished=True, + ) + _log_restart( + database, + device, + result="error", + actor=actor, + duration_ms=elapsed(), + detail=problem, + ) + return + + update_job(database, job_id, progress=40, message="Warte auf Rueckkehr des Geraets") + time.sleep(REBOOT_GRACE_SECONDS) + deadline = time.monotonic() + wait_seconds + ssh_port = probe_port + emit(f"Warte darauf, dass {host}:{ssh_port} wieder antwortet.") + while time.monotonic() < deadline: + # TCP statt ICMP: der Webprozess laeuft ohne CAP_NET_RAW, dort schlaegt + # jeder Ping fehl -- der Auftrag lief deshalb immer in die + # Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist + # "SSH antwortet wieder" die Aussage, auf die es hier ankommt. + if tcp_reachable(host, ssh_port): + emit(f"Geraet ist wieder erreichbar (Port {ssh_port} antwortet).") + update_job( + database, + job_id, + state="succeeded", + progress=100, + message="Neustart abgeschlossen.", + finished=True, + ) + with database.session() as conn, database.transaction(conn): + audit.log( + conn, + actor="system", + action="maintenance.reboot_finished", + target=name, + detail="erfolgreich", + severity="notice", + ) + _log_restart( + database, + device, + result="ok", + actor=actor, + duration_ms=elapsed(), + detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.", + ) + return + time.sleep(REBOOT_POLL_SECONDS) + + emit(f"{host}:{ssh_port} hat innerhalb der Wartezeit nicht wieder geantwortet.") + update_job( + database, + job_id, + state="failed", + progress=100, + message=f"Geraet nicht zurueckgekehrt ({host}:{ssh_port} antwortet nicht).", + finished=True, + ) + _log_restart( + database, + device, + result="error", + actor=actor, + duration_ms=elapsed(), + detail=( + f"Neustart ausgeloest, aber {host}:{ssh_port} hat innerhalb von " + f"{wait_seconds}s nicht wieder geantwortet." + ), + ) + + +def _log_restart( + database: Any, + device: dict[str, Any], + *, + result: str, + actor: str, + duration_ms: int, + detail: str, + method: str = METHOD_SSH, +) -> None: + """Traegt den Neustart in die geraeteweite Neustart-Historie ein. + + Der Wartungsauftrag steht ohnehin in ``jobs`` -- aber wer wissen will, + warum ein *Geraet* weg war, sieht in dessen Neustart-Historie nach, nicht + in einer Auftragsliste. Vorher tauchten dort nur PoE-Neustarts auf. + """ + device_id = device.get("id") + if not device_id: + return + try: + with database.session() as conn, database.transaction(conn): + record_restart( + conn, + device_id=int(device_id), + action="reboot", + result=result, + trigger="maintenance", + actor=actor, + duration_ms=duration_ms, + detail=detail, + method=method, + ) + except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen + logger.exception("Neustart konnte nicht protokolliert werden (Geraet %s)", device_id) + + +def start_background(target: Callable[..., None], **kwargs: Any) -> None: + thread = threading.Thread(target=target, kwargs=kwargs, daemon=True) + thread.start() diff --git a/apps/tesm/src/tesm/services/monitor.py b/apps/tesm/src/tesm/services/monitor.py new file mode 100644 index 0000000..0fa669b --- /dev/null +++ b/apps/tesm/src/tesm/services/monitor.py @@ -0,0 +1,595 @@ +"""Erreichbarkeitspruefung und automatischer PoE-Neustart. + +Ersetzt drei Bausteine des Vorgaengers durch einen: ``poe.sh`` (Bash/expect), +``poe_wrapper.py`` und ``generate_ips.py``. Vorteile, die daraus folgen: + +* **Ein Prozess, eine Sprache.** Kein Datenaustausch mehr ueber eine + pipe-getrennte Textzeile pro Geraet, kein zweiter Interpreter, keine + Passwoerter in Kommandozeilen. +* **Echte Nebenlaeufigkeit mit Grenze.** Der Vorgaenger startete pro Geraet eine + Hintergrund-Subshell -- bei vielen Geraeten ein Fork-Sturm. Hier laeuft ein + Thread-Pool mit fester Obergrenze. +* **Jeder Vorgang ist nachvollziehbar.** Erreichbarkeit landet in + ``device_status``, jeder PoE-Vorgang mit Ergebnis und Transkript in + ``restart_events``. +* **Zustandswechsel statt Dauerrauschen.** Ins laufende Protokoll wird + geschrieben, wenn sich etwas *aendert* -- nicht bei jedem Durchlauf jede Zeile. + +Beibehalten wird die Lizenzregel des Vorgaengers: der automatische Neustart ist +lizenzpflichtig, der manuelle Knopf im Dashboard nicht. Und: das Recht +``devices.execute`` gilt nur fuer den manuellen Knopf -- der automatische Lauf +gehoert dem System. +""" + +from __future__ import annotations + +import logging +import platform +import re +import shutil +import sqlite3 +import subprocess +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +from tesm_core import audit +from tesm_core.db import Database + +from . import inventory +from .ssh import SshError +from .switchcli import SwitchCli + +logger = logging.getLogger(__name__) + +DEFAULT_INTERVAL_SECONDS = 300 +PING_TIMEOUT_SECONDS = 2 +MAX_PARALLEL_CHECKS = 24 +#: Ab wie vielen aufeinanderfolgenden Fehlschlaegen ein Neustart ausgeloest wird. +DEFAULT_FAILURES_BEFORE_RESTART = 2 +#: Mindestabstand zwischen zwei automatischen Neustarts desselben Geraets. +RESTART_COOLDOWN_SECONDS = 900 + +_LATENCY_RE = re.compile(r"time[=<]\s*([\d.]+)\s*ms", re.IGNORECASE) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +@dataclass(frozen=True, slots=True) +class PingResult: + ok: bool + latency_ms: float | None = None + detail: str = "" + + +def tcp_reachable(host: str, port: int, *, timeout: float = 3.0) -> bool: + """Nimmt der Host auf diesem Port eine Verbindung an? + + Braucht -- anders als ICMP -- keinerlei Sonderrechte. Der Webprozess laeuft + mit leerem ``CapabilityBoundingSet``; damit greift die Datei-Capability von + ``/usr/bin/ping`` nicht mehr (``NoNewPrivileges`` schaltet sie ab) und jeder + Ping schlaegt dort fehl. Ein Wartungsauftrag, der auf die Rueckkehr eines + Geraets wartet, wuerde dadurch **immer** in die Zeitueberschreitung laufen, + obwohl das Geraet laengst wieder da ist -- genau so beobachtet. + + Fuer die Wartung ist der TCP-Test ohnehin der treffendere: sie will nicht + wissen, ob der Kernel wieder pingbar ist, sondern ob SSH wieder antwortet. + """ + import socket + + try: + with socket.create_connection((host, int(port)), timeout=timeout): + return True + except OSError: + return False + + +def ping(host: str, *, timeout: int = PING_TIMEOUT_SECONDS) -> PingResult: + """Ein einzelner ICMP-Versuch, wie im Vorgaenger (kein Retry). + + Ein Retry hier wuerde nur den Ausfall verschleiern; die Entscheidung + "Neustart oder nicht" faellt ueber mehrere *Durchlaeufe*, nicht ueber + mehrere Pakete. + """ + binary = shutil.which("ping") + if not binary: + return PingResult(False, detail="ping ist nicht installiert") + if platform.system() == "Windows": + command = [binary, "-n", "1", "-w", str(timeout * 1000), host] + else: + command = [binary, "-c", "1", "-W", str(timeout), host] + try: + completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell + command, capture_output=True, text=True, timeout=timeout + 3, check=False + ) + except subprocess.TimeoutExpired: + return PingResult(False, detail="Zeitlimit ueberschritten") + except OSError as exc: + return PingResult(False, detail=str(exc)) + + if completed.returncode != 0: + return PingResult(False, detail="keine Antwort") + match = _LATENCY_RE.search(completed.stdout) + latency = float(match.group(1)) if match else None + return PingResult(True, latency_ms=latency) + + +# --------------------------------------------------------------------------- # +# Laufendes Protokoll +# --------------------------------------------------------------------------- # + + +class LiveLog: + """Schreibt das laufende Protokoll. + + Eine Datei, fortlaufend beschrieben; Rotation macht ``logrotate`` mit + ``copytruncate`` (keiner der schreibenden Prozesse kann ein Reopen-Signal + verarbeiten). Zeilen sind bewusst maschinenlesbar aufgebaut: + ``ZEIT EBENE BEREICH MELDUNG``. + """ + + def __init__(self, path: Path) -> None: + self.path = Path(path) + self._lock = threading.Lock() + + def write(self, level: str, scope: str, message: str) -> None: + line = f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {level:<7} {scope:<10} {message}\n" + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + with self._lock, self.path.open("a", encoding="utf-8", newline="\n") as stream: + stream.write(line) + except OSError as exc: # pragma: no cover - vollgelaufene Platte o. Ae. + logger.warning("Konnte nicht ins laufende Protokoll schreiben: %s", exc) + + def info(self, scope: str, message: str) -> None: + self.write("INFO", scope, message) + + def warning(self, scope: str, message: str) -> None: + self.write("WARNUNG", scope, message) + + def error(self, scope: str, message: str) -> None: + self.write("FEHLER", scope, message) + + def tail(self, lines: int = 400, *, needle: str = "") -> list[str]: + """Liest die letzten Zeilen -- ohne die ganze Datei in den Speicher zu holen.""" + if not self.path.is_file(): + return [] + try: + size = self.path.stat().st_size + block = min(size, max(8192, lines * 200)) + with self.path.open("rb") as stream: + stream.seek(max(0, size - block)) + content = stream.read().decode("utf-8", errors="replace") + except OSError: + return [] + result = content.splitlines() + if needle: + lowered = needle.lower() + result = [line for line in result if lowered in line.lower()] + return result[-lines:] + + +# --------------------------------------------------------------------------- # +# Geraetepruefung +# --------------------------------------------------------------------------- # + + +def load_monitored_devices(conn: sqlite3.Connection) -> list[dict[str, Any]]: + """Alle aktiven Geraete mit ihren Switch- und Zugangsdaten.""" + return Database.all( + conn, + """ + SELECT d.id, d.mac, d.ip, d.name, d.port, d.auto_restart, d.switch_id, + s.hostname AS switch_hostname, s.ip AS switch_ip, s.ssh_port AS switch_ssh_port, + s.credential_id AS switch_credential_id, + st.state, st.fail_count, st.last_restart_at + FROM devices d + LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL + LEFT JOIN device_status st ON st.device_id = d.id + WHERE d.deleted_at IS NULL AND d.is_active = 1 + ORDER BY d.name COLLATE NOCASE + """, + ) + + +def _within_restart_grace(row: Any) -> bool: + """Liegt der letzte Neustart noch im Kulanzfenster?""" + if not row: + return False + moment = (row or {}).get("last_restart_at") + if not moment: + return False + try: + stamp = datetime.fromisoformat(str(moment)) + except ValueError: + return False + if stamp.tzinfo is None: + stamp = stamp.replace(tzinfo=timezone.utc) + return (datetime.now(timezone.utc) - stamp).total_seconds() < RESTART_GRACE_SECONDS + + +def record_status( + conn: sqlite3.Connection, device_id: int, result: PingResult +) -> tuple[str, str]: + """Schreibt den Zustand fort. Rueckgabe: ``(vorher, nachher)``.""" + row = Database.one( + conn, + "SELECT state, fail_count, last_restart_at FROM device_status WHERE device_id=?", + (device_id,), + ) + previous = str(row["state"]) if row else "unknown" + new_state = "online" if result.ok else "offline" + # Ein Geraet, das gerade neu startet, ist nicht "nicht erreichbar" -- es ist + # erwartbar kurz weg. Erst wenn es nach dem Kulanzfenster noch schweigt, + # wird daraus ein Ausfall. + if not result.ok and previous == STATE_RESTARTING and _within_restart_grace(row): + new_state = STATE_RESTARTING + fail_count = 0 if result.ok else int((row or {}).get("fail_count") or 0) + 1 + changed = previous != new_state + + conn.execute( + """ + INSERT INTO device_status (device_id, state, latency_ms, fail_count, last_check_at, + last_ok_at, last_change_at, detail) + VALUES (?,?,?,?,?,?,?,?) + ON CONFLICT(device_id) DO UPDATE SET + state=excluded.state, + latency_ms=excluded.latency_ms, + fail_count=excluded.fail_count, + last_check_at=excluded.last_check_at, + last_ok_at=COALESCE(excluded.last_ok_at, device_status.last_ok_at), + last_change_at=CASE WHEN device_status.state <> excluded.state + THEN excluded.last_change_at ELSE device_status.last_change_at END, + detail=excluded.detail + """, + ( + device_id, + new_state, + result.latency_ms, + fail_count, + _now(), + _now() if result.ok else None, + _now(), + result.detail, + ), + ) + return previous, new_state if changed else previous + + +#: Wie ein Geraet neu gestartet wurde. +METHOD_POE = "poe" +METHOD_SSH = "ssh" +METHOD_RPC = "rpc" +METHOD_LABELS = { + METHOD_POE: "PoE-Port", + METHOD_SSH: "Neustart ueber SSH", + METHOD_RPC: "Neustart ueber RPC", +} + +#: Zustand zwischen ausgeloestem Neustart und Rueckkehr. +#: +#: Ohne ihn blieb die Kachel auf "online" stehen: der Zustand wechselt erst +#: beim naechsten Pruefdurchlauf, und der laeuft standardmaessig alle fuenf +#: Minuten -- ein Neustart ueber SSH ist in vierzig Sekunden durch. Das Geraet +#: war also weg und wieder da, ohne dass es je jemand gesehen haette. +STATE_RESTARTING = "restarting" + +#: Wie lange ein Geraet als "startet neu" gilt, wenn es nicht antwortet. +#: Danach ist es schlicht nicht erreichbar -- ein Neustart, der zehn Minuten +#: dauert, ist keiner mehr. +RESTART_GRACE_SECONDS = 300 + + +def record_restart( + conn: sqlite3.Connection, + *, + device_id: int, + action: str, + result: str, + trigger: str, + actor: str, + duration_ms: int, + detail: str, + method: str = METHOD_POE, +) -> None: + """Schreibt einen Neustart ins Protokoll -- unabhaengig davon, wie er lief. + + PoE-Portzyklus und ``reboot`` ueber SSH sind fuer den Betrieb dasselbe + Ereignis. Sie landen deshalb in derselben Tabelle und unterscheiden sich + nur in ``method``. Vorher gab es nur die PoE-Haelfte, und ein per Wartung + neu gestartetes Geraet tauchte in der Neustart-Historie ueberhaupt nicht + auf. + """ + conn.execute( + "INSERT INTO restart_events " + "(device_id, ts, trigger, actor, action, result, duration_ms, detail, method) " + "VALUES (?,?,?,?,?,?,?,?,?)", + (device_id, _now(), trigger, actor, action, result, duration_ms, detail[:8000], method), + ) + if result == "ok": + # Der Zustand wird hier gesetzt, nicht in den einzelnen Neustartwegen: + # PoE, SSH und RPC laufen alle durch diese Funktion. Eine zweite Stelle + # waere eine, die beim naechsten Verfahren vergessen wird. + conn.execute( + "UPDATE device_status SET last_restart_at=?, restart_count=restart_count+1, " + "state=?, last_change_at=?, fail_count=0 WHERE device_id=?", + (_now(), STATE_RESTARTING, _now(), device_id), + ) + + +# --------------------------------------------------------------------------- # +# PoE-Neustart +# --------------------------------------------------------------------------- # + + +def restart_device_poe( + conn: sqlite3.Connection, + device: dict[str, Any], + *, + keystore: Any, + trigger: str, + actor: str, + live: LiveLog | None = None, + database: Any = None, +) -> dict[str, Any]: + """Fuehrt einen PoE-Neustart durch und protokolliert ihn. + + Rueckgabe: ``{"ok": bool, "message": str, "transcript": str}``. + + ``database`` bestimmt, wie geschrieben wird. Wird es uebergeben, oeffnet + jede Schreiboperation ihre **eigene kurze** Transaktion -- die SSH-Sitzung + zum Switch laeuft dann ausserhalb. Das ist fuer den Ueberwachungsdienst + entscheidend: ein Durchlauf mit drei nicht erreichbaren Switchen dauert + knapp 40 Sekunden, und solange lag die Schreibsperre auf der gesamten + Datenbank. Die Oberflaeche antwortete in dieser Zeit mit "database is + locked". + + Ohne ``database`` (aus einem Request heraus, wo ohnehin schon eine kurze + Transaktion offen ist) wird direkt auf ``conn`` geschrieben. + """ + from contextlib import nullcontext + + def write(): # type: ignore[no-untyped-def] + return database.transaction(conn) if database is not None else nullcontext() + + name = str(device.get("name") or device.get("mac") or device.get("id")) + port = str(device.get("port") or "") + switch_ip = str(device.get("switch_ip") or "") + credential_id = device.get("switch_credential_id") + + if not port or not switch_ip: + message = ( + "Kein Switch oder kein Port zugeordnet -- ein PoE-Neustart ist nicht moeglich." + ) + with write(): + record_restart( + conn, + device_id=int(device["id"]), + action="cycle", + result="skipped", + trigger=trigger, + actor=actor, + duration_ms=0, + detail=message, + ) + return {"ok": False, "message": message, "transcript": ""} + + if not credential_id: + message = f"Fuer Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt." + with write(): + record_restart( + conn, + device_id=int(device["id"]), + action="cycle", + result="error", + trigger=trigger, + actor=actor, + duration_ms=0, + detail=message, + ) + return {"ok": False, "message": message, "transcript": ""} + + # Bewusst kein Rueckruf: der wuerde die vorhandene Verbindung nicht kennen + # und sich eine neue oeffnen -- mitten in dieser Schreibtransaktion. Genau + # daran ist der Ueberwachungsdienst gestorben. + username, password = inventory.credential_secret(conn, keystore, int(credential_id)) + started = time.monotonic() + try: + with SwitchCli( + conn, + host=switch_ip, + port=int(device.get("switch_ssh_port") or 22), + username=username, + password=password, + ) as cli: + outcome = cli.cycle_poe(port) + except SshError as exc: + duration = int((time.monotonic() - started) * 1000) + with write(): + record_restart( + conn, + device_id=int(device["id"]), + action="cycle", + result="error", + trigger=trigger, + actor=actor, + duration_ms=duration, + detail=str(exc), + ) + if live: + live.error("poe", f"{name}: {exc}") + return {"ok": False, "message": str(exc), "transcript": ""} + + duration = int((time.monotonic() - started) * 1000) + with write(): + record_restart( + conn, + device_id=int(device["id"]), + action="cycle", + result="ok" if outcome.ok else "error", + trigger=trigger, + actor=actor, + duration_ms=duration, + detail=(outcome.error or "; ".join(outcome.steps))[:8000], + ) + if live: + if outcome.ok: + live.info("poe", f"{name}: PoE-Neustart auf {device.get('switch_hostname')} {port} erfolgreich") + else: + live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}") + return { + "ok": outcome.ok, + "message": outcome.error or "PoE-Neustart durchgefuehrt.", + "transcript": outcome.transcript, + } + + +# --------------------------------------------------------------------------- # +# Durchlauf +# --------------------------------------------------------------------------- # + + +@dataclass(slots=True) +class SweepReport: + checked: int = 0 + online: int = 0 + offline: int = 0 + restarts_triggered: int = 0 + restarts_ok: int = 0 + skipped_unlicensed: int = 0 + duration_ms: int = 0 + + +def run_sweep( + *, + database: Any, + live: LiveLog, + keystore: Any, + license_allows_auto_restart: bool, + failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART, +) -> SweepReport: + """Ein vollstaendiger Ueberwachungsdurchlauf. + + Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge + danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und + keine halb geschriebenen Durchlaeufe. + """ + report = SweepReport() + started = time.monotonic() + + with database.session() as conn: + devices = load_monitored_devices(conn) + + if not devices: + report.duration_ms = int((time.monotonic() - started) * 1000) + return report + + with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool: + results = list(pool.map(lambda device: (device, ping(str(device["ip"]))), devices)) + + restart_candidates: list[dict[str, Any]] = [] + with database.session() as conn: + with database.transaction(conn): + for device, result in results: + report.checked += 1 + previous, current = record_status(conn, int(device["id"]), result) + if result.ok: + report.online += 1 + else: + report.offline += 1 + + if previous != current: + name = str(device["name"]) + if current == "online": + live.info("ping", f"{name} ({device['ip']}) ist wieder erreichbar") + else: + live.warning("ping", f"{name} ({device['ip']}) antwortet nicht") + + if result.ok or not int(device.get("auto_restart") or 0): + continue + fail_count = int((device.get("fail_count") or 0)) + 1 + if fail_count < failures_before_restart: + continue + if _in_cooldown(device.get("last_restart_at")): + continue + restart_candidates.append(device) + + for device in restart_candidates: + name = str(device["name"]) + if not license_allows_auto_restart: + report.skipped_unlicensed += 1 + live.warning( + "poe", + f"{name}: automatischer PoE-Neustart uebersprungen -- keine gueltige Lizenz.", + ) + continue + report.restarts_triggered += 1 + # Bewusst **keine** umschliessende Transaktion: der Neustart oeffnet eine + # SSH-Sitzung zum Switch, und die dauert bei einem nicht erreichbaren + # Geraet zehn Sekunden und mehr. Lag die Schreibsperre solange auf der + # Datenbank, antwortete die Oberflaeche mit "database is locked". Die + # Aufzeichnung schreibt jetzt in eigenen, kurzen Transaktionen. + with database.session() as conn: + outcome = restart_device_poe( + conn, + device, + keystore=keystore, + trigger="auto", + actor="system", + live=live, + database=database, + ) + with database.transaction(conn): + audit.log( + conn, + actor="system", + action="device.poe_auto_restart", + target=name, + detail=outcome["message"], + severity="notice" if outcome["ok"] else "warning", + ) + if outcome["ok"]: + report.restarts_ok += 1 + + report.duration_ms = int((time.monotonic() - started) * 1000) + return report + + +def _in_cooldown(last_restart_at: Any) -> bool: + if not last_restart_at: + return False + try: + moment = datetime.fromisoformat(str(last_restart_at)) + except ValueError: + return False + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return (datetime.now(timezone.utc) - moment).total_seconds() < RESTART_COOLDOWN_SECONDS + + +def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]: + row = Database.one( + conn, + """ + SELECT + COUNT(*) AS total, + SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled, + SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='online' THEN 1 ELSE 0 END) AS online, + SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='offline' THEN 1 ELSE 0 END) AS offline, + SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='unknown' THEN 1 ELSE 0 END) AS unknown, + SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting + FROM devices d LEFT JOIN device_status st ON st.device_id = d.id + WHERE d.deleted_at IS NULL + """, + ) + row = row or {} + return { + key: int(row.get(key) or 0) + for key in ("total", "online", "offline", "unknown", "disabled", "restarting") + } diff --git a/apps/tesm/src/tesm/services/restart.py b/apps/tesm/src/tesm/services/restart.py new file mode 100644 index 0000000..83a7ec6 --- /dev/null +++ b/apps/tesm/src/tesm/services/restart.py @@ -0,0 +1,117 @@ +"""Welche Neustartwege ein Geraet hat -- und welcher davon der beste ist. + +Ein Geraet kann auf mehreren Wegen neu startbar sein: ueber den PoE-Port des +Switches, ueber SSH, oder bei Windows ueber RPC. Bisher gab es nur den ersten, +und der Knopf verschwand einfach, wenn Switch oder Port fehlten -- ohne zu +sagen, dass ein anderer Weg offen gestanden haette. + +Die Rangfolge ist bewusst festgelegt und nicht dem Zufall ueberlassen: + +1. **PoE-Port.** Schaltet die Stromversorgung. Wirkt auch dann noch, wenn das + Betriebssystem nicht mehr antwortet -- und genau darum geht es meistens, + wenn jemand einen Neustart ausloest. +2. **SSH** (``reboot``). Sauberes Herunterfahren, setzt aber ein Geraet + voraus, das noch reagiert. +3. **RPC** (Windows). Wie SSH, nur ueber einen anderen Weg. + +Stehen mehrere Wege offen, ist PoE vorausgewaehlt; die Oberflaeche laesst die +Wahl trotzdem zu. Ein sauberes Herunterfahren ist manchmal das Richtige -- +etwa bei einem Geraet, das schreibend auf eine Platte zugreift. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +METHOD_POE = "poe" +METHOD_SSH = "ssh" +METHOD_RPC = "rpc" + +#: Rangfolge -- der erste verfuegbare Weg ist die Vorauswahl. +PREFERENCE: tuple[str, ...] = (METHOD_POE, METHOD_SSH, METHOD_RPC) + + +@dataclass(frozen=True, slots=True) +class Method: + key: str + label: str + hint: str + #: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"? + licensed: bool = False + + @property + def is_poe(self) -> bool: + return self.key == METHOD_POE + + +DEFINITIONS: dict[str, Method] = { + METHOD_POE: Method( + key=METHOD_POE, + label="PoE-Port", + hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Geraet haengt.", + ), + METHOD_SSH: Method( + key=METHOD_SSH, + label="Neustart ueber SSH", + hint="Faehrt das Betriebssystem sauber herunter. Setzt ein antwortendes Geraet " + "und einen freigegebenen Host-Schluessel voraus.", + licensed=True, + ), + METHOD_RPC: Method( + key=METHOD_RPC, + label="Neustart ueber RPC", + hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.", + licensed=True, + ), +} + + +def available(device: Any, *, maintenance_active: bool = True) -> list[Method]: + """Alle Wege, die bei diesem Geraet tatsaechlich zu etwas fuehren. + + Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt + als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist + schlimmer als kein Knopf. + """ + + def field(name: str) -> Any: + try: + return device[name] + except (KeyError, IndexError, TypeError): + return None + + methods: list[Method] = [] + if field("switch_id") and str(field("port") or "").strip(): + methods.append(DEFINITIONS[METHOD_POE]) + + category = str(field("credential_category") or "") + has_login = bool(field("credential_id")) + if has_login and maintenance_active: + if category == "windows": + methods.append(DEFINITIONS[METHOD_RPC]) + elif category == "linux": + methods.append(DEFINITIONS[METHOD_SSH]) + + return sorted(methods, key=lambda entry: PREFERENCE.index(entry.key)) + + +def preferred(device: Any, *, maintenance_active: bool = True) -> Method | None: + """Der Weg, der vorausgewaehlt wird -- oder ``None``, wenn es keinen gibt.""" + options = available(device, maintenance_active=maintenance_active) + return options[0] if options else None + + +def resolve(device: Any, requested: str, *, maintenance_active: bool = True) -> Method | None: + """Prueft eine Anforderung gegen das, was tatsaechlich moeglich ist. + + Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der + fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt -- + sonst startet jemand ein Geraet anders neu, als er dachte. + """ + options = {entry.key: entry for entry in available(device, maintenance_active=maintenance_active)} + if not options: + return None + if not requested: + return options[sorted(options, key=PREFERENCE.index)[0]] + return options.get(requested) diff --git a/apps/tesm/src/tesm/services/shellcli.py b/apps/tesm/src/tesm/services/shellcli.py new file mode 100644 index 0000000..b85d2c8 --- /dev/null +++ b/apps/tesm/src/tesm/services/shellcli.py @@ -0,0 +1,149 @@ +"""Zeilenweise Kommandoausfuehrung auf einem Linux-Client. + +Gegenstueck zu :mod:`switchcli`. Der Unterschied liegt im Verfahren, nicht in +der Bedienung: + +* ArubaOS braucht eine **interaktive Shell** mit PTY, und die Antwort endet + erst am Prompt -- ``switchcli`` muss den Prompt erkennen. +* Ein Linux-Client kann jedes Kommando in einer eigenen ``exec``-Sitzung + ausfuehren. Das ist deutlich robuster: kein Prompt-Raten, kein PTY, ein + sauberer Rueckgabewert je Kommando, und ein haengendes Kommando reisst die + Sitzung nicht mit. + +Warum ueberhaupt eine Konsole fuer Clients? Ohne sie gab es keinen Weg, einen +unbekannten Host-Schluessel im laufenden Betrieb zu pruefen und freizugeben -- +und ohne freigegebenen Schluessel verweigert die Wartung den Dienst. Genau in +diese Sackgasse laeuft man sonst: die Wartung sagt "Schluessel unbekannt", und +es gibt keine Stelle, an der man ihn ansehen koennte. + +Bewusst **keine** interaktive Shell fuer Clients: eine solche Sitzung braucht +einen dauerhaft blockierten Thread je Nutzer und einen Prompt-Parser, der bei +jedem abweichenden ``PS1`` bricht. Wer wirklich interaktiv arbeiten will, +nimmt einen SSH-Client -- dafuer ist er da. +""" + +from __future__ import annotations + +import sqlite3 +import time +from typing import Any + +from .ssh import SshError, open_transport + +DEFAULT_COMMAND_TIMEOUT = 60 +MAX_OUTPUT_BYTES = 256 * 1024 + + +class ShellSession: + """Offene SSH-Verbindung zu einem Client; ein ``exec``-Kanal je Kommando.""" + + def __init__( + self, + conn: sqlite3.Connection, + *, + host: str, + port: int, + username: str, + password: str, + allow_unknown_host_key: bool = False, + ) -> None: + self.conn = conn + self.host = host + self.port = int(port) + self.username = username + self.password = password + self.allow_unknown_host_key = allow_unknown_host_key + self._transport: Any = None + + def __enter__(self) -> "ShellSession": + self._transport = open_transport( + self.conn, + host=self.host, + port=self.port, + username=self.username, + password=self.password, + allow_unknown=self.allow_unknown_host_key, + ) + return self + + def __exit__(self, *_exc: object) -> None: + self.close() + + def close(self) -> None: + try: + if self._transport is not None: + self._transport.close() + except Exception: # noqa: BLE001 + pass + self._transport = None + + # ------------------------------------------------------------------ # + + def banner(self) -> str: + """Kurze Auskunft ueber das Gegenueber -- als erste Zeile der Sitzung.""" + try: + result = self.run("hostname; uname -sr; id -un", timeout=15) + except SshError: + return "" + return result["output"].strip() + + def run(self, command: str, *, timeout: int = DEFAULT_COMMAND_TIMEOUT) -> dict[str, Any]: + """Fuehrt ein Kommando aus und liefert Ausgabe samt Rueckgabewert. + + Rueckgabe: ``{"output": str, "status": int, "duration_ms": int}``. + """ + if self._transport is None or not self._transport.is_active(): + raise SshError("Die Verbindung wurde getrennt.") + + started = time.monotonic() + channel = self._transport.open_session() + channel.settimeout(timeout) + channel.set_combine_stderr(True) + try: + channel.exec_command(command) + except Exception as exc: # noqa: BLE001 + channel.close() + raise SshError(f"Das Kommando konnte nicht gestartet werden ({exc}).") from exc + + chunks: list[bytes] = [] + size = 0 + deadline = time.monotonic() + timeout + truncated = False + try: + while True: + if channel.recv_ready(): + chunk = channel.recv(32768) + if not chunk: + break + size += len(chunk) + if size > MAX_OUTPUT_BYTES: + truncated = True + break + chunks.append(chunk) + elif channel.exit_status_ready(): + # Rest abholen, bevor der Kanal zugeht. + while channel.recv_ready(): + chunks.append(channel.recv(32768)) + break + elif time.monotonic() > deadline: + channel.close() + raise SshError( + f"Das Kommando hat das Zeitlimit von {timeout}s ueberschritten." + ) + else: + time.sleep(0.05) + status = channel.recv_exit_status() if not truncated else -1 + finally: + try: + channel.close() + except Exception: # noqa: BLE001 + pass + + output = b"".join(chunks).decode("utf-8", errors="replace") + if truncated: + output += f"\n[Ausgabe nach {MAX_OUTPUT_BYTES // 1024} KB abgeschnitten]" + return { + "output": output, + "status": int(status), + "duration_ms": int((time.monotonic() - started) * 1000), + } diff --git a/apps/tesm/src/tesm/services/ssh.py b/apps/tesm/src/tesm/services/ssh.py new file mode 100644 index 0000000..ddaf015 --- /dev/null +++ b/apps/tesm/src/tesm/services/ssh.py @@ -0,0 +1,317 @@ +"""SSH-Grundlagen: Host-Schluessel-Pruefung und Verbindungsaufbau. + +Der Vorgaenger hatte hier zwei Verfahren nebeneinander: das Browser-Terminal +akzeptierte unbekannte Host-Schluessel interaktiv (``AutoAddPolicy``-artig), die +Wartung wies sie hart ab (``RejectPolicy``). Die Begruendung war richtig -- beim +Terminal sieht ein Mensch zu, bei einem Hintergrundjob nicht -- aber die +Umsetzung lag in einer Datei ``known_hosts``, die niemand einsehen konnte. + +Hier liegt die Vertrauensentscheidung in der Datenbank (``switch_host_keys`` -- +der Name stammt aus der Zeit, als es nur Switche gab; die Tabelle ist nach +Host und Port geschluesselt und gilt fuer Switche wie Clients gleichermassen): + +* **Unbekannter Schluessel**: Aufbau wird abgebrochen. Die Oberflaeche zeigt den + Fingerprint und erlaubt eine ausdrueckliche, protokollierte Freigabe. Kein + Hintergrundprozess kann diese Freigabe erteilen. +* **Geaenderter Schluessel**: immer ein Fehler, nie eine stille Uebernahme -- + das ist der Fall, der bei einem Man-in-the-Middle auftritt. +* Jede Freigabe steht mit Zeitpunkt und Benutzer im Aenderungsprotokoll. +""" + +from __future__ import annotations + +import base64 +import hashlib +import logging +import re +import socket +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +from tesm_core.db import Database + +logger = logging.getLogger(__name__) + +CONNECT_TIMEOUT = 12 +BANNER_TIMEOUT = 20 +AUTH_TIMEOUT = 20 + + +class SshError(Exception): + """Verbindungs- oder Anmeldefehler mit Klartextmeldung fuer die Oberflaeche.""" + + +class UnknownHostKey(SshError): + """Der Host-Schluessel ist unbekannt und muss freigegeben werden.""" + + def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None: + super().__init__( + f"Der SSH-Host-Schluessel von {host}:{port} ist unbekannt " + f"({key_type}, {fingerprint}). Bitte pruefen und ausdruecklich freigeben." + ) + self.host = host + self.port = port + self.key_type = key_type + self.fingerprint = fingerprint + + +class HostKeyChanged(SshError): + """Der Host-Schluessel weicht vom gespeicherten ab.""" + + def __init__(self, host: str, port: int, expected: str, received: str) -> None: + super().__init__( + f"Der SSH-Host-Schluessel von {host}:{port} hat sich geaendert " + f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. " + "Das kann ein Geraetetausch sein -- oder ein Angriff." + ) + self.host = host + self.port = port + self.expected = expected + self.received = received + + +#: Ein SHA256-Fingerprint in OpenSSH-Schreibweise: "SHA256:" plus 43 Zeichen +#: Base64 ohne Fuellzeichen (32 Byte Hash). +FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$") + + +def valid_fingerprint(value: str) -> bool: + """Prueft die Form eines Fingerprints, bevor er gespeichert wird. + + Vorher wurde nur auf das Praefix ``SHA256:`` geprueft -- ``SHA256:unsinn`` + kam damit durch. Ein abgeschnittener oder vertippter Wert laesst sich + danach von keinem echten Schluessel mehr treffen: die Anwendung meldet bei + jeder Verbindung "Schluessel geaendert" und der Verdacht faellt auf das + Geraet statt auf den Tippfehler. + """ + return bool(FINGERPRINT_RE.match((value or "").strip())) + + +def fingerprint_of(key: Any) -> str: + """OpenSSH-kompatibler SHA256-Fingerprint (``SHA256:...``).""" + digest = hashlib.sha256(key.asbytes()).digest() + return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=") + + +@dataclass(frozen=True, slots=True) +class HostKeyRecord: + host: str + port: int + key_type: str + fingerprint: str + + +def known_key( + conn: sqlite3.Connection, host: str, port: int, key_type: str +) -> HostKeyRecord | None: + row = Database.one( + conn, + "SELECT host, port, key_type, fingerprint FROM switch_host_keys " + "WHERE host=? AND port=? AND key_type=?", + (host, int(port), key_type), + ) + if row is None: + return None + return HostKeyRecord( + host=str(row["host"]), + port=int(row["port"]), + key_type=str(row["key_type"]), + fingerprint=str(row["fingerprint"]), + ) + + +def trust_key( # noqa: PLR0913 - benannte Argumente, bewusst explizit + conn: sqlite3.Connection, + *, + host: str, + port: int, + key_type: str, + fingerprint: str, + actor: str, +) -> None: + conn.execute( + "INSERT INTO switch_host_keys (host, port, key_type, fingerprint, added_at, added_by) " + "VALUES (?,?,?,?,?,?) ON CONFLICT(host, port, key_type) DO UPDATE SET " + "fingerprint=excluded.fingerprint, added_at=excluded.added_at, added_by=excluded.added_by", + ( + host, + int(port), + key_type, + fingerprint, + datetime.now(timezone.utc).isoformat(timespec="seconds"), + actor, + ), + ) + + +def forget_key(conn: sqlite3.Connection, host: str, port: int) -> None: + conn.execute("DELETE FROM switch_host_keys WHERE host=? AND port=?", (host, int(port))) + + +def all_known_keys(conn: sqlite3.Connection, host: str, port: int) -> list[HostKeyRecord]: + """Alle gespeicherten Schluessel eines Ziels, unabhaengig vom Typ.""" + return [ + HostKeyRecord( + host=str(row["host"]), + port=int(row["port"]), + key_type=str(row["key_type"]), + fingerprint=str(row["fingerprint"]), + ) + for row in Database.all( + conn, + "SELECT host, port, key_type, fingerprint FROM switch_host_keys " + "WHERE host=? AND port=? ORDER BY key_type", + (host, int(port)), + ) + ] + + +def is_trusted(conn: sqlite3.Connection, host: str, port: int) -> bool: + """Gibt es ueberhaupt einen freigegebenen Schluessel fuer dieses Ziel?""" + return bool(all_known_keys(conn, host, int(port))) + + +def describe_probe( + conn: sqlite3.Connection, record: HostKeyRecord +) -> tuple[str, str]: + """Formuliert das Ergebnis einer Schluesselpruefung als (Stufe, Text). + + Steht hier und nicht in den Blueprints, weil Switche und Clients denselben + Ablauf haben -- und weil die drei Faelle (unbekannt, abweichend, gleich) + ueberall gleich beurteilt werden muessen. Zwei Kopien dieser Logik waeren + zwei Gelegenheiten, den mittleren Fall zu verharmlosen. + """ + known = known_key(conn, record.host, record.port, record.key_type) + if known is None: + return ( + "info", + f"Host-Schluessel gelesen: {record.key_type} {record.fingerprint}. " + "Bitte gegen eine unabhaengige Quelle pruefen und dann freigeben.", + ) + if known.fingerprint != record.fingerprint: + return ( + "danger", + f"Achtung: der Schluessel weicht vom gespeicherten ab. Gespeichert " + f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein " + "Geraetetausch sein -- oder ein Angriff.", + ) + return ("success", "Der Host-Schluessel stimmt mit dem gespeicherten ueberein.") + + +def probe_host_key(host: str, port: int = 22, *, timeout: int = CONNECT_TIMEOUT) -> HostKeyRecord: + """Holt den Host-Schluessel, ohne sich anzumelden -- fuer den Freigabedialog.""" + import paramiko + + try: + sock = socket.create_connection((host, int(port)), timeout=timeout) + except OSError as exc: + raise SshError(f"{host}:{port} ist nicht erreichbar ({exc}).") from exc + transport = paramiko.Transport(sock) + try: + transport.start_client(timeout=timeout) + key = transport.get_remote_server_key() + return HostKeyRecord( + host=host, port=int(port), key_type=key.get_name(), fingerprint=fingerprint_of(key) + ) + except paramiko.SSHException as exc: + raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc + finally: + transport.close() + try: + sock.close() + except OSError: + pass + + +def open_transport( + conn: sqlite3.Connection, + *, + host: str, + port: int, + username: str, + password: str, + allow_unknown: bool = False, +) -> Any: + """Baut eine geprueft-vertrauenswuerdige SSH-Verbindung auf. + + Bewusst die Low-Level-``Transport``-API statt ``SSHClient.connect()``: nur so + laesst sich der Host-Schluessel *vor* der Passwortuebergabe pruefen -- und nur + so kann das Browser-Terminal eine Freigabe zwischen Handshake und Anmeldung + einschieben. + + Authentisierungsreihenfolge ``password`` vor ``keyboard-interactive``: + mehrere ArubaOS-Varianten bieten letzteres unzuverlaessig an; wer damit + beginnt, sieht nie einen Prompt und bekommt sofort "Zugangsdaten falsch". + """ + import paramiko + + try: + sock = socket.create_connection((host, int(port)), timeout=CONNECT_TIMEOUT) + except OSError as exc: + raise SshError(f"{host}:{port} ist nicht erreichbar ({exc}).") from exc + + transport = paramiko.Transport(sock) + transport.banner_timeout = BANNER_TIMEOUT + transport.auth_timeout = AUTH_TIMEOUT + + # Wenn fuer dieses Ziel schon Schluessel freigegeben sind, wird die + # Aushandlung auf genau diese Typen begrenzt -- so macht es OpenSSH mit + # known_hosts auch. + # + # Ohne diese Begrenzung entsteht ein Zustand, der niemandem zu erklaeren + # ist: die Oberflaeche zeigt "freigegeben", der Verbindungsversuch meldet + # trotzdem "Schluessel unbekannt". Grund waere schlicht, dass der Server + # einen anderen Typ angeboten hat (etwa ssh-rsa statt ssh-ed25519) als den + # freigegebenen. Der Fehler steckt dann nicht im Vertrauen, sondern in der + # Auswahl -- und die laesst sich hier treffen. + trusted_types = [record.key_type for record in all_known_keys(conn, host, int(port))] + if trusted_types: + options = transport.get_security_options() + supported = [name for name in options.key_types if name in trusted_types] + if supported: + options.key_types = tuple(supported) + + try: + transport.start_client(timeout=CONNECT_TIMEOUT) + except paramiko.SSHException as exc: + transport.close() + if trusted_types: + raise SshError( + f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). " + f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- " + "bietet das Geraet inzwischen einen anderen Schluesseltyp an, muss der " + "Schluessel neu ausgelesen und freigegeben werden." + ) from exc + raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc + + server_key = transport.get_remote_server_key() + key_type = server_key.get_name() + received = fingerprint_of(server_key) + record = known_key(conn, host, int(port), key_type) + + if record is None: + if not allow_unknown: + transport.close() + raise UnknownHostKey(host, int(port), key_type, received) + elif record.fingerprint != received: + transport.close() + raise HostKeyChanged(host, int(port), record.fingerprint, received) + + try: + transport.auth_password(username, password) + except paramiko.AuthenticationException: + try: + transport.auth_interactive_dumb(username, lambda *_: [password]) + except paramiko.SSHException as exc: + transport.close() + raise SshError("Die Zugangsdaten wurden abgewiesen.") from exc + except paramiko.SSHException as exc: + transport.close() + raise SshError(f"Anmeldung fehlgeschlagen ({exc}).") from exc + + if not transport.is_authenticated(): + transport.close() + raise SshError("Die Zugangsdaten wurden abgewiesen.") + return transport diff --git a/apps/tesm/src/tesm/services/switchcli.py b/apps/tesm/src/tesm/services/switchcli.py new file mode 100644 index 0000000..e95f141 --- /dev/null +++ b/apps/tesm/src/tesm/services/switchcli.py @@ -0,0 +1,255 @@ +"""ArubaOS-Kommandozeile: PoE schalten und Zustand auslesen. + +Der Vorgaenger steuerte den Switch ueber ein ``expect``-Skript. Das brachte drei +Probleme mit, die hier verschwinden: + +1. **Fehlererkennung war unmoeglich.** Ein Versuch, harte Zeitlimits pro + Schritt einzufuehren, war eine Regression: das Prompt-Muster erkannte nur den + Operator-Modus (``switch>``), nicht den privilegierten (``switch#``), den + manche Konten direkt nach der Anmeldung haben -- gueltige Anmeldungen wurden + als Fehlschlag gewertet. + Loesung hier: das Prompt-Muster erkennt **beide** Modi, und der Erfolg wird + nicht am Prompt gemessen, sondern am tatsaechlichen Zustand + (``show power-over-ethernet``). Damit braucht es kein Raten mehr. +2. **Passwoerter standen in der Prozessliste.** ``expect`` bekam sie als + Argument. Hier gehen sie nie durch eine Kommandozeile. +3. **Kein Transkript.** Jetzt liefert jeder Vorgang die vollstaendige Ausgabe + zurueck, die als Ereignis gespeichert wird. +""" + +from __future__ import annotations + +import logging +import re +import sqlite3 +import time +from dataclasses import dataclass, field +from typing import Any + +from .ssh import SshError, open_transport + +logger = logging.getLogger(__name__) + +#: Erkennt Operator- (``>``) und privilegierten Modus (``#``) sowie den +#: Konfigurationsmodus (``switch(config)#``). +PROMPT_RE = re.compile(rb"[\r\n][^\r\n]{0,80}?[>#]\s?$") +PAGER_RE = re.compile(rb"(--\s?MORE\s?--|Press any key to continue)", re.IGNORECASE) +ERROR_RE = re.compile( + r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE +) + +READ_CHUNK = 65536 +DEFAULT_COMMAND_TIMEOUT = 20 +POE_SETTLE_SECONDS = 2 + + +@dataclass(slots=True) +class CliResult: + ok: bool + transcript: str = "" + error: str = "" + steps: list[str] = field(default_factory=list) + + def add(self, message: str) -> None: + self.steps.append(message) + + +class SwitchCli: + """Interaktive CLI-Sitzung auf einem ArubaOS-Switch.""" + + def __init__( + self, + conn: sqlite3.Connection, + *, + host: str, + port: int, + username: str, + password: str, + allow_unknown_host_key: bool = False, + ) -> None: + self.conn = conn + self.host = host + self.port = int(port) + self.username = username + self.password = password + self.allow_unknown_host_key = allow_unknown_host_key + self._transport: Any = None + self._channel: Any = None + self._buffer = b"" + self.transcript: list[str] = [] + + # ------------------------------------------------------------------ # + + def __enter__(self) -> "SwitchCli": + self._transport = open_transport( + self.conn, + host=self.host, + port=self.port, + username=self.username, + password=self.password, + allow_unknown=self.allow_unknown_host_key, + ) + self._channel = self._transport.open_session() + self._channel.settimeout(DEFAULT_COMMAND_TIMEOUT) + # ArubaOS braucht ein PTY; ohne bleibt die Shell stumm. + self._channel.get_pty(term="vt100", width=200, height=1000) + self._channel.invoke_shell() + self._read_until_prompt(timeout=DEFAULT_COMMAND_TIMEOUT, initial=True) + # Seitenweise Ausgabe abschalten -- sonst blockiert jedes "show" am Pager. + for command in ("no page", "terminal length 1000"): + self.send(command, tolerate_error=True) + return self + + def __exit__(self, *_exc: object) -> None: + self.close() + + def close(self) -> None: + for closer in (self._channel, self._transport): + try: + if closer is not None: + closer.close() + except Exception: # noqa: BLE001 + pass + self._channel = None + self._transport = None + + # ------------------------------------------------------------------ # + + def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str: + assert self._channel is not None + deadline = time.monotonic() + timeout + collected = b"" + while time.monotonic() < deadline: + if self._channel.recv_ready(): + chunk = self._channel.recv(READ_CHUNK) + if not chunk: + break + collected += chunk + if PAGER_RE.search(collected[-200:]): + self._channel.send(" ") + collected = PAGER_RE.sub(b"", collected) + continue + if PROMPT_RE.search(collected[-200:]): + break + # Der Login-Banner mancher Geraete verlangt eine Bestaetigung. + if initial and collected.rstrip().endswith(b"continue"): + self._channel.send("\r\n") + else: + time.sleep(0.05) + text = collected.decode("utf-8", errors="replace") + self.transcript.append(text) + return text + + def send( + self, command: str, *, timeout: int = DEFAULT_COMMAND_TIMEOUT, tolerate_error: bool = False + ) -> str: + """Schickt ein Kommando und liest bis zum Prompt. + + Ein erkannter Fehlertext fuehrt zu einer Ausnahme -- ausser bei + ``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt). + """ + if self._channel is None: + raise SshError("Die Sitzung ist nicht geoeffnet.") + self.transcript.append(f"\n$ {command}\n") + self._channel.send(command + "\r\n") + output = self._read_until_prompt(timeout=timeout) + if not tolerate_error and ERROR_RE.search(output): + raise SshError(f"Der Switch hat das Kommando abgelehnt: {command!r}") + return output + + @property + def full_transcript(self) -> str: + return "".join(self.transcript) + + # ------------------------------------------------------------------ # + # PoE + # ------------------------------------------------------------------ # + + def set_poe(self, port: str, enabled: bool) -> None: + self.send("configure terminal") + try: + self.send(f"interface {port}") + self.send("power-over-ethernet" if enabled else "no power-over-ethernet") + finally: + # Immer zurueck in den privilegierten Modus, auch nach einem Fehler -- + # sonst bleibt die Sitzung im Konfigurationsmodus haengen. + self.send("exit", tolerate_error=True) + self.send("exit", tolerate_error=True) + + def poe_state(self, port: str) -> str: + """``enabled`` / ``disabled`` / ``unknown`` -- gelesen, nicht geraten.""" + output = self.send(f"show power-over-ethernet {port}", tolerate_error=True) + lowered = output.lower() + if "power enable" in lowered: + for line in lowered.splitlines(): + if "power enable" in line: + if "yes" in line or "enabled" in line: + return "enabled" + if "no" in line or "disabled" in line: + return "disabled" + if re.search(r"poe port status\s*:\s*enabled", lowered): + return "enabled" + if re.search(r"poe port status\s*:\s*disabled", lowered): + return "disabled" + return "unknown" + + def cycle_poe(self, port: str) -> CliResult: + """Schaltet PoE aus, wartet und wieder ein. Prueft das Ergebnis.""" + result = CliResult(ok=False) + try: + before = self.poe_state(port) + result.add(f"Zustand vor dem Vorgang: {before}") + + self.set_poe(port, enabled=False) + result.add("PoE ausgeschaltet") + time.sleep(POE_SETTLE_SECONDS) + + off_state = self.poe_state(port) + if off_state == "enabled": + result.error = ( + "Der Switch meldet den Port nach dem Abschalten weiter als aktiv -- " + "der Neustart hat nicht stattgefunden." + ) + result.transcript = self.full_transcript + return result + result.add(f"Zustand im ausgeschalteten Zustand: {off_state}") + + self.set_poe(port, enabled=True) + result.add("PoE wieder eingeschaltet") + + after = self.poe_state(port) + result.add(f"Zustand nach dem Vorgang: {after}") + if after == "disabled": + result.error = "Der Port ist nach dem Vorgang weiter abgeschaltet." + result.transcript = self.full_transcript + return result + + result.ok = True + except SshError as exc: + result.error = str(exc) + except Exception as exc: # noqa: BLE001 - Netzabbruch mitten im Vorgang + logger.exception("PoE-Vorgang auf %s Port %s fehlgeschlagen", self.host, port) + result.error = f"Unerwarteter Fehler: {exc}" + result.transcript = self.full_transcript + return result + + # ------------------------------------------------------------------ # + + def interface_overview(self) -> str: + return self.send("show power-over-ethernet brief", tolerate_error=True) + + def system_info(self) -> str: + return self.send("show system information", tolerate_error=True) + + +#: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet. +SAFE_COMMANDS: tuple[tuple[str, str], ...] = ( + ("show system information", "Systeminformationen"), + ("show power-over-ethernet brief", "PoE-Uebersicht aller Ports"), + ("show interfaces brief", "Portstatus"), + ("show vlans", "VLAN-Uebersicht"), + ("show mac-address", "MAC-Adresstabelle"), + ("show lldp info remote-device", "Nachbargeraete (LLDP)"), + ("show version", "Firmware-Version"), + ("show logging", "Switch-Protokoll"), +) diff --git a/apps/tesm/src/tesm/services/transfer.py b/apps/tesm/src/tesm/services/transfer.py new file mode 100644 index 0000000..b640f90 --- /dev/null +++ b/apps/tesm/src/tesm/services/transfer.py @@ -0,0 +1,637 @@ +"""Sicherung von TESM: welche Kategorien es gibt und wie sie gelesen/geschrieben werden. + +Der verschluesselte Umschlag selbst (Argon2id + AES-256-GCM, Zwischenstand des +Imports) liegt im Kern -- ``tesm_core.transfer``. Hier steht nur die Fachlogik. +""" + +from __future__ import annotations + +import json +import logging +import sqlite3 +from datetime import datetime, timezone +from typing import Any, Callable, Iterable + +from tesm_core.db import Database +from tesm_core.keystore import Keystore +from tesm_core.transfer import ( + MIN_PASSPHRASE_LENGTH, + PREVIEW_TTL_MINUTES, + Category, + TransferError, + drop_preview, + load_preview, + seal as _seal, + store_preview, + summarize as _summarize, + unseal as _unseal, +) + +logger = logging.getLogger(__name__) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + +APP_TAG = "tesm" + + +def seal(payload: dict[str, Any], passphrase: str) -> str: + return _seal(payload, passphrase, app_tag=APP_TAG) + + +def unseal(raw: bytes | str, passphrase: str) -> tuple[dict[str, Any], dict[str, Any]]: + return _unseal(raw, passphrase, app_tag=APP_TAG) + + +# --------------------------------------------------------------------------- # +# Kategorien +# --------------------------------------------------------------------------- # + + +CATEGORIES: tuple[Category, ...] = ( + Category("devices", "Clients", "Geraetestammdaten inklusive Switch- und Portzuordnung"), + Category("switches", "Switche", "Switch-Inventar"), + Category( + "credentials", + "Zugangsdaten", + "SSH-Logins. Die Passwoerter liegen im Export im Klartext -- geschuetzt " + "ausschliesslich durch die Passphrase.", + sensitive=True, + ), + Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"), + Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"), + Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True), + Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True), + Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True), +) + +CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES} + + +# --------------------------------------------------------------------------- # +# Export +# --------------------------------------------------------------------------- # + + +def build_export( + conn: sqlite3.Connection, + keystore: Keystore, + *, + categories: Iterable[str], + include_secrets: bool, +) -> dict[str, Any]: + from .inventory import aad_for_credential + + wanted = [key for key in categories if key in CATEGORY_BY_KEY] + payload: dict[str, Any] = {} + + if "devices" in wanted: + payload["devices"] = Database.all( + conn, + "SELECT d.mac, d.ip, d.name, d.port, d.is_active, d.ssh_port, d.category, " + "d.location, d.notes, d.auto_restart, s.hostname AS switch_hostname, " + "c.name AS credential_name FROM devices d " + "LEFT JOIN switches s ON s.id = d.switch_id " + "LEFT JOIN credentials c ON c.id = d.credential_id " + "WHERE d.deleted_at IS NULL", + ) + if "switches" in wanted: + payload["switches"] = Database.all( + conn, + "SELECT s.hostname, s.ip, s.ssh_port, s.model, s.location, s.notes, " + "c.name AS credential_name FROM switches s " + "LEFT JOIN credentials c ON c.id = s.credential_id WHERE s.deleted_at IS NULL", + ) + if "credentials" in wanted: + rows = Database.all( + conn, + "SELECT id, name, username, category, description, secret_encrypted FROM credentials " + "WHERE deleted_at IS NULL", + ) + entries = [] + for row in rows: + entry = { + "name": row["name"], + "username": row["username"], + "category": row["category"], + "description": row["description"], + } + if include_secrets and str(row["secret_encrypted"] or ""): + try: + entry["secret"] = keystore.decrypt( + str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"])) + ) + except Exception: # noqa: BLE001 + entry["secret_error"] = "nicht entschluesselbar" + entries.append(entry) + payload["credentials"] = entries + if "dhcp" in wanted: + payload["dhcp"] = { + "subnets": Database.all( + conn, + "SELECT interface, range_start, range_end, gateway, dns, comment, enabled, " + "sort_order FROM dhcp_subnets", + ), + "options": Database.all( + conn, + "SELECT o.code, o.name, o.type, o.description, o.is_standard, v.value, " + "d.mac AS device_mac FROM dhcp_options o " + "JOIN dhcp_option_values v ON v.option_id = o.id " + "LEFT JOIN devices d ON d.id = v.device_id", + ), + "reservations": Database.all( + conn, "SELECT mac, ip, hostname, comment FROM dhcp_reservations" + ), + } + if "fileshare" in wanted: + payload["fileshare"] = Database.all( + conn, "SELECT ad_group_dn, ad_group_name, label, unc FROM fileshare_mappings" + ) + if "users" in wanted: + # Verzeichniskonten werden nie exportiert -- sie legen sich am Ziel bei + # der ersten Anmeldung selbst an, und ihr Passwort liegt ohnehin nicht hier. + payload["users"] = Database.all( + conn, + "SELECT u.username, u.password_hash, u.is_admin, u.is_locked, u.first_name, " + "u.last_name, u.email, u.must_change_password, " + "(SELECT group_concat(g.name, char(10)) FROM user_groups ug " + " JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = u.id) AS group_names " + "FROM users u WHERE u.deleted_at IS NULL AND u.auth_source='local'", + ) + if "groups" in wanted: + payload["groups"] = [ + { + "name": row["name"], + "description": row["description"], + "is_default": row["is_default"], + "is_system": row["is_system"], + "preset_key": row["preset_key"], + "permissions": [ + str(entry["permission"]) + for entry in Database.all( + conn, + "SELECT permission FROM group_permissions WHERE group_id=? ORDER BY permission", + (row["id"],), + ) + ], + } + for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL") + ] + if "settings" in wanted: + payload["settings"] = { + str(row["key"]): str(row["value"]) + for row in Database.all(conn, "SELECT key, value FROM settings") + # Lizenz- und Aktivierungszustand gehoeren zur Maschine, nicht zur + # Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben. + if not str(row["key"]).startswith("license_") + } + return payload + + +# --------------------------------------------------------------------------- # +# Zwischenstand des Imports +# --------------------------------------------------------------------------- # + + +# ``store_preview``/``load_preview``/``drop_preview`` kommen aus dem Kern (siehe +# Import oben). Hier standen zeichengleiche Kopien, die die Importe +# ueberschatteten -- und zwar defekte: ihnen fehlten ``secrets``, ``datetime``, +# ``timedelta``, ``timezone``, ``_now`` und ``PREVIEW_JOB_KIND``. Jeder Aufruf +# waere mit NameError abgebrochen, also der ganze Importweg. Aufgefallen ist es +# erst, als der erste Test ihn wirklich benutzt hat. + + +def summarize(payload: dict[str, Any]) -> list[dict[str, Any]]: + entries = _summarize(payload, CATEGORIES) + for entry in entries: + if entry["key"] == "credentials": + data = payload.get("credentials") or [] + entry["has_secrets"] = any( + isinstance(row, dict) and "secret" in row for row in data + ) + else: + entry["has_secrets"] = False + return entries + + +# --------------------------------------------------------------------------- # +# Uebernahme +# --------------------------------------------------------------------------- # + + +def apply_import( + conn: sqlite3.Connection, + keystore: Keystore, + payload: dict[str, Any], + *, + categories: Iterable[str], + set_setting: Callable[[str, Any], None], +) -> dict[str, dict[str, int]]: + """Uebernimmt ausgewaehlte Kategorien. Rueckgabe: ``{kategorie: {angelegt, aktualisiert}}``. + + Abgleich nach natuerlichem Schluessel (Name/Hostname/MAC/Benutzername). + Eintraege, die im Papierkorb liegen, werden **nicht** stillschweigend + wiederbelebt -- das war im Vorgaenger ein Fehler, der geloeschte Geraete + ueber den Import zurueckbrachte. + """ + from .inventory import ValidationError, aad_for_credential, normalize_mac + + def mac_or_none(value: Any) -> str | None: + """MAC normalisieren, ohne den ganzen Import zu sprengen. + + ``normalize_mac`` wirft bei unbrauchbarer Eingabe -- auch bei leerer. + Aufgerufen wurde es hier ungeprueft, obwohl der Code danach ``if not + mac: continue`` erwartet. Ein einziger Eintrag ohne MAC haette damit + den gesamten Import mit einer Meldung ueber Hexzeichen abgebrochen. + """ + try: + return normalize_mac(str(value or "")) + except ValidationError: + return None + + + wanted = {key for key in categories if key in CATEGORY_BY_KEY} + report: dict[str, dict[str, int]] = {} + stamp = _now().isoformat(timespec="seconds") + + def bump(category: str, field: str) -> None: + report.setdefault(category, {"created": 0, "updated": 0, "skipped": 0})[field] += 1 + + # Reihenfolge ist wichtig: Zugangsdaten vor Switchen, Switche vor Geraeten. + if "credentials" in wanted: + for entry in payload.get("credentials", []): + name = str(entry.get("name") or "").strip() + if not name: + continue + existing = Database.one( + conn, + "SELECT id FROM credentials WHERE name=? COLLATE NOCASE AND deleted_at IS NULL", + (name,), + ) + if existing is None: + cursor = conn.execute( + "INSERT INTO credentials (name, username, secret_encrypted, category, " + "description, created_at, updated_at, updated_by) VALUES (?,?,'',?,?,?,?,'import')", + ( + name, + str(entry.get("username") or ""), + str(entry.get("category") or "switch"), + str(entry.get("description") or ""), + stamp, + stamp, + ), + ) + credential_id = int(cursor.lastrowid or 0) + bump("credentials", "created") + else: + credential_id = int(existing["id"]) + conn.execute( + "UPDATE credentials SET username=?, category=?, description=?, updated_at=?, " + "updated_by='import' WHERE id=?", + ( + str(entry.get("username") or ""), + str(entry.get("category") or "switch"), + str(entry.get("description") or ""), + stamp, + credential_id, + ), + ) + bump("credentials", "updated") + if entry.get("secret"): + conn.execute( + "UPDATE credentials SET secret_encrypted=? WHERE id=?", + ( + keystore.encrypt(str(entry["secret"]), aad=aad_for_credential(credential_id)), + credential_id, + ), + ) + + def credential_id_by_name(name: str | None) -> int | None: + if not name: + return None + value = Database.value( + conn, + "SELECT id FROM credentials WHERE name=? COLLATE NOCASE AND deleted_at IS NULL", + (name,), + ) + return int(value) if value is not None else None + + if "switches" in wanted: + for entry in payload.get("switches", []): + hostname = str(entry.get("hostname") or "").strip() + if not hostname: + continue + existing = Database.one( + conn, + "SELECT id FROM switches WHERE hostname=? COLLATE NOCASE AND deleted_at IS NULL", + (hostname,), + ) + values = ( + str(entry.get("ip") or ""), + int(entry.get("ssh_port") or 22), + credential_id_by_name(entry.get("credential_name")), + str(entry.get("model") or ""), + str(entry.get("location") or ""), + str(entry.get("notes") or ""), + ) + if existing is None: + conn.execute( + "INSERT INTO switches (hostname, ip, ssh_port, credential_id, model, location, " + "notes, created_at, updated_at, updated_by) VALUES (?,?,?,?,?,?,?,?,?,'import')", + (hostname, *values, stamp, stamp), + ) + bump("switches", "created") + else: + conn.execute( + "UPDATE switches SET ip=?, ssh_port=?, credential_id=?, model=?, location=?, " + "notes=?, updated_at=?, updated_by='import' WHERE id=?", + (*values, stamp, existing["id"]), + ) + bump("switches", "updated") + + def switch_id_by_hostname(hostname: str | None) -> int | None: + if not hostname: + return None + value = Database.value( + conn, + "SELECT id FROM switches WHERE hostname=? COLLATE NOCASE AND deleted_at IS NULL", + (hostname,), + ) + return int(value) if value is not None else None + + if "devices" in wanted: + for entry in payload.get("devices", []): + mac = normalize_mac(str(entry.get("mac") or "")) + if not mac: + bump("devices", "skipped") + continue + existing = Database.one( + conn, + "SELECT id, deleted_at FROM devices WHERE mac=? COLLATE NOCASE", + (mac,), + ) + if existing is not None and existing["deleted_at"]: + # Im Papierkorb liegende Geraete werden nicht wiederbelebt. + bump("devices", "skipped") + continue + values = ( + str(entry.get("ip") or ""), + str(entry.get("name") or mac), + switch_id_by_hostname(entry.get("switch_hostname")), + str(entry.get("port") or ""), + int(bool(entry.get("is_active", 1))), + credential_id_by_name(entry.get("credential_name")), + int(entry.get("ssh_port") or 22), + str(entry.get("category") or ""), + str(entry.get("location") or ""), + str(entry.get("notes") or ""), + int(bool(entry.get("auto_restart", 1))), + ) + if existing is None: + cursor = conn.execute( + "INSERT INTO devices (mac, ip, name, switch_id, port, is_active, credential_id, " + "ssh_port, category, location, notes, auto_restart, created_at, updated_at, " + "updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,'import')", + (mac, *values, stamp, stamp), + ) + conn.execute( + "INSERT INTO device_status (device_id, state, last_change_at) " + "VALUES (?,'unknown',?)", + (int(cursor.lastrowid or 0), stamp), + ) + bump("devices", "created") + else: + conn.execute( + "UPDATE devices SET ip=?, name=?, switch_id=?, port=?, is_active=?, " + "credential_id=?, ssh_port=?, category=?, location=?, notes=?, auto_restart=?, " + "updated_at=?, updated_by='import' WHERE id=?", + (*values, stamp, existing["id"]), + ) + bump("devices", "updated") + + if "dhcp" in wanted: + data = payload.get("dhcp") or {} + for entry in data.get("subnets", []): + conn.execute( + "INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, " + "comment, enabled, sort_order, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)", + ( + str(entry.get("interface") or ""), + str(entry.get("range_start") or ""), + str(entry.get("range_end") or ""), + str(entry.get("gateway") or ""), + str(entry.get("dns") or ""), + str(entry.get("comment") or ""), + int(bool(entry.get("enabled", 1))), + int(entry.get("sort_order") or 0), + stamp, + stamp, + ), + ) + bump("dhcp", "created") + # Optionen: Definition und Wert stehen je Zeile beieinander. Sie wurden + # exportiert, aber nie importiert -- ein Export/Import verlor damit + # stillschweigend alle eigenen Optionen und alle Werte, auch die je + # Geraet gesetzten. + for entry in data.get("options", []): + code = int(entry.get("code") or 0) + name = str(entry.get("name") or "").strip() + if not code or not name: + continue + conn.execute( + "INSERT INTO dhcp_options (code, name, type, description, is_standard) " + "VALUES (?,?,?,?,?) ON CONFLICT(code) DO UPDATE SET name=excluded.name, " + "type=excluded.type, description=excluded.description", + ( + code, + name, + str(entry.get("type") or "string"), + str(entry.get("description") or ""), + int(bool(entry.get("is_standard"))), + ), + ) + option_id = Database.value(conn, "SELECT id FROM dhcp_options WHERE code=?", (code,)) + if option_id is None: + continue + wert = str(entry.get("value") or "") + if not wert: + continue + roh_mac = str(entry.get("device_mac") or "") + geraet_mac = mac_or_none(roh_mac) if roh_mac else None + if roh_mac and geraet_mac is None: + bump("dhcp", "skipped") + continue + device_id = ( + Database.value( + conn, + "SELECT id FROM devices WHERE mac=? AND deleted_at IS NULL", + (geraet_mac,), + ) + if geraet_mac + else None + ) + # Ein Wert, der zu einem Geraet gehoert, das es hier nicht gibt, + # darf nicht als globaler Wert landen -- das wuerde ihn an alle + # ausliefern. Er wird uebersprungen. + if geraet_mac and device_id is None: + bump("dhcp", "skipped") + continue + if device_id is None: + conn.execute( + "INSERT INTO dhcp_option_values (option_id, device_id, value) " + "VALUES (?,NULL,?) ON CONFLICT(option_id) WHERE device_id IS NULL " + "DO UPDATE SET value=excluded.value", + (int(option_id), wert), + ) + else: + conn.execute( + "INSERT INTO dhcp_option_values (option_id, device_id, value) " + "VALUES (?,?,?) ON CONFLICT(option_id, device_id) WHERE device_id IS NOT NULL " + "DO UPDATE SET value=excluded.value", + (int(option_id), int(device_id), wert), + ) + bump("dhcp", "updated") + for entry in data.get("reservations", []): + mac = mac_or_none(entry.get("mac")) + if not mac: + bump("dhcp", "skipped") + continue + conn.execute( + "INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) " + "VALUES (?,?,?,?,?) ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, " + "hostname=excluded.hostname, comment=excluded.comment", + ( + mac, + str(entry.get("ip") or ""), + str(entry.get("hostname") or ""), + str(entry.get("comment") or ""), + stamp, + ), + ) + bump("dhcp", "updated") + + if "fileshare" in wanted: + for entry in payload.get("fileshare", []): + conn.execute( + "INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) " + "VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET " + "label=excluded.label, ad_group_name=excluded.ad_group_name", + ( + str(entry.get("ad_group_dn") or ""), + str(entry.get("ad_group_name") or ""), + str(entry.get("label") or ""), + str(entry.get("unc") or ""), + stamp, + ), + ) + bump("fileshare", "updated") + + if "groups" in wanted: + for entry in payload.get("groups", []): + name = str(entry.get("name") or "").strip() + if not name: + continue + existing = Database.one( + conn, + "SELECT id FROM groups WHERE name=? COLLATE NOCASE AND deleted_at IS NULL", + (name,), + ) + if existing is None: + cursor = conn.execute( + "INSERT INTO groups (name, description, is_default, is_system, preset_key, " + "created_at, updated_at) VALUES (?,?,?,?,?,?,?)", + ( + name, + str(entry.get("description") or ""), + int(bool(entry.get("is_default"))), + int(bool(entry.get("is_system"))), + str(entry.get("preset_key") or ""), + stamp, + stamp, + ), + ) + group_id = int(cursor.lastrowid or 0) + bump("groups", "created") + else: + group_id = int(existing["id"]) + bump("groups", "updated") + conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) + conn.executemany( + "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)", + [(group_id, str(permission)) for permission in entry.get("permissions", [])], + ) + + if "users" in wanted: + for entry in payload.get("users", []): + username = str(entry.get("username") or "").strip() + if not username: + continue + existing = Database.one( + conn, + "SELECT id, deleted_at FROM users WHERE username=? COLLATE NOCASE", + (username,), + ) + if existing is not None and existing["deleted_at"]: + bump("users", "skipped") + continue + if existing is None: + cursor = conn.execute( + "INSERT INTO users (username, password_hash, is_admin, is_locked, auth_source, " + "first_name, last_name, email, must_change_password, created_at, updated_at) " + "VALUES (?,?,?,?,'local',?,?,?,?,?,?)", + ( + username, + str(entry.get("password_hash") or ""), + int(bool(entry.get("is_admin"))), + int(bool(entry.get("is_locked"))), + str(entry.get("first_name") or ""), + str(entry.get("last_name") or ""), + str(entry.get("email") or ""), + int(bool(entry.get("must_change_password"))), + stamp, + stamp, + ), + ) + user_id = int(cursor.lastrowid or 0) + bump("users", "created") + else: + user_id = int(existing["id"]) + conn.execute( + "UPDATE users SET first_name=?, last_name=?, email=?, updated_at=? WHERE id=?", + ( + str(entry.get("first_name") or ""), + str(entry.get("last_name") or ""), + str(entry.get("email") or ""), + stamp, + user_id, + ), + ) + bump("users", "updated") + names = [ + name for name in str(entry.get("group_names") or "").split("\n") if name.strip() + ] + if names: + conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) + for name in names: + group_id = Database.value( + conn, + "SELECT id FROM groups WHERE name=? COLLATE NOCASE AND deleted_at IS NULL", + (name.strip(),), + ) + if group_id is not None: + conn.execute( + "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)", + (user_id, int(group_id)), + ) + + if "settings" in wanted: + for key, value in (payload.get("settings") or {}).items(): + if str(key).startswith("license_"): + continue + # Ueber den Store, damit Typ und Wertebereich geprueft werden -- und + # bewusst mit derselben Verbindung (keine zweite oeffnen!). + set_setting(str(key), value) + bump("settings", "updated") + + return report diff --git a/apps/tesm/src/tesm/services/windowsops.py b/apps/tesm/src/tesm/services/windowsops.py new file mode 100644 index 0000000..3e57468 --- /dev/null +++ b/apps/tesm/src/tesm/services/windowsops.py @@ -0,0 +1,168 @@ +"""Windows-Clients aus der Ferne neu starten -- ohne SSH. + +Windows-Geraete werden hier **nicht** ueber SSH verwaltet. Sie haben in aller +Regel keinen SSH-Dienst, und damit gibt es auch keinen Host-Schluessel, den +jemand freigeben koennte. Alles, was in TESM mit Host-Schluesseln zu tun hat, +laesst Windows-Geraete deshalb bewusst aus. + +Stattdessen der klassische Weg von einem Linux-Host aus: ``net rpc shutdown`` +aus ``samba-common-bin``. Das spricht denselben RPC-Dienst an, den auch +``shutdown /m \\\\host`` unter Windows benutzt, und funktioniert mit lokalen +wie mit Domaenenkonten. + +Zum Passwort: es geht **nie** ueber die Kommandozeile. ``net -U benutzer%pass`` +waere auf dem Zielsystem in ``ps`` sichtbar -- und auch auf diesem hier. Es +wird darum in eine 0600-Datei geschrieben, die nur der Dienstbenutzer lesen +kann, und ueber ``--authentication-file`` uebergeben. Die Datei wird danach +ueberschrieben und geloescht. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import tempfile +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +#: Das Paket, das ``net`` mitbringt. Steht in der Positivliste des Helfers, +#: damit es sich aus der Oberflaeche nachinstallieren laesst. +REQUIRED_PACKAGE = "samba-common-bin" + +DEFAULT_TIMEOUT = 45 + + +@dataclass(frozen=True, slots=True) +class RemoteResult: + ok: bool + output: str + hint: str = "" + + +def available() -> bool: + """Ist ``net`` auf diesem Host installiert?""" + return shutil.which("net") is not None + + +#: Fehlerbilder von ``net rpc``, uebersetzt in etwas Handlungsleitendes. +HINTS: tuple[tuple[str, str], ...] = ( + ( + "NT_STATUS_LOGON_FAILURE", + "Benutzername oder Passwort wurden vom Windows-Geraet abgewiesen. Bei einem " + "Domaenenkonto die Form DOMAENE\\\\benutzer verwenden.", + ), + ( + "NT_STATUS_ACCESS_DENIED", + "Das Konto darf auf diesem Geraet keinen Neustart ausloesen. Es braucht das Recht " + "'Herunterfahren von einem Remotesystem aus' -- Mitglied der lokalen " + "Administratoren genuegt dafuer in der Regel.", + ), + ( + "NT_STATUS_CONNECTION_REFUSED", + "Das Geraet nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss " + "aktiv und Port 445 in der Windows-Firewall freigegeben sein.", + ), + ( + "NT_STATUS_IO_TIMEOUT", + "Keine Antwort auf Port 445. Ist das Geraet erreichbar und die Firewall offen?", + ), + ( + "NT_STATUS_HOST_UNREACHABLE", + "Das Geraet ist nicht erreichbar.", + ), +) + + +def hint_for(output: str) -> str: + for needle, hint in HINTS: + if needle in output: + return hint + return "" + + +def reboot( + host: str, + username: str, + password: str, + *, + domain: str = "", + comment: str = "Neustart durch TESM-Wartung", + delay: int = 5, + timeout: int = DEFAULT_TIMEOUT, +) -> RemoteResult: + """Loest einen Neustart auf einem Windows-Geraet aus.""" + binary = shutil.which("net") + if binary is None: + return RemoteResult( + False, + "", + f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' laesst sich " + "ein Windows-Geraet von hier nicht neu starten.", + ) + if not username: + return RemoteResult(False, "", "Fuer das Geraet sind keine Zugangsdaten hinterlegt.") + + # Ein Benutzer der Form DOMAENE\benutzer bringt seine Domaene selbst mit. + if "\\" in username and not domain: + domain, username = username.split("\\", 1) + + handle, path = tempfile.mkstemp(prefix="tesm-winauth-") + try: + # mkstemp legt die Datei auf POSIX bereits mit 0600 an. Hier trotzdem + # ausdruecklich -- die Datei traegt gleich ein Klartextpasswort, das + # will man nicht von einer Vorgabe abhaengig machen. + try: + os.chmod(path, 0o600) + except OSError: # pragma: no cover - Windows kennt den Modus nicht + pass + with os.fdopen(handle, "w", encoding="utf-8", newline="\n") as stream: + stream.write(f"username={username}\npassword={password}\n") + if domain: + stream.write(f"domain={domain}\n") + + command = [ + binary, + "rpc", + "shutdown", + "-r", # neu starten statt herunterfahren + "-f", # laufende Anwendungen nicht blockieren lassen + "-t", + str(max(0, int(delay))), + "-C", + comment, + "-I", + host, + "--authentication-file", + path, + ] + try: + completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell + command, capture_output=True, text=True, timeout=timeout, check=False + ) + except subprocess.TimeoutExpired: + return RemoteResult(False, "", f"Zeitlimit von {timeout}s ueberschritten.") + except OSError as exc: + return RemoteResult(False, "", str(exc)) + + output = (completed.stdout + completed.stderr).strip() + if completed.returncode == 0: + return RemoteResult(True, output or "Neustart ausgeloest.") + return RemoteResult(False, output, hint_for(output)) + finally: + # Ueberschreiben, dann loeschen -- die Datei enthaelt ein Klartextpasswort. + try: + with open(path, "r+b") as stream: + length = stream.seek(0, os.SEEK_END) + stream.seek(0) + stream.write(b"\0" * length) + stream.flush() + os.fsync(stream.fileno()) + except OSError: # pragma: no cover - defensiv + logger.warning("Anmeldedatei konnte nicht ueberschrieben werden: %s", path) + try: + os.unlink(path) + except OSError: # pragma: no cover - defensiv + pass diff --git a/apps/tesm/src/tesm/settings_spec.py b/apps/tesm/src/tesm/settings_spec.py new file mode 100644 index 0000000..28983a3 --- /dev/null +++ b/apps/tesm/src/tesm/settings_spec.py @@ -0,0 +1,129 @@ +"""Laufzeiteinstellungen von TESM.""" + +from __future__ import annotations + +from tesm_core.settings_store import SettingSpec + +from .services.kea import DEFAULT_CONFIG_PATH + +TESM_SETTINGS: tuple[SettingSpec, ...] = ( + # -- Ueberwachung ------------------------------------------------------- # + SettingSpec( + "monitor_interval_seconds", + 300, + "int", + "Pruefintervall (Sekunden)", + hint="Abstand zwischen zwei Erreichbarkeitspruefungen. Lizenzpflichtig.", + section="monitoring", + minimum=30, + maximum=86400, + ), + SettingSpec( + "monitor_failures_before_restart", + 2, + "int", + "Fehlversuche bis zum automatischen PoE-Neustart", + hint="Erst nach so vielen aufeinanderfolgenden Fehlschlaegen wird geschaltet. " + "Der Vorgaenger schaltete sofort beim ersten Ausfall.", + section="monitoring", + minimum=1, + maximum=10, + ), + SettingSpec( + "monitor_auto_restart", + True, + "bool", + "Automatischen PoE-Neustart bei Ausfall aktivieren", + section="monitoring", + ), + SettingSpec( + "dashboard_public", + True, + "bool", + "Statusuebersicht ohne Anmeldung erreichbar", + hint="Zeigt ausschliesslich Name und Erreichbarkeit aktiver Geraete -- keine " + "IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.", + section="monitoring", + ), + # -- DHCP --------------------------------------------------------------- # + SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"), + SettingSpec( + "dhcp_lease_default", 600, "int", "Lease-Dauer (Sekunden)", section="dhcp", + minimum=60, maximum=604800, + ), + SettingSpec( + "dhcp_lease_max", 7200, "int", "Maximale Lease-Dauer (Sekunden)", section="dhcp", + minimum=60, maximum=604800, + ), + SettingSpec( + "dhcp_output_path", + DEFAULT_CONFIG_PATH, + "str", + "Zielpfad der Kea-Konfiguration", + section="dhcp", + ), + # -- Dateifreigaben ----------------------------------------------------- # + SettingSpec( + "fileshare_mount_root", + "/mnt/tesm-shares", + "str", + "Wurzelverzeichnis der Einbindungen", + section="fileshare", + ), + SettingSpec( + "fileshare_max_age_hours", + 12, + "int", + "Einbindungen automatisch loesen nach (Stunden)", + section="fileshare", + minimum=1, + maximum=168, + ), + SettingSpec( + "fileshare_upload_max_mb", + 512, + "int", + "Maximale Dateigroesse beim Hochladen (MB)", + section="fileshare", + minimum=1, + maximum=8192, + ), + # -- Wartung ------------------------------------------------------------ # + SettingSpec( + "maintenance_command_timeout", + 900, + "int", + "Zeitlimit eines Wartungslaufs (Sekunden)", + section="maintenance", + minimum=60, + maximum=7200, + ), + SettingSpec( + "maintenance_reboot_wait", + 300, + "int", + "Wartezeit auf die Rueckkehr nach einem Neustart (Sekunden)", + section="maintenance", + minimum=30, + maximum=1800, + ), + # -- Terminal ----------------------------------------------------------- # + SettingSpec( + "terminal_enabled", + True, + "bool", + "Browser-Terminal fuer Switche aktiv", + hint="Auch wenn aktiv: erfordert das Recht 'Ausfuehren' auf Switche, eine frische " + "Passwortbestaetigung und wird vollstaendig protokolliert.", + section="monitoring", + ), + SettingSpec( + "terminal_idle_timeout", + 300, + "int", + "Terminal bei Untaetigkeit trennen nach (Sekunden)", + section="monitoring", + minimum=60, + maximum=3600, + ), +) diff --git a/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html b/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html new file mode 100644 index 0000000..7f9da98 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html @@ -0,0 +1,125 @@ +{# Teilbereich der Statusuebersicht -- wird auch einzeln per fetch geladen. #} +{% from "tesm_core/macros.html" import pill, empty_state %} + +{# Beide Bloecke in einen Stapel: sonst klebt die erste Gruppe an den + Filterkacheln, waehrend die Gruppen untereinander Abstand haben. #} +
+
+ + + + {% if detailed %} + + {% else %} +
+ Noch nicht geprueft + {{ counts.unknown }} +
+ {% endif %} +
+ +
+ {% for section in sections %} + {% if section.tiles %} +
+ +
+
+ {{ pill(section.tiles | length, section.kind if section.kind != 'muted' else '') }} + {{ section.label }} +
+
+ {{ icon('chevron-down', size=16) }} +
+
+
+ {% for tile in section.tiles %} + {# Kopf zuerst Kennzeichen, dann Name: das Auge findet die Kachel + ueber Farbe und Symbol, den genauen Namen liest es danach. + Die IP steht gross darunter statt in der Merkmalliste -- sie ist + die Angabe, nach der man auf dieser Seite tatsaechlich sucht. #} +
+
+ + {% if tile.state == 'online' %}{{ pill('Online', 'online') }} + {% elif tile.state == 'restarting' %}{{ pill('Startet neu', 'warning') }} + {% elif tile.state == 'offline' %}{{ pill('Offline', 'offline') }} + {% elif tile.state == 'disabled' %}{{ pill('Deaktiviert', '') }} + {% else %}{{ pill('Unbekannt', 'warning') }}{% endif %} +
+
{{ tile.name }}
+ {% if detailed %}
{{ tile.ip }}
{% endif %} + {% if tile.location %} +
{{ icon('map-pin', size=12) }} {{ tile.location }}
+ {% endif %} + {% if detailed %} +
+ {% if tile.switch %} +
Switch
{{ tile.switch }} / {{ tile.port }}
+ {% endif %} + {% if tile.latency_ms %} +
Antwortzeit
{{ '%.1f' | format(tile.latency_ms) }} ms
+ {% endif %} + {% if tile.last_change_at %} +
Seit
+
{{ tile.last_change_at | dt }}
+ {% endif %} +
+
+ + Details + + {% if current_user.has('devices.execute') and tile.restart_methods %} + {% set method = tile.restart_methods[0] %} + + + + + + {% endif %} + {% if current_user.has('devices.edit') and tile.state == 'disabled' %} +
+ + + +
+ {% endif %} +
+ {% endif %} +
+ {% endfor %} +
+
+
+ {% endif %} + {% endfor %} + + {% if not sections | map(attribute='tiles') | select | list %} + {{ empty_state('Keine Geraete erfasst', + 'Legen Sie das erste Geraet unter Ueberwachung an.', 'cpu') }} + {% endif %} +
+
diff --git a/apps/tesm/src/tesm/templates/tesm/_maintenance_output.html b/apps/tesm/src/tesm/templates/tesm/_maintenance_output.html new file mode 100644 index 0000000..5cf4aea --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/_maintenance_output.html @@ -0,0 +1 @@ +
{{ job.output or 'Noch keine Ausgabe.' }}
diff --git a/apps/tesm/src/tesm/templates/tesm/backup.html b/apps/tesm/src/tesm/templates/tesm/backup.html new file mode 100644 index 0000000..32a6a00 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/backup.html @@ -0,0 +1,161 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, checkbox, alert, pill %} + +{% block page_title %}Sicherung und Uebertragung{% endblock %} +{% block page_sub %}Verschluesselter Export · zweistufiger Import{% endblock %} + +{% block content %} +{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %} + +{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschluesselt (AES-256-GCM). Ohne +diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertuer. Bewahren Sie +sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }} + +
+
+
+
+
{{ icon('download', size=17) }} Sicherung erstellen
+
Auswahl je Kategorie · verlangt eine frische Passwortbestaetigung
+
+
+
+ +
+
+ Kategorien +
+ {% for category in all_categories %} + {% set allowed = category in categories %} + + {% endfor %} +
+
+ + {% if can_secrets %} + {{ checkbox('include_secrets', 'Passwoerter der Zugangsdaten mit exportieren', + hint_text='Sie liegen dann im Klartext innerhalb der verschluesselten + Datei. Ohne dieses Kennzeichen enthaelt die Sicherung nur Namen und + Benutzer.') }} + {% endif %} + +
+ {{ field('passphrase', 'Passphrase', type='password', required=True, + autocomplete='new-password', + hint_text='Mindestens ' ~ min_passphrase ~ ' Zeichen.') }} + {{ field('passphrase_repeat', 'Passphrase wiederholen', type='password', required=True, + autocomplete='new-password') }} +
+
+ +
+
+ +
+ {% if preview %} +
+
+
+
{{ icon('upload', size=17) }} Geoeffnete Sicherung
+
+ Waehlen Sie, was uebernommen werden soll. Gueltig fuer {{ preview_ttl }} Minuten. +
+
+
+
+ +
+
+ + + + {% for entry in preview.summary %} + + + + + + {% endfor %} + +
KategorieEintraege
+ + + {{ entry.label }} + {% if entry.has_secrets %}{{ pill('mit Passwoertern', 'danger') }}{% endif %} + {{ entry.count }}
+
+ {{ alert('Bestehende Eintraege werden nach ihrem natuerlichen Schluessel abgeglichen + (Name, Hostname, MAC, Benutzername) und aktualisiert. Eintraege, die im Papierkorb + liegen, werden nicht wiederbelebt.', 'info') }} +
+ +
+
+ {% else %} +
+
+
+
{{ icon('upload', size=17) }} Sicherung einspielen
+
Erst oeffnen und pruefen, dann uebernehmen
+
+
+
+ +
+ + {{ field('passphrase', 'Passphrase der Datei', type='password', required=True, + autocomplete='off') }} +
+ +
+
+ {% endif %} + +
+
+
{{ icon('info', size=17) }} Was gesichert wird
+
+
+
    +
  • Verzeichniskonten werden nie exportiert -- sie entstehen am Ziel bei der ersten Anmeldung neu.
  • +
  • Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehoeren zur Maschine, nicht zur Sicherung.
  • +
  • Sitzungen, Ratenbegrenzung und das Aenderungsprotokoll sind nicht Teil der Sicherung.
  • +
  • Der Zwischenstand des Imports liegt in der Datenbank -- ein Dienst-Neustall verliert ihn nicht.
  • +
+
+
+
+
+{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/console.html b/apps/tesm/src/tesm/templates/tesm/console.html new file mode 100644 index 0000000..e29a5c4 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/console.html @@ -0,0 +1,160 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import alert, pill %} + +{% set is_switch = target.kind == 'switch' %} +{% set back_url = url_for(target.back_endpoint, switch_id=target.id) if is_switch + else url_for(target.back_endpoint, device_id=target.id) %} + +{% block page_title %}Konsole {{ target.label }}{% endblock %} +{% block page_sub %}{{ target.host }}:{{ target.port }} · Sitzung wird mitgeschrieben{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block content %} +{% if not host_key_ready %} + {{ alert('Fuer dieses Ziel ist kein SSH-Host-Schluessel freigegeben. Die Konsole verweigert + den Aufbau, bis das geschehen ist -- die Freigabe erfolgt auf der Detailseite.', + 'danger', 'Host-Schluessel fehlt') }} +

Zur Detailseite und Schluessel freigeben

+{% endif %} + +{{ alert('Die Konsole ist zeilenorientiert: ein Kommando pro Zeile, Antwort darunter. Jede +Eingabe und jede Ausgabe wird auf dem Server mitgeschrieben und im Aenderungsprotokoll +vermerkt. Bei Untaetigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekunden.', +'warning', 'Mitschrift') }} + +{% if not is_switch %} + {{ alert('Jedes Kommando laeuft in einer eigenen Sitzung. Das heisst: ein "cd" wirkt nicht + auf das naechste Kommando, und interaktive Programme (Editoren, Passwortabfragen) laufen + hier nicht. Fuer solche Arbeiten einen richtigen SSH-Client nehmen.', 'info', + 'Ein Kommando, eine Sitzung') }} +{% endif %} + +
+
+
+
+ {{ icon('terminal', size=17) }} {{ target.label }} + {{ pill('verbinde ...', 'warning') }} +
+
Warte auf Verbindung
+
+ +
+

+  
+
+ +

+ Pfeiltasten hoch/runter blättern durch die letzten Eingaben. exit beendet die Sitzung. +

+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/credential_reveal.html b/apps/tesm/src/tesm/templates/tesm/credential_reveal.html new file mode 100644 index 0000000..e9d8765 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/credential_reveal.html @@ -0,0 +1,38 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import alert %} +{% block page_title %}{{ entry.name }}{% endblock %} +{% block page_sub %}Klartextanzeige – dieser Zugriff wurde protokolliert{% endblock %} +{% block breadcrumb %} + +{% endblock %} +{% block content %} +{{ alert('Dieser Aufruf steht mit Zeitpunkt, Benutzer und IP im Aenderungsprotokoll. +Schliessen Sie die Seite, sobald Sie den Wert uebernommen haben.', 'warning') }} +
+
+
{{ icon('key', size=17) }} {{ entry.name }}
+
{{ entry.category }}
+
+
+ + +
+ +
+{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/credentials.html b/apps/tesm/src/tesm/templates/tesm/credentials.html new file mode 100644 index 0000000..676b6ec --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/credentials.html @@ -0,0 +1,138 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, textarea_field, pill, search_box, modal, + empty_state, alert %} + +{% macro credential_form(entry=None) %} +
+ {{ field('name', 'Name', value=entry.name if entry else '', required=True, + hint_text='Frei waehlbar, z. B. "Switch-Admin" oder "Linux-Wartung".') }} + {{ field('username', 'Benutzer', value=entry.username if entry else '', autocomplete='off') }} + {{ field('secret', 'Passwort' ~ (' (leer lassen = unveraendert)' if entry else ''), + type='password', autocomplete='new-password', + required=False if entry else True) }} + + {{ textarea_field('description', 'Beschreibung', value=entry.description if entry else '', + rows=2, extra_class='field--full') }} +
+{% endmacro %} + +{% block page_title %}Zugangsdaten{% endblock %} +{% block page_sub %}{{ credentials | length }} Eintraege · verschluesselt mit AES-256-GCM{% endblock %} + +{% block topbar_extra %} + {{ search_box('credentials-table', 'Name, Benutzer ...') }} + {% if current_user.has('credentials.create') %} + + {% endif %} +{% endblock %} + +{% block content %} +{{ alert('Passwoerter liegen AES-256-GCM-verschluesselt und an ihren Verwendungszweck gebunden. +Sie im Klartext zu sehen braucht ein eigenes Recht, eine frische Passwortbestaetigung und wird +protokolliert. Die Kategorie setzen Sie ausdruecklich -- sie wird bewusst nicht geraten, weil +davon abhaengt, welche Geraete auf der Wartungsseite erscheinen.', 'info') }} + +
+
+ + + + + + + + + + + + + {% for entry in credentials %} + + + + + + + + + {% endfor %} + +
NameBenutzerKategorieVerwendet vonPasswortAktion
+
{{ entry.name }}
+ {% if entry.description %}
{{ entry.description }}
{% endif %} +
{{ entry.username or '–' | safe }}{{ dict(categories).get(entry.category, entry.category) }}{{ entry.in_use }} + {% if entry.has_secret %}{{ pill('gesetzt', 'online') }} + {% else %}{{ pill('fehlt', 'warning') }}{% endif %} + + {% if current_user.has('credentials.secrets') and entry.has_secret %} +
+ + +
+ {% endif %} + {% if current_user.has('credentials.edit') %} + + {% endif %} + {% if current_user.has('credentials.delete') %} +
+ + +
+ {% endif %} +
+
+ {% if not credentials %} + {{ empty_state('Keine Zugangsdaten', 'Legen Sie den ersten Eintrag an.', 'key') }} + {% endif %} +
+{% endblock %} + +{% block dialogs %} +{% if current_user.has('credentials.create') %} + {% call modal('credential-new', 'Zugangsdaten anlegen') %} +
+ + + +
+ {% endcall %} +{% endif %} +{% if current_user.has('credentials.edit') %} + {% for entry in credentials %} + {% call modal('credential-edit-' ~ entry.id, entry.name) %} +
+ + + +
+ {% endcall %} + {% endfor %} +{% endif %} +{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/dashboard.html b/apps/tesm/src/tesm/templates/tesm/dashboard.html new file mode 100644 index 0000000..4f00ef6 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/dashboard.html @@ -0,0 +1,27 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import search_box %} + +{% block page_title %}Statusuebersicht{% endblock %} +{% block page_sub %} + {{ counts.total }} Geraete · Pruefintervall {{ interval }} s + {% if not detailed %}· oeffentliche Ansicht{% endif %} +{% endblock %} + +{% block topbar_extra %} + {{ search_box('tile-sections', 'Geraet suchen ...') }} + {% if current_user.has('settings_system.execute') %} +
+ + +
+ {% endif %} +{% endblock %} + +{% block content %} +
+ {% include "tesm/_dashboard_tiles.html" %} +
+{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/device_detail.html b/apps/tesm/src/tesm/templates/tesm/device_detail.html new file mode 100644 index 0000000..06504ca --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/device_detail.html @@ -0,0 +1,244 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import pill, empty_state, alert %} + +{% block page_title %}{{ device.name }}{% endblock %} +{% block page_sub %}{{ device.ip }} · {{ device.mac }}{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block topbar_extra %} + {% if terminal_enabled and current_user.has('maintenance.execute') and device.credential_id %} + + {{ icon('terminal', size=15) }} Konsole + + {% endif %} + {% if current_user.has('devices.execute') and restart_methods %} + {% for method in restart_methods %} +
+ + + +
+ {% endfor %} + {% endif %} +{% endblock %} + +{% block content %} +{% if device.deleted_at %} + {{ alert('Dieses Geraet liegt im Papierkorb (geloescht am ' ~ (device.deleted_at | dt) ~ ').', + 'warning') }} +{% endif %} + +
+
+
+
+
+
+ {{ icon('activity', size=17) }} Zustand + {% if not device.is_active %}{{ pill('deaktiviert', '') }} + {% elif device.state == 'online' %}{{ pill('online', 'online') }} + {% elif device.state == 'offline' %}{{ pill('offline', 'offline') }} + {% else %}{{ pill('unbekannt', 'warning') }}{% endif %} +
+
+
+
+
+
+ Antwortzeit + {{ '%.1f' | format(device.latency_ms) if device.latency_ms else '–' | safe }} + Millisekunden +
+
+ Fehlversuche + {{ device.fail_count or 0 }} + in Folge +
+
+ Neustarts + {{ device.restart_count or 0 }} + insgesamt +
+
+ Zuletzt erreichbar + + {% if device.last_ok_at %} + {{ device.last_ok_at | dt }} + {% else %}–{% endif %} + +
+
+
+
+ +
+
+
+
{{ icon('power', size=17) }} Neustarts
+
+ Die letzten {{ events | length }} Neustarts -- ueber den PoE-Port wie per SSH +
+
+
+ {% if events %} +
+ + + + {% for event in events %} + + + + + + + + + {% endfor %} + +
ZeitMethodeAusloeserErgebnisDauerDetail
{{ event.ts | dt }} + {% if event.method == 'ssh' %}{{ pill('SSH', 'info') }} + {% else %}{{ pill('PoE-Port', '') }}{% endif %} + {{ event.trigger }} / {{ event.actor }} + {% if event.result == 'ok' %}{{ pill('erfolgreich', 'online') }} + {% elif event.result == 'skipped' %}{{ pill('uebersprungen', 'warning') }} + {% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %} + {{ event.duration_ms or 0 }} ms{{ event.detail }}
+
+ {% else %} + {{ empty_state('Noch keine Neustarts', 'Dieses Geraet wurde noch nie neu gestartet.', 'power') }} + {% endif %} +
+ +
+
+
+
+ {{ icon('shield', size=17) }} SSH-Host-Schluessel + {% if host_keys %}{{ pill('freigegeben', 'online') }} + {% else %}{{ pill('offen', 'warning') }}{% endif %} +
+
+ Ohne freigegebenen Schluessel verweigern Wartung und Konsole die Verbindung. +
+
+ {% if current_user.has('devices.edit') %} +
+ + +
+ {% endif %} +
+
+ {% set probed_type = request.args.get('probed_type') %} + {% set probed_fingerprint = request.args.get('probed_fingerprint') %} + {% if probed_type and probed_fingerprint and current_user.has('devices.edit') %} +
+ {{ icon('alert', size=18) }} +
+
Gelesener Schluessel
+
{{ probed_type }} {{ probed_fingerprint }}
+

+ Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle (Konsole am + Geraet, Inventardokumentation), bevor Sie ihn freigeben. +

+
+ + + + +
+
+
+ {% endif %} + + {% if host_keys %} +
+ {% for key in host_keys %} +
{{ key.key_type }}
+
{{ key.fingerprint }}
+ {% endfor %} +
+ {% if current_user.has('devices.edit') %} +
+ + +
+ {% endif %} + {% else %} +

+ Noch kein Schluessel hinterlegt. Erst lesen, Fingerprint pruefen, dann freigeben. +

+ {% endif %} +
+
+
+ +
+
+
{{ icon('info', size=17) }} Stammdaten
+ {% if current_user.has('devices.edit') %} + Bearbeiten + {% endif %} +
+
+
+
Name
{{ device.name }}
+
IP
{{ device.ip }}
+
MAC
{{ device.mac }}
+
Switch
+
+ {% if device.switch_hostname %} + {{ device.switch_hostname }} + / {{ device.port }} + {% else %}nicht zugeordnet{% endif %} +
+
Kategorie
{{ device.category or '–' | safe }}
+
Standort
{{ device.location or '–' | safe }}
+
Zugangsdaten
{{ device.credential_name or 'keine' }}
+
Automatischer Neustart
{{ device.auto_restart | yes_no }}
+
Angelegt
{{ device.created_at | dt }}
+
Geaendert
+
{{ device.updated_at | dt }} von {{ device.updated_by or '-' }}
+
+ {% if device.notes %}

{{ device.notes }}

{% endif %} +
+ {% if current_user.has('devices.edit') %} + + {% endif %} +
+
+{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/devices.html b/apps/tesm/src/tesm/templates/tesm/devices.html new file mode 100644 index 0000000..6af8763 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/devices.html @@ -0,0 +1,279 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, select_field, textarea_field, checkbox, pill, + search_box, modal, empty_state, alert %} + +{# Neustart-Bedienelement. + + Ein einziger moeglicher Weg: ein Knopf, der ihn benennt. Mehrere: ein Knopf + fuer den bevorzugten Weg (PoE, weil er auch bei haengendem Betriebssystem + wirkt) und daneben die Auswahl. Kein Weg: gar nichts -- ein Knopf, der + verlaesslich nichts bewirkt, ist schlimmer als kein Knopf. #} +{% macro restart_control(device, methods, compact=False) %} + {% if methods %} + {% set first = methods[0] %} +
+ + + +
+ {% if methods | length > 1 %} + {% for other in methods[1:] %} +
+ + + +
+ {% endfor %} + {% endif %} + {% endif %} +{% endmacro %} + +{% macro device_form(device=None, scope='neu') %} +
+ {{ field('name', 'Name', value=device.name if device else '', required=True) }} + {{ field('mac', 'MAC-Adresse', value=device.mac if device else '', required=True, + placeholder='aa:bb:cc:dd:ee:ff', extra_class='') }} + {{ field('ip', 'IP-Adresse', value=device.ip if device else '', required=True, + placeholder='192.168.1.50') }} + + {{ field('port', 'Port am Switch', value=device.port if device else '', + placeholder='1 oder 1/1/3', + hint_text='Pflicht, sobald ein Switch zugeordnet ist.') }} + + {% if current_user.has('credentials.create') %} + + {% endif %} + {{ field('ssh_port', 'SSH-Port des Geraets', value=device.ssh_port if device else 22, type='number') }} + + {{ field('location', 'Standort', value=device.location if device else '') }} + {{ textarea_field('notes', 'Notizen', value=device.notes if device else '', rows=2, + extra_class='field--full') }} +
+
+ {{ checkbox('is_active', 'Geraet wird ueberwacht', checked=device.is_active if device else True) }} + {{ checkbox('auto_restart', 'Automatischer PoE-Neustart bei Ausfall', + checked=device.auto_restart if device else True, + hint_text='Greift nur mit gueltiger Lizenz und zugeordnetem Switch-Port.') }} +
+{% endmacro %} + +{% block page_title %}Clients{% endblock %} +{% block page_sub %} + {{ devices | length }} Geraete · {{ counts.online }} erreichbar, {{ counts.offline }} nicht erreichbar +{% endblock %} + +{% block topbar_extra %} + {{ search_box('devices-table', 'Name, IP, MAC, Switch ...', count_id='devices-visible') }} + {% if current_user.has('devices.create') %} + + {% endif %} +{% endblock %} + +{% block content %} +
+
+ {{ devices | length }} sichtbar + + + {{ icon('power', size=15) }} Neustarts + +
+
+ + + + + + + + + + + + + + + + {% for device in devices %} + + + + + + + + + + + + {% endfor %} + +
NameZustandIPMACZugangsdatenSwitch / PortLetzte PruefungHost-SchluesselAktion
+ {{ device.name }} + {% if not device.is_active %}{{ pill('deaktiviert', '') }}{% endif %} + {% if not device.auto_restart %} + ! + {% endif %} + {% if device.location %}
{{ device.location }}
{% endif %} +
+ {% if not device.is_active %}{{ pill('aus', '') }} + {% elif device.state == 'online' %}{{ pill('online', 'online') }} + {% elif device.state == 'offline' %}{{ pill('offline', 'offline') }} + {% else %}{{ pill('unbekannt', 'warning') }}{% endif %} + {% if device.fail_count %}({{ device.fail_count }}×){% endif %} + {{ device.ip }}{{ device.mac }} + {% if device.credential_name %} + {{ device.credential_name }} +
+ {{ category_labels.get(device.credential_category, device.credential_category) }} +
+ {% else %}keine{% endif %} +
+ {% if device.switch_hostname %} + {{ device.switch_hostname }} / {{ device.port }} + {% else %}nicht zugeordnet{% endif %} + + {% if device.last_check_at %} + {{ device.last_check_at | dt }} + {% else %}nie{% endif %} + + {% if device.credential_category == 'windows' %} + + nicht noetig + + {% elif device.host_key_known %}{{ pill('freigegeben', 'online') }} + {% else %} + + {{ pill('offen', 'warning') }} + + {% endif %} + + {% if current_user.has('devices.execute') %} + {{ restart_control(device, restart_methods.get(device.id, []), compact=True) }} + {% endif %} + {% if current_user.has('devices.edit') %} + + {% endif %} + {% if current_user.has('devices.delete') %} +
+ + +
+ {% endif %} +
+
+ {% if not devices %} + {{ empty_state('Keine Geraete', 'Legen Sie das erste ueberwachte Geraet an.', 'cpu') }} + {% endif %} +
+ +{{ alert('Ein PoE-Neustart setzt Switch und Port voraus, ein Neustart ueber SSH oder +RPC setzt passende Zugangsdaten voraus. Ist keine Methode moeglich, erscheint der Knopf +nicht -- damit kein Neustart vorgetaeuscht wird, der nichts bewirkt.', 'info') }} +{% endblock %} + + +{% block dialogs %} +{% if current_user.has('devices.create') %} + {% call modal('device-new', 'Geraet anlegen', 'MAC und IP sind Pflicht', 'wide') %} +
+ + + +
+ {% endcall %} +{% endif %} + +{% if current_user.has('devices.edit') %} + {% for device in devices %} + {% call modal('device-edit-' ~ device.id, device.name, device.mac, 'wide') %} +
+ + + +
+ {% endcall %} + {% endfor %} +{% endif %} +{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/dhcp.html b/apps/tesm/src/tesm/templates/tesm/dhcp.html new file mode 100644 index 0000000..585fec2 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/dhcp.html @@ -0,0 +1,446 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, checkbox, pill, empty_state, alert, search_box %} + +{% block page_title %}DHCP-Server{% endblock %} +{% block page_sub %} + Kea · {{ subnets | length }} Subnetze · {{ reservations | length }} Reservierungen +{% endblock %} + +{% block content %} +{% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %} +{% if not sysops_available %} + {{ alert('Systemhelfer nicht verfuegbar -- Konfiguration und Dienstzustand koennen hier nur + angezeigt, nicht geschrieben werden.', 'warning') }} +{% endif %} + +
+
+ Dienst + {{ service.get('active', 'unbekannt') }} + Autostart: {{ service.get('enabled', '?') }} +
+
+ Programm + {{ service.get('binary', '?') }} + kea-dhcp4 +
+
+ Reservierungen + {{ reservations | length }} + automatisch + manuell +
+
+ Leases + {{ leases | rejectattr('is_expired') | list | length }} + + gueltig{% if leases | selectattr('is_expired') | list %}, + {{ leases | selectattr('is_expired') | list | length }} abgelaufen{% endif %} + +
+
+ +{% if current_user.has('dhcp.execute') %} +
+
+
+
{{ icon('power', size=17) }} Dienst
+
+ Bewusst getrennt vom Speichern: eine abgelegte Konfiguration startet keinen Dienst. +
+
+
+
+ {% for action, label, style, question in [ + ('install', 'Kea installieren', '', 'Kea-DHCP-Server per apt installieren?'), + ('start', 'Aktivieren und starten', 'primary', 'Dienst aktivieren und starten? Er verteilt danach IP-Adressen im Netz.'), + ('stop', 'Stoppen und deaktivieren', 'danger', 'Dienst stoppen und deaktivieren? Clients erhalten danach keine Adressen mehr.')] %} +
+ + +
+ {% endfor %} +
+
+{% endif %} + +{# -- Subnetze ---------------------------------------------------------- #} +
+
+
+
{{ icon('network', size=17) }} Subnetze
+
+ Subnetz und Maske kommen vom laufenden System -- eingegeben wird nur der Adressbereich. +
+
+
+ {% if subnets %} +
+ + + + {% for subnet in subnets %} + + + + + + + + + {% endfor %} + +
InterfaceBereichGatewayDNSAktiv
{{ subnet.interface }}{{ subnet.range_start }} – {{ subnet.range_end }}{{ subnet.gateway or '–' | safe }}{{ subnet.dns or '–' | safe }}{% if subnet.enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('pausiert', '') }}{% endif %} + {% if current_user.has('dhcp.edit') %} +
+ + +
+ {% endif %} +
+
+ {% else %} + {{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }} + {% endif %} + + {% if current_user.has('dhcp.edit') %} +
+ +
+
+ + {{ field('range_start', 'Von', required=True, placeholder='192.168.1.100') }} + {{ field('range_end', 'Bis', required=True, placeholder='192.168.1.200') }} + {{ field('gateway', 'Gateway', value=default_gateway) }} + {{ field('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }} + {{ field('comment', 'Kommentar') }} +
+
+ +
+ {% endif %} +
+ +{# -- Globale Einstellungen --------------------------------------------- #} +{% if current_user.has('dhcp.edit') %} +
+
+
{{ icon('sliders', size=17) }} Globale Einstellungen
+
+
+ +
+
+ {{ field('dhcp_domain', 'Domainname', value=values.dhcp_domain) }} + {{ field('dhcp_lease_default', 'Lease-Dauer (s)', value=values.dhcp_lease_default, type='number') }} + {{ field('dhcp_lease_max', 'Maximale Lease-Dauer (s)', value=values.dhcp_lease_max, type='number') }} + {{ field('dhcp_output_path', 'Zielpfad der Konfiguration', value=values.dhcp_output_path, + extra_class='field--full') }} +
+
+ +
+
+{% endif %} + +{# -- Optionen ---------------------------------------------------------- #} +
+
+
+
{{ icon('puzzle', size=17) }} DHCP-Optionen
+
+ {{ options | length }} Optionen · Standardoptionen sind nicht loeschbar, ihr Wert + laesst sich leeren +
+
+ {{ search_box('options-table', 'Option suchen ...') }} +
+
+ + + + {% for option in options %} + + + + + + + + {% endfor %} + +
CodeNameTypWert
{{ option.code }} +
{{ option.name }}
+
{{ option.description }}
+
{{ option.type }} + {% if current_user.has('dhcp.edit') %} +
+ + + + +
+ {% else %} + {{ option.value or '–' | safe }} + {% endif %} +
+ {% if current_user.has('dhcp.edit') and not option.is_standard %} +
+ + +
+ {% endif %} +
+
+ {% if current_user.has('dhcp.edit') %} +
+ +
+
+ {{ field('code', 'Code', type='number', required=True) }} + {{ field('name', 'Name', required=True, placeholder='meine-option') }} + + {{ field('description', 'Beschreibung') }} +
+
+ +
+ + {# Der Wert einer Option gilt sonst fuer alle. Genau dafuer gibt es + ``dhcp_option_values.device_id``: ein eigener Wert je Geraet, der in + dessen Reservierung geschrieben wird -- etwa eine Terminal-URL, die je + Geraet anders lautet. Auswertbar war das von Anfang an, eingeben liess + es sich nicht. #} +
+
+
{{ icon('sliders', size=17) }} Option nur fuer ein Geraet
+
+ Der Wert landet in der Reservierung dieses Geraets und ueberschreibt dort den + globalen Wert. Leeres Feld entfernt ihn wieder. +
+
+
+
+ +
+
+ + + {{ field('value', 'Wert', extra_class='field--full', + hint_text='Leer = Wert fuer dieses Geraet entfernen') }} +
+
+ +
+ {% endif %} +
+ +{# -- Reservierungen und Leases ----------------------------------------- #} +
+
+
+
+
{{ icon('credit-card', size=17) }} Reservierungen
+
+ Automatisch aus dem Geraetebestand; bei MAC-Kollision gewinnt der Geraetebestand. + Nur Adressen aus eingerichteten Bereichen. +
+
+
+ {% if skipped_reservations %} + {# Nicht verschweigen, sondern benennen: vorher standen diese Eintraege in + der Liste, obwohl Kea sie nie ausgeliefert hat. #} +
+ {{ alert(skipped_reservations | length ~ ' Geraet(e) liegen in keinem eingerichteten + Bereich und bekommen keine Reservierung. Legen Sie den passenden Bereich an, wenn sie + eine bekommen sollen.', 'warning') }} +
+ Welche? +
    + {% for entry in skipped_reservations %} +
  • {{ entry.ip }} – {{ entry.name }} + ({{ entry.source }})
  • + {% endfor %} +
+
+
+ {% endif %} +
+ + + + + {% for reservation in reservations %} + + + + + + + + + + {% endfor %} + +
MACIPHostnameZustandQuelleEigene Optionen
{{ reservation.mac }}{{ reservation.ip }}{{ reservation.hostname }} + {{ pill(reservation.state | replace('_', ' '), reservation.state_style) }} + {% if reservation.state == 'andere_adresse' and reservation.lease %} +
jetzt {{ reservation.lease.address }}
+ {% elif reservation.state == 'aktiv' and reservation.lease %} +
{{ reservation.lease.remaining_text }}
+ {% endif %} +
{{ pill(reservation.source, 'accent' if reservation.source == 'auto' else '') }} + {% set eigene = device_option_values.get(reservation.device_id) if reservation.device_id else none %} + {% if eigene %} + {% for wert in eigene %} +
{{ wert.code }} {{ wert.name }} = {{ wert.value }}
+ {% endfor %} + {% else %} + + {% endif %} +
+ {% if reservation.source == 'manuell' and current_user.has('dhcp.edit') %} +
+ + +
+ {% endif %} +
+
+ {% if current_user.has('dhcp.edit') %} +
+ +
+
+ {{ field('mac', 'MAC', required=True, placeholder='aa:bb:cc:dd:ee:ff') }} + {{ field('ip', 'IP', required=True) }} + {{ field('hostname', 'Hostname') }} + {{ field('comment', 'Kommentar') }} +
+
+ +
+ {% endif %} +
+ +
+
+
+
{{ icon('clock', size=17) }} Leases
+
+ Direkt aus {{ lease_file }} gelesen – je Adresse gilt der letzte Eintrag. +
+
+
+ {% if leases and service.get('active') != 'active' %} + {# Sonst sieht man alte Eintraege und haelt sie fuer den aktuellen Stand: + ohne laufenden Dienst wird keine Lease erneuert, und die Datei + veraltet still. #} +
+ {{ alert('Der DHCP-Dienst laeuft nicht (' ~ service.get('active', 'unbekannt') ~ '). + Es wird keine Lease erneuert -- die Liste zeigt den letzten Stand der Datei, nicht die + aktuelle Lage. Der Grund steht im Kea-Protokoll.', 'warning') }} +
+ {% endif %} + {% if leases %} +
+ + + + + {% for lease in leases %} + + + + + + + + + {% endfor %} + +
IPMACHostnameArtZustandLaeuft ab
{{ lease.address }}{{ lease.mac }}{{ lease.hostname or '–' | safe }} + {% if lease.address in reserved_ips %}{{ pill('Reservierung', 'accent') }} + {% else %}{{ pill('dynamisch', '') }}{% endif %} + + {% if lease.is_expired %}{{ pill('abgelaufen', 'warning') }} + {% elif lease.is_active %}{{ pill('gueltig', 'online') }} + {% else %}{{ pill('inaktiv', '') }}{% endif %} + {{ lease.expires_text }} +
{{ lease.remaining_text }}
+
+
+ {% else %} + {{ empty_state('Keine Leases', 'Es sind keine vergebenen Adressen bekannt.', 'clock') }} + {% endif %} +
+
+ +{# -- Vorschau ---------------------------------------------------------- #} +
+ +
+
{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration
+
Vorschau – so wuerde die Datei geschrieben
+
+ {{ icon('chevron-down', size=16) }} +
+
{{ preview }}
+ {% if current_user.has('dhcp.edit') %} + + {% endif %} +
+{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/fileshare.html b/apps/tesm/src/tesm/templates/tesm/fileshare.html new file mode 100644 index 0000000..532c402 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/fileshare.html @@ -0,0 +1,168 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, empty_state, alert, pill, modal %} + +{% block page_title %}Dateifreigaben{% endblock %} +{% block page_sub %}{{ mounts | length }} eingebundene Freigabe(n){% endblock %} + +{% block topbar_extra %} + {% if current_user.has('fileshare.edit') %} + + {{ icon('settings', size=15) }} Zuordnungen + + {% endif %} +{% endblock %} + +{% block content %} +{% if not mounts %} + {{ empty_state('Keine Freigaben eingebunden', + 'Einbindungen entstehen nach einer Anmeldung gegen den Verzeichnisdienst und richten sich + nach Ihrer Gruppenmitgliedschaft.', 'folder') }} +{% else %} + {% if error %}{{ alert(error, 'danger') }}{% endif %} + +
+ {% for mount in mounts %} + {{ mount.label }} + {% endfor %} +
+ + {% if listing %} + {% set current = (mounts | selectattr('token', 'equalto', token) | list | first) %} +
+
+
+ +
{{ current.unc }}
+
+ + Einbindung endet {{ current.expires_at | dt }} + + {% if current_user.has('fileshare.create') %} + + {% endif %} +
+ +
+ + +
+ + + + {% if current_user.has('fileshare.delete') %}{% endif %} + + + + + + + + {% if listing.breadcrumb %} + + {% if current_user.has('fileshare.delete') %}{% endif %} + + + {% endif %} + {% for entry in listing.directories %} + + {% if current_user.has('fileshare.delete') %} + + {% endif %} + + + + + + {% endfor %} + {% for entry in listing.files %} + + {% if current_user.has('fileshare.delete') %} + + {% endif %} + + + + + + {% endfor %} + +
NameGroesseGeaendertAktion
+ + {{ icon('chevron-right', size=14) }} eine Ebene hoeher + +
+ {{ icon('folder', size=15) }} + {{ entry.name }} + {{ entry.modified | dt }}
{{ icon('file-text', size=15) }} {{ entry.name }}{{ entry.size | bytes }}{{ entry.modified | dt }} + {% if entry.previewable %} + {{ icon('eye', size=15) }} + {% endif %} + {{ icon('download', size=15) }} +
+
+ {% if not listing.directories and not listing.files %} + {{ empty_state('Leeres Verzeichnis', '', 'folder') }} + {% endif %} + {% if current_user.has('fileshare.delete') %} + + {% endif %} +
+
+ + {% if current_user.has('fileshare.create') %} +
+
+
+
{{ icon('upload', size=17) }} Dateien hochladen
+
nach {{ listing.relative or 'Wurzelverzeichnis' }} · max. {{ max_upload_mb }} MB je Datei
+
+
+
+ + +
+ + +
+
+
+ {% endif %} + {% endif %} +{% endif %} +{% endblock %} + +{% block dialogs %} +{% if listing and current_user.has('fileshare.create') %} + {% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %} +
+ + + + +
+ {% endcall %} +{% endif %} +{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/fileshare_mappings.html b/apps/tesm/src/tesm/templates/tesm/fileshare_mappings.html new file mode 100644 index 0000000..28ce10a --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/fileshare_mappings.html @@ -0,0 +1,72 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/_group_picker.html" import group_picker %} +{% from "tesm_core/macros.html" import field, empty_state, alert %} +{% block page_title %}Freigaben einrichten{% endblock %} +{% block page_sub %}{{ mappings | length }} Zuordnungen · Wurzel {{ mount_root }}{% endblock %} +{% block content %} + +{{ alert('Eine Freigabe wird bei der Anmeldung eingebunden, wenn der Benutzer Mitglied der +zugeordneten Verzeichnisgruppe ist. Eingebunden wird mit dem Konto dieses Benutzers -- deshalb +ist die Verzeichnisgruppe das einzige moegliche Ziel, und lokale Konten bekommen keine +Einbindungen.', 'info') }} + +{% if not directory_enabled %} + {{ alert('Die Verzeichnisanmeldung ist nicht aktiv. Ohne sie kann keine Freigabe eingebunden + werden.', 'warning') }} +{% endif %} + +
+
+
{{ icon('folder', size=17) }} Zuordnungen
+
+ {% if mappings %} +
+ + + + + + {% for entry in mappings %} + + + + + + + {% endfor %} + +
AnzeigenameUNC-PfadVerzeichnisgruppe
{{ entry.label }}{{ entry.unc }}{{ entry.ad_group_name }} +
{{ entry.ad_group_dn }}
+
+ + +
+
+
+ {% else %} + {{ empty_state('Keine Zuordnungen', 'Legen Sie die erste Freigabe an.', 'folder') }} + {% endif %} +
+ +
+
+
{{ icon('plus', size=17) }} Freigabe einrichten
+
+
+ +
+
+ {{ field('label', 'Anzeigename', required=True, placeholder='Abteilung Technik') }} + {{ field('unc', 'UNC-Pfad', required=True, placeholder='\\\\dc2022\\daten', + hint_text='Schraegstriche gehen auch: //dc2022/daten') }} + {{ group_picker('ad_group_name', 'ad_group_dn', 'share', required=True, + label='Verzeichnisgruppe, die diese Freigabe bekommt') }} +
+
+ +
+
+{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/hostkeys.html b/apps/tesm/src/tesm/templates/tesm/hostkeys.html new file mode 100644 index 0000000..4033373 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/hostkeys.html @@ -0,0 +1,179 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %} + +{% block page_title %}Host-Schluessel{% endblock %} +{% block page_sub %} + {{ targets | length }} Ziele mit SSH-Zugang + {%- if open_count %} · {{ open_count }} ohne Freigabe{% endif %} +{% endblock %} + +{% block topbar_extra %}{{ search_box('hostkey-table', 'Geraet, IP, Fingerprint ...') }}{% endblock %} + +{% block content %} +{% if open_count %} + {{ alert('Fuer ' ~ open_count ~ ' Ziel(e) ist noch kein Schluessel freigegeben. Wartung, + Konsole und PoE-Neustart brechen dort mit "Schluessel unbekannt" ab -- absichtlich: ein + Hintergrundauftrag darf keinen fremden Schluessel annehmen.', 'warning', + 'Freigaben offen') }} +{% else %} + {{ alert('Fuer jedes Ziel mit SSH-Zugang liegt ein freigegebener Schluessel vor.', + 'success', 'Vollstaendig') }} +{% endif %} + +{{ alert('Ein Schluessel wird in zwei Schritten uebernommen: erst auslesen, dann -- nach +Vergleich mit einer unabhaengigen Quelle -- freigeben. Gespeichert wird nach IP und Port; +aendert sich die Adresse eines Geraets, ist erneut freizugeben. Ein *geaenderter* Schluessel +wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff auftritt.', +'info', 'So funktioniert die Freigabe') }} + +{% if probed_fingerprint %} +
+ {{ icon('alert', size=18) }} +
+
Gelesener Schluessel von {{ probed_host }}:{{ probed_port }}
+
{{ probed_type }} {{ probed_fingerprint }}
+

+ Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle -- Konsolenzugang am + Geraet, Inventardokumentation --, bevor Sie ihn unten freigeben. +

+
+
+{% endif %} + +
+
+
+
{{ icon('shield', size=17) }} Ziele
+
Ohne Freigabe zuerst
+
+
+
+ + + + + + + + + + + + + {% for row in targets %} + {% set may_edit = current_user.has(edit_right[row.kind]) %} + + + + + + + + + {% endfor %} + +
ZielArtAdresseZustandFingerprint
+ {{ row.label }} + + {% if row.kind == 'switch' %}{{ pill('Switch', 'info') }} + {% else %}{{ pill('Client', '') }}{% endif %} + {{ row.host }}:{{ row.port }} + {% if row.trusted %}{{ pill('freigegeben', 'online') }} + {% else %}{{ pill('offen', 'danger') }}{% endif %} + + {% for key in row.host_keys %} +
{{ key.key_type }} {{ key.fingerprint }}
+ {% else %} + + {% endfor %} +
+ {% if may_edit %} +
+
+ + +
+ {% if probed_fingerprint and probed_host == row.host + and probed_port == row.port | string %} +
+ + + + +
+ {% endif %} + {% if row.trusted %} +
+ + +
+ {% endif %} +
+ {% else %} + keine Berechtigung + {% endif %} +
+
+ {% if not targets %} + {{ empty_state('Keine Ziele', 'Legen Sie zuerst Clients oder Switche an.', 'shield') }} + {% endif %} +
+ +{% if orphans %} +
+
+
+
{{ icon('alert', size=17) }} Verwaiste Freigaben
+
+ Zu diesen Eintraegen gibt es kein Ziel mehr -- geloescht oder mit neuer IP. +
+
+
+
+ + + + {% for row in orphans %} + + + + + + + + {% endfor %} + +
AdresseTypFingerprintFreigegeben
{{ row.host }}:{{ row.port }}{{ row.key_type }}{{ row.fingerprint }}{{ row.added_at | dt }} von {{ row.added_by }} + {% if current_user.has('devices.edit') or current_user.has('switches.edit') %} +
+ + + + +
+ {% endif %} +
+
+
+{% endif %} +{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/license.html b/apps/tesm/src/tesm/templates/tesm/license.html new file mode 100644 index 0000000..5071580 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/license.html @@ -0,0 +1,312 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, checkbox, alert, pill, empty_state %} + +{% block page_title %}Lizenz{% endblock %} +{% block page_sub %} + {% if state and state.valid and state.activated %}aktiv + {% elif state and state.valid %}gueltig, noch nicht aktiviert + {% elif state and state.present %}ungueltig + {% else %}keine Lizenz installiert{% endif %} +{% endblock %} + +{% block content %} +{% if state and state.error %}{{ alert(state.error, 'danger') }}{% endif %} +{% for warning in (state.warnings if state else []) %}{{ alert(warning, 'warning') }}{% endfor %} + +
+
+ {% if state and state.valid and state.status %} +
+
+
+
+ {{ icon('key', size=17) }} Installierte Lizenz + {% if state.activated %}{{ pill('aktiviert', 'online') }} + {% else %}{{ pill('nicht aktiviert', 'warning') }}{% endif %} + {% if state.issuer_pinned %}{{ pill('Aussteller gepinnt', 'info') }}{% endif %} +
+
+ {{ type_labels.get(state.status.type, state.status.type) }} · + {{ role_labels.get(state.role, state.role) }} +
+
+
+
+
+
Kunde
{{ state.customer_name or '–' | safe }}
+
Lizenz-ID
{{ state.status.license_id }}
+
Revision
{{ state.status.seq }}
+
Ausgestellt
{{ state.status.issued_at | dt }}
+
Laeuft ab
+
+ {% if state.status.lifetime %}{{ pill('unbefristet', 'online') }} + {% else %} + {{ state.status.expires_at | dt }} + {% if state.status.expired %} + {{ pill('abgelaufen seit ' ~ state.status.days_since_expiry ~ ' Tagen', 'danger') }} + {% if state.status.grace_active %} +
+ Kulanzfrist laeuft ({{ grace_days }} Tage) – Funktionen bleiben vorerst nutzbar. +
+ {% endif %} + {% elif state.status.expiring_soon %} + {{ pill('in ' ~ state.status.days_left ~ ' Tagen', 'warning') }} + {% else %} + {{ pill('in ' ~ state.status.days_left ~ ' Tagen', 'online') }} + {% endif %} + {% endif %} +
+
Module
+
+ {% if state.modules %} + {% for module in modules %} + {% if module in state.modules %}{{ pill(module_labels[module], 'online') }} + {% else %}{{ pill(module_labels[module], '') }}{% endif %} + {% endfor %} + {% else %}keine Zusatzmodule{% endif %} +
+
Lizenzserver
+
{{ state.status.master_endpoint or 'nicht hinterlegt (nur Offline-Weg)' }}
+
Letzter Heartbeat
+
+ {{ state.last_heartbeat_at | dt }} + {% if state.heartbeat_error %} +
{{ state.heartbeat_error }}
+ {% endif %} +
+
+
+ {% if current_user.has('settings_license.edit') %} + + {% endif %} +
+ {% endif %} + + {% if pending %} +
+
+
+
{{ icon('transfer', size=17) }} Offene Anfrage (Offline-Weg)
+
+ Aktion: {{ pending.action }} · erzeugt {{ pending.created_at | dt }} +
+
+
+
+ {% if pending.reason %} + {{ alert(pending.reason, 'warning', 'Warum der Online-Weg nicht ging') }} + {% endif %} + {{ alert('Beide Codes sind signiert -- sie lassen sich unterwegs nicht veraendern. + Der Anfragecode gehoert zu genau dieser Maschine und genau dieser Anfrage; eine + Antwort auf einen anderen Code wird abgelehnt.', 'info') }} + + {% if pending.code %} +
+ + Anfragecode -- an den Anbieter uebermitteln + +
{{ pending.code }}
+
+ +
+
+ {% else %} + {{ alert('Zu dieser Anfrage liegt kein Code vor -- sie stammt aus einer aelteren + Fassung. Loesen Sie die Aktion erneut aus, dann entsteht ein neuer Code.', + 'warning') }} + {% endif %} + +
+ + +
+ +
+
+
+
+ {% endif %} + + {% if current_user.has('settings_license.edit') %} +
+
+
+
{{ icon('shield', size=17) }} Zertifikat des Lizenzservers
+
+ Nur noetig, wenn der Lizenzserver ein eigenes Zertifikat benutzt +
+
+
+
+ {{ alert('Ein Lizenzserver im eigenen Netz hat meist ein selbstsigniertes + Zertifikat. Ohne Anker scheitert die Pruefung, und jeder Versuch faellt auf den + Offline-Weg zurueck. Die Pruefung abzuschalten waere der falsche Weg -- hinterlegen + Sie stattdessen das Zertifikat, dann wird gegen genau dieses geprueft.', 'info') }} +
+ + +
+ +
+
+
+
+ {% endif %} + + {% if current_user.has('settings_license.edit') %} +
+
+
+
{{ icon('upload', size=17) }} Lizenzdatei einspielen
+
+ Die Datei enthaelt das Lizenzdokument und den zugehoerigen Client-Schluessel +
+
+
+
+ +
+ + {% if trusted_keys and current_user.is_admin %} + {{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdruecklich zulassen', + hint_text='Nur ankreuzen, wenn die neue Lizenz bewusst von einem anderen + Lizenzserver kommt.') }} + {% endif %} +
+ +
+
+ {% endif %} +
+ +
+
+
+
+
{{ icon('hard-drive', size=17) }} Diese Maschine
+
Wird zur Bindung der Lizenz verwendet
+
+
+
+
+
Hostname
{{ hostname }}
+
Fingerprint
{{ fingerprint }}
+
+

+ Der Fingerprint stammt aus der stabilen Maschinenidentitaet. Eine Umbenennung des Hosts + aendert ihn nicht mehr -- im Vorgaenger fuehrte genau das zu + „bereits anderweitig gebunden“. +

+
+
+ +
+
+
+
{{ icon('shield', size=17) }} Vertrauenswuerdige Aussteller
+
{{ trusted_keys | length }} gepinnte Schluessel
+
+
+ {% if trusted_keys %} +
+ + + + {% for key in trusted_keys %} + + + + + + + {% endfor %} + +
Schluessel-IDBezeichnungQuelle
{{ key.key_id }}{{ key.label }} +
{{ key.added_at | dt }}
{{ key.source }} + {% if current_user.is_admin %} +
+ + +
+ {% endif %} +
+
+ {% else %} + {{ empty_state('Kein Aussteller gepinnt', + 'Beim ersten Import wird der Schluessel des Lizenzservers uebernommen und danach + erwartet.', 'shield') }} + {% endif %} +
+ +
+
+
+
{{ icon('puzzle', size=17) }} Was die Lizenz freischaltet
+
+
+
+
    + {% for key, label in features.items() %} +
  • + {% if feature_allowed(key) %} + {{ icon('check', size=14) }} + {% else %} + {{ icon('lock', size=14) }} + {% endif %} + {{ label }} +
  • + {% endfor %} +
+

+ Diese Liste ist die einzige Wahrheit: was hier steht, wird serverseitig durchgesetzt -- + nicht nur in der Oberflaeche ausgegraut. +

+
+
+
+
+{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/logs_kea.html b/apps/tesm/src/tesm/templates/tesm/logs_kea.html new file mode 100644 index 0000000..acf8a93 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/logs_kea.html @@ -0,0 +1,14 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import alert %} +{% block page_title %}Kea-DHCP-Protokoll{% endblock %} +{% block page_sub %}{{ path }}{% endblock %} +{% block content %} +{% if error %}{{ alert(error, 'warning') }}{% endif %} +{{ alert('Das Protokoll liegt unter /var/log/kea/. Es darf nicht in das +Protokollverzeichnis der Anwendung wandern: das AppArmor-Profil von Kea erlaubt +ausschliesslich diesen fest einprogrammierten Pfad.', 'info') }} +
+
{{ icon('network', size=17) }} Letzte Zeilen
+ {% include "tesm_core/_log_lines.html" %} +
+{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/logs_restarts.html b/apps/tesm/src/tesm/templates/tesm/logs_restarts.html new file mode 100644 index 0000000..9e8a396 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/logs_restarts.html @@ -0,0 +1,58 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %} +{% block page_title %}Neustarts{% endblock %} +{% block page_sub %}{{ rows | length }} Neustarts (neueste zuerst){% endblock %} +{% block topbar_extra %}{{ search_box('restart-table', 'Geraet, Ergebnis, Methode ...') }}{% endblock %} +{% block content %} +{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Ausloeser festgehalten -- automatisch +wie manuell, ueber den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung. +Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }} + +
+
+
+
Verlauf
+
+ {% for value, label in methods %} + {{ label }} + {% endfor %} + Alle +
+
+
+
+ + + + + + + + + {% for row in rows %} + + + + + + + + + + + {% endfor %} + +
ZeitGeraetMethodeAusloeserBenutzerErgebnisDauerDetail
{{ row.ts | dt }}{{ row.device_name or '–' | safe }} + {% if row.method == 'ssh' %}{{ pill('SSH', 'info') }} + {% else %}{{ pill('PoE-Port', '') }}{% endif %} + {{ row.trigger }}{{ row.actor }} + {% if row.result == 'ok' %}{{ pill('erfolgreich', 'online') }} + {% elif row.result == 'skipped' %}{{ pill('uebersprungen', 'warning') }} + {% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %} + {{ row.duration_ms or 0 }} ms{{ row.detail }}
+
+ {% if not rows %}{{ empty_state('Noch keine Neustarts', '', 'power') }}{% endif %} +
+{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/maintenance.html b/apps/tesm/src/tesm/templates/tesm/maintenance.html new file mode 100644 index 0000000..9feac70 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/maintenance.html @@ -0,0 +1,219 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %} + +{% block page_title %}Wartung{% endblock %} +{% block page_sub %} + {{ devices | length }} Geraete mit Zugangsdaten der Kategorie + „Linux-Client“ oder „Windows-Client“ +{% endblock %} + +{% block topbar_extra %}{{ search_box('maintenance-table', 'Geraet suchen ...') }}{% endblock %} + +{% block content %} +{% if devices | rejectattr('host_key_ready') | list %} + {{ alert('Fuer mindestens ein Geraet ist der SSH-Host-Schluessel noch nicht freigegeben. + Die Wartung bricht dort mit "Schluessel unbekannt" ab. Die Freigabe erfolgt auf der + Detailseite des Geraets -- erst auslesen, Fingerprint pruefen, dann freigeben.', + 'warning', 'Host-Schluessel offen') }} +{% endif %} + +{% if windows_tool_missing %} + {# Die Meldung im Auftrag nennt das fehlende Paket -- ohne diesen Knopf waere sie + eine Sackgasse. #} +
+ {{ icon('alert', size=18) }} +
+ Windows-Neustart nicht moeglich + Auf diesem Server fehlt das Paket {{ windows_package }}. Es bringt + net mit, ueber das der Neustart eines Windows-Geraetes laeuft. +
+
+ + +
+
+{% endif %} + +{{ alert('Welche Geraete hier erscheinen und was mit ihnen moeglich ist, entscheidet die +Kategorie ihrer Zugangsdaten. Linux-Clients werden ueber SSH aktualisiert und neu gestartet. +Windows-Clients werden nur neu gestartet, und zwar ueber RPC (wie "shutdown /m") -- sie +brauchen dafuer weder SSH noch einen Host-Schluessel, sondern die offene Datei- und +Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Port.', +'info') }} + +
+ +
+
+ + + {% if current_user.has('maintenance.execute') %} + + + {% endif %} +
+ +
+ + + + + + + + + + + + + + {% for device in devices %} + + + + + + + + + + {% endfor %} + +
GeraetIPZustandZugangsdatenHost-Schluessel
+ + {{ device.name }}{{ device.ip }} + {% if device.state == 'online' %}{{ pill('online', 'online') }} + {% elif device.state == 'offline' %}{{ pill('offline', 'offline') }} + {% else %}{{ pill('unbekannt', 'warning') }}{% endif %} + + {{ device.credential_name }} +
{{ category_labels.get(device.category, device.category) }}
+
+ {% if not device.ssh_managed %} + + nicht noetig + + {% elif device.host_key_ready %}{{ pill('freigegeben', 'online') }} + {% else %} + + {{ pill('offen', 'danger') }} + + {% endif %} + + {% if terminal_enabled and device.ssh_managed + and current_user.has('maintenance.execute') %} + + {{ icon('terminal', size=14) }} Konsole + + {% endif %} +
+
+ {% if not devices %} + {{ empty_state('Keine passenden Geraete', + 'Setzen Sie bei den Zugangsdaten die Kategorie "Linux-Client" und ordnen Sie + sie einem Geraet zu.', 'wrench') }} + {% endif %} +
+
+ +{# Kein data-refresh-url: die Schleife in app.js schreibt die Antwort in + innerHTML. Hier zeigte sie auf die Seite selbst -- die komplette + Wartungsseite landete damit in dieser Karte, mitsamt Seitenleiste und + Suchfeld. Aktualisiert wird ueber die Abfrage unten, die bei laufenden + Auftraegen die ganze Seite neu laedt. #} +
+
+
+
{{ icon('clock', size=17) }} Auftraege
+
+ Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr +
+
+
+ {% if jobs %} +
+ + + + {% for job in jobs %} + + + + + + + + + {% endfor %} + +
GestartetGeraetArtZustandFortschrittMeldung
{{ job.created_at | dt }} + {{ job.subject }} + {{ 'Aktualisierung' if job.kind.endswith('update') else 'Neustart' }} + {% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }} + {% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }} + {% elif job.state == 'running' %}{{ pill('laeuft', 'info') }} + {% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }} + {% else %}{{ pill('wartet', '') }}{% endif %} + +
+
+
+
{{ job.message }}
+
+ {% else %} + {{ empty_state('Keine Auftraege', 'Es wurde noch keine Wartung gestartet.', 'clock') }} + {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/maintenance_job.html b/apps/tesm/src/tesm/templates/tesm/maintenance_job.html new file mode 100644 index 0000000..11cc87d --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/maintenance_job.html @@ -0,0 +1,52 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import pill %} +{% block page_title %}{{ job.subject }}{% endblock %} +{% block page_sub %}{{ 'Aktualisierung' if job.kind.endswith('update') else 'Neustart' }} · {{ job.created_at | dt }}{% endblock %} +{% block breadcrumb %} + +{% endblock %} +{% block content %} +
+
+
+
+ {{ icon('wrench', size=17) }} Auftrag + {% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }} + {% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }} + {% elif job.state == 'running' %}{{ pill('laeuft', 'info') }} + {% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }} + {% else %}{{ pill('wartet', '') }}{% endif %} +
+
{{ job.message }}
+
+ gestartet von {{ job.started_by }} +
+
+
+
+
+
+
+ {% include "tesm/_maintenance_output.html" %} +
+
+
+ Zurueck +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/settings_system.html b/apps/tesm/src/tesm/templates/tesm/settings_system.html new file mode 100644 index 0000000..59786f9 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/settings_system.html @@ -0,0 +1,169 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %} + +{% block page_title %}Systemeinstellungen{% endblock %} +{% block page_sub %}Ueberwachung, Protokolle, Host, Navigation{% endblock %} + +{% block content %} +{% if not sysops_available %} + {{ alert('Systemhelfer nicht verfuegbar -- Hostname und Zeitzone koennen hier nur angezeigt, + nicht geaendert werden. In einer Entwicklungsumgebung ist das der erwartete Zustand.', + 'info') }} +{% endif %} + +
+
+
+
+
+
{{ icon('activity', size=17) }} Ueberwachung
+
Wie oft geprueft und wann automatisch geschaltet wird
+
+ {% if current_user.has('settings_system.execute') %} +
+ + +
+ {% endif %} +
+
+ +
+ {% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %} +
+ {{ field('monitor_interval_seconds', 'Pruefintervall (Sekunden)', + value=values.monitor_interval_seconds, type='number', + disabled=bool(license_hint), + hint_text=specs['monitor_interval_seconds'].hint) }} + {{ field('monitor_failures_before_restart', 'Fehlversuche bis zum PoE-Neustart', + value=values.monitor_failures_before_restart, type='number', + hint_text=specs['monitor_failures_before_restart'].hint) }} +
+ {{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren', + checked=values.monitor_auto_restart, + hint_text='Zusaetzlich lizenzpflichtig. Der manuelle Knopf im Dashboard + funktioniert unabhaengig davon.') }} + {{ checkbox('dashboard_public', 'Statusuebersicht ohne Anmeldung erreichbar', + checked=values.dashboard_public, + hint_text=specs['dashboard_public'].hint) }} +
+ +
+
+ +
+
+
+
{{ icon('terminal', size=17) }} Switch-Konsole
+
+ Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestaetigung, + vollstaendige Mitschrift +
+
+
+
+ +
+ {{ checkbox('terminal_enabled', 'Browser-Konsole fuer Switche zulassen', + checked=values.terminal_enabled) }} + {{ field('terminal_idle_timeout', 'Trennen bei Untaetigkeit (Sekunden)', + value=values.terminal_idle_timeout, type='number') }} +
+ +
+
+ +
+
+
+
{{ icon('file-text', size=17) }} Protokolle und Papierkorb
+
+
+
+ +
+
+ {{ select_field('log_rotate_interval', 'Rotationsintervall', + specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }} + {{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep, + type='number') }} + {{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)', + value=values.trash_retention_days, type='number', + hint_text=specs['trash_retention_days'].hint) }} +
+
+ +
+
+
+ +
+
+
+
{{ icon('server', size=17) }} Host
+
+
+
+ + {{ field('hostname', 'Hostname', value=hostname, disabled=not sysops_available) }} + +
+
+
+ + + +
+
+
+ + {% if current_user.is_admin %} +
+
+
+
{{ icon('sliders', size=17) }} Navigationsreihenfolge
+
Gilt fuer alle Benutzer; jeder sieht weiter nur, was er darf
+
+
+
+ +
+
    + {% for key in nav_order %} + {% set item = nav_items | selectattr('key', 'equalto', key) | list | first %} + {% if item %} +
  1. + + + {{ icon(item.icon, size=15) }} {{ item.label }} +
  2. + {% endif %} + {% endfor %} +
+

+ Die Reihenfolge folgt der Liste. Zum Umsortieren die Eintraege in der gewuenschten + Reihenfolge speichern (Drag-and-drop folgt in einer spaeteren Version). +

+
+ +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/switch_command.html b/apps/tesm/src/tesm/templates/tesm/switch_command.html new file mode 100644 index 0000000..dce72ed --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/switch_command.html @@ -0,0 +1,23 @@ +{% extends "tesm_core/base.html" %} +{% block page_title %}{{ switch.hostname }}{% endblock %} +{% block page_sub %}{{ command }}{% endblock %} +{% block breadcrumb %} + +{% endblock %} +{% block content %} +
+
+
{{ icon('terminal', size=17) }} Ausgabe
+
{{ command }}
+ +
+
{{ output }}
+
+
+ Zurueck +
+{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/switch_detail.html b/apps/tesm/src/tesm/templates/tesm/switch_detail.html new file mode 100644 index 0000000..6f6b0c0 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/switch_detail.html @@ -0,0 +1,184 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import pill, empty_state, alert, select_field %} + +{% block page_title %}{{ switch.hostname }}{% endblock %} +{% block page_sub %}{{ switch.ip }}:{{ switch.ssh_port }}{% if switch.model %} · {{ switch.model }}{% endif %}{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block topbar_extra %} + {% if terminal_enabled and current_user.has('switches.execute') and host_keys %} + + {{ icon('terminal', size=15) }} Konsole + + {% endif %} +{% endblock %} + +{% block content %} +
+
+
+
+
+
+ {{ icon('lock', size=17) }} SSH-Host-Schluessel + {% if host_keys %}{{ pill('freigegeben', 'online') }}{% else %}{{ pill('offen', 'danger') }}{% endif %} +
+
+ Ohne freigegebenen Schluessel verweigert die Anwendung jeden Zugriff auf diesen Switch. +
+
+ {% if current_user.has('switches.edit') %} +
+ + +
+ {% endif %} +
+
+ {% if request.args.get('probed_fingerprint') %} +
+ {{ icon('alert', size=18) }} +
+
Gelesener Schluessel
+
+ {{ request.args.get('probed_type') }} {{ request.args.get('probed_fingerprint') }} +
+

+ Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle (Konsolenzugang am + Switch, Inventardokumentation), bevor Sie ihn freigeben. +

+ {% if current_user.has('switches.edit') %} +
+ + + + +
+ {% endif %} +
+
+ {% endif %} + + {% if host_keys %} +
+ {% for key in host_keys %} +
{{ key.key_type }}
+
{{ key.fingerprint }} +
freigegeben {{ key.added_at | dt }} von {{ key.added_by }}
+
+ {% endfor %} +
+ {% if current_user.has('switches.edit') %} +
+ + +
+ {% endif %} + {% else %} +

Noch kein Schluessel hinterlegt. Bitte auslesen und pruefen.

+ {% endif %} +
+
+ + {% if current_user.has('switches.execute') %} +
+
+
+
{{ icon('terminal', size=17) }} Kommando ausfuehren
+
+ Positivliste lesender Kommandos – verlangt eine frische Passwortbestaetigung +
+
+
+
+ +
+ +
+ +
+
+ {% endif %} +
+ +
+
+
+
{{ icon('info', size=17) }} Stammdaten
+
+
+
+
Hostname
{{ switch.hostname }}
+
IP
{{ switch.ip }}
+
SSH-Port
{{ switch.ssh_port }}
+
Zugangsdaten
{{ switch.credential_name or 'keine' }}
+
Standort
{{ switch.location or '–' | safe }}
+
Zuletzt gesehen
{{ switch.last_seen_at | dt }}
+
Geaendert
{{ switch.updated_at | dt }} von {{ switch.updated_by or '-' }}
+
+ {% if switch.notes %}

{{ switch.notes }}

{% endif %} +
+
+ +
+
+
+
{{ icon('cpu', size=17) }} Angeschlossene Geraete
+
{{ devices | length }} zugeordnet
+
+
+ {% if devices %} +
+ + + + {% for device in devices %} + + + + + + {% endfor %} + +
PortGeraetZustand
{{ device.port or '–' | safe }} + {{ device.name }} +
{{ device.ip }}
+
+ {% if device.state == 'online' %}{{ pill('online', 'online') }} + {% elif device.state == 'offline' %}{{ pill('offline', 'offline') }} + {% else %}{{ pill('unbekannt', 'warning') }}{% endif %} +
+
+ {% else %} + {{ empty_state('Keine Geraete', 'Diesem Switch ist noch kein Geraet zugeordnet.', 'cpu') }} + {% endif %} +
+
+
+{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/switches.html b/apps/tesm/src/tesm/templates/tesm/switches.html new file mode 100644 index 0000000..b9d57c5 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/switches.html @@ -0,0 +1,174 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, textarea_field, pill, search_box, modal, + empty_state, alert %} + +{% macro switch_form(switch=None) %} +
+ {{ field('hostname', 'Hostname', value=switch.hostname if switch else '', required=True) }} + {{ field('ip', 'IP-Adresse', value=switch.ip if switch else '', required=True) }} + {{ field('ssh_port', 'SSH-Port', value=switch.ssh_port if switch else 22, type='number') }} + + {{ field('model', 'Modell', value=switch.model if switch else '', placeholder='z. B. Aruba 2930M') }} + {{ field('location', 'Standort', value=switch.location if switch else '') }} + {{ textarea_field('notes', 'Notizen', value=switch.notes if switch else '', rows=2, + extra_class='field--full') }} +
+{% endmacro %} + +{% block page_title %}Switche{% endblock %} +{% block page_sub %}{{ switches | length }} Switche{% endblock %} + +{% block topbar_extra %} + {{ search_box('switches-table', 'Hostname, IP, Standort ...') }} + {% if current_user.has('switches.create') %} + + {% endif %} +{% endblock %} + +{% block content %} +{{ alert('Vor dem ersten Zugriff muss der SSH-Host-Schluessel eines Switches ausdruecklich +freigegeben werden. Unbekannte oder geaenderte Schluessel werden abgewiesen -- nicht +stillschweigend uebernommen.', 'info', 'Host-Schluessel') }} + +
+
+ + + + + + + + + + + + + + {% for switch in switches %} + + + + + + + + + + {% endfor %} + +
HostnameIPPortZugangsdatenGeraeteHost-SchluesselAktion
+ + {{ switch.hostname }} + + {% if switch.model %}
{{ switch.model }}
{% endif %} +
{{ switch.ip }}{{ switch.ssh_port }} + {% if switch.credential_name %}{{ switch.credential_name }} + {% else %}keine{% endif %} + {{ switch.device_count }} + {% if switch.host_key_known %}{{ pill('freigegeben', 'online') }} + {% else %} + + {{ pill('offen', 'warning') }} + + {% endif %} + + {% if terminal_enabled and current_user.has('switches.execute') and switch.host_key_known %} + + {{ icon('terminal', size=15) }} + + {% endif %} + {% if current_user.has('switches.edit') %} + + {% endif %} + {% if current_user.has('switches.delete') %} +
+ + +
+ {% endif %} +
+
+ {% if not switches %} + {{ empty_state('Keine Switche', 'Legen Sie den ersten Switch an.', 'switch') }} + {% endif %} +
+ +{% if host_keys %} +
+
+
+
{{ icon('lock', size=17) }} Freigegebene Host-Schluessel
+
{{ host_keys | length }} Eintraege
+
+
+
+ + + + {% for key in host_keys %} + + + + + + + + {% endfor %} + +
HostPortTypFingerprintFreigegeben
{{ key.host }}{{ key.port }}{{ key.key_type }}{{ key.fingerprint }}{{ key.added_at | dt }} von {{ key.added_by }}
+
+
+{% endif %} +{% endblock %} + +{% block dialogs %} +{% if current_user.has('switches.create') %} + {% call modal('switch-new', 'Switch anlegen', 'Host-Schluessel wird danach freigegeben') %} +
+ + + +
+ {% endcall %} +{% endif %} +{% if current_user.has('switches.edit') %} + {% for switch in switches %} + {% call modal('switch-edit-' ~ switch.id, switch.hostname, switch.ip) %} +
+ + + +
+ {% endcall %} + {% endfor %} +{% endif %} +{% endblock %} diff --git a/apps/tesm/src/tesm/worker.py b/apps/tesm/src/tesm/worker.py new file mode 100644 index 0000000..98ab2da --- /dev/null +++ b/apps/tesm/src/tesm/worker.py @@ -0,0 +1,137 @@ +"""Ueberwachungsprozess: prueft Geraete, schaltet PoE, raeumt auf. + +Laeuft als eigene systemd-Unit (``tesm-monitor.service``), getrennt vom +Webprozess. Ein Absturz der Weboberflaeche stoppt die Ueberwachung damit nicht -- +und umgekehrt. + +Ersetzt ``poe.sh`` + ``poe_wrapper.py`` + ``generate_ips.py`` + den +``tesm-check-restart``-Timer des Vorgaengers. Den Timer braucht es nicht mehr: +das Intervall wird bei **jedem** Durchlauf frisch aus der Datenbank gelesen, +eine Aenderung greift also beim naechsten Lauf statt erst nach einem +naechtlichen Neustart des Dienstes. +""" + +from __future__ import annotations + +import logging +import signal +import sys +import time +from types import FrameType + +from tesm_core.extension import core + +from . import create_app +from .services import fileshare, inventory, monitor + +logger = logging.getLogger("tesm.monitor") + +MIN_INTERVAL = 30 +FILESHARE_SWEEP_EVERY = 30 * 60 + + +class MonitorDaemon: + def __init__(self) -> None: + self.app = create_app() + self.extension = core(self.app) + self.running = True + self._last_fileshare_sweep = 0.0 + + def stop(self, signum: int, _frame: FrameType | None) -> None: + logger.info("Signal %s empfangen -- beende nach dem laufenden Durchlauf.", signum) + self.running = False + + # ------------------------------------------------------------------ # + + def _interval(self) -> int: + with self.extension.database.session() as conn: + value = int( + self.extension.settings.get(conn, "monitor_interval_seconds") or 300 + ) + return max(MIN_INTERVAL, value) + + def _auto_restart_allowed(self) -> bool: + settings_on = True + with self.extension.database.session() as conn: + settings_on = bool(self.extension.settings.get(conn, "monitor_auto_restart")) + if self.extension.license is not None: + self.extension.license.reload(conn) + if not settings_on: + return False + if self.extension.license is None: + return True + return self.extension.license.feature_allowed("auto_poe_restart") + + def _failures_before_restart(self) -> int: + with self.extension.database.session() as conn: + return int( + self.extension.settings.get(conn, "monitor_failures_before_restart") or 2 + ) + + def run(self) -> int: + live = self.extension.registry["live_log"] + live.info("monitor", "Ueberwachungsprozess gestartet") + logger.info("Ueberwachung laeuft, Datenbank: %s", self.extension.config.db_path) + + while self.running: + interval = self._interval() + started = time.monotonic() + try: + with self.app.app_context(): + report = monitor.run_sweep( + database=self.extension.database, + live=live, + keystore=self.extension.keystore, + license_allows_auto_restart=self._auto_restart_allowed(), + failures_before_restart=self._failures_before_restart(), + ) + logger.info( + "Durchlauf: %s geprueft, %s online, %s offline, %s Neustarts (%s erfolgreich), " + "%s ohne Lizenz uebersprungen, %sms", + report.checked, + report.online, + report.offline, + report.restarts_triggered, + report.restarts_ok, + report.skipped_unlicensed, + report.duration_ms, + ) + except Exception: # noqa: BLE001 - der Dienst darf nie sterben + logger.exception("Durchlauf fehlgeschlagen") + live.error("monitor", "Durchlauf fehlgeschlagen -- siehe app.log") + + if time.monotonic() - self._last_fileshare_sweep > FILESHARE_SWEEP_EVERY: + self._last_fileshare_sweep = time.monotonic() + try: + with self.app.app_context(): + released = fileshare.sweep_expired(self.extension.database) + if released: + live.info("fileshare", f"{released} abgelaufene Einbindungen geloest") + except Exception: # noqa: BLE001 + logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen") + + elapsed = time.monotonic() - started + remaining = max(1.0, interval - elapsed) + # In Sekundenschritten warten, damit ein Stoppsignal schnell greift. + while remaining > 0 and self.running: + time.sleep(min(1.0, remaining)) + remaining -= 1.0 + + live.info("monitor", "Ueberwachungsprozess beendet") + return 0 + + +def main() -> int: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", + stream=sys.stderr, + ) + daemon = MonitorDaemon() + signal.signal(signal.SIGTERM, daemon.stop) + signal.signal(signal.SIGINT, daemon.stop) + return daemon.run() + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/apps/tesm/src/tesm/wsgi.py b/apps/tesm/src/tesm/wsgi.py new file mode 100644 index 0000000..c63c3d2 --- /dev/null +++ b/apps/tesm/src/tesm/wsgi.py @@ -0,0 +1,7 @@ +"""WSGI-Einstiegspunkt fuer gunicorn: ``gunicorn tesm.wsgi:app``.""" + +from __future__ import annotations + +from . import create_app + +app = create_app() diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..a27801a --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,558 @@ +#!/usr/bin/env bash +# ============================================================================= +# Installation und Aktualisierung von TESM und TESM-Lizenzserver. +# +# Aufruf (aus dem entpackten Release-Verzeichnis): +# sudo bash deploy/install.sh --app tesm +# sudo bash deploy/install.sh --app tesm-license +# sudo bash deploy/install.sh --app tesm --instance test --port 5100 +# +# Die im Vorgaengerprojekt real aufgetretenen Fallen sind hier von vornherein +# vermieden -- jeweils mit Kommentar an der Stelle, wo es zaehlt: +# +# * Die nginx-Site-Datei wird bei jedem Lauf **inhaltlich verglichen** und bei +# Abweichung erneuert. Der Vorgaenger schrieb sie nur, wenn sie fehlte -- ein +# einmal falscher alias-Pfad blieb dadurch ueber Jahre stehen und lieferte +# alle statischen Dateien als 404. +# * pip laeuft immer ueber "python3 -m pip", nie ueber den venv-Shim: dessen +# Shebang baeckt einen absoluten Pfad ein, der bei einer Verzeichnis- +# umbenennung bricht (live auf POETEST reproduziert). +# * Vor jedem Update ein vollstaendiges Backup -- unabhaengig davon, ob sich +# das Schema geaendert hat. Ein reiner Code-Fehler soll durch dasselbe Netz +# laufen. +# * Nach dem Update ein Health-Check mit automatischem Rollback. +# * Instanzdaten (Datenbank, Schluessel, Lizenz) sind vom Abgleich +# ausgeschlossen und werden nie ueberschrieben. +# ============================================================================= +set -euo pipefail + +APP="" +INSTANCE="" +PORT="" +HTTP_PORT="" +SKIP_PACKAGES=0 +ASSUME_YES=0 +HTTPS=0 +HTTPS_PORT="" +DOMAIN="" +ACME_EMAIL="" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m' +step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; } +warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; } +fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; } +info() { printf ' %s\n' "$*"; } + +usage() { + cat <<'USAGE' +Verwendung: install.sh --app [Optionen] + + --app Welche Anwendung installiert wird (Pflicht) + --instance Zusaetzliche Instanz neben der Hauptinstallation. + Erzeugt /srv/-, -.service usw. + Damit laesst sich eine neue Version testen, ohne die + laufende anzufassen. + --port Port des Anwendungsservers (Standard: 5000 bzw. 5001) + --http-port Port, auf dem nginx diese Instanz anbietet (Standard 80). + Fuer eine Testinstanz neben einer laufenden Produktion: + auf Port 80 kann nur eine Site die Vorgabe sein. + --https HTTPS einrichten: Port 443 mit TLS, Port 80 leitet + dorthin um (ausser dem ACME-Pfad), HSTS aktiv, + Sitzungscookie mit Secure-Flag. Ohne --domain wird ein + selbstsigniertes Zertifikat erzeugt (geschlossene Netze); + mit --domain wird zusaetzlich versucht, ein + Let's-Encrypt-Zertifikat zu holen. + --https-port Wie --https, aber auf abweichendem Port (Testinstanz). + --domain Oeffentlicher Name dieser Installation. + --acme-email Kontaktadresse fuer Let's Encrypt. + --skip-packages Keine Systempakete installieren + --yes Rueckfragen ueberspringen +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --app) APP="${2:?}"; shift 2 ;; + --instance) INSTANCE="${2:?}"; shift 2 ;; + --port) PORT="${2:?}"; shift 2 ;; + --http-port) HTTP_PORT="${2:?}"; shift 2 ;; + --https) HTTPS=1; shift ;; + --https-port) HTTPS_PORT="${2:?}"; HTTPS=1; shift 2 ;; + --domain) DOMAIN="${2:?}"; shift 2 ;; + --acme-email) ACME_EMAIL="${2:?}"; shift 2 ;; + --skip-packages) SKIP_PACKAGES=1; shift ;; + --yes|-y) ASSUME_YES=1; shift ;; + -h|--help) usage; exit 0 ;; + *) fail "unbekannte Option: $1" ;; + esac +done + +[[ -n "$APP" ]] || { usage; fail "--app fehlt"; } +[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || fail "--app muss tesm oder tesm-license sein" +# Seit die beiden Anwendungen in getrennten Repositorien liegen, bringt ein +# Auscheckout nur die eigene mit. Ohne diese Pruefung waere die Meldung ein +# Folgefehler tief im Installationslauf statt eines klaren Satzes hier. +[[ -d "$REPO_DIR/apps/$APP" ]] || fail "In diesem Verzeichnis liegt '$APP' nicht ($REPO_DIR/apps/$APP fehlt). Vorhanden: $( + ls -1 "$REPO_DIR/apps" 2>/dev/null | tr ' +' ' ' || echo 'nichts')" +[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren" +[[ -z "$INSTANCE" || "$INSTANCE" =~ ^[a-z0-9-]{1,16}$ ]] || fail "ungueltiger Instanzname" + +# -- Namensraum -------------------------------------------------------------- +NAME="$APP" +[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE" +ROOT="/srv/$NAME" +LOG_DIR="/var/log/$NAME" +SERVICE_USER="$APP" +UNIT="$NAME.service" +MONITOR_UNIT="$NAME-monitor.service" +NGINX_SITE="/etc/nginx/sites-available/$NAME" +ENV_PREFIX="TESM_" +[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_" +if [[ -z "$PORT" ]]; then + PORT=5000 + [[ "$APP" == "tesm-license" ]] && PORT=5001 + [[ -n "$INSTANCE" ]] && PORT=$((PORT + 100)) +fi +if [[ -z "$HTTP_PORT" ]]; then + HTTP_PORT=80 + [[ -n "$INSTANCE" ]] && HTTP_PORT=8080 +fi +if [[ -z "$HTTPS_PORT" ]]; then + HTTPS_PORT=443 + [[ -n "$INSTANCE" ]] && HTTPS_PORT=8443 +fi +# Das Secure-Flag am Sitzungscookie darf erst gesetzt werden, wenn es +# tatsaechlich HTTPS gibt -- sonst kommt das Cookie nie beim Browser an und die +# Anmeldung schlaegt ohne erkennbaren Grund fehl. +# Bei einer Aktualisierung zaehlt, was schon gilt: wer HTTPS eingerichtet hat, +# darf es durch ein Update ohne --https nicht verlieren. Ein zurueckgesetztes +# Secure-Flag wuerde die Anmeldung stillschweigend unmoeglich machen -- der +# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es +# aber ohne das Flag neu, und beide reden aneinander vorbei. +COOKIE_SECURE=0 +if [[ -f "/etc/tesm/$NAME.env" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "/etc/tesm/$NAME.env"; then + COOKIE_SECURE=1 +fi +[[ $HTTPS -eq 1 ]] && COOKIE_SECURE=1 +[[ -z "$DOMAIN" || "$DOMAIN" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || fail "ungueltige Domain: $DOMAIN" + +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_DIR="/srv/$NAME-backup-$TIMESTAMP" +FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP" + +# Einziges Signal fuer "schon installiert": die Datenbank ist da. +IS_UPDATE=0 +[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1 + +step "TESM-Installation" +info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}" +info "Ziel : $ROOT" +info "Dienst : $UNIT auf 127.0.0.1:$PORT" +if [[ $HTTPS -eq 1 ]]; then + info "nginx : Port $HTTPS_PORT (HTTPS), Port $HTTP_PORT leitet um" +elif [[ "$COOKIE_SECURE" == "1" ]]; then + info "nginx : HTTPS ist bereits eingerichtet und bleibt unveraendert" + if [[ -n "$DOMAIN" ]]; then + info "Zertifikat : Lets Encrypt fuer $DOMAIN, sonst selbstsigniert" + else + info "Zertifikat : selbstsigniert (keine Domain angegeben)" + fi +else + info "nginx : Port $HTTP_PORT (nur HTTP)" +fi +info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)" + +if [[ $ASSUME_YES -eq 0 ]]; then + read -r -p "Fortfahren? [j/N] " answer + [[ "$answer" =~ ^[jJyY]$ ]] || fail "abgebrochen" +fi + +# -- Systempakete ------------------------------------------------------------ +if [[ $SKIP_PACKAGES -eq 0 ]]; then + step "Systempakete" + PACKAGES=(python3 python3-venv python3-pip nginx sqlite3 rsync logrotate ca-certificates curl) + if [[ "$APP" == "tesm" ]]; then + PACKAGES+=(iputils-ping openssh-client cifs-utils) + fi + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq "${PACKAGES[@]}" + info "${#PACKAGES[@]} Pakete geprueft" +else + info "Systempakete uebersprungen" +fi + +# -- Dienstbenutzer ---------------------------------------------------------- +step "Dienstbenutzer" +if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then + useradd --system --home-dir "$ROOT" --shell /usr/sbin/nologin "$SERVICE_USER" + info "Benutzer $SERVICE_USER angelegt" +else + info "Benutzer $SERVICE_USER existiert" +fi +# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte +# gibt es ausschliesslich ueber den Helfer. + +# -- Backup ------------------------------------------------------------------ +if [[ $IS_UPDATE -eq 1 ]]; then + step "Sicherung vor der Aktualisierung" + systemctl stop "$UNIT" 2>/dev/null || true + [[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true + cp -a "$ROOT" "$BACKUP_DIR" + info "Sicherung: $BACKUP_DIR" +fi + +# -- Dateien abgleichen ------------------------------------------------------ +step "Anwendungsdateien" +mkdir -p "$ROOT" +rsync -a --delete \ + --exclude 'venv' \ + --exclude 'data' \ + --exclude '*.pyc' \ + --exclude '__pycache__' \ + --exclude '.git' \ + --exclude 'tests' \ + "$REPO_DIR/" "$ROOT/" +mkdir -p "$ROOT/data" "$LOG_DIR" +info "Dateien abgeglichen (Instanzdaten unter data/ bleiben unangetastet)" + +# -- venv -------------------------------------------------------------------- +step "Python-Umgebung" +if [[ ! -x "$ROOT/venv/bin/python3" ]]; then + python3 -m venv "$ROOT/venv" + info "venv erzeugt" +fi +# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten +# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht. +"$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel +"$ROOT/venv/bin/python3" -m pip install --quiet \ + "$ROOT/packages/tesm-licensing" \ + "$ROOT/packages/tesm-core" \ + "$ROOT/apps/$APP" \ + gunicorn +if [[ "$APP" == "tesm" ]]; then + "$ROOT/venv/bin/python3" -m pip install --quiet flask-sock +fi +info "Abhaengigkeiten installiert" + +# -- Statische Dateien fuer nginx -------------------------------------------- +step "Statische Dateien" +rm -rf "$ROOT/static" "$ROOT/static-core" +CORE_STATIC="$("$ROOT/venv/bin/python3" -c \ + 'import tesm_core, pathlib; print(pathlib.Path(tesm_core.__file__).parent / "static" / "tesm_core")')" +APP_MODULE="$APP" +[[ "$APP" == "tesm-license" ]] && APP_MODULE="tesm_license" +APP_STATIC="$("$ROOT/venv/bin/python3" -c \ + "import $APP_MODULE, pathlib; print(pathlib.Path($APP_MODULE.__file__).parent / 'static')")" +cp -a "$CORE_STATIC" "$ROOT/static-core" +[[ -d "$APP_STATIC" ]] && cp -a "$APP_STATIC" "$ROOT/static" || mkdir -p "$ROOT/static" +info "nginx liefert /core-assets/ und /assets/ direkt aus" + +# -- Privilegierter Helfer --------------------------------------------------- +step "Privilegierter Helfer und sudoers" +install -d -o root -g root -m 0755 /usr/local/lib/tesm +install_if_changed() { + local src="$1" dst="$2" mode="$3" owner="${4:-root:root}" + if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then + return 1 + fi + install -o "${owner%%:*}" -g "${owner##*:}" -m "$mode" "$src" "$dst" + return 0 +} +install_if_changed "$ROOT/deploy/tesm-helper" /usr/local/lib/tesm/tesm-helper 0755 && \ + info "Helfer aktualisiert" || info "Helfer unveraendert" + +# visudo -c prueft die Datei, bevor sie aktiv wird -- eine kaputte sudoers-Datei +# waere sonst ein Totalausfall der Administration. +TMP_SUDO="$(mktemp)" +cp "$ROOT/deploy/sudoers/$APP" "$TMP_SUDO" +visudo -c -f "$TMP_SUDO" >/dev/null || fail "sudoers-Datei ist fehlerhaft" +install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP" +rm -f "$TMP_SUDO" +info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen" + +# -- Rechte ------------------------------------------------------------------ +step "Dateirechte" +chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR" +chown -R root:root "$ROOT/packages" "$ROOT/apps" "$ROOT/deploy" 2>/dev/null || true +chmod 0750 "$ROOT/data" +chmod 0755 "$LOG_DIR" +find "$ROOT/data" -maxdepth 1 -name '*.key*' -o -name 'data.keys' -o -name '*signing_key*' \ + | xargs -r chmod 0600 +info "Datenverzeichnis 0750, Schluesseldateien 0600" + +# -- Umgebungsdatei ---------------------------------------------------------- +step "Umgebung" +# Eine Quelle fuer beide Seiten: die systemd-Unit liest sie ueber +# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das +# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere +# Datenbank im Arbeitsverzeichnis anlegen. +install -d -o root -g root -m 0755 /etc/tesm +ENV_FILE="/etc/tesm/$NAME.env" +TMP_ENV="$(mktemp)" +{ + echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten." + echo "${ENV_PREFIX}BASE_DIR=$ROOT" + echo "${ENV_PREFIX}DATA_DIR=$ROOT/data" + echo "${ENV_PREFIX}LOG_DIR=$LOG_DIR" + # Name dieser Installation auf dem Host. Alles ausserhalb von /srv/ + # -- nginx-Site, netplan-Datei, logrotate-Regel, Zertifikatsverzeichnis, + # Sitzungscookie -- laeuft darueber. Ohne das wuerde eine Testinstanz beim + # Speichern der Webserver-Einstellungen die Site der Produktion + # ueberschreiben und beide teilten sich dasselbe Cookie. + echo "${ENV_PREFIX}SITE_KEY=$NAME" + # Genau die Adresse, auf die gunicorn hoert. nginx nimmt sie von hier als + # proxy_pass-Ziel -- so koennen Dienst und Reverse Proxy nicht auseinander + # laufen. Vorher stand die 5000 fest im Code: eine Instanz auf 5100 erzeugte + # damit eine Site, die auf die andere Installation zeigte. + echo "${ENV_PREFIX}BIND=127.0.0.1:$PORT" + echo "${ENV_PREFIX}COOKIE_SECURE=$COOKIE_SECURE" + echo "${ENV_PREFIX}TRUSTED_PROXY_HOPS=1" + echo "PYTHONUNBUFFERED=1" +} > "$TMP_ENV" +install_if_changed "$TMP_ENV" "$ENV_FILE" 0640 "root:$SERVICE_USER" >/dev/null && info "Umgebungsdatei $ENV_FILE geschrieben" || info "Umgebungsdatei unveraendert" +rm -f "$TMP_ENV" +if [[ "$COOKIE_SECURE" == "1" ]]; then + info "COOKIE_SECURE=1 -- die Anmeldung funktioniert damit nur ueber HTTPS." +else + info "Hinweis: COOKIE_SECURE=0 bis HTTPS eingerichtet ist. Danach: install.sh --https" + info " erneut aufrufen oder in $ENV_FILE auf 1 setzen und Dienst neu starten." +fi + +# Wrapper, damit die Werkzeuge dieselbe Umgebung sehen wie der Dienst. +ADMIN_BIN="tesm-admin" +[[ "$APP" == "tesm-license" ]] && ADMIN_BIN="tesm-license-admin" +TMP_WRAPPER="$(mktemp)" +cat > "$TMP_WRAPPER" </dev/null && info "Werkzeug: $NAME-admin" || true +rm -f "$TMP_WRAPPER" + +# -- systemd ----------------------------------------------------------------- +step "systemd" +UNITS_CHANGED=0 +render_unit() { + local src="$1" dst="$2" + local tmp; tmp="$(mktemp)" + sed -e "s#/srv/$APP#$ROOT#g" \ + -e "s#/var/log/$APP#$LOG_DIR#g" \ + -e "s#127.0.0.1:5000#127.0.0.1:$PORT#" \ + -e "s#127.0.0.1:5001#127.0.0.1:$PORT#" \ + -e "/^WorkingDirectory=/a EnvironmentFile=$ENV_FILE" \ + "$src" > "$tmp" + install_if_changed "$tmp" "$dst" 0644 && UNITS_CHANGED=1 + rm -f "$tmp" +} +render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT" +if [[ "$APP" == "tesm" ]]; then + render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT" +fi +[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \ + || info "Units unveraendert" + +# -- logrotate --------------------------------------------------------------- +TMP_ROTATE="$(mktemp)" +sed -e "s#/var/log/$APP/#$LOG_DIR/#" "$ROOT/deploy/logrotate/$APP" > "$TMP_ROTATE" +install_if_changed "$TMP_ROTATE" "/etc/logrotate.d/$NAME" 0644 >/dev/null && info "logrotate aktualisiert" +rm -f "$TMP_ROTATE" + +# -- nginx ------------------------------------------------------------------- +step "nginx" +TMP_SITE="$(mktemp)" +sed -e "s#/srv/$APP/#$ROOT/#g" \ + -e "s#127.0.0.1:5000#127.0.0.1:$PORT#g" \ + -e "s#127.0.0.1:5001#127.0.0.1:$PORT#g" \ + -e "s#listen 80;#listen $HTTP_PORT;#" \ + -e "s#listen \[::\]:80;#listen [::]:$HTTP_PORT;#" \ + -e "s#/var/www/$APP-acme#/var/www/$NAME-acme#g" \ + "$ROOT/deploy/nginx/$APP.conf" > "$TMP_SITE" +# Auf Port 80 kann nur eine Site die Vorgabe sein. Eine Instanz neben der +# Produktion bekommt darum ueber --http-port einen eigenen Port -- sonst +# verdeckt sie die laufende Installation (nginx meldet dann +# "conflicting server name" und ignoriert eine der beiden). +if [[ -n "$INSTANCE" && "$HTTP_PORT" == "80" ]]; then + warn "Instanz $INSTANCE auf Port 80: sie verdeckt moeglicherweise die Hauptinstallation." +fi + +# Inhaltsvergleich statt reiner Existenzpruefung -- genau der Punkt, an dem der +# Vorgaenger einen falschen alias-Pfad jahrelang mitschleppte. Die Anwendung +# schreibt diese Datei spaeter ohnehin selbst neu (Verwaltung -> Webserver); +# hier geht es nur um einen funktionierenden Erststart. +if [[ -f "$NGINX_SITE" ]] && ! cmp -s "$TMP_SITE" "$NGINX_SITE"; then + if grep -qE "alias\s+$ROOT/(static|static-core)/;" "$NGINX_SITE"; then + info "Vorhandene Site-Konfiguration behalten (alias-Pfade stimmen)" + else + warn "Vorhandene Site-Konfiguration zeigt auf falsche Pfade -- wird erneuert" + cp "$NGINX_SITE" "$NGINX_SITE.bak-$TIMESTAMP" + install -m 0644 "$TMP_SITE" "$NGINX_SITE" + fi +elif [[ ! -f "$NGINX_SITE" ]]; then + install -m 0644 "$TMP_SITE" "$NGINX_SITE" + info "Site-Konfiguration angelegt" +fi +TMP_SITE_KEEP="$TMP_SITE" +ln -sfn "$NGINX_SITE" "/etc/nginx/sites-enabled/$NAME" +rm -f /etc/nginx/sites-enabled/default +mkdir -p "/var/www/$NAME-acme" +chown -R www-data:www-data "/var/www/$NAME-acme" + +# "alias-Pfade stimmen" heisst noch nicht "nginx akzeptiert die Datei". Eine +# von Hand nachgebesserte Site kann syntaktisch kaputt sein -- und dann +# scheitert nicht nur diese Installation, sondern jeder Reload des Webservers, +# auch fuer alle anderen Sites. Darum: pruefen, und im Zweifel die frisch +# erzeugte Vorlage nehmen. +if ! nginx -t >/dev/null 2>&1; then + warn "nginx lehnt die vorhandene Site-Konfiguration ab -- sie wird durch die Vorlage ersetzt." + [[ -f "$NGINX_SITE" ]] && cp "$NGINX_SITE" "$NGINX_SITE.bak-$TIMESTAMP" + install -m 0644 "$TMP_SITE_KEEP" "$NGINX_SITE" + nginx -t >/dev/null 2>&1 || fail "nginx lehnt auch die Vorlage ab -- bitte 'nginx -t' pruefen. Sicherung: $NGINX_SITE.bak-$TIMESTAMP" +fi +rm -f "$TMP_SITE_KEEP" +systemctl reload nginx +info "nginx neu geladen" + +# -- Datenbank und Start ----------------------------------------------------- +step "Anwendung starten" +# Das Schema entsteht beim ersten Start durch die Migrationen -- es gibt kein +# separates Init-Skript mehr. +systemctl enable --now "$UNIT" >/dev/null +if [[ "$APP" == "tesm" ]]; then + systemctl enable --now "$MONITOR_UNIT" >/dev/null +fi + +# -- Health-Check und Rollback ---------------------------------------------- +step "Gesundheitspruefung" +HEALTHY=0 +for attempt in $(seq 1 20); do + if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then + HEALTHY=1 + info "Antwort nach ${attempt}x" + break + fi + sleep 2 +done + +if [[ $HEALTHY -eq 0 ]]; then + warn "Die Anwendung antwortet nicht." + if [[ $IS_UPDATE -eq 1 ]]; then + warn "Rollback wird ausgefuehrt." + systemctl stop "$UNIT" 2>/dev/null || true + [[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true + mv "$ROOT" "$FAILED_DIR" + cp -a "$BACKUP_DIR" "$ROOT" + systemctl start "$UNIT" || true + [[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true + fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt. + Fehlgeschlagene Version : $FAILED_DIR + Sicherung : $BACKUP_DIR + Protokoll : journalctl -u $UNIT -n 100" + fi + fail "Neuinstallation fehlgeschlagen. Protokoll: journalctl -u $UNIT -n 100" +fi + +URL_HINT="http://:$HTTP_PORT/" +[[ "$HTTP_PORT" == "80" ]] && URL_HINT="http:///" +# HTTPS war schon eingerichtet und wird durch dieses Update nicht angefasst. +[[ $HTTPS -eq 0 && "$COOKIE_SECURE" == "1" ]] && URL_HINT="https:/// (unveraendert)" + +# -- HTTPS ------------------------------------------------------------------- +# Bewusst erst hier: die Anwendung muss laufen, denn sie erzeugt die +# TLS-Konfiguration selbst (tesm-admin web-setup). Das Installationsskript +# baut sich keine zweite, konkurrierende Vorlage -- genau daran ist der +# Vorgaenger gescheitert. +if [[ $HTTPS -eq 1 ]]; then + step "HTTPS einrichten" + WEB_ARGS=(web-setup --https --apply --self-signed + --http-port "$HTTP_PORT" --https-port "$HTTPS_PORT") + if [[ -n "$DOMAIN" ]]; then + # HSTS nur mit einem Zertifikat, dem Browser auch trauen. Bei einem + # selbstsignierten waere es eine Falle: der Browser merkt sich fuer Monate, + # dass er die Seite nur ueber HTTPS aufrufen darf -- und zeigt bei jedem + # Aufruf eine Warnung, die sich nicht mehr wegklicken laesst. + WEB_ARGS+=(--domain "$DOMAIN") + if [[ -n "$ACME_EMAIL" ]]; then + # Let's Encrypt verlangt ohnehin eine Kontaktadresse. Sie ist damit das + # eindeutige Signal "hol ein oeffentliches Zertifikat" -- ohne sie bleibt + # es bei einem selbstsignierten, auch wenn eine Domain gesetzt ist + # (interne Namen wie lizenz.firma.local kann keine CA bestaetigen). + WEB_ARGS+=(--acme --acme-email "$ACME_EMAIL" --hsts) + info "Versuche Lets Encrypt fuer $DOMAIN; bei Misserfolg selbstsigniert." + else + info "Domain $DOMAIN, aber keine --acme-email: selbstsigniert, ohne HSTS." + fi + else + info "Ohne Domain: selbstsigniertes Zertifikat, ohne HSTS." + fi + if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" "${WEB_ARGS[@]}"; then + info "HTTPS aktiv." + URL_HINT="https:///" + [[ "$HTTPS_PORT" == "443" ]] || URL_HINT="https://:$HTTPS_PORT/" + else + warn "HTTPS-Einrichtung fehlgeschlagen -- die Installation laeuft weiter ueber HTTP." + warn "Nachtraeglich: sudo -u $SERVICE_USER $NAME-admin web-setup --https --self-signed --hsts --apply" + # Ein Secure-Cookie ohne HTTPS macht die Anmeldung unmoeglich. Lieber + # zurueckdrehen als eine Installation hinterlassen, an der niemand mehr + # ankommt. + if grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "$ENV_FILE"; then + sed -i "s/^${ENV_PREFIX}COOKIE_SECURE=1/${ENV_PREFIX}COOKIE_SECURE=0/" "$ENV_FILE" + systemctl restart "$UNIT" + warn "COOKIE_SECURE auf 0 zurueckgesetzt, damit die Anmeldung weiter funktioniert." + fi + fi +fi + +if [[ $IS_UPDATE -eq 1 ]]; then + rm -rf "$BACKUP_DIR" + info "Sicherung entfernt (Aktualisierung erfolgreich)" +fi + +# -- Abschluss --------------------------------------------------------------- +step "Fertig" +ADMIN_CMD="tesm-admin" +[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin" + +cat < Lizenz +SUMMARY +fi + +cat <<'SUMMARY' + Danach in der Anwendung unter "Verwaltung -> Webserver und TLS" die Domain + eintragen, ein Zertifikat anfordern und die Konfiguration uebernehmen -- + damit laeuft der Zugriff ueber HTTPS auf Port 443 mit Umleitung von 80. + +SUMMARY diff --git a/deploy/logrotate/tesm b/deploy/logrotate/tesm new file mode 100644 index 0000000..16a2270 --- /dev/null +++ b/deploy/logrotate/tesm @@ -0,0 +1,14 @@ +# Von TESM installiert. +# copytruncate, weil keiner der schreibenden Prozesse (Web, Ueberwachung, Kea) +# ein Reopen-Signal verarbeitet. +/var/log/tesm/*.log { + weekly + rotate 4 + compress + delaycompress + missingok + notifempty + copytruncate + su tesm tesm + create 0640 tesm tesm +} diff --git a/deploy/logrotate/tesm-license b/deploy/logrotate/tesm-license new file mode 100644 index 0000000..e5344ce --- /dev/null +++ b/deploy/logrotate/tesm-license @@ -0,0 +1,14 @@ +# Von TESM installiert. +# copytruncate, weil keiner der schreibenden Prozesse (Web, Ueberwachung, Kea) +# ein Reopen-Signal verarbeitet. +/var/log/tesm-license/*.log { + weekly + rotate 4 + compress + delaycompress + missingok + notifempty + copytruncate + su tesm-license tesm-license + create 0640 tesm-license tesm-license +} diff --git a/deploy/nginx/tesm-license.conf b/deploy/nginx/tesm-license.conf new file mode 100644 index 0000000..e15df6a --- /dev/null +++ b/deploy/nginx/tesm-license.conf @@ -0,0 +1,43 @@ +# Erststart-Konfiguration. Ab der ersten Uebernahme in der Anwendung +# (Verwaltung -> Webserver und TLS) wird diese Datei vollstaendig neu +# geschrieben -- inklusive HTTPS auf 443, Umleitung von 80 und HSTS. +server { + listen 80; + listen [::]:80; + server_name _; + + location /.well-known/acme-challenge/ { + alias /var/www/tesm-license-acme/.well-known/acme-challenge/; + default_type "text/plain"; + } + + client_max_body_size 160m; + + location /assets/ { + alias /srv/tesm-license/static/; + expires 7d; + access_log off; + } + + location /core-assets/ { + alias /srv/tesm-license/static-core/; + expires 7d; + access_log off; + } + + + location / { + # $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf + # abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst + # fehlschlagen. + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:5001; + proxy_http_version 1.1; + proxy_read_timeout 120s; + } +} diff --git a/deploy/nginx/tesm.conf b/deploy/nginx/tesm.conf new file mode 100644 index 0000000..4975340 --- /dev/null +++ b/deploy/nginx/tesm.conf @@ -0,0 +1,61 @@ +# Erststart-Konfiguration. Ab der ersten Uebernahme in der Anwendung +# (Verwaltung -> Webserver und TLS) wird diese Datei vollstaendig neu +# geschrieben -- inklusive HTTPS auf 443, Umleitung von 80 und HSTS. +server { + listen 80; + listen [::]:80; + server_name _; + + location /.well-known/acme-challenge/ { + alias /var/www/tesm-acme/.well-known/acme-challenge/; + default_type "text/plain"; + } + + client_max_body_size 160m; + + location /assets/ { + alias /srv/tesm/static/; + expires 7d; + access_log off; + } + + location /core-assets/ { + alias /srv/tesm/static-core/; + expires 7d; + access_log off; + } + + location /ws/ { + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + # $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf + # abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst + # fehlschlagen. + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:5000; + proxy_http_version 1.1; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } + + location / { + # $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf + # abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst + # fehlschlagen. + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:5000; + proxy_http_version 1.1; + proxy_read_timeout 120s; + } +} diff --git a/deploy/release.sh b/deploy/release.sh new file mode 100644 index 0000000..e0213c5 --- /dev/null +++ b/deploy/release.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Paketiert ein Release aus einem vorhandenen Tag. +# +# Auf Windows-Buildmaschinen ist -c core.autocrlf=false zwingend -- sonst landen +# CRLF-Zeilenenden im Tarball und die Shell-Skripte scheitern auf dem Ziel mit +# "bad interpreter". Das ist real passiert. +# +# Der Name kommt aus dem Repository, nicht aus einer festen Zeichenkette: die +# beiden Anwendungen liegen in getrennten Repositorien, und ein +# Lizenzserver-Release, das "tesm-..." heisst, verwechselt man genau einmal. +set -euo pipefail + +TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Vorgabe: der Name der einzigen Anwendung im Baum, sonst der Verzeichnisname. +if [[ -n "${2:-}" ]]; then + NAME="$2" +else + mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true) + if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi +fi + +OUT="$NAME-$TAG.tar.gz" +git -c core.autocrlf=false -C "$REPO_DIR" archive --format=tar.gz --prefix="$NAME/" -o "$OUT" "$TAG" +echo "Erzeugt: $OUT" +file "$OUT" +echo "Pruefsumme:"; sha256sum "$OUT" diff --git a/deploy/sudoers/tesm b/deploy/sudoers/tesm new file mode 100644 index 0000000..d9fbff4 --- /dev/null +++ b/deploy/sudoers/tesm @@ -0,0 +1,9 @@ +# Von TESM installiert. Der Dienstbenutzer darf genau ein Programm als root +# aufrufen -- den Helfer. Dessen Argumentpruefung ist die eigentliche +# Sicherheitsgrenze; sie liegt in /usr/local/lib/tesm/tesm-helper und gehoert +# root (Modus 0755). +# +# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe +# Problem, indem die gesamte Anwendung als root lief. +Defaults:tesm !requiretty +tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper diff --git a/deploy/sudoers/tesm-license b/deploy/sudoers/tesm-license new file mode 100644 index 0000000..0f42b61 --- /dev/null +++ b/deploy/sudoers/tesm-license @@ -0,0 +1,9 @@ +# Von TESM installiert. Der Dienstbenutzer darf genau ein Programm als root +# aufrufen -- den Helfer. Dessen Argumentpruefung ist die eigentliche +# Sicherheitsgrenze; sie liegt in /usr/local/lib/tesm/tesm-helper und gehoert +# root (Modus 0755). +# +# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe +# Problem, indem die gesamte Anwendung als root lief. +Defaults:tesm-license !requiretty +tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper diff --git a/deploy/systemd/tesm-license.service b/deploy/systemd/tesm-license.service new file mode 100644 index 0000000..376b5be --- /dev/null +++ b/deploy/systemd/tesm-license.service @@ -0,0 +1,78 @@ +[Unit] +Description=TESM-Lizenzserver +Documentation=file:/srv/tesm-license/docs/BETRIEB.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify-reload +User=tesm-license +Group=tesm-license +WorkingDirectory=/srv/tesm-license +Environment=TESM_LICENSE_BASE_DIR=/srv/tesm-license +Environment=TESM_LICENSE_DATA_DIR=/srv/tesm-license/data +Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license +Environment=TESM_LICENSE_WEB_PROCESS=1 +Environment=TESM_LICENSE_COOKIE_SECURE=1 +Environment=PYTHONUNBUFFERED=1 +ExecStart=/srv/tesm-license/venv/bin/gunicorn \ + --workers 1 --worker-class gthread --threads 8 \ + --bind 127.0.0.1:5001 \ + --access-logfile - --error-logfile - \ + tesm_license.wsgi:app +Restart=always +RestartSec=5 + +# -- Haertung --------------------------------------------------------------- +# Der Dienst braucht kein Root, keine neuen Privilegien und keinen Zugriff auf +# das System ausserhalb seiner eigenen Verzeichnisse. Was er trotzdem braucht +# (systemctl, nginx, netplan), laeuft ueber den Helfer und sudo. +# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer +# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der +# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus +# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der +# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument +# gegen eine Positivliste). Siehe docs/SICHERHEIT.md. +PrivateTmp=yes +PrivateDevices=yes +# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum** +# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx, +# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert +# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc +# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt +# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit +# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser +# Schalter. +ProtectSystem=full +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +ProtectProc=invisible +RestrictSUIDSGID=yes +RestrictRealtime=yes +RestrictNamespaces=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK +LockPersonality=yes +MemoryDenyWriteExecute=no +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM +# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht +# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt, +# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung +# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die +# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter. +UMask=0027 +# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr. +# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben +# scheitert der Helfer mit "Read-only file system", obwohl er root ist +# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die +# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu. +ReadWritePaths=/srv/tesm-license/data /var/log/tesm-license /etc/nginx /etc/netplan /etc/logrotate.d /etc/tesm /run + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/tesm-monitor.service b/deploy/systemd/tesm-monitor.service new file mode 100644 index 0000000..9375c42 --- /dev/null +++ b/deploy/systemd/tesm-monitor.service @@ -0,0 +1,62 @@ +[Unit] +Description=TESM -- Ueberwachung der PoE-Endgeraete +After=network-online.target tesm.service +Wants=network-online.target + +[Service] +Type=simple +User=tesm +Group=tesm +WorkingDirectory=/srv/tesm +Environment=TESM_BASE_DIR=/srv/tesm +Environment=TESM_DATA_DIR=/srv/tesm/data +Environment=TESM_LOG_DIR=/var/log/tesm +Environment=PYTHONUNBUFFERED=1 +ExecStart=/srv/tesm/venv/bin/tesm-monitor +Restart=always +RestartSec=10 + +# Das Pruefintervall liest der Prozess bei jedem Durchlauf frisch aus der +# Datenbank -- der naechtliche Neustart-Timer des Vorgaengers entfaellt damit. +# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer +# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der +# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus +# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der +# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument +# gegen eine Positivliste). Siehe docs/SICHERHEIT.md. +PrivateTmp=yes +# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum** +# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx, +# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert +# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc +# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt +# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit +# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser +# Schalter. +ProtectSystem=full +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +RestrictNamespaces=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK +LockPersonality=yes +SystemCallArchitectures=native +UMask=0027 +# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr. +# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben +# scheitert der Helfer mit "Read-only file system", obwohl er root ist +# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die +# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu. +ReadWritePaths=/srv/tesm/data /var/log/tesm /etc/tesm /run +# ping braucht CAP_NET_RAW -- als einzige Ausnahme, und nur diese. +AmbientCapabilities=CAP_NET_RAW +# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht +# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt, +# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung +# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die +# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter. + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/tesm.service b/deploy/systemd/tesm.service new file mode 100644 index 0000000..51cb663 --- /dev/null +++ b/deploy/systemd/tesm.service @@ -0,0 +1,78 @@ +[Unit] +Description=TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten +Documentation=file:/srv/tesm/docs/BETRIEB.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify-reload +User=tesm +Group=tesm +WorkingDirectory=/srv/tesm +Environment=TESM_BASE_DIR=/srv/tesm +Environment=TESM_DATA_DIR=/srv/tesm/data +Environment=TESM_LOG_DIR=/var/log/tesm +Environment=TESM_WEB_PROCESS=1 +Environment=TESM_COOKIE_SECURE=1 +Environment=PYTHONUNBUFFERED=1 +ExecStart=/srv/tesm/venv/bin/gunicorn \ + --workers 1 --worker-class gthread --threads 16 --timeout 0 \ + --bind 127.0.0.1:5000 \ + --access-logfile - --error-logfile - \ + tesm.wsgi:app +Restart=always +RestartSec=5 + +# -- Haertung --------------------------------------------------------------- +# Der Dienst braucht kein Root, keine neuen Privilegien und keinen Zugriff auf +# das System ausserhalb seiner eigenen Verzeichnisse. Was er trotzdem braucht +# (systemctl, nginx, netplan), laeuft ueber den Helfer und sudo. +# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer +# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der +# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus +# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der +# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument +# gegen eine Positivliste). Siehe docs/SICHERHEIT.md. +PrivateTmp=yes +PrivateDevices=yes +# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum** +# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx, +# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert +# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc +# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt +# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit +# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser +# Schalter. +ProtectSystem=full +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +ProtectProc=invisible +RestrictSUIDSGID=yes +RestrictRealtime=yes +RestrictNamespaces=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK +LockPersonality=yes +MemoryDenyWriteExecute=no +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM +# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht +# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt, +# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung +# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die +# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter. +UMask=0027 +# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr. +# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben +# scheitert der Helfer mit "Read-only file system", obwohl er root ist +# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die +# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu. +ReadWritePaths=/srv/tesm/data /var/log/tesm /mnt/tesm-shares /etc/nginx /etc/netplan /etc/kea /etc/logrotate.d /etc/tesm /var/lib/kea /var/log/kea /run + +[Install] +WantedBy=multi-user.target diff --git a/deploy/tesm-helper b/deploy/tesm-helper new file mode 100644 index 0000000..c508b21 --- /dev/null +++ b/deploy/tesm-helper @@ -0,0 +1,351 @@ +#!/bin/bash +# ============================================================================= +# tesm-helper -- die einzige Stelle, an der TESM Root-Rechte bekommt. +# +# Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht durch +# dieses Skript: es gehoert root, ist per sudoers mit NOPASSWD nur fuer genau +# diesen Pfad freigegeben und prueft jedes Argument gegen eine Positivliste. +# +# Damit ist die Sicherheitsgrenze klein, an einer Stelle und pruefbar -- statt +# ueber tausende Zeilen Anwendungscode verteilt. Der Vorgaenger liess die +# komplette Flask-Anwendung als root laufen; eine einzige Luecke in irgendeinem +# Pfad haette den Host bedeutet. +# +# Regeln fuer Aenderungen an dieser Datei: +# * Jedes neue Verb braucht eine Argumentpruefung. Ohne Pruefung kein Verb. +# * Nie "$@" ungeprueft an eine Shell weiterreichen. +# * Nie einen Pfad akzeptieren, der nicht gegen eine feste Liste geprueft ist. +# ============================================================================= +set -euo pipefail +umask 022 + +APP_KEYS_ALLOWED=("tesm" "tesm-license") +SERVICES_ALLOWED=( + "tesm.service" "tesm-monitor.service" + "tesm-license.service" + "kea-dhcp4-server" "kea-dhcp4-server.service" + "nginx" "nginx.service" +) +PACKAGES_ALLOWED=("kea-dhcp4-server" "kea-common" "cifs-utils" "certbot" "expect" + "samba-common-bin") + +fail() { echo "tesm-helper: $*" >&2; exit 2; } + +require_root() { + [[ "$(id -u)" -eq 0 ]] || fail "muss als root laufen (ueber sudo aufrufen)" +} + +# -- Argumentpruefungen ------------------------------------------------------ + +check_in_list() { + local needle="$1"; shift + local item + for item in "$@"; do + [[ "$needle" == "$item" ]] && return 0 + done + return 1 +} + +# Name einer Installation auf diesem Host: entweder die Hauptinstallation +# ("tesm") oder eine benannte Instanz daneben ("tesm-opus"). Mehr als diese +# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen +# Pfad ausserhalb des vorgesehenen Namensraums erzeugen. +check_app_key() { + local key="$1" app + for app in "${APP_KEYS_ALLOWED[@]}"; do + [[ "$key" == "$app" ]] && return 0 + [[ "$key" == "$app-"* && "${key#"$app"-}" =~ ^[a-z0-9][a-z0-9-]{0,15}$ ]] && return 0 + done + fail "unbekannter App-Schluessel: $key" +} + +check_service() { + check_in_list "$1" "${SERVICES_ALLOWED[@]}" || fail "Dienst nicht erlaubt: $1" +} + +check_hostname() { + [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || fail "ungueltiger Hostname" +} + +check_timezone() { + [[ "$1" =~ ^[A-Za-z]+(/[A-Za-z0-9_+-]+){0,2}$ ]] || fail "ungueltige Zeitzone" + [[ -f "/usr/share/zoneinfo/$1" ]] || fail "unbekannte Zeitzone" +} + +check_domain() { + [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || fail "ungueltige Domain" +} + +check_email() { + [[ "$1" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]] || fail "ungueltige E-Mail" +} + +check_unc() { + [[ "$1" =~ ^//[A-Za-z0-9._-]+/[^[:space:]]+$ || "$1" =~ ^\\\\[A-Za-z0-9._-]+\\[^[:space:]]+$ ]] \ + || fail "ungueltiger Freigabepfad" +} + +check_mount_target() { + # Nur unterhalb der bekannten Einbindungswurzeln, kein ".." im Pfad. + [[ "$1" == /mnt/tesm-shares/* ]] || fail "Einbindungsziel liegt ausserhalb von /mnt/tesm-shares" + [[ "$1" != *".."* ]] || fail "unzulaessiger Pfad" +} + +check_readable_path() { + # Positivliste der Dateien, die die Anwendung lesen darf, obwohl sie ihr + # nicht gehoeren. Ohne die ".."-Sperre koennte ein Muster wie + # /etc/nginx/sites-available/../../../root/tesm die Liste unterlaufen: der + # Basisname waere "tesm" und damit gueltig, gelesen wuerde aber /root/tesm. + [[ "$1" != *".."* ]] || fail "unzulaessiger Pfad" + case "$1" in + /var/log/kea/kea-dhcp4.log) ;; + /var/lib/kea/kea-leases4.csv) ;; + /etc/netplan/90-*.yaml) + check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;; + /etc/nginx/sites-available/*) + check_app_key "$(basename "$1")" ;; + *) fail "Datei nicht in der Leseliste: $1" ;; + esac +} + +read_stdin_limited() { + # Maximal 1 MiB aus stdin -- verhindert, dass ein Fehler die Platte fuellt. + head -c 1048576 +} + +write_atomic() { + local target="$1" mode="$2" owner="$3" + local tmp + tmp="$(mktemp "${target}.XXXXXX")" + read_stdin_limited > "$tmp" + chmod "$mode" "$tmp" + [[ -n "$owner" ]] && chown "$owner" "$tmp" + mv -f "$tmp" "$target" +} + +# -- Verben ------------------------------------------------------------------ + +verb="${1:-}"; shift || true +require_root + +case "$verb" in + + service-status) + check_service "${1:?Dienst fehlt}" + systemctl is-active "$1" || true + systemctl is-enabled "$1" || true + ;; + + service-start|service-stop|service-restart|service-enable|service-disable) + check_service "${1:?Dienst fehlt}" + systemctl "${verb#service-}" "$1" + ;; + + nginx-test) + nginx -t + ;; + + nginx-reload) + nginx -t && systemctl reload nginx + ;; + + nginx-write) + check_app_key "${1:?App-Schluessel fehlt}" + target="/etc/nginx/sites-available/$1" + write_atomic "$target" 0644 "root:root" + ln -sfn "$target" "/etc/nginx/sites-enabled/$1" + rm -f /etc/nginx/sites-enabled/default + ;; + + nginx-apply) + # Schreiben, pruefen, neu laden -- als *eine* Operation, mit Ruecknahme. + # + # Getrennte Verben waren hier ein echter Fehler: "nginx-write" verlinkt die + # Datei sofort nach sites-enabled. Schlug das anschliessende "nginx -t" + # fehl, blieb eine kaputte Konfiguration aktiviert liegen. Der laufende + # nginx merkte davon nichts -- aber der naechste Reload oder Neustart durch + # irgendwen (auch fuer eine voellig andere Site) schlug fehl. Aus einem + # abgelehnten Formular wurde so ein Ausfall des ganzen Webservers. + check_app_key "${1:?App-Schluessel fehlt}" + target="/etc/nginx/sites-available/$1" + link="/etc/nginx/sites-enabled/$1" + backup="$(mktemp /run/tesm-nginx.XXXXXX)" + had_file=0; had_link=0 + [[ -f "$target" ]] && { cp -a "$target" "$backup"; had_file=1; } + [[ -L "$link" || -e "$link" ]] && had_link=1 + + write_atomic "$target" 0644 "root:root" + ln -sfn "$target" "$link" + + if nginx -t 2>&1; then + rm -f /etc/nginx/sites-enabled/default + systemctl reload nginx + rm -f "$backup" + echo "uebernommen" + else + # Genau in den Zustand zurueck, der vorher galt. + if [[ $had_file -eq 1 ]]; then + cp -a "$backup" "$target" + else + rm -f "$target" + fi + [[ $had_link -eq 1 ]] || rm -f "$link" + rm -f "$backup" + # Nach der Ruecknahme muss die Konfiguration wieder sauber sein. Ist sie + # es nicht, war sie schon vorher kaputt -- das gehoert ins Protokoll. + nginx -t >/dev/null 2>&1 || echo "Warnung: nginx-Konfiguration war schon vorher fehlerhaft." >&2 + fail "nginx hat die Konfiguration abgelehnt -- alter Stand wiederhergestellt" + fi + ;; + + netplan-write) + check_app_key "${1:?App-Schluessel fehlt}" + # Eigene Datei je Anwendung: der Vorgaenger schrieb aus beiden Apps nach + # /etc/netplan/90-tesm.yaml und ueberschrieb sich damit gegenseitig. + write_atomic "/etc/netplan/90-$1.yaml" 0600 "root:root" + ;; + + netplan-apply) + netplan apply + ;; + + hostname-set) + check_hostname "${1:?Hostname fehlt}" + hostnamectl set-hostname "$1" + ;; + + timezone-set) + check_timezone "${1:?Zeitzone fehlt}" + timedatectl set-timezone "$1" + ;; + + kea-write) + install -d -o root -g root -m 0755 /etc/kea + # Kea laeuft als eigener, unprivilegierter Benutzer und muss seine + # Lease-Datei und sein Protokoll schreiben koennen. Vor dem Test anlegen, + # sonst beanstandet Kea die fehlenden Verzeichnisse. + install -d -o _kea -g _kea -m 0750 /var/lib/kea /var/log/kea 2>/dev/null || true + + # Erst pruefen, dann uebernehmen. Vorher wurde jede Konfiguration + # ungeprueft abgelegt -- eine fehlerhafte fiel erst beim naechsten Start + # auf, und dann lag der Dienst, bis jemand ins Journal sah. + tmp="$(mktemp /etc/kea/kea-dhcp4.conf.XXXXXX)" + chmod 0640 "$tmp"; chown root:_kea "$tmp" + read_stdin_limited > "$tmp" + if command -v kea-dhcp4 >/dev/null 2>&1; then + if ! pruefung="$(kea-dhcp4 -t "$tmp" 2>&1)"; then + rm -f "$tmp" + # Nur die aussagekraeftige Zeile zurueckgeben, nicht das ganze Protokoll. + fail "Konfiguration abgelehnt: $(printf '%s' "$pruefung" | grep -m1 -i "error" || printf '%s' "$pruefung" | tail -1)" + fi + fi + mv -f "$tmp" /etc/kea/kea-dhcp4.conf + ;; + + logrotate-write) + check_app_key "${1:?App-Schluessel fehlt}" + write_atomic "/etc/logrotate.d/$1" 0644 "root:root" + ;; + + mount-cifs) + check_unc "${1:?Freigabe fehlt}" + check_mount_target "${2:?Ziel fehlt}" + share="${1//\\//}" + target="$2" + creds="$(mktemp /run/tesm-cifs.XXXXXX)" + chmod 600 "$creds" + # Zugangsdaten kommen ueber stdin in eine 0600-Datei -- nie ueber die + # Kommandozeile (Prozessliste) und nie ueber die Umgebung + # (/proc//environ). + read_stdin_limited > "$creds" + mkdir -p "$target" + if mount -t cifs "$share" "$target" \ + -o "credentials=$creds,uid=$(id -u tesm 2>/dev/null || echo 0),gid=$(id -g tesm 2>/dev/null || echo 0),file_mode=0640,dir_mode=0750,vers=3.1.1,seal" + then + shred -u "$creds" 2>/dev/null || rm -f "$creds" + else + shred -u "$creds" 2>/dev/null || rm -f "$creds" + fail "Einbindung fehlgeschlagen" + fi + ;; + + umount) + check_mount_target "${1:?Ziel fehlt}" + umount -l "$1" || true + rmdir "$1" 2>/dev/null || true + ;; + + apt-install) + [[ $# -gt 0 ]] || fail "kein Paket angegeben" + for package in "$@"; do + check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package" + done + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq "$@" + ;; + + certbot-issue) + check_domain "${1:?Domain fehlt}" + check_email "${2:?E-Mail fehlt}" + webroot="${3:?Webroot fehlt}" + [[ "$webroot" == /var/www/* && "$webroot" != *".."* ]] || fail "ungueltiger Webroot" + mkdir -p "$webroot" + # Bewusst --webroot statt des nginx-Plugins: das Plugin wuerde die von der + # Anwendung erzeugte Site-Datei selbst umschreiben. + certbot certonly --webroot -w "$webroot" -d "$1" \ + --email "$2" --agree-tos --non-interactive --keep-until-expiring + ;; + + selfsigned-issue) + # Fuer Installationen ohne oeffentliche Domain: ein selbstsigniertes + # Zertifikat, damit Port 443 ueberhaupt bedient werden kann. Bewusst + # getrennt vom certbot-Pfad -- hier wird nichts nach draussen gesprochen. + check_app_key "${1:?App-Schluessel fehlt}" + subject="${2:?Common Name fehlt}" + check_domain "$subject" + dir="/etc/tesm/certs/$1" + install -d -m 0755 /etc/tesm/certs + # 0755 auf dem Verzeichnis, nicht 0750: die Anwendung muss das Zertifikat + # lesen koennen, um Aussteller und Ablauf anzuzeigen. Geheim ist nur der + # private Schluessel -- der bleibt 0640 root:root und wird ausschliesslich + # vom nginx-Masterprozess (root) gelesen. Dieselbe Aufteilung wie bei + # Let's Encrypt: live/ ist begehbar, privkey.pem nicht lesbar. + install -d -m 0755 "$dir" + + # Alternative Namen: alles, was ab Argument 3 kommt -- jeweils geprueft und + # nach DNS- bzw. IP-Eintrag sortiert. Kein Argument geht ungeprueft weiter. + alt="DNS:$subject" + shift 2 + for name in "$@"; do + if [[ "$name" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then + alt="$alt,IP:$name" + elif [[ "$name" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]]; then + [[ "$name" == "$subject" ]] || alt="$alt,DNS:$name" + else + fail "ungueltiger alternativer Name: $name" + fi + done + + openssl req -x509 -newkey rsa:3072 -sha256 -days 825 -nodes -keyout "$dir/privkey.pem" -out "$dir/fullchain.pem" -subj "/CN=$subject" -addext "subjectAltName=$alt" -addext "basicConstraints=critical,CA:FALSE" -addext "keyUsage=critical,digitalSignature,keyEncipherment" -addext "extendedKeyUsage=serverAuth" >/dev/null 2>&1 || fail "openssl ist fehlgeschlagen" + chown root:root "$dir/privkey.pem" "$dir/fullchain.pem" + chmod 0640 "$dir/privkey.pem" + # Das Zertifikat selbst ist oeffentlich -- es geht bei jedem + # TLS-Handschlag an jeden Client. + chmod 0644 "$dir/fullchain.pem" + # nginx laeuft als root und liest die Datei selbst -- die Anwendung braucht + # den privaten Schluessel nie. Nur der Pfad wird zurueckgegeben. + echo "$dir/fullchain.pem" + echo "$dir/privkey.pem" + ;; + + file-read) + check_readable_path "${1:?Pfad fehlt}" + [[ -f "$1" ]] || exit 0 + cat "$1" + ;; + + *) + fail "unbekanntes Verb: ${verb:-}" + ;; +esac diff --git a/deploy/update.sh b/deploy/update.sh new file mode 100644 index 0000000..feb2e04 --- /dev/null +++ b/deploy/update.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# ============================================================================= +# Bootstrapper: laedt ein Release herunter und ruft dessen install.sh auf. +# Enthaelt selbst keinerlei Installationslogik. +# +# Zwei Fallen des Vorgaengers sind hier bewusst vermieden: +# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die +# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein +# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets. +# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt +# denselben Fehler ein zweites Mal ab. +# ============================================================================= +set -euo pipefail + +APP="${1:-}" +TAG="${2:-latest}" +BASE="${TESM_RELEASE_BASE:-https://gitea.example.invalid}" +OWNER="${TESM_RELEASE_OWNER:-wis}" +REPO="${TESM_RELEASE_REPO:-tesm}" + +[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { + echo "Verwendung: update.sh [tag]" >&2 + exit 2 +} + +WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)" +trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt + +if [[ "$TAG" == "latest" ]]; then + echo "==> Ermittle aktuellen Release-Tag ..." + TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" + echo " $TAG" +fi + +ASSET="$REPO-$TAG.tar.gz" +echo "==> Lade $ASSET ..." +curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET" + +echo "==> Entpacke ..." +tar -xzf "$WORK/$ASSET" -C "$WORK" +SRC="$(find "$WORK" -maxdepth 2 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)" +[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; } +SRC="$(cd "$SRC" && pwd)" + +echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..." +if file "$SRC/deploy/install.sh" | grep -q CRLF; then + echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2 + echo "'git -c core.autocrlf=false archive'." >&2 + exit 1 +fi + +read -r -p "Version $TAG fuer $APP installieren? [j/N] " answer +[[ "$answer" =~ ^[jJyY]$ ]] || exit 0 + +exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes diff --git a/docs/ARCHITEKTUR.md b/docs/ARCHITEKTUR.md new file mode 100644 index 0000000..cdecece --- /dev/null +++ b/docs/ARCHITEKTUR.md @@ -0,0 +1,166 @@ +# Architektur + +Zwei Anwendungen, ein gemeinsamer Kern, ein gemeinsames Lizenzprotokoll. + +``` +packages/ + tesm-licensing/ Lizenzformat und -protokoll (Ed25519). Keine Web-Abhaengigkeit. + tesm-core/ Der Kern: Datenbank, Rechte, Anmeldung, Sitzungen, Protokolle, + Einstellungen, Systemzugriff, Oberflaechenbausteine. +apps/ + tesm/ Geraete- und Switchverwaltung (PoE, DHCP, Dateifreigaben, Wartung). + tesm-license/ Lizenzserver (Kunden, Tickets, Lizenzen, Portal). +deploy/ Helfer, sudoers, systemd, nginx, logrotate, install.sh +tests/ Unit- und Integrationstests, dazu drei E2E-Skripte fuer den Zielhost. +``` + +## Warum genau diese Schnitte + +**`tesm-licensing` ist ein eigenes Paket, keine kopierte Datei.** +Im Vorgaengerprojekt lag `licensing.py` zweimal im Repo -- einmal beim Client, +einmal beim Server -- mit der Auflage, beide byte-identisch zu halten. Das ist +keine Invariante, die ein Mensch dauerhaft einhaelt. Hier gibt es das Modul +einmal; beide Anwendungen installieren dasselbe Paket. +`tests/test_repo_hygiene.py` schlaegt Alarm, sobald irgendwo wieder eine Datei +namens `licensing.py` auftaucht. + +**`tesm-core` enthaelt alles, was beide Anwendungen gleich machen.** +Nicht nur Technik (Datenbank, Sitzungen, CSRF), sondern auch ganze Seiten: +Benutzer- und Gruppenverwaltung, Protokollansichten, Papierkorb, Diagnose, +Netzwerk-, Webserver- und Verzeichnisdiensteinstellungen. Eine Seite, die in +beiden Anwendungen gleich aussehen soll, existiert genau einmal. + +**Die Apps bringen nur ihre Fachlichkeit mit** -- plus vier Beschreibungen, die +der Kern auswertet: `permissions.py` (Rechtebaum), `navigation.py`, +`schema.py` (Migrationen), `settings_spec.py`. + +## Datenbank + +Rohes `sqlite3`, kein ORM. Begruendung: das Datenmodell ist klein und stabil, +die Abfragen sind lesbar, und es gibt keine Schicht, die im Fehlerfall erst +verstanden werden muss. + +Die Klasse `tesm_core.db.Database` erzwingt drei Dinge: + +* WAL-Modus, `foreign_keys=ON`, `busy_timeout=15000`. +* Verschachtelte Transaktionen laufen ueber SAVEPOINTs. +* Ein `connect()` **waehrend** einer offenen Schreibtransaktion loest einen + `NestedConnectionError` aus, statt in einen Deadlock zu laufen. Das ist der + strukturelle Riegel gegen die haeufigste Fehlerklasse des Vorgaengers: eine + Funktion oeffnete waehrend eines Schreibvorgangs eine zweite Verbindung, und + die Anwendung blieb stehen, bis das Zeitlimit zuschlug. + +Migrationen sind versioniert nach `(komponente, version)` mit Pruefsumme. Sie +laufen ueber `split_statements()`, nicht ueber `executescript()` -- letzteres +committet implizit und zerlegt die umgebende Transaktion. + +## Rechte + +Ein Rechtebaum aus **Bereich -> Ressource -> Aktion**. Die Aktionen sind ein +festes Vokabular: `VIEW`, `CREATE`, `EDIT`, `DELETE`, `EXECUTE`, `EXPORT`, +`SECRETS`. + +* `SECRETS` und `EXPORT` sind eigene Rechte. Wer Zugangsdaten bearbeiten darf, + darf sie damit noch nicht im Klartext sehen oder ausfuehren. +* Bereiche haben einen Hauptschalter: ein abgeschalteter Bereich ist weg, + unabhaengig von Einzelrechten. +* **Keine Rechteausweitung:** niemand kann einem anderen Konto ein Recht geben, + das er selbst nicht hat (`guard_no_amplification`). Ebenso wenig laesst sich + der letzte Administrator entfernen oder sperren (`guard_last_admin`). +* Jede Pruefung ist serverseitig. Das Template blendet zusaetzlich aus, was + nicht erlaubt ist -- aber es *entscheidet* nichts. Im Vorgaenger wurden + Verzeichnisdienst und Webserver nur im Template geprueft; ein direkter POST + kam durch. + +## Lizenzierung + +Ed25519, mit den Haertungen, die ein Signaturprotokoll erst brauchbar machen: + +* **Getrennte Signaturkontexte** fuer Lizenz, Anfrage und Antwort. Eine + Signatur aus dem einen Kontext ist im anderen wertlos. +* **Kanonisches JSON** -- die Signatur haengt nicht an der Schluesselreihenfolge. +* **Nonce gegen Wiedereinspielung**, Antwort an die Anfrage gebunden. +* **Frischefenster** gegen alte, mitgeschnittene Antworten. +* **`seq` gegen Rueckstufung:** eine aeltere Revision wird abgelehnt. +* **Aussteller-Pinning (Trust on first use).** Die erste eingespielte Lizenz + pinnt den oeffentlichen Schluessel ihres Ausstellers. Ein Wechsel verlangt + eine ausdrueckliche Bestaetigung durch einen Administrator. +* **Fingerabdruck ohne Hostnamen.** Er bildet sich aus Maschinen-ID und + DMI-UUID. Der Hostname ist bewusst **nicht** enthalten: im Vorgaenger brach + jedes Umbenennen des Rechners die Bindung. + +Der Ablauf: Kunde -> Ticket -> Lizenz. Das Ticket haelt die Parameter (Typ, +Module, Laufzeit, Rolle); die Lizenz ist das signierte Ergebnis. Ein zweiter +Klick auf "Ausstellen" erzeugt keine zweite Lizenz, sondern zeigt die offene. + +Aenderungen an einer laufenden Lizenz brauchen keine Neuinstallation: der +Anbieter signiert eine neue Revision, der Client holt sie beim naechsten +Heartbeat. + +## Systemzugriff + +Der Webprozess laeuft unprivilegiert. Root-Aktionen gehen ausschliesslich durch +`deploy/tesm-helper`: ein Skript mit fester Verbliste, Argumentpruefung gegen +Positivlisten und ohne jede Weitergabe an eine Shell. sudoers gibt genau diesen +einen Pfad frei. + +Aktionen, die mehrere Schritte brauchen, sind **ein** Verb -- nicht drei. +`nginx-apply` etwa schreibt, prueft und laedt neu; scheitert `nginx -t`, stellt +es den vorherigen Stand wieder her. Getrennte Verben hatten hier eine echte +Luecke: `nginx-write` verlinkt sofort nach `sites-enabled`, und eine +anschliessend abgelehnte Datei blieb aktiviert liegen. Der laufende nginx merkte +nichts -- der naechste Reload durch irgendwen legte den ganzen Webserver lahm. + +## Mehrere Installationen auf einem Host + +Zwei Schluessel, mit klarer Aufgabenteilung: + +* **`app_key`** (`tesm`, `tesm-license`) -- was die Anwendung *ist*. Bestimmt + Env-Praefix, Rechtebaum, Migrationsnamensraum. +* **`site_key`** (`tesm`, `tesm-opus`, ...) -- welche Installation es *ist*. + Bestimmt alles ausserhalb von `/srv/`: nginx-Site, netplan-Datei, + logrotate-Regel, Zertifikatsverzeichnis und den Namen des Sitzungscookies. + +Ohne diese Trennung wuerde eine Testinstanz beim Speichern der +Webserver-Einstellungen die Site-Datei der Produktion ueberschreiben -- und +beide Installationen teilten sich dasselbe Sitzungscookie, weil Cookies keine +Ports unterscheiden. + +Die Adresse des Anwendungsservers (`BIND`) steht in derselben Umgebungsdatei, +aus der auch gunicorn seinen `--bind` bekommt. Sie ist zugleich das +`proxy_pass`-Ziel der erzeugten nginx-Site. Vorher war die `5000` fest im Code: +eine Instanz auf Port 5100 erzeugte damit eine Konfiguration, die auf die +*andere* Installation zeigte. + +## Oberflaeche + +Handgeschriebenes CSS in `@layer`n mit semantischen Tokens, drei Themen (hell, +dunkel, System). JavaScript als ES-Module, angebunden ueber +`data-behavior`-Attribute -- kein Inline-Script, kein Build-Schritt, und damit +vertraeglich mit einer strikten CSP, die `unsafe-inline` fuer Skripte nicht +erlaubt. + +## Tests + +| Datei | Was sie absichert | +|---|---| +| `test_licensing.py` | Format, Signaturen, Protokoll, Fingerabdruck | +| `test_core_smoke.py` | Kern: Datenbank, Migrationen, Schluesselspeicher, Sitzungen | +| `test_core_security.py` | Passwoerter, TOTP, Ratenbegrenzung, CSRF, Rechte | +| `test_webserver.py` | nginx-Vorlage, TLS, Zertifikate, Instanztrennung | +| `test_tesm_smoke.py` | TESM: Seiten, Validierung, Rechtematrix | +| `test_license_server.py` | Lizenzserver: Ausstellung, Portal, API | +| `test_repo_hygiene.py` | Zeilenenden, Geheimnisse im Repo, Helferpruefungen, sudoers | + +Dazu drei Skripte, die **auf dem Zielhost gegen die echte Installation** laufen +(nginx, gunicorn, systemd, TLS -- keine Attrappen): + +```bash +python3 tests/e2e_smoke.py https://host admin 'Passwort' +python3 tests/e2e_license_server.py https://lizenz.host admin 'Passwort' +python3 tests/e2e_license.py https://lizenz.host https://host admin 'Passwort' http://127.0.0.1:5001 +``` + +Das dritte spielt den vollstaendigen Lizenz-Lebenszyklus ueber beide +Anwendungen hinweg durch: ausstellen, herunterladen, einspielen, aktivieren, +Module pruefen, Heartbeat, Revision, Widerruf. diff --git a/docs/BETRIEB.md b/docs/BETRIEB.md new file mode 100644 index 0000000..6cdf02f --- /dev/null +++ b/docs/BETRIEB.md @@ -0,0 +1,295 @@ +# Betrieb + +Dieses Dokument richtet sich an die Person, die die Anlage installiert, +aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf +dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird. + +--- + +## 1. Was wo liegt + +| Zweck | Pfad | +|---|---| +| Anwendung (Code, venv, statische Dateien) | `/srv//` | +| Instanzdaten (Datenbank, Schluessel, Lizenz) | `/srv//data/` | +| Protokolle | `/var/log//` | +| Umgebungsdatei | `/etc/tesm/.env` | +| Zertifikate (selbstsigniert) | `/etc/tesm/certs//` | +| nginx-Site | `/etc/nginx/sites-available/` | +| systemd-Unit | `/etc/systemd/system/.service` | +| Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` | +| Verwaltungswerkzeug | `/usr/local/bin/-admin` | + +`` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusaetzlichen +Instanz, `tesm-`. Dieser Name (der **SITE_KEY**) benennt alles, was +ausserhalb von `/srv/` liegt. Deshalb koennen zwei Installationen +derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die +nginx-Site, die netplan-Datei oder das Sitzungscookie zu ueberschreiben. + +Alles, was ein Mensch verlieren wuerde, liegt unter `data/`. Das Verzeichnis +wird von `install.sh` nie angefasst. + +--- + +## 2. Installation + +```bash +sudo bash deploy/install.sh --app tesm +sudo bash deploy/install.sh --app tesm-license +``` + +Wichtige Optionen: + +| Option | Wirkung | +|---|---| +| `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. | +| `--domain ` | Oeffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. | +| `--acme-email ` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestaetigen. | +| `--instance ` | Zusaetzliche Instanz neben der Hauptinstallation. | +| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur fuer Testinstanzen sinnvoll). | +| `--skip-packages` | Keine Systempakete installieren. | + +Danach: + +```bash +sudo -u tesm tesm-admin create-admin +``` + +Beim Lizenzserver zusaetzlich, **in dieser Reihenfolge**: + +```bash +sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel -- sofort sichern! +sudo -u tesm-license tesm-license-admin bootstrap # eigene Master-Lizenz +sudo -u tesm-license tesm-license-admin create-admin +``` + +> **Der Signaturschluessel ist nicht wiederherstellbar.** Geht +> `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr +> pruefbar. Sichern Sie ihn getrennt vom Server. + +### Zwei Anwendungen auf einem Host + +Beide wollen Port 80 und 443. nginx unterscheidet sie am Namen: + +```bash +sudo bash deploy/install.sh --app tesm --https +sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.local +``` + +Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und +beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf +demselben Port weist nginx ab -- richtig so, und beim Uebernehmen sofort +sichtbar. + +--- + +## 3. Aktualisierung + +```bash +sudo bash deploy/install.sh --app tesm +``` + +Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine +Aktualisierung ist, und tut dann zusaetzlich: + +1. **Vollstaendige Sicherung** nach `/srv/-backup-` -- + unabhaengig davon, ob sich das Schema geaendert hat. +2. Dateiabgleich; `data/` bleibt unangetastet. +3. Schema-Migrationen beim Start (versioniert, mit Pruefsumme). +4. **Gesundheitspruefung.** Antwortet die Anwendung nicht, wird der alte Stand + automatisch zurueckgeholt; die fehlgeschlagene Version bleibt unter + `/srv/-failed-` zur Analyse liegen. + +Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne +`--https`. + +--- + +## 4. HTTPS + +Der Zielzustand ist immer: **Port 443 mit TLS, Port 80 leitet um, ausser dem +ACME-Pfad.** + +Drei Wege dorthin: + +**a) Bei der Installation** -- siehe oben, `--https`. + +**b) Nachtraeglich ueber die Kommandozeile:** + +```bash +sudo -u tesm tesm-admin web-setup --https --self-signed --apply +sudo -u tesm tesm-admin web-setup --https --domain tesm.firma.de \ + --acme --acme-email it@firma.de --hsts --apply +``` + +`--print-config` zeigt die Datei, ohne etwas zu schreiben. + +**c) In der Oberflaeche** unter *Einstellungen -> Webserver und TLS*. + +Danach muss `COOKIE_SECURE` in `/etc/tesm/.env` auf `1` stehen (alle drei +Wege erledigen das selbst) und der Dienst neu gestartet werden. + +### Die Site-Datei wird immer neu erzeugt + +Sie ist **kein** Ort fuer Handarbeit. Jede Uebernahme schreibt sie vollstaendig +aus der Vorlage neu. Was frueher von Hand hineingeschrieben wurde -- Domain, +Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der +Anwendung. + +Der Grund steht im Vorgaengerprojekt: dort wurde die Datei nur angelegt, wenn +sie fehlte. Ein einmal falscher `alias`-Pfad ueberlebte dadurch jahrelang jedes +Update, und die Oberflaeche kam die ganze Zeit ohne Design. + +### Selbstsigniert oder Let's Encrypt? + +* **Oeffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS + sinnvoll. +* **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die + Verbindung ist trotzdem verschluesselt und das Sitzungscookie darf `Secure` + tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe + monatelang und die Warnung laesst sich dann nicht mehr wegklicken. + +Die Erneuerung von Let's Encrypt laeuft ueber `certbot certonly --webroot` +(bewusst nicht ueber das nginx-Plugin, das die erzeugte Site umschreiben +wuerde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar. + +--- + +## 5. Dienste + +```bash +systemctl status tesm.service # Weboberflaeche (gunicorn) +systemctl status tesm-monitor.service # Ueberwachungsschleife (nur TESM) +systemctl status tesm-license.service +journalctl -u tesm -n 100 -f +``` + +Die Units laufen unprivilegiert (`User=tesm`) mit +`NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` und einer engen +`RestrictAddressFamilies`-Liste. Schreibrechte gibt es nur auf `/srv//data` +und `/var/log/`. + +--- + +## 6. Root-Rechte + +Die Anwendung hat keine. Alles, was Root braucht, geht durch **einen** Pfad: + +``` +/usr/local/lib/tesm/tesm-helper [argumente] +``` + +Der Helfer gehoert `root`, ist per sudoers ausschliesslich fuer genau diesen +Pfad mit `NOPASSWD` freigegeben und prueft jedes Argument gegen eine +Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein +`sh -c`). + +Verben ansehen: + +```bash +sudo /usr/local/lib/tesm/tesm-helper +grep -n ')$' /usr/local/lib/tesm/tesm-helper +``` + +Regel fuer Aenderungen: **Jedes neue Verb braucht eine Argumentpruefung.** +`tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb +im Helfer vorkommt und dass jede Pfadpruefung `..` abweist. + +--- + +## 7. Sicherung und Wiederherstellung + +In der Oberflaeche unter *Sicherung* -- verschluesselte Ausfuhr (Argon2id + +AES-256-GCM) mit einem selbst gewaehlten Kennwort. Der Import zeigt zuerst eine +Vorschau, bevor irgendetwas geschrieben wird. + +Auf dem Host reicht: + +```bash +sudo systemctl stop tesm +sudo tar -czf /root/tesm-data-$(date +%F).tar.gz -C /srv/tesm data +sudo systemctl start tesm +``` + +Unverzichtbar: + +* `data/app.db` -- alle Daten +* `data/secret.key` -- ohne sie sind alle Sitzungen ungueltig +* `data/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren** +* `data/license.json` und `data/license_key.json` +* Lizenzserver zusaetzlich: `data/master_signing_key.json` + +--- + +## 8. Wenn etwas nicht geht + +**Die Anwendung antwortet nicht.** +```bash +journalctl -u tesm -n 200 --no-pager +curl -s http://127.0.0.1:5000/gesundheit +``` +Antwortet gunicorn, aber der Browser nicht, liegt es an nginx. + +**nginx laesst sich nicht neu laden.** +```bash +sudo nginx -t +``` +Die Uebernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst +zurueck. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft +`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt. + +**Anmeldung schlaegt ohne Fehlermeldung fehl.** +Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie, +das er ueber HTTP nie zuruecksendet. +```bash +grep COOKIE_SECURE /etc/tesm/tesm.env +``` + +**"Die Anfrage kam von einer fremden Herkunft (Origin/Referer)."** +Der Reverse Proxy reicht den Host falsch durch. In der Site-Datei muss +`proxy_set_header Host $http_host;` stehen -- **nicht** `$host`, das verwirft +den Port. + +**Statische Dateien fehlen, die Oberflaeche ist unformatiert.** +```bash +grep alias /etc/nginx/sites-available/tesm +ls /srv/tesm/static /srv/tesm/static-core +``` +Beide `alias`-Pfade muessen in das Installationsverzeichnis zeigen. + +**Zwei Instanzen stoeren sich.** +`SITE_KEY` in beiden `/etc/tesm/*.env` vergleichen -- er muss sich +unterscheiden. Sonst teilen sich beide nginx-Site und Sitzungscookie. + +**Die Wartung meldet "Der SSH-Host-Schluessel ist unbekannt".** +Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden +Schluessel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf +der Detailseite des Geraets (*Clients -> Geraet -> SSH-Host-Schluessel*): +erst **Auslesen**, dann den Fingerprint mit einer unabhaengigen Quelle +vergleichen, dann **Freigeben**. Die Wartungsuebersicht zeigt in einer eigenen +Spalte, fuer welche Geraete das noch offen ist. Fuer Switche liegt dieselbe +Freigabe auf der Switch-Detailseite. + +Der Schluessel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM +verbindet sich immer ueber die hinterlegte IP-Adresse. Aendert sich die IP +eines Geraets, ist der Schluessel erneut freizugeben. + +**Die Pruefkette des Aenderungsprotokolls ist gebrochen.** +Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst, +dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es +nicht mehr stimmt, wird genannt. + +--- + +## 9. Zurueck auf eine aeltere Version + +```bash +sudo systemctl stop tesm +sudo mv /srv/tesm /srv/tesm-kaputt +sudo cp -a /srv/tesm-backup- /srv/tesm +sudo systemctl start tesm +``` + +Schema-Migrationen laufen nur vorwaerts. Ein Ruecksprung ueber eine +Schema-Aenderung hinweg braucht deshalb auch die Datenbank aus der Sicherung -- +genau das liegt im Backup-Verzeichnis. diff --git a/docs/SICHERHEIT.md b/docs/SICHERHEIT.md new file mode 100644 index 0000000..feed727 --- /dev/null +++ b/docs/SICHERHEIT.md @@ -0,0 +1,183 @@ +# Sicherheit + +Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind. + +--- + +## 1. Anmeldung + +| | | +|---|---| +| Speicherung | Argon2id (argon2-cffi) | +| Altbestand | bcrypt wird noch geprueft und bei der naechsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt | +| Zweiter Faktor | TOTP, optional erzwingbar fuer Administratoren | +| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen | +| Sperre je Adresse | zusaetzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst laesst sich die Kontosperre umgehen, indem man viele Konten je einmal probiert | +| Verzeichnisdienst | LDAP nur ueber TLS, Zertifikat wird geprueft. Ohne gueltiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet | + +Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session +Fixation) und das CSRF-Token gewechselt. + +### Erneute Bestaetigung ("sudo-Modus") + +Alles, was Geheimnisse offenlegt oder Daten aus dem System heraustraegt, +verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten, +Ausfuhr, Herunterladen eines Lizenzbundles, Schluesselrotation, +2FA-Aenderungen, Deaktivierung einer Lizenz. + +Ein uebernommenes Sitzungscookie allein reicht dafuer nicht. + +--- + +## 2. Sitzungen + +Serverseitig in SQLite, nicht im Cookie. Das Cookie traegt nur eine +Zufallskennung. + +* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist +* Leerlaufzeit **und** absolute Hoechstdauer +* Der Cookiename enthaelt den `SITE_KEY` -- zwei Installationen auf einem Host + koennen sich die Sitzung nicht gegenseitig ueberschreiben (Cookies + unterscheiden keine Ports) +* Aktive Sitzungen sind einsehbar und einzeln beendbar + +--- + +## 3. CSRF + +Drei voneinander unabhaengige Schichten: + +1. `SameSite=Lax` am Sitzungscookie. +2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen. +3. Origin-/Referer-Pruefung gegen den Host der Anfrage. + +Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die +Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf +demselben Rechner ist ein anderer Ursprung. + +Der Vorgaenger hatte gar keinen CSRF-Schutz. Eine praeparierte Seite konnte im +Namen eines angemeldeten Administrators Geraete loeschen oder Benutzer anlegen. + +Ausnahmen (`@csrf.exempt`) gibt es nur fuer Maschinenschnittstellen, die sich +**nicht** ueber Cookies autorisieren. Eine cookie-autorisierte Route ohne +CSRF-Schutz waere eine Luecke. + +--- + +## 4. Header + +Bei jeder Antwort: + +* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein** + `unsafe-inline` fuer Skripte -- deshalb gibt es im ganzen Projekt kein + Inline-Script ohne Nonce, und JavaScript haengt ueber + `data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil + einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt + werden; Style-Attribute fuehren keinen Code aus. +* `X-Content-Type-Options: nosniff` +* `X-Frame-Options: DENY`, `frame-ancestors 'none'` +* `Referrer-Policy: same-origin` +* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet +* `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy` +* `Strict-Transport-Security` nur, wenn TLS tatsaechlich terminiert wird + +> HSTS gehoert **nicht** zu einem selbstsignierten Zertifikat. Der Browser +> merkt sich die Vorgabe monatelang; die Warnung laesst sich danach nicht mehr +> wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt. + +--- + +## 5. Geheimnisse im Ruhezustand + +Ein Schluesselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600): + +* Versionierte Schluessel, rotierbar. Chiffrate tragen ihre Schluesselkennung + (`v1...`) und bleiben nach einer Rotation lesbar. +* Der Verwendungszweck geht als AAD in die Verschluesselung ein. Ein Chiffrat + aus einem Kontext laesst sich in einem anderen nicht entschluesseln. +* Beim Start wird geprueft, ob die Rechte auf Schluessel- und Lizenzdateien + noch stimmen; Abweichungen erscheinen unter *Diagnose*. + +Passwoerter fuer Mounts gehen ueber `stdin` an den privilegierten Helfer, der +sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste) +und nie ueber die Umgebung (`/proc//environ`). + +--- + +## 6. Rechtetrennung auf dem Host + +Der Webprozess laeuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch +genau einen allowlisted Helfer (`/usr/local/lib/tesm/tesm-helper`), der per +sudoers ausschliesslich fuer diesen Pfad freigegeben ist. + +Regeln, die im Repository durch Tests erzwungen werden: + +* Jedes Verb, das die Anwendung kennt, existiert im Helfer. +* Kein `eval`, kein `bash -c`, kein `sh -c`. +* Jede Pfadpruefung weist `..` ab -- sonst laesst sich eine Positivliste ueber + den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat + den zulaessigen Basisnamen `x`). +* Der Instanzname ist auf `tesm[-]` begrenzt; er landet in Pfaden. +* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht. + +Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`, +`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste. + +Der Vorgaenger liess die komplette Flask-Anwendung als root laufen. Eine +einzige Luecke in irgendeinem Pfad haette den Host bedeutet. + +--- + +## 7. Aenderungsprotokoll + +Verkettet gehasht: jeder Eintrag enthaelt den Hash seines Vorgaengers. Ein +nachtraegliches Aendern oder Loeschen faellt auf und wird unter *Diagnose* +angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht. + +Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdruecklich +auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`). + +Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen. + +--- + +## 8. Lizenzprotokoll + +Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz: +getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung, +Frischefenster, `seq` gegen Rueckstufung, Antwort an die Anfrage gebunden, +Aussteller-Pinning. + +Das Auslieferungsbundle enthaelt den privaten Clientschluessel und ist damit +selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische +Passwortbestaetigung, `Cache-Control: no-store`, Eintrag im Zustell- und im +Aenderungsprotokoll. + +--- + +## 9. Ein- und Ausfuhr + +Verschluesselte Umschlaege (Argon2id-Ableitung + AES-256-GCM) mit einem selbst +gewaehlten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben +wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb +braucht der Dienst kein `--workers 1` mehr. + +--- + +## 10. Bewusst nicht getan + +* **Kein Schalter, der die LDAP-Zertifikatspruefung abschaltet.** Wer ihn + einmal setzt, setzt ihn dauerhaft. +* **Kein CSRF-Freibrief fuer cookie-autorisierte Routen.** +* **Kein `unsafe-inline` fuer Skripte**, auch nicht "voruebergehend". +* **Keine Rechteausweitung**, auch nicht fuer Administratoren untereinander. +* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung + nicht brechen. +* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt. + +--- + +## 11. Wenn Sie eine Schwachstelle finden + +Bitte nicht oeffentlich melden. Wenden Sie sich an die im Anbieterprofil des +Lizenzservers hinterlegte Adresse. diff --git a/etc/nginx/sites-available/tesm b/etc/nginx/sites-available/tesm deleted file mode 100644 index 1a6c51c..0000000 --- a/etc/nginx/sites-available/tesm +++ /dev/null @@ -1,48 +0,0 @@ -# Für den WebSocket-Upgrade des SSH-Terminals (/ws/ssh_terminal) nötig — -# ohne diesen Map-Trick würde nginx den "Connection: Upgrade"-Header nicht -# durchreichen und Flask/Werkzeug lehnt den Handshake dann mit 400 ab -# (live an einer echten Ubuntu-Installation reproduziert: direkt gegen -# Flask:5000 funktionierte der Handshake, durch nginx auf Port 80 nicht). -map $http_upgrade $connection_upgrade { - default upgrade; - '' close; -} - -server { - listen 80; - server_name _; - - # Eigener Block vor "location /", da WebSockets lang offene Verbindungen - # sind — die kurzen Standard-Timeouts von nginx (60s) würden ein länger - # inaktives SSH-Terminal sonst hart trennen. - location /ws/ { - proxy_pass http://127.0.0.1:5000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 3600s; - proxy_send_timeout 3600s; - } - - 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; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /static/ { - alias /srv/tesm/static/; - expires 7d; - } -} diff --git a/etc/systemd/system/tesm-check-restart.service b/etc/systemd/system/tesm-check-restart.service deleted file mode 100644 index edd81bb..0000000 --- a/etc/systemd/system/tesm-check-restart.service +++ /dev/null @@ -1,6 +0,0 @@ -[Unit] -Description=Restart TESM Check - -[Service] -Type=oneshot -ExecStart=/bin/systemctl restart tesm-check.service diff --git a/etc/systemd/system/tesm-check-restart.timer b/etc/systemd/system/tesm-check-restart.timer deleted file mode 100644 index 352997c..0000000 --- a/etc/systemd/system/tesm-check-restart.timer +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Restart TESM Check daily at 00:00 - -[Timer] -OnCalendar=*-*-* 00:00:00 -Persistent=true -Unit=tesm-check-restart.service - -[Install] -WantedBy=timers.target diff --git a/etc/systemd/system/tesm-check.service b/etc/systemd/system/tesm-check.service deleted file mode 100644 index 3f2776b..0000000 --- a/etc/systemd/system/tesm-check.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=TESM Ping Check -After=network.target - -[Service] -Type=simple -User=root -WorkingDirectory=/srv/tesm -ExecStart=/srv/tesm/venv/bin/python3 /srv/tesm/poe_wrapper.py -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target diff --git a/etc/systemd/system/tesm.service b/etc/systemd/system/tesm.service deleted file mode 100644 index ac13618..0000000 --- a/etc/systemd/system/tesm.service +++ /dev/null @@ -1,37 +0,0 @@ -[Unit] -Description=TESM Web App -After=network.target - -[Service] -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 -# gthread" (blockierende WebSocket-Threads für das Browser-SSH-Terminal). -# "--timeout 0" deaktiviert Gunicorns Worker-Timeout: der greift sonst -# nach 30s auch bei einer offenen, aber gerade inaktiven SSH-Terminal- -# WebSocket-Verbindung und würde den Worker (und damit JEDE Verbindung, -# da nur ein Prozess läuft) killen. -# "--bind 127.0.0.1:5000" statt 0.0.0.0: Flask/Gunicorn soll ausschließlich -# über den nginx-Reverse-Proxy (siehe Einstellungen -> NGINX) erreichbar -# sein, nicht zusätzlich direkt und unverschlüsselt über Port 5000 aus dem -# Netz -- nginx selbst greift ohnehin nur über 127.0.0.1 zu. -ExecStart=/srv/tesm/venv/bin/gunicorn --workers 1 --worker-class gthread --threads 16 \ - --timeout 0 --bind 127.0.0.1:5000 app:app -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target diff --git a/etc/update-motd.d/50-tesm b/etc/update-motd.d/50-tesm deleted file mode 100644 index 2b52651..0000000 --- a/etc/update-motd.d/50-tesm +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -# TESM -- Live-Flotten-Status im SSH-Login-Banner (dynamisches MOTD, siehe -# /etc/pam.d/sshd: "session optional pam_motd.so motd=/run/motd.dynamic"). -# Ruft motd_banner.py im TESM-Quellverzeichnis über dessen venv auf, da -# app.py (die eigentliche Datenquelle) Flask u.a. importiert, die nur dort -# installiert sind -- gleiches Muster wie generate_ips.py/poe.sh. -# "timeout" verhindert, dass ein hängender DB-/Dateizugriff den Login -# blockiert; "|| true" sorgt dafür, dass ein Fehler hier nie den SSH-Login -# selbst stört -- im schlimmsten Fall bleibt der Banner einfach leer. -timeout 3 /srv/tesm/venv/bin/python3 /srv/tesm/motd_banner.py 2>/dev/null || true diff --git a/install.sh b/install.sh deleted file mode 100644 index 14875da..0000000 --- a/install.sh +++ /dev/null @@ -1,253 +0,0 @@ -#!/bin/bash -# ============================================================================ -# TESM — Installer/Updater -# Richtet System-Pakete, die Flask-App (systemd) sowie den Erreichbarkeits- -# Check-Dienst (systemd + Timer) ein. Auszuführen als root, nachdem dieses -# Repo/Release-Paket z.B. nach /root/tesm entpackt wurde. -# -# Erkennt selbstständig, ob unter /srv/tesm bereits eine Installation -# existiert (an sqlite.db), und verhält sich entsprechend: -# -# - Keine vorhandene Installation -> normale Frischinstallation, kein -# Backup nötig (nichts, worauf zurückgerollt werden könnte). -# - Vorhanden (egal ob SCHEMA_VERSION gleich geblieben oder sich -# geändert hat) -> Update MIT Sicherheitsnetz: die komplette bisherige -# Installation (Code, venv, Datenbank, Schlüssel) wird vorher 1:1 nach -# /srv/tesm-backup-pre-update- kopiert. Eine eigentliche -# Schema-Migration übernimmt danach die App selbst beim Start (siehe -# _ensure_schema() in app.py). Kommt der Dienst anschließend -# nachweislich NICHT gesund hoch (HTTP-Check auf /login), wird -# automatisch die komplette Sicherung zurückgespielt (alter Code + -# alte Datenbank zusammen -- ein reines DB-Rollback allein würde -# denselben Fehler beim nächsten Start mit dem neuen Code sofort -# wiederholen) und das Backup bleibt zur manuellen Prüfung erhalten. -# Kommt er gesund hoch, gilt das Update als erfolgreich und das -# Backup wird gelöscht. -# -# Das Sicherheitsnetz greift bewusst bei JEDEM Update einer bestehenden -# Installation, nicht nur bei einer erkannten Schema-Änderung -- ein -# rein code-seitiger Fehler im neuen Paket hat mit dem Datenbank-Schema -# nichts zu tun, hätte den Dienst bei einem "Schema unverändert -> kein -# Backup"-Kurzschluss aber trotzdem ungeschützt lahmgelegt (live -# reproduziert). SCHEMA_VERSION bestimmt nur noch den Wortlaut der -# Log-Meldung (In-Place-Update vs. Migration), nicht mehr, OB -# abgesichert wird. -# ============================================================================ -set -e - -REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -print_status() { echo -e "${GREEN}✔${NC} ${1} completed."; } -step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/tesm-install.log; } - -# Kopiert nur, wenn der Inhalt sich tatsächlich unterscheidet (oder das -# Ziel noch fehlt) -- bei einem Update sollen ausschließlich Dateien -# angefasst werden, die sich zwischen der laufenden und der neuen Version -# wirklich geändert haben (unverändert lässt "cp" Zeitstempel/Inode aber -# trotzdem hochzählen, was z.B. Monitoring/Backups unnötig triggert). -# Gibt 0 zurück, wenn tatsächlich kopiert wurde, 1 wenn übersprungen. -install_if_changed() { - local src="$1" dst="$2" mode="${3:-644}" - if [ -f "$dst" ] && cmp -s "$src" "$dst"; then - return 1 - fi - sudo install -m "$mode" "$src" "$dst" - return 0 -} - -# "clear" schlägt ohne TERM/Pseudo-Terminal fehl (z.B. bei Automatisierung -# per SSH exec_command ohne pty, genau wie update.sh dieses Skript aufruft) -# -- mit "set -e" bricht das sonst die GESAMTE Installation sofort und ohne -# jede Fehlermeldung an dieser Stelle ab, noch bevor auch nur ein Paket -# installiert wurde (live so reproduziert). Rein kosmetisch, daher bewusst -# nie fatal. -clear 2>/dev/null || true - -# ---- Bestehende Installation erkennen ---- -# WICHTIG: das Sicherheitsnetz (Backup + Health-Check + ggf. Rückroll) -# greift bei JEDEM Update einer bestehenden Installation, nicht nur bei -# einer erkannten Schema-Änderung -- live so gefunden: ein rein -# code-seitiger Fehler im neuen Paket (z.B. ein kaputtes app.py, mit dem -# Schema selbst hat das nichts zu tun) hätte bei einem reinen "Schema -# unverändert -> kein Backup"-Kurzschluss ungeschützt den Dienst -# lahmgelegt, ganz ohne automatische Wiederherstellung. Die -# SCHEMA_VERSION-Auswertung dient jetzt nur noch der Log-Meldung (WARUM -# aktualisiert wird), nicht mehr der Entscheidung OB abgesichert wird. -EXISTING_INSTALL=0 -[ -f /srv/tesm/sqlite.db ] && EXISTING_INSTALL=1 - -NEEDS_MIGRATION_GUARD=0 -BACKUP_DIR="" - -if [ "$EXISTING_INSTALL" -eq 1 ]; then - OLD_SCHEMA_VERSION="" - [ -f /srv/tesm/SCHEMA_VERSION ] && OLD_SCHEMA_VERSION="$(tr -d '[:space:]' < /srv/tesm/SCHEMA_VERSION)" - NEW_SCHEMA_VERSION="" - [ -f "$REPO_DIR/srv/tesm/SCHEMA_VERSION" ] && NEW_SCHEMA_VERSION="$(tr -d '[:space:]' < "$REPO_DIR/srv/tesm/SCHEMA_VERSION")" - - step "Stopping tesm.service for update" - systemctl stop tesm.service tesm-check.service tesm-check-restart.timer 2>/dev/null || true - print_status "Service stopped" - - if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ] && [ "$OLD_SCHEMA_VERSION" == "$NEW_SCHEMA_VERSION" ]; then - echo -e "${GREEN}Bestehende Installation erkannt, Datenbank-Schema unverändert (Version ${OLD_SCHEMA_VERSION}):${NC} In-Place-Update." - else - if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ]; then - reason="Datenbank-Schema hat sich geändert (${OLD_SCHEMA_VERSION} -> ${NEW_SCHEMA_VERSION})" - else - reason="Schema-Version der bestehenden Installation oder dieses Pakets unbekannt" - fi - echo -e "${YELLOW}Bestehende Installation erkannt, ${reason}:${NC}" - echo "Update mit automatischer Migration." - fi - NEEDS_MIGRATION_GUARD=1 - BACKUP_DIR="/srv/tesm-backup-pre-update-$(date +%Y%m%d-%H%M%S)" - step "Backing up current installation to $BACKUP_DIR before update" - cp -a /srv/tesm "$BACKUP_DIR" - print_status "Backup created" -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 cifs-utils >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed" - -# ---- Log-Verzeichnis ---- -# NICHT weltweit beschreibbar (0755 reicht) -- sowohl tesm.service als -# auch tesm-check.service (poe.sh) laufen als root, kein anderer -# Dienstbenutzer schreibt hierher (Keas eigenes Log liegt separat unter -# TESM_KEA_LOG_PATH, i.d.R. /var/log/kea/). Ein group-/world-writable -# Verzeichnis lässt logrotate die Rotation aus Sicherheitsgründen -# komplett verweigern ("insecure permissions") -- live reproduziert: bei -# 0777 rotierte live.log über Wochen hinweg gar nicht mehr. Die -# eigentliche logrotate-Konfiguration (/etc/logrotate.d/tesm) schreibt -# die App selbst beim Start bzw. beim Speichern unter -# Systemeinstellungen → Logs. -sudo mkdir -p /var/log/tesm -sudo chmod 755 /var/log/tesm - -# ---- App-Verzeichnis ---- -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' --exclude 'license.json' \ - "$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1 -print_status "Application files copied" - -# ---- Python venv ---- -# "python3 -m pip" statt des "pip"-Skripts im venv selbst: dessen Shebang -# enthält einen zur Erstellungszeit FEST einprogrammierten, absoluten Pfad -# zum venv-Python. Wird das venv-Verzeichnis später umbenannt/verschoben -# (wie hier historisch bei der Umbenennung "poe_manager" -> "tesm" -# passiert), bleibt die alte, nicht mehr existierende Pfadangabe stehen -- -# "./venv/bin/pip install ..." schlägt dann mit "cannot execute: required -# file not found" fehl (live auf POETEST reproduziert), obwohl das venv -# selbst voll funktionsfähig ist. "python3 -m pip" braucht dagegen nur den -# (korrekt referenzierten) venv-Python-Interpreter selbst. -step "Setting up Python virtual environment" -cd /srv/tesm -sudo python3 -m venv venv -sudo ./venv/bin/python3 -m pip install --upgrade pip >>/var/log/tesm-install.log 2>&1 -sudo ./venv/bin/python3 -m pip install -r requirements.txt >>/var/log/tesm-install.log 2>&1 -print_status "Virtual environment ready" - -# ---- Datenbank ---- -if [ ! -f /srv/tesm/sqlite.db ]; then - step "Initializing database" - sudo ./venv/bin/python3 create_db.py >>/var/log/tesm-install.log 2>&1 - print_status "Database initialized" - echo -e "${RED}→${NC} Kein Admin-Benutzer vorhanden. Bitte danach ausführen: sudo /srv/tesm/venv/bin/python3 /srv/tesm/create_admin.py" -fi - -# ---- Custom-Skripte + systemd ---- -step "Copying custom scripts and systemd units" -sudo mkdir -p /usr/local/bin/custom -UNITS_CHANGED=0 -install_if_changed "$REPO_DIR/usr/local/bin/custom/poe.sh" /usr/local/bin/custom/poe.sh 755 || true -for f in "$REPO_DIR"/etc/systemd/system/*.service "$REPO_DIR"/etc/systemd/system/*.timer; do - if install_if_changed "$f" "/etc/systemd/system/$(basename "$f")" 644; then - UNITS_CHANGED=1 - fi -done -print_status "Scripts and units copied" - -# ---- SSH-Login-Banner (MOTD) ---- -# Live-Flotten-Status (Online/Offline, DHCP) statt der Standard-Ubuntu- -# Willkommensmeldung -- siehe srv/tesm/motd_banner.py für die Datenquellen. -# Die Standard-Skripte bleiben erhalten, werden aber deaktiviert (chmod -x, -# nicht gelöscht), damit ein Rückbau jederzeit per chmod +x möglich ist. -step "Setting up TESM SSH login banner" -install_if_changed "$REPO_DIR/etc/update-motd.d/50-tesm" /etc/update-motd.d/50-tesm 755 || true -sudo chmod -x /etc/update-motd.d/00-header /etc/update-motd.d/10-help-text \ - /etc/update-motd.d/50-motd-news /etc/update-motd.d/60-unminimize \ - /etc/update-motd.d/91-release-upgrade /etc/update-motd.d/92-unattended-upgrades \ - 2>/dev/null || true -print_status "SSH login banner configured" - -# ---- nginx ---- -# Nur bei einer Frischinstallation aus dem Repo-Template geschrieben -- -# eine bereits vorhandene Konfiguration (egal ob unverändert oder von der -# App selbst z.B. um SSL/HSTS erweitert, siehe Systemeinstellungen -> SSL) -# wird NIE wieder überschrieben. Symlink/Default-Site-Entfernung und der -# Validierungs-Reload laufen dagegen immer -- rein idempotent, ändern an -# einer bestehenden Konfiguration selbst nichts. -step "Configuring nginx reverse proxy" -if [ ! -f /etc/nginx/sites-available/tesm ]; then - sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm" /etc/nginx/sites-available/tesm -fi -sudo ln -sf /etc/nginx/sites-available/tesm /etc/nginx/sites-enabled/tesm -sudo rm -f /etc/nginx/sites-enabled/default -sudo nginx -t >>/var/log/tesm-install.log 2>&1 && sudo systemctl reload nginx -print_status "nginx configured" - -# ---- Dienste aktivieren ---- -step "Enabling services" -if [ "$UNITS_CHANGED" -eq 1 ]; then - sudo systemctl daemon-reload -fi -sudo systemctl enable --now tesm.service -sudo systemctl enable --now tesm-check.service tesm-check-restart.timer -print_status "Services enabled and started" - -# ---- Sicherheitsnetz prüfen ---- -# Läuft bei JEDEM Update einer bestehenden Installation (NEEDS_MIGRATION_ -# GUARD=1, siehe oben) -- nur bei einer Frischinstallation ist BACKUP_DIR -# leer und dieser Block wird komplett übersprungen. -if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then - step "Verifying migrated installation" - HEALTHY=0 - for i in $(seq 1 15); do - sleep 2 - if curl -fsS -o /dev/null http://127.0.0.1:5000/login 2>/dev/null; then - HEALTHY=1 - break - fi - done - if [ "$HEALTHY" -eq 1 ]; then - print_status "Migration verified healthy" - rm -rf "$BACKUP_DIR" - echo -e "${GREEN}✔${NC} Update erfolgreich -- Backup wieder entfernt." - else - echo -e "${RED}✖ Update fehlgeschlagen -- Dienst antwortet nicht gesund. Rolle zurück...${NC}" - sudo systemctl stop tesm.service 2>/dev/null || true - # Fehlgeschlagene Installation NICHT einfach löschen, sondern separat - # beiseite legen (zur Fehlersuche) -- und das Backup per "cp" statt - # "mv" zurückspielen, damit BEIDES danach getrennt erhalten bleibt - # (sonst würde das Backup beim Zurückspielen selbst zum neuen - # /srv/tesm werden und als eigenständige Sicherung verschwinden). - FAILED_DIR="/srv/tesm-failed-update-$(date +%Y%m%d-%H%M%S)" - sudo mv /srv/tesm "$FAILED_DIR" - sudo cp -a "$BACKUP_DIR" /srv/tesm - sudo systemctl start tesm.service - echo -e "${YELLOW}Zurückgerollt auf den Stand vor dem Update (alter Code + alte Datenbank zusammen).${NC}" - echo "Backup bleibt erhalten unter: ${BACKUP_DIR}" - echo "Die fehlgeschlagene Installation liegt zur Fehlersuche unter: ${FAILED_DIR}" - echo "Bitte /var/log/tesm/app.log dort prüfen, bevor erneut aktualisiert wird." - fi -fi - -echo -e "${GREEN}✔${NC} Installation abgeschlossen. Web-App erreichbar auf Port 80 (nginx) bzw. 5000 (Flask direkt)." diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml new file mode 100644 index 0000000..1be1f58 --- /dev/null +++ b/packages/tesm-core/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "tesm-core" +version = "2.0.0" +description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" +requires-python = ">=3.11" +dependencies = [ + "Flask>=3.1", + "cryptography>=42", + "argon2-cffi>=23", + "tesm-licensing>=2.0.0", +] + +[project.optional-dependencies] +ldap = ["ldap3>=2.9"] +legacy-hashes = ["bcrypt>=4"] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +tesm_core = ["templates/**/*.html", "static/**/*"] diff --git a/packages/tesm-core/src/tesm_core/__init__.py b/packages/tesm-core/src/tesm_core/__init__.py new file mode 100644 index 0000000..ed00273 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/__init__.py @@ -0,0 +1,38 @@ +"""tesm_core -- gemeinsames Anwendungsgeruest von TESM und TESM-Lizenzserver. + +Enthaelt alles, was beide Anwendungen teilen: Konfiguration, Datenbankzugriff, +Migrationen, Schluesselverwaltung, Sitzungen, CSRF, Rechtesystem, Navigation, +Benutzer-/Gruppen-/Kontoverwaltung, Papierkorb, Diagnose und das +Design-System. +""" + +from __future__ import annotations + +__version__ = "2.0.0" + +from .app_factory import create_base_app +from .config import CoreConfig, SecurityConfig, load_core_config +from .db import Database, NestedConnectionError +from .extension import TesmCore, core, db, transaction +from .migrations import Migration +from .nav import NavItem, NavTree +from .settings_store import SettingSpec, SettingsStore + +__all__ = [ + "CoreConfig", + "Database", + "Migration", + "NavItem", + "NavTree", + "NestedConnectionError", + "SecurityConfig", + "SettingSpec", + "SettingsStore", + "TesmCore", + "core", + "create_base_app", + "db", + "load_core_config", + "transaction", + "__version__", +] diff --git a/packages/tesm-core/src/tesm_core/app_factory.py b/packages/tesm-core/src/tesm_core/app_factory.py new file mode 100644 index 0000000..d6feb2b --- /dev/null +++ b/packages/tesm-core/src/tesm_core/app_factory.py @@ -0,0 +1,374 @@ +"""Anwendungsfabrik: verdrahtet alle Bausteine zu einer lauffaehigen Flask-App. + +Jede der beiden Anwendungen ruft ``create_base_app()`` und ergaenzt danach nur +noch ihre eigenen Blueprints. Alles, was beide teilen -- Datenbank, +Migrationen, Sitzungen, CSRF, Header, Protokollierung, Rechtesystem, +Navigation, Benutzer-/Gruppenverwaltung, Kontoseite, Diagnose -- entsteht hier +genau einmal. +""" + +from __future__ import annotations + +import secrets +from datetime import datetime, timezone +from typing import Any, Sequence + +from flask import Blueprint, Flask, g, render_template, request +from werkzeug.exceptions import HTTPException + +from . import audit, logging_setup, ui +from .config import CoreConfig +from .core_schema import CORE_MIGRATIONS +from .db import Database +from .extension import TesmCore, close_db, core, db +from .keystore import Keystore, load_or_create_secret_key +from .licensing_client import LicenseManager +from .migrations import Migration +from .nav import NavTree +from .rbac import service as rbac +from .rbac.model import PermissionTree, RolePreset +from .security import csrf +from .security import sessions as session_store +from .security.sessions import SqliteSessionInterface +from .settings_store import SettingSpec, SettingsStore +from .sysops import SysOps +from .auth import directory +from .auth import routes as auth_routes +from .auth import service as auth_service +from .auth.models import ANONYMOUS, current_user +from .views import account as account_views +from .views import admin as admin_views +from .views import diagnostics as diagnostics_views +from .views import hostsettings as hostsettings_views +from .views import logs as logs_views +from .views import trash as trash_views + +CORE_SETTINGS: tuple[SettingSpec, ...] = ( + SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"), + SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"), + SettingSpec( + "trash_retention_days", + 30, + "int", + "Aufbewahrung im Papierkorb (Tage)", + hint="Nach dieser Frist werden Eintraege beim naechsten Aufruf endgueltig entfernt.", + section="system", + minimum=1, + maximum=3650, + ), + SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"), + SettingSpec("hostname", "", "str", "Hostname", section="system"), + SettingSpec( + "log_rotate_interval", + "weekly", + "str", + "Rotationsintervall der Protokolle", + section="system", + choices=(("daily", "taeglich"), ("weekly", "woechentlich"), ("monthly", "monatlich")), + ), + SettingSpec( + "log_rotate_keep", 4, "int", "Aufbewahrte Rotationen", section="system", minimum=1, maximum=52 + ), + # -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- # + # Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung. + SettingSpec( + "license_server_ca", + "", + "str", + "Zertifikat des Lizenzservers (PEM)", + section="license", + ), + SettingSpec("web_domain", "", "str", "Domain", section="web"), + SettingSpec("web_https_enabled", False, "bool", "HTTPS aktiv", section="web"), + SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"), + SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"), + SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"), + SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"), + SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", section="web"), + SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"), + # Abweichende Ports braucht nur eine Testinstanz neben der Produktion -- + # im Regelbetrieb bleibt es bei 80 und 443. + SettingSpec( + "web_http_port", 80, "int", "HTTP-Port", section="web", minimum=1, maximum=65535 + ), + SettingSpec( + "web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535 + ), + SettingSpec( + "web_max_body_mb", 128, "int", "Maximale Anfragegroesse (MB)", section="web", + minimum=1, maximum=8192, + ), + *directory.SETTINGS, +) + + +def create_base_app( + *, + config: CoreConfig, + permissions: PermissionTree, + nav: NavTree, + role_presets: Sequence[RolePreset], + app_migrations: dict[str, Sequence[Migration]] | None = None, + settings_specs: Sequence[SettingSpec] = (), + import_name: str = "tesm_core", + app_short: str = "TESM", + app_edition: str = "", + #: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = kein + #: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die + #: Funktionsliste von TESM waere dort schlicht falsch. + auth_tagline: str = "", + app_version: str = "2.0.0", + built_in_trusted_keys: tuple[str, ...] = (), + enable_license: bool = True, + template_folder: str | None = None, + static_folder: str | None = None, +) -> Flask: + app = Flask( + import_name, + template_folder=template_folder or "templates", + static_folder=static_folder or "static", + static_url_path="/assets", + ) + + # Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und + # werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede + # Anwendung zusaetzlich ihre eigenen templates/static mitbringen, ohne dass + # sich Pfade in die Quere kommen. + core_bp = Blueprint( + "tesm_core", + "tesm_core", + template_folder="templates", + static_folder="static/tesm_core", + static_url_path="/core-assets", + ) + app.register_blueprint(core_bp) + + config.data_dir.mkdir(parents=True, exist_ok=True) + config.log_dir.mkdir(parents=True, exist_ok=True) + + app.config.update( + SECRET_KEY=load_or_create_secret_key(config.secret_key_file), + # site_key statt app_key: Cookies unterscheiden keine Ports. Zwei + # Instanzen derselben Anwendung auf einem Host wuerden sich sonst + # gegenseitig die Sitzung ueberschreiben. + SESSION_COOKIE_NAME=f"{config.site_key.replace('-', '_')}_session", + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SECURE=config.security.cookie_secure, + SESSION_COOKIE_SAMESITE=config.security.cookie_samesite, + MAX_CONTENT_LENGTH=config.security.max_content_length, + JSON_SORT_KEYS=False, + TRAP_HTTP_EXCEPTIONS=False, + PREFERRED_URL_SCHEME="https" if config.security.cookie_secure else "http", + TEMPLATES_AUTO_RELOAD=config.debug, + EXPLAIN_TEMPLATE_LOADING=False, + ) + app.debug = config.debug + + logging_setup.init_app(app, log_dir=config.log_dir, debug=config.debug) + + database = Database(config.db_path) + settings = SettingsStore([*CORE_SETTINGS, *settings_specs]) + keystore = Keystore(config.data_key_file) + + extension = TesmCore( + config=config, + database=database, + keystore=keystore, + permissions=permissions, + nav=nav, + settings=settings, + role_presets=tuple(role_presets), + migrations={"core": CORE_MIGRATIONS, **(app_migrations or {})}, + sysops=SysOps(app_key=config.app_key, site_key=config.site_key), + ) + if enable_license: + extension.license = LicenseManager( + database=database, + settings=settings, + license_path=config.license_path, + key_path=config.license_key_path, + app_key=config.app_key, + built_in_trusted_keys=built_in_trusted_keys, + ) + app.extensions["tesm"] = extension + + app.session_interface = SqliteSessionInterface( + database, + cookie_name=app.config["SESSION_COOKIE_NAME"], + idle_minutes=config.security.session_idle_minutes, + absolute_hours=config.security.session_absolute_hours, + ) + + # -- Schema und Startzustand ------------------------------------------ # + applied = extension.run_migrations() + with database.session() as conn, database.transaction(conn): + created = rbac.ensure_presets(conn, permissions, extension.role_presets) + if applied or created: + audit.log( + conn, + actor="system", + action="system.startup_migration", + detail="; ".join([*applied, *(f"Gruppe {name} angelegt" for name in created)]), + severity="notice", + ) + session_store.prune(conn) + if extension.license is not None: + with database.session() as conn: + extension.license.reload(conn) + + # -- Request-Lebenszyklus --------------------------------------------- # + csrf.init_app(app) + ui.init_app(app) + app.teardown_appcontext(close_db) + + @app.before_request + def _prepare_request() -> None: + g.csp_nonce = secrets.token_urlsafe(16) + auth_service.load_request_user() + + @app.after_request + def _finish_request(response): # type: ignore[no-untyped-def] + hsts = "" + if config.security.hsts_enabled: + hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains" + return csrf.apply_security_headers( + response, nonce=getattr(g, "csp_nonce", ""), hsts=hsts + ) + + # -- Template-Kontext -------------------------------------------------- # + from .rbac.model import ACTION_META + + @app.context_processor + def _inject_context() -> dict[str, Any]: + user = getattr(g, "user", ANONYMOUS) + conn = db() + order = settings.get(conn, "nav_order") or nav.default_order() + child_order = settings.get(conn, "nav_child_order") or {} + pills: list[dict[str, str]] = [] + if extension.license is not None and user.is_authenticated: + pill = extension.license.topbar_pill() + if pill: + pills.append(pill) + pills.extend(extension.registry.get("status_pill_builder", lambda _u: [])(user)) + + nav_items = nav.resolve(user, order=order, child_order=child_order) + palette = [ + {**entry, "url": _safe_url(entry["endpoint"])} + for entry in nav.searchable(user) + if entry["endpoint"] + ] + return { + "app_name": config.app_name, + "app_short": app_short, + "app_edition": app_edition, + "auth_tagline": auth_tagline, + "app_version": app_version, + "current_year": datetime.now(timezone.utc).year, + "current_user": current_user, + "nav_items": nav_items, + "palette_entries": palette, + "status_pills": pills, + "csp_nonce": getattr(g, "csp_nonce", ""), + "permission_tree": permissions, + "action_meta": ACTION_META, + "license_state": extension.license.state if extension.license else None, + "license_active": extension.license.active() if extension.license else True, + "module_active": ( + extension.license.module_active if extension.license else (lambda _n: True) + ), + "feature_allowed": ( + extension.license.feature_allowed if extension.license else (lambda _n: True) + ), + "migration_warning": bool(extension.registry.get("migration_warning")), + } + + # -- Fehlerseiten ------------------------------------------------------ # + @app.errorhandler(HTTPException) + def _http_error(error: HTTPException): # type: ignore[no-untyped-def] + if _wants_json(): + return { + "error": error.name, + "message": error.description, + "request_id": getattr(g, "request_id", ""), + }, error.code or 500 + return ( + render_template( + "tesm_core/error.html", + code=error.code or 500, + name=error.name, + description=error.description, + request_id=getattr(g, "request_id", ""), + back_url=request.referrer or "/", + ), + error.code or 500, + ) + + @app.errorhandler(Exception) + def _unhandled(error: Exception): # type: ignore[no-untyped-def] + app.logger.exception("Unbehandelter Fehler: %s", error) + if _wants_json(): + return { + "error": "Interner Fehler", + "message": "Der Vorgang konnte nicht abgeschlossen werden.", + "request_id": getattr(g, "request_id", ""), + }, 500 + return ( + render_template( + "tesm_core/error.html", + code=500, + name="Interner Fehler", + description=( + "Der Vorgang konnte nicht abgeschlossen werden. Der Fehler wurde " + "protokolliert." + ), + request_id=getattr(g, "request_id", ""), + back_url=request.referrer or "/", + ), + 500, + ) + + # -- Gemeinsame Blueprints -------------------------------------------- # + app.register_blueprint(auth_routes.bp) + app.register_blueprint(admin_views.bp) + app.register_blueprint(account_views.bp) + app.register_blueprint(diagnostics_views.bp) + app.register_blueprint(trash_views.bp) + app.register_blueprint(hostsettings_views.bp) + app.register_blueprint(logs_views.bp) + with app.app_context(): + trash_views.register_core_providers() + + app.add_url_rule("/gesundheit", "health", _health, methods=["GET"]) + + return app + + +def _safe_url(endpoint: str) -> str: + from flask import url_for + + try: + return url_for(endpoint) + except Exception: # noqa: BLE001 - Endpunkt existiert in dieser App nicht + return "" + + +def _wants_json() -> bool: + if request.path.startswith("/api/"): + return True + accept = request.accept_mimetypes + return bool(accept.accept_json and not accept.accept_html) + + +def _health() -> tuple[dict[str, Any], int]: + """Gesundheitsendpunkt fuer install.sh und Monitoring. + + Bewusst ohne Anmeldung, aber ohne jede verwertbare Information: nur + "laeuft die App und antwortet die Datenbank". + """ + extension = core() + try: + with extension.database.session() as conn: + conn.execute("SELECT 1").fetchone() + except Exception: # noqa: BLE001 + return {"status": "degraded"}, 503 + return {"status": "ok", "version": extension.config.app_key}, 200 diff --git a/packages/tesm-core/src/tesm_core/audit.py b/packages/tesm-core/src/tesm_core/audit.py new file mode 100644 index 0000000..b8a5133 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/audit.py @@ -0,0 +1,283 @@ +"""Aenderungsprotokoll mit Hash-Kette. + +Ein Audit-Log, das der Administrator selbst manipulieren kann, ist kein +Audit-Log. Der Vorgaenger schrieb einfache Zeilen in eine Tabelle -- wer +Schreibzugriff auf die SQLite-Datei hatte (auf dem Zielsystem: jeder root, und +die App lief als root), konnte Eintraege spurlos entfernen. + +Hier bindet jede Zeile den Hash ihrer Vorgaengerin ein: + + hash_n = sha256(hash_(n-1) || kanonische Felder von Zeile n) + +Eine geloeschte oder geaenderte Zeile bricht die Kette an genau dieser Stelle. +``verify_chain()`` findet den Bruch; die Diagnoseseite zeigt ihn an. Das +verhindert Manipulation nicht, macht sie aber nachweisbar -- und genau darum +geht es bei einem Protokoll. + +Archivierung: ab ``archive_threshold`` Zeilen werden die aeltesten vollstaendigen +Kalendertage in Dateien ``audit-JJJJ-MM-TT.jsonl`` ausgelagert, inklusive ihrer +Hashes, sodass die Kette ueber die Archivgrenze hinweg pruefbar bleibt. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import sqlite3 +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +from .db import Database + +logger = logging.getLogger(__name__) + +GENESIS = "0" * 64 +ARCHIVE_THRESHOLD = 20000 +ARCHIVE_TARGET = 15000 + +SEVERITIES = ("info", "notice", "warning", "critical") + + +def _canonical(row: dict[str, Any]) -> bytes: + payload = { + "ts": row.get("ts", ""), + "actor": row.get("actor", ""), + "actor_id": row.get("actor_id"), + "action": row.get("action", ""), + "target": row.get("target", ""), + "detail": row.get("detail", ""), + "ip": row.get("ip", ""), + "request_id": row.get("request_id", ""), + "severity": row.get("severity", "info"), + } + return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def compute_hash(prev_hash: str, row: dict[str, Any]) -> str: + return hashlib.sha256((prev_hash or GENESIS).encode("ascii") + _canonical(row)).hexdigest() + + +def log( + conn: sqlite3.Connection, + *, + actor: str, + action: str, + target: str = "", + detail: str = "", + actor_id: int | None = None, + ip: str = "", + request_id: str = "", + severity: str = "info", + ts: datetime | None = None, +) -> int: + """Schreibt einen Protokolleintrag auf der uebergebenen Verbindung. + + Wichtig: **immer** die bestehende Verbindung nutzen. Eine Protokollfunktion, + die selbst eine Verbindung oeffnet, blockiert die laufende Transaktion -- + das war im Vorgaenger eine reale Deadlock-Ursache. + """ + if severity not in SEVERITIES: + severity = "info" + row = { + "ts": (ts or datetime.now(timezone.utc)).isoformat(timespec="seconds"), + "actor": actor or "system", + "actor_id": actor_id, + "action": action, + "target": target, + "detail": detail, + "ip": ip, + "request_id": request_id, + "severity": severity, + } + prev_hash = ( + Database.value(conn, "SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1") or GENESIS + ) + row_hash = compute_hash(str(prev_hash), row) + cursor = conn.execute( + "INSERT INTO audit_log (ts, actor, actor_id, action, target, detail, ip, request_id, " + "severity, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?,?,?,?)", + ( + row["ts"], + row["actor"], + row["actor_id"], + row["action"], + row["target"], + row["detail"], + row["ip"], + row["request_id"], + row["severity"], + prev_hash, + row_hash, + ), + ) + return int(cursor.lastrowid or 0) + + +def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]: + """Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``.""" + sql = "SELECT * FROM audit_log ORDER BY id" + params: tuple[Any, ...] = () + if limit: + sql = "SELECT * FROM (SELECT * FROM audit_log ORDER BY id DESC LIMIT ?) ORDER BY id" + params = (limit,) + + checked = 0 + expected_prev: str | None = None + for row in conn.execute(sql, params): + data = {key: row[key] for key in row.keys()} + if expected_prev is not None and data["prev_hash"] != expected_prev: + return { + "ok": False, + "checked": checked, + "broken_at": data["id"], + "reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.", + } + if compute_hash(str(data["prev_hash"]), data) != data["hash"]: + return { + "ok": False, + "checked": checked, + "broken_at": data["id"], + "reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.", + } + expected_prev = str(data["hash"]) + checked += 1 + return {"ok": True, "checked": checked, "broken_at": None, "reason": ""} + + +def search( + conn: sqlite3.Connection, + *, + query: str = "", + action: str = "", + actor: str = "", + severity: str = "", + limit: int = 200, + offset: int = 0, +) -> list[dict[str, Any]]: + clauses: list[str] = [] + params: list[Any] = [] + if query: + clauses.append("(target LIKE ? OR detail LIKE ? OR action LIKE ? OR actor LIKE ?)") + params.extend([f"%{query}%"] * 4) + if action: + clauses.append("action = ?") + params.append(action) + if actor: + clauses.append("actor = ?") + params.append(actor) + if severity: + clauses.append("severity = ?") + params.append(severity) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + params.extend([limit, offset]) + return Database.all( + conn, + f"SELECT * FROM audit_log {where} ORDER BY id DESC LIMIT ? OFFSET ?", + tuple(params), + ) + + +def count(conn: sqlite3.Connection) -> int: + return int(Database.value(conn, "SELECT COUNT(*) FROM audit_log", default=0) or 0) + + +def distinct_actions(conn: sqlite3.Connection) -> list[str]: + return [ + str(row["action"]) + for row in Database.all(conn, "SELECT DISTINCT action FROM audit_log ORDER BY action") + ] + + +def archive_old_rows( + conn: sqlite3.Connection, + archive_dir: Path, + *, + threshold: int = ARCHIVE_THRESHOLD, + target: int = ARCHIVE_TARGET, +) -> list[Path]: + """Laagert ganze Kalendertage aus, bis die Tabelle unter ``target`` liegt. + + Nur *vollstaendige* Tage werden ausgelagert -- der laufende Tag bleibt immer + in der Datenbank, damit die Auditseite nie halbe Tage zeigt. + """ + total = count(conn) + if total <= threshold: + return [] + + archive_dir.mkdir(parents=True, exist_ok=True) + today = datetime.now(timezone.utc).date().isoformat() + written: list[Path] = [] + + while total > target: + # Nach Zeitstempel, nicht nach id: Eintraege koennen mit einem + # nachgereichten ts geschrieben worden sein (Import, Migration). + oldest_day = Database.value(conn, "SELECT MIN(substr(ts,1,10)) FROM audit_log") + if not oldest_day or str(oldest_day) >= today: + break + rows = Database.all( + conn, + "SELECT * FROM audit_log WHERE substr(ts,1,10)=? ORDER BY id", + (str(oldest_day),), + ) + if not rows: + break + path = archive_dir / f"audit-{oldest_day}.jsonl" + with path.open("a", encoding="utf-8", newline="\n") as stream: + for row in rows: + stream.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n") + conn.execute("DELETE FROM audit_log WHERE substr(ts,1,10)=?", (str(oldest_day),)) + written.append(path) + total -= len(rows) + logger.info("Auditlog: %s Zeilen von %s nach %s ausgelagert", len(rows), oldest_day, path) + + return written + + +def archived_days(archive_dir: Path) -> list[dict[str, Any]]: + """Liste der Archivdateien fuer die Verlaufsseite.""" + if not archive_dir.exists(): + return [] + entries: list[dict[str, Any]] = [] + for path in sorted(archive_dir.glob("audit-*.jsonl"), reverse=True): + try: + stat = path.stat() + except OSError: + continue + entries.append( + { + "day": path.stem.removeprefix("audit-"), + "path": path, + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat( + timespec="seconds" + ), + } + ) + return entries + + +def read_archived_day(archive_dir: Path, day: str) -> list[dict[str, Any]]: + path = archive_dir / f"audit-{day}.jsonl" + if not path.is_file(): + return [] + rows: list[dict[str, Any]] = [] + with path.open("r", encoding="utf-8") as stream: + for line in stream: + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except ValueError: + continue + return rows + + +def summarize_by_day(rows: Iterable[dict[str, Any]]) -> list[tuple[str, int]]: + buckets: dict[str, int] = defaultdict(int) + for row in rows: + buckets[str(row.get("ts", ""))[:10]] += 1 + return sorted(buckets.items(), reverse=True) diff --git a/packages/tesm-core/src/tesm_core/auth/__init__.py b/packages/tesm-core/src/tesm_core/auth/__init__.py new file mode 100644 index 0000000..36990c5 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/auth/__init__.py @@ -0,0 +1,27 @@ +"""Authentisierung: Benutzerobjekt, Anmeldedienst, Verzeichnisanbindung.""" + +from __future__ import annotations + +from .models import ( + ANONYMOUS, + AnonymousUser, + Group, + User, + admin_required, + current_user, + login_required, + permission_required, + reauth_required, +) + +__all__ = [ + "ANONYMOUS", + "AnonymousUser", + "Group", + "User", + "admin_required", + "current_user", + "login_required", + "permission_required", + "reauth_required", +] diff --git a/packages/tesm-core/src/tesm_core/auth/directory.py b/packages/tesm-core/src/tesm_core/auth/directory.py new file mode 100644 index 0000000..77fb828 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/auth/directory.py @@ -0,0 +1,581 @@ +"""Anmeldung gegen Active Directory / LDAP (Same Sign-On). + +"Same Sign-On", nicht "Single Sign-On": der Benutzer tippt sein +Verzeichnispasswort weiterhin auf der normalen Anmeldeseite ein; es wird bei +jeder Anmeldung live gegen das Verzeichnis geprueft, nie hier gespeichert. + +Sicherheitsaenderungen gegenueber dem Vorgaenger: + +* **Transportverschluesselung ist Pflicht** (LDAPS oder StartTLS). Der + Vorgaenger erlaubte unverschluesseltes LDAP auf Port 389 ohne Warnung -- das + Passwort ging im Klartext ueber das Netz. Wer es dennoch braucht, muss + ``ldap_allow_insecure`` ausdruecklich setzen; das wird bei jeder Anmeldung + protokolliert. +* **Serverzertifikat wird geprueft.** Der Vorgaenger validierte es nicht -- + LDAPS ohne Validierung schuetzt nur gegen passives Mitlesen, nicht gegen + einen Man-in-the-Middle. +* Das Bind-Passwort des Dienstkontos liegt AES-GCM-verschluesselt in + ``service_accounts`` (an den Zweck gebunden), nicht Fernet-verschluesselt + ohne Kontext. +""" + +from __future__ import annotations + +import logging +import re +import socket +import sqlite3 +import ssl +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Literal + +from .. import audit +from ..db import Database +from ..extension import core +from ..settings_store import SettingSpec + +logger = logging.getLogger(__name__) + +SERVICE_PURPOSE = "ldap_bind" +AAD_LDAP_BIND = "service_accounts.ldap_bind" + +#: userAccountControl-Bit fuer "Konto deaktiviert". +ACCOUNTDISABLE = 0x0002 +#: OID fuer rekursive Gruppenauflösung in AD. +MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941" + +Status = Literal["ok", "invalid", "unavailable", "disabled"] + +SETTINGS: tuple[SettingSpec, ...] = ( + SettingSpec("ldap_enabled", False, "bool", "Verzeichnisanmeldung aktiv", section="ldap"), + SettingSpec("ldap_host", "", "str", "Server (Hostname)", section="ldap"), + SettingSpec("ldap_port", 636, "int", "Port", section="ldap", minimum=1, maximum=65535), + SettingSpec( + "ldap_security", + "ldaps", + "str", + "Transport", + section="ldap", + choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")), + ), + SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"), + SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"), + SettingSpec( + "ldap_allow_insecure", + False, + "bool", + "Unverschluesselte Verbindung ausdruecklich erlauben", + hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.", + section="ldap", + ), + SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"), + SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"), + SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"), + SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"), + SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60), +) + +_USERNAME_RE = re.compile(r"^[A-Za-z0-9._\-@ ]{1,128}$") + + +@dataclass(frozen=True, slots=True) +class DirectoryProfile: + username: str + dn: str + first_name: str = "" + last_name: str = "" + email: str = "" + group_dns: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class DirectoryResult: + status: Status + profile: DirectoryProfile | None = None + message: str = "" + warnings: tuple[str, ...] = field(default=()) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def is_enabled(conn: sqlite3.Connection) -> bool: + settings = core().settings + if not settings.get(conn, "ldap_enabled"): + return False + return bool(settings.get(conn, "ldap_host")) + + +def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]: + """Benutzer und Passwort des Bind-Dienstkontos (entschluesselt).""" + row = Database.one( + conn, + "SELECT username, password_encrypted FROM service_accounts WHERE purpose=?", + (SERVICE_PURPOSE,), + ) + if row is None: + return "", "" + encrypted = str(row.get("password_encrypted") or "") + if not encrypted: + return str(row.get("username") or ""), "" + try: + secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND) + except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt + logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden") + return str(row.get("username") or ""), "" + return str(row.get("username") or ""), secret + + +def store_bind_credentials(conn: sqlite3.Connection, username: str, password: str | None) -> None: + """Speichert das Dienstkonto. ``password=None`` laesst das bestehende unveraendert.""" + existing = Database.one( + conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,) + ) + encrypted = str(existing.get("password_encrypted") or "") if existing else "" + if password: + encrypted = core().keystore.encrypt(password, aad=AAD_LDAP_BIND) + conn.execute( + "INSERT INTO service_accounts (purpose, username, password_encrypted, updated_at) " + "VALUES (?,?,?,?) ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, " + "password_encrypted=excluded.password_encrypted, updated_at=excluded.updated_at", + (SERVICE_PURPOSE, username.strip(), encrypted, _now()), + ) + + +# --------------------------------------------------------------------------- # +# Verbindung +# --------------------------------------------------------------------------- # + + +def _tls_object(config: dict[str, Any]): + from ldap3 import Tls + + verify = bool(config["ldap_verify_cert"]) + ca_file = str(config["ldap_ca_file"] or "") or None + return Tls( + validate=ssl.CERT_REQUIRED if verify else ssl.CERT_NONE, + version=ssl.PROTOCOL_TLS_CLIENT, + ca_certs_file=ca_file, + ) + + +def _server(config: dict[str, Any]): + from ldap3 import Server + + security = str(config["ldap_security"]) + return Server( + str(config["ldap_host"]), + port=int(config["ldap_port"]), + use_ssl=security == "ldaps", + tls=_tls_object(config) if security in {"ldaps", "starttls"} else None, + get_info="ALL", + connect_timeout=int(config["ldap_timeout"]), + ) + + +def _connect(config: dict[str, Any], user: str, password: str): + """Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen.""" + from ldap3 import Connection + + security = str(config["ldap_security"]) + if security == "none" and not config["ldap_allow_insecure"]: + raise PermissionError( + "Unverschluesselte Verzeichnisverbindungen sind deaktiviert. " + "Nutzen Sie LDAPS oder StartTLS." + ) + connection = Connection( + _server(config), + user=user or None, + password=password or None, + auto_bind=False, + raise_exceptions=False, + receive_timeout=int(config["ldap_timeout"]), + ) + if security == "starttls": + if not connection.open(): + raise ConnectionError("Verbindung zum Verzeichnisdienst fehlgeschlagen") + if not connection.start_tls(): + raise ConnectionError( + f"StartTLS fehlgeschlagen: {connection.result.get('description', 'unbekannt')}" + ) + if not connection.bind(): + return connection, False + return connection, True + + +def _read_config(conn: sqlite3.Connection) -> dict[str, Any]: + settings = core().settings + return settings.get_many(conn, [spec.key for spec in SETTINGS]) + + +def _base_dn(connection, config: dict[str, Any]) -> str: + configured = str(config["ldap_base_dn"] or "").strip() + if configured: + return configured + info = getattr(connection.server, "info", None) + naming = getattr(info, "other", {}).get("defaultNamingContext") if info else None + if isinstance(naming, list) and naming: + return str(naming[0]) + if isinstance(naming, str): + return naming + contexts = getattr(info, "naming_contexts", None) if info else None + if contexts: + return str(contexts[0]) + return "" + + +def _escape(value: str) -> str: + """RFC-4515-Escaping fuer Filterwerte -- verhindert LDAP-Injection.""" + replacements = {"\\": r"\5c", "*": r"\2a", "(": r"\28", ")": r"\29", "\x00": r"\00", "/": r"\2f"} + return "".join(replacements.get(char, char) for char in value) + + +def authenticate(conn: sqlite3.Connection, username: str, password: str) -> DirectoryResult: + """Suchen-und-Binden gegen das Verzeichnis.""" + try: + import ldap3 # noqa: F401 + except ImportError: + return DirectoryResult("unavailable", message="Das Paket ldap3 ist nicht installiert.") + + if not _USERNAME_RE.match(username or ""): + return DirectoryResult("invalid") + + config = _read_config(conn) + if not config.get("ldap_enabled") or not config.get("ldap_host"): + return DirectoryResult("disabled") + + bind_user, bind_password = bind_credentials(conn) + warnings: list[str] = [] + if str(config["ldap_security"]) == "none": + warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung") + if not config["ldap_verify_cert"]: + warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft") + + try: + service_conn, bound = _connect(config, bind_user, bind_password) + except (PermissionError, ConnectionError) as exc: + logger.error("Verzeichnisdienst: %s", exc) + return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings)) + except (socket.error, ssl.SSLError, OSError) as exc: + logger.error("Verzeichnisdienst nicht erreichbar: %s", exc) + return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings)) + + if not bound: + detail = service_conn.result.get("description", "unbekannt") if service_conn.result else "" + logger.error("Bind des Dienstkontos fehlgeschlagen: %s", detail) + return DirectoryResult( + "unavailable", + message=f"Anmeldung des Dienstkontos fehlgeschlagen ({detail}).", + warnings=tuple(warnings), + ) + + try: + base = _base_dn(service_conn, config) + if not base: + return DirectoryResult( + "unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings) + ) + + attribute = str(config["ldap_user_attribute"] or "sAMAccountName") + email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName") + wanted = ["distinguishedName", "givenName", "sn", "userAccountControl", email_attribute, attribute] + + service_conn.search( + search_base=base, + search_filter=f"(&(objectClass=user)({attribute}={_escape(username)}))", + attributes=list(dict.fromkeys(wanted)), + size_limit=2, + ) + entries = list(service_conn.entries) + if len(entries) != 1: + return DirectoryResult("invalid", warnings=tuple(warnings)) + + entry = entries[0] + dn = str(entry.entry_dn) + control = _attr_int(entry, "userAccountControl") + if control is not None and control & ACCOUNTDISABLE: + return DirectoryResult("invalid", message="Konto im Verzeichnis deaktiviert.", warnings=tuple(warnings)) + + # Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche + # Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden. + user_conn, user_bound = _connect(config, dn, password) + try: + if not user_bound: + return DirectoryResult("invalid", warnings=tuple(warnings)) + finally: + try: + user_conn.unbind() + except Exception: # noqa: BLE001 + pass + + group_dns: tuple[str, ...] = () + if config["ldap_group_sync"]: + group_dns = _resolve_groups(service_conn, base, dn) + + profile = DirectoryProfile( + username=_attr(entry, attribute) or username, + dn=dn, + first_name=_attr(entry, "givenName"), + last_name=_attr(entry, "sn"), + email=_attr(entry, email_attribute), + group_dns=group_dns, + ) + return DirectoryResult("ok", profile=profile, warnings=tuple(warnings)) + finally: + try: + service_conn.unbind() + except Exception: # noqa: BLE001 + pass + + +def _attr(entry: Any, name: str) -> str: + try: + value = entry[name].value + except Exception: # noqa: BLE001 + return "" + if isinstance(value, (list, tuple)): + return str(value[0]) if value else "" + return "" if value is None else str(value) + + +def _attr_int(entry: Any, name: str) -> int | None: + raw = _attr(entry, name) + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]: + """Rekursive Gruppenauflösung ueber LDAP_MATCHING_RULE_IN_CHAIN.""" + try: + connection.search( + search_base=base, + search_filter=f"(member:{MATCHING_RULE_IN_CHAIN}:={_escape(user_dn)})", + attributes=["distinguishedName", "cn"], + size_limit=500, + ) + return tuple(str(entry.entry_dn) for entry in connection.entries) + except Exception as exc: # noqa: BLE001 + logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc) + return () + + +# --------------------------------------------------------------------------- # +# Schattenkonto +# --------------------------------------------------------------------------- # + + +def sync_account(conn: sqlite3.Connection, profile: DirectoryProfile) -> dict[str, Any]: + """Legt das lokale Schattenkonto an bzw. aktualisiert es und mappt Gruppen.""" + from ..rbac import service as rbac + + row = rbac.find_user_by_username(conn, profile.username) + with core().database.transaction(conn): + if row is None: + cursor = conn.execute( + "INSERT INTO users (username, password_hash, is_admin, auth_source, first_name, " + "last_name, email, created_at, updated_at) VALUES (?,?,0,'ldap',?,?,?,?,?)", + ( + profile.username, + "", # Kein lokales Passwort -- Anmeldung laeuft immer ueber das Verzeichnis. + profile.first_name, + profile.last_name, + profile.email, + _now(), + _now(), + ), + ) + user_id = int(cursor.lastrowid or 0) + audit.log( + conn, + actor=profile.username, + actor_id=user_id, + action="auth.directory_account_created", + target=profile.username, + detail=profile.dn, + severity="notice", + ) + else: + user_id = int(row["id"]) + conn.execute( + "UPDATE users SET first_name=?, last_name=?, email=?, auth_source='ldap', " + "updated_at=? WHERE id=?", + (profile.first_name, profile.last_name, profile.email, _now(), user_id), + ) + + _apply_group_mappings(conn, user_id, profile.group_dns) + + result = rbac.get_user(conn, user_id) + assert result is not None + return result + + +def _apply_group_mappings( + conn: sqlite3.Connection, user_id: int, group_dns: tuple[str, ...] +) -> None: + """Setzt die Gruppen additiv aus den Verzeichnisgruppen. + + Bewusst *ersetzend*: wer im Verzeichnis aus einer Gruppe entfernt wurde, + verliert das Recht bei der naechsten Anmeldung. Manuell in der Anwendung + zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping + haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg. + """ + if not group_dns: + return + mappings = Database.all( + conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings" + ) + if not mappings: + return + mapped_group_ids = {int(row["app_group_id"]) for row in mappings} + lowered = {dn.lower() for dn in group_dns} + wanted = { + int(row["app_group_id"]) + for row in mappings + if str(row["ad_group_dn"]).lower() in lowered + } + + current = {int(row["id"]) for row in _user_group_ids(conn, user_id)} + # Alles, was ueber ein Mapping kommt, wird neu gesetzt; alles andere bleibt. + keep = {gid for gid in current if gid not in mapped_group_ids} + final = keep | wanted + + if final == current: + return + conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) + conn.executemany( + "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)", + [(user_id, gid) for gid in sorted(final)], + ) + + +def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]: + return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,)) + + +def search_directory_users( + conn: sqlite3.Connection, query: str, *, limit: int = 20 +) -> list[dict[str, str]]: + """Live-Suche fuer die Vorab-Zuweisung von Verzeichniskonten.""" + if len(query.strip()) < 2: + return [] + try: + import ldap3 # noqa: F401 + except ImportError: + return [] + + config = _read_config(conn) + if not config.get("ldap_enabled") or not config.get("ldap_host"): + return [] + bind_user, bind_password = bind_credentials(conn) + try: + connection, bound = _connect(config, bind_user, bind_password) + except Exception: # noqa: BLE001 + return [] + if not bound: + return [] + try: + base = _base_dn(connection, config) + attribute = str(config["ldap_user_attribute"] or "sAMAccountName") + email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName") + escaped = _escape(query.strip()) + connection.search( + search_base=base, + search_filter=( + f"(&(objectClass=user)(|({attribute}={escaped}*)" + f"(givenName={escaped}*)(sn={escaped}*)(displayName={escaped}*)))" + ), + attributes=[attribute, "givenName", "sn", email_attribute], + size_limit=limit, + ) + return [ + { + "username": _attr(entry, attribute), + "first_name": _attr(entry, "givenName"), + "last_name": _attr(entry, "sn"), + "email": _attr(entry, email_attribute), + "dn": str(entry.entry_dn), + } + for entry in connection.entries + if _attr(entry, attribute) + ] + except Exception: # noqa: BLE001 + return [] + finally: + try: + connection.unbind() + except Exception: # noqa: BLE001 + pass + + +def search_directory_groups(conn: sqlite3.Connection, query: str, *, limit: int = 20) -> list[dict[str, str]]: + """Live-Suche nach Verzeichnisgruppen fuer die Mapping-Oberflaeche.""" + if len(query.strip()) < 2: + return [] + try: + import ldap3 # noqa: F401 + except ImportError: + return [] + config = _read_config(conn) + if not config.get("ldap_enabled") or not config.get("ldap_host"): + return [] + bind_user, bind_password = bind_credentials(conn) + try: + connection, bound = _connect(config, bind_user, bind_password) + except Exception: # noqa: BLE001 + return [] + if not bound: + return [] + try: + base = _base_dn(connection, config) + escaped = _escape(query.strip()) + connection.search( + search_base=base, + search_filter=f"(&(objectClass=group)(cn={escaped}*))", + attributes=["cn", "distinguishedName"], + size_limit=limit, + ) + return [ + {"name": _attr(entry, "cn"), "dn": str(entry.entry_dn)} for entry in connection.entries + ] + except Exception: # noqa: BLE001 + return [] + finally: + try: + connection.unbind() + except Exception: # noqa: BLE001 + pass + + +def test_connection(conn: sqlite3.Connection) -> dict[str, Any]: + """Verbindungstest fuer die Einstellungsseite -- ohne Anmeldung eines Benutzers.""" + try: + import ldap3 # noqa: F401 + except ImportError: + return {"ok": False, "message": "Das Paket ldap3 ist nicht installiert."} + config = _read_config(conn) + if not config.get("ldap_host"): + return {"ok": False, "message": "Es ist kein Server konfiguriert."} + bind_user, bind_password = bind_credentials(conn) + try: + connection, bound = _connect(config, bind_user, bind_password) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "message": str(exc)} + if not bound: + detail = connection.result.get("description", "unbekannt") if connection.result else "" + return {"ok": False, "message": f"Anmeldung des Dienstkontos fehlgeschlagen ({detail})."} + try: + base = _base_dn(connection, config) + return { + "ok": True, + "message": f"Verbindung erfolgreich. Basis-DN: {base or 'nicht ermittelbar'}", + "base_dn": base, + } + finally: + try: + connection.unbind() + except Exception: # noqa: BLE001 + pass diff --git a/packages/tesm-core/src/tesm_core/auth/models.py b/packages/tesm-core/src/tesm_core/auth/models.py new file mode 100644 index 0000000..fba7265 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/auth/models.py @@ -0,0 +1,297 @@ +"""Benutzerobjekt, Rechtepruefung und Zugriffsdekoratoren.""" + +from __future__ import annotations + +import functools +from dataclasses import dataclass, field +from typing import Any, Callable, Iterable, Sequence + +from flask import abort, flash, g, redirect, request, session, url_for +from werkzeug.local import LocalProxy + +from ..rbac.model import Action, PermissionTree + +AUTH_SOURCES = ("local", "ldap") + + +@dataclass(frozen=True, slots=True) +class Group: + id: int + name: str + is_system: bool = False + is_default: bool = False + + +@dataclass(slots=True) +class User: + """Angemeldeter Benutzer. Unveraenderlich fuer die Dauer eines Requests.""" + + id: int + username: str + is_admin: bool = False + is_locked: bool = False + auth_source: str = "local" + first_name: str = "" + last_name: str = "" + email: str = "" + avatar_filename: str = "" + totp_enabled: bool = False + must_change_password: bool = False + groups: tuple[Group, ...] = () + granted: frozenset[str] = frozenset() + _tree: PermissionTree | None = field(default=None, repr=False, compare=False) + _effective: frozenset[str] | None = field(default=None, repr=False, compare=False) + + # -- Anzeige ------------------------------------------------------- # + + @property + def is_authenticated(self) -> bool: + return True + + @property + def display_name(self) -> str: + full = f"{self.first_name} {self.last_name}".strip() + return full or self.username + + @property + def initials(self) -> str: + parts = [p for p in (self.first_name, self.last_name) if p] + if parts: + return "".join(p[0] for p in parts[:2]).upper() + return (self.username[:2] or "?").upper() + + @property + def role_label(self) -> str: + if self.is_admin: + return "Administrator" + if self.groups: + return ", ".join(group.name for group in self.groups) + return "Ohne Gruppe" + + @property + def is_directory_account(self) -> bool: + return self.auth_source == "ldap" + + # -- Rechte -------------------------------------------------------- # + + @property + def effective_permissions(self) -> frozenset[str]: + """Rechte nach Anwendung der Bereichs-Kill-Switches.""" + if self._effective is None: + tree = self._tree + self._effective = ( + frozenset(tree.effective(self.granted)) if tree is not None else frozenset() + ) + return self._effective + + def has(self, *permissions: str) -> bool: + """True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles.""" + if self.is_admin: + return True + if not permissions: + return True + return all(permission in self.effective_permissions for permission in permissions) + + def has_any(self, permissions: Iterable[str]) -> bool: + if self.is_admin: + return True + effective = self.effective_permissions + return any(permission in effective for permission in permissions) + + def can(self, resource: str, action: Action | str) -> bool: + value = action.value if isinstance(action, Action) else str(action) + return self.has(f"{resource}.{value}") + + def can_any(self, resource: str, *actions: Action | str) -> bool: + return self.has_any( + f"{resource}.{a.value if isinstance(a, Action) else a}" for a in actions + ) + + def as_audit_actor(self) -> str: + return self.username + + +class AnonymousUser: + """Nicht angemeldet. Hat keine Rechte -- auch nicht ``view``.""" + + id = 0 + username = "" + is_admin = False + is_locked = False + auth_source = "anonymous" + first_name = "" + last_name = "" + email = "" + avatar_filename = "" + totp_enabled = False + must_change_password = False + groups: tuple[Group, ...] = () + granted: frozenset[str] = frozenset() + + @property + def is_authenticated(self) -> bool: + return False + + @property + def display_name(self) -> str: + return "Gast" + + @property + def initials(self) -> str: + return "?" + + @property + def role_label(self) -> str: + return "Nicht angemeldet" + + @property + def is_directory_account(self) -> bool: + return False + + @property + def effective_permissions(self) -> frozenset[str]: + return frozenset() + + def has(self, *permissions: str) -> bool: + return False + + def has_any(self, permissions: Iterable[str]) -> bool: + return False + + def can(self, resource: str, action: Action | str) -> bool: + return False + + def can_any(self, resource: str, *actions: Action | str) -> bool: + return False + + def as_audit_actor(self) -> str: + return "anonym" + + +ANONYMOUS = AnonymousUser() + + +def _lookup_user() -> User | AnonymousUser: + return getattr(g, "user", ANONYMOUS) + + +#: Aktueller Benutzer -- in Templates und Views gleich benutzbar. +current_user: Any = LocalProxy(_lookup_user) + + +# --------------------------------------------------------------------------- # +# Dekoratoren +# --------------------------------------------------------------------------- # + + +def _deny(message: str) -> Any: + """Nicht angemeldet -> Login; angemeldet aber ohne Recht -> 403.""" + if not current_user.is_authenticated: + if request.method == "GET" and not request.headers.get("X-Requested-With"): + session["_after_login"] = request.full_path if request.query_string else request.path + if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html: + abort(401, description="Anmeldung erforderlich.") + flash("Bitte melden Sie sich an.", "info") + return redirect(url_for("auth.login")) + abort(403, description=message) + + +def login_required(view: Callable[..., Any]) -> Callable[..., Any]: + @functools.wraps(view) + def wrapper(*args: Any, **kwargs: Any) -> Any: + if not current_user.is_authenticated: + return _deny("Anmeldung erforderlich.") + return view(*args, **kwargs) + + return wrapper + + +def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]: + """Erzwingt Rechte serverseitig. + + ``mode="any"`` fuer Seiten, die mehrere unabhaengige Rechte akzeptieren -- + wichtig, weil im Vorgaenger eine Seite mit reinem Export-Recht nicht + erreichbar war, obwohl das Recht vergeben war. + """ + + def decorator(view: Callable[..., Any]) -> Callable[..., Any]: + @functools.wraps(view) + def wrapper(*args: Any, **kwargs: Any) -> Any: + ok = ( + current_user.has_any(permissions) + if mode == "any" + else current_user.has(*permissions) + ) + if not ok: + return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.") + return view(*args, **kwargs) + + wrapper._required_permissions = permissions # type: ignore[attr-defined] + return wrapper + + return decorator + + +def admin_required(view: Callable[..., Any]) -> Callable[..., Any]: + """Nur echte Administratoren -- nicht ueber Gruppenrechte delegierbar. + + Fuer Grenzen, die einen lokalen Admin *nicht* ueberleben muessen, aber auch + nicht an eine Gruppe vergeben werden sollen (Rechtevergabe selbst, + Navigationsreihenfolge, Schluesselrotation). + """ + + @functools.wraps(view) + def wrapper(*args: Any, **kwargs: Any) -> Any: + if not current_user.is_authenticated or not current_user.is_admin: + return _deny("Diese Aktion ist Administratoren vorbehalten.") + return view(*args, **kwargs) + + return wrapper + + +def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]: + """Verlangt eine frische Passworteingabe ("sudo-Modus"). + + Vorgeschaltet bei allem, was Geheimnisse offenlegt oder Daten aus dem System + heraustraegt: Klartextpasswoerter, Export, Schluesselrotation, 2FA-Aenderung. + Ein uebernommenes Sitzungscookie allein reicht dafuer nicht mehr aus. + """ + + @functools.wraps(view) + def wrapper(*args: Any, **kwargs: Any) -> Any: + from .service import reauth_is_fresh # zyklischen Import vermeiden + + if not current_user.is_authenticated: + return _deny("Anmeldung erforderlich.") + if not reauth_is_fresh(): + session["_after_reauth"] = request.full_path if request.query_string else request.path + flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info") + return redirect(url_for("auth.reauth")) + return view(*args, **kwargs) + + return wrapper + + +def build_user( + row: dict[str, Any], + *, + groups: Sequence[Group], + granted: Iterable[str], + tree: PermissionTree, +) -> User: + return User( + id=int(row["id"]), + username=str(row["username"]), + is_admin=bool(row.get("is_admin")), + is_locked=bool(row.get("is_locked")), + auth_source=str(row.get("auth_source") or "local"), + first_name=str(row.get("first_name") or ""), + last_name=str(row.get("last_name") or ""), + email=str(row.get("email") or ""), + avatar_filename=str(row.get("avatar_filename") or ""), + totp_enabled=bool(row.get("totp_secret")), + must_change_password=bool(row.get("must_change_password")), + groups=tuple(groups), + granted=frozenset(granted), + _tree=tree, + ) diff --git a/packages/tesm-core/src/tesm_core/auth/routes.py b/packages/tesm-core/src/tesm_core/auth/routes.py new file mode 100644 index 0000000..264c058 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/auth/routes.py @@ -0,0 +1,135 @@ +"""Anmelde-Routen: Login, zweiter Faktor, Abmeldung, erneute Bestaetigung.""" + +from __future__ import annotations + +from urllib.parse import urlparse + +from flask import Blueprint, flash, redirect, render_template, request, session, url_for + +from ..extension import core, db +from . import directory, service +from .models import current_user, login_required + +bp = Blueprint("auth", __name__) + + +def _safe_next(candidate: str | None, fallback_endpoint: str = "index") -> str: + """Verhindert Open Redirects: nur pfad-relative Ziele derselben Anwendung.""" + default = url_for(fallback_endpoint) + if not candidate: + return default + parsed = urlparse(candidate) + if parsed.scheme or parsed.netloc or not candidate.startswith("/"): + return default + return candidate + + +@bp.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(_safe_next(session.pop("_after_login", None))) + + conn = db() + username = "" + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = request.form.get("password") or "" + outcome = service.authenticate( + conn, username, password, ip=service.client_ip() + ) + + if outcome.status == "needs_totp" and outcome.user_row: + service.start_totp_challenge(outcome.user_row) + return redirect(url_for("auth.second_factor")) + + if outcome.ok and outcome.user_row: + user = service.complete_login( + conn, + outcome.user_row, + ip=service.client_ip(), + secret=password, + group_dns=outcome.group_dns, + ) + flash(f"Willkommen, {user.display_name}.", "success") + if user.must_change_password: + flash("Bitte legen Sie zuerst ein neues Passwort fest.", "warning") + return redirect(url_for("account.security")) + return redirect(_safe_next(session.pop("_after_login", None))) + + flash(outcome.message or service.GENERIC_FAILURE, "danger") + + return render_template( + "tesm_core/login.html", + username=username, + directory_enabled=directory.is_enabled(conn), + public_dashboard_url=core().registry.get("public_dashboard_url", ""), + ) + + +@bp.route("/login/zweiter-faktor", methods=["GET", "POST"]) +def second_factor(): + conn = db() + user_row = service.pending_totp_user(conn) + if user_row is None: + flash("Die Anmeldung ist abgelaufen. Bitte erneut versuchen.", "info") + return redirect(url_for("auth.login")) + + if request.method == "POST": + outcome = service.verify_second_factor( + conn, user_row, request.form.get("code") or "", ip=service.client_ip() + ) + if outcome.ok: + # Kein ``secret``: das Passwort liegt seit dem ersten Schritt nicht + # mehr vor, und es fuer den zweiten Schritt in der Sitzung + # zwischenzulagern waere ein schlechter Tausch. Freigaben, die die + # Anmeldung des Benutzers weiterreichen, binden sich bei aktivem + # zweitem Faktor deshalb nicht ein -- Freigaben mit hinterlegtem + # Konto schon. + user = service.complete_login(conn, user_row, ip=service.client_ip()) + flash(f"Willkommen, {user.display_name}.", "success") + return redirect(_safe_next(session.pop("_after_login", None))) + flash(outcome.message, "danger") + + return render_template("tesm_core/second_factor.html") + + +@bp.post("/logout") +@login_required +def logout(): + service.logout(db()) + flash("Sie wurden abgemeldet.", "info") + return redirect(url_for("auth.login")) + + +@bp.get("/logout") +def logout_get(): + """Abmeldung ueber einen Link -- fuer den Abbruch des 2FA-Schritts. + + Aendert nur die eigene Sitzung und ist damit kein CSRF-relevanter Vorgang + mit Datenwirkung; die eigentliche Abmeldung im angemeldeten Bereich laeuft + trotzdem ueber POST mit Token. + """ + if current_user.is_authenticated: + service.logout(db()) + else: + session.clear() + return redirect(url_for("auth.login")) + + +@bp.route("/bestaetigen", methods=["GET", "POST"]) +@login_required +def reauth(): + target = _safe_next(session.get("_after_reauth")) + if request.method == "POST": + password = request.form.get("password") or "" + if service.confirm_password(db(), current_user, password): + service.mark_reauth() + session.pop("_after_reauth", None) + return redirect(target) + flash("Das Passwort ist nicht korrekt.", "danger") + + return render_template( + "tesm_core/reauth.html", + cancel_url=url_for("index"), + reauth_minutes=core().config.security.reauth_valid_minutes, + ) diff --git a/packages/tesm-core/src/tesm_core/auth/service.py b/packages/tesm-core/src/tesm_core/auth/service.py new file mode 100644 index 0000000..c824af0 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/auth/service.py @@ -0,0 +1,499 @@ +"""Anmeldung, Sitzungsaufbau, erneute Passwortbestaetigung. + +Ablauf einer Anmeldung (bewusst in dieser Reihenfolge): + +1. Ratenbegrenzung pro IP **und** pro Benutzername pruefen -- bevor irgendein + Hash berechnet wird. +2. Lokales Konto suchen. Existiert es mit ``auth_source='local'``, wird das + Passwort gegen den Argon2-Hash geprueft; existiert es nicht oder als + Verzeichniskonto, greift (falls konfiguriert) die LDAP-Pruefung. +3. Bei Erfolg: gesperrt? -> abweisen. 2FA aktiv? -> Zwischenschritt. +4. Sitzung rotieren (neue ID), Rechte laden, Erfolg protokollieren. + +Bei jedem Fehlschlag wird dieselbe, nichts verratende Meldung ausgegeben und +dieselbe Rechenzeit verbraucht (``passwords.dummy_verify``) -- die Antwort +erlaubt keinen Rueckschluss darauf, ob ein Konto existiert. +""" + +from __future__ import annotations + +import logging +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Literal + +from flask import current_app, g, request, session + +from .. import audit +from ..db import Database +from ..extension import core, db +from ..rbac import service as rbac +from ..security import passwords, ratelimit +from ..security import sessions as session_store +from .models import ANONYMOUS, Group, User, build_user + +logger = logging.getLogger(__name__) + +SESSION_USER_KEY = "user_id" +SESSION_PENDING_KEY = "_pending_user_id" +SESSION_REAUTH_KEY = "_reauth_at" + +BUCKET_LOGIN_USER = "login:user" +BUCKET_LOGIN_IP = "login:ip" +BUCKET_TOTP = "totp:user" + +GENERIC_FAILURE = "Benutzername oder Passwort ist falsch." + +Status = Literal["ok", "invalid", "locked", "rate_limited", "needs_totp", "directory_unavailable"] + + +@dataclass(frozen=True, slots=True) +class AuthOutcome: + status: Status + message: str = "" + user_row: dict[str, Any] | None = None + retry_after: int = 0 + #: Verzeichnisgruppen der laufenden Anmeldung. Sie stehen nur hier zur + #: Verfuegung -- gespeichert werden sie bewusst nicht, denn beim naechsten + #: Mal koennen es andere sein. + group_dns: tuple[str, ...] = () + + @property + def ok(self) -> bool: + return self.status == "ok" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(value: datetime) -> str: + return value.isoformat(timespec="seconds") + + +def client_ip() -> str: + """Client-IP unter Beruecksichtigung vertrauenswuerdiger Proxy-Hops.""" + hops = core().config.security.trusted_proxy_hops + if hops > 0: + forwarded = request.headers.get("X-Forwarded-For", "") + if forwarded: + parts = [part.strip() for part in forwarded.split(",") if part.strip()] + if parts: + # Der letzte vertrauenswuerdige Hop steht von rechts gezaehlt. + index = max(0, len(parts) - hops) + return parts[index] + return request.remote_addr or "" + + +# --------------------------------------------------------------------------- # +# Benutzer laden +# --------------------------------------------------------------------------- # + + +def load_user(conn: sqlite3.Connection, user_id: int) -> User | None: + row = Database.one( + conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (user_id,) + ) + if row is None or row.get("is_locked"): + return None + groups = [ + Group( + id=int(entry["id"]), + name=str(entry["name"]), + is_system=bool(entry["is_system"]), + is_default=bool(entry["is_default"]), + ) + for entry in rbac.user_groups(conn, user_id) + ] + return build_user( + row, + groups=groups, + granted=rbac.granted_permissions(conn, user_id), + tree=core().permissions, + ) + + +def load_request_user() -> None: + """``before_request``-Hook: setzt ``g.user``.""" + g.user = ANONYMOUS + user_id = session.get(SESSION_USER_KEY) + if not user_id: + return + user = load_user(db(), int(user_id)) + if user is None: + # Konto geloescht, gesperrt oder Rechte entzogen -> Sitzung beenden. + session.clear() + return + g.user = user + + +# --------------------------------------------------------------------------- # +# Anmeldung +# --------------------------------------------------------------------------- # + + +def authenticate( + conn: sqlite3.Connection, username: str, password: str, *, ip: str +) -> AuthOutcome: + security = core().config.security + name = (username or "").strip() + if not name or not password: + return AuthOutcome("invalid", GENERIC_FAILURE) + + ip_verdict = ratelimit.check( + conn, + BUCKET_LOGIN_IP, + ip or "unbekannt", + limit=security.login_max_attempts_per_ip, + window_minutes=security.login_lockout_minutes, + ) + if ip_verdict.blocked: + return AuthOutcome( + "rate_limited", + "Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.", + retry_after=ip_verdict.retry_after_seconds, + ) + + user_verdict = ratelimit.check( + conn, + BUCKET_LOGIN_USER, + name.lower(), + limit=security.login_max_attempts, + window_minutes=security.login_lockout_minutes, + ) + if user_verdict.blocked: + return AuthOutcome( + "rate_limited", + "Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.", + retry_after=user_verdict.retry_after_seconds, + ) + + row = rbac.find_user_by_username(conn, name) + outcome = _verify_credentials(conn, row, name, password) + + if not outcome.ok: + with core().database.transaction(conn): + ratelimit.record(conn, BUCKET_LOGIN_IP, ip or "unbekannt") + ratelimit.record(conn, BUCKET_LOGIN_USER, name.lower()) + audit.log( + conn, + actor=name, + action="auth.login_failed", + target=name, + detail=outcome.status, + ip=ip, + severity="warning", + request_id=getattr(g, "request_id", ""), + ) + return outcome + + user_row = outcome.user_row or {} + if bool(user_row.get("is_locked")): + with core().database.transaction(conn): + audit.log( + conn, + actor=str(user_row.get("username") or name), + action="auth.login_locked", + target=str(user_row.get("username") or name), + ip=ip, + severity="warning", + ) + return AuthOutcome("locked", "Dieses Konto ist gesperrt. Bitte wenden Sie sich an die IT.") + + with core().database.transaction(conn): + ratelimit.clear(conn, BUCKET_LOGIN_USER, name.lower()) + + if str(user_row.get("totp_secret") or ""): + return AuthOutcome("needs_totp", "", user_row=user_row, group_dns=outcome.group_dns) + + if core().config.security.require_totp_for_admins and bool(user_row.get("is_admin")): + # Kein harter Block: der Admin kommt herein, muss aber sofort 2FA + # einrichten (die Kontoseite fuehrt ihn dorthin). + logger.warning( + "Administrator %s hat keine 2FA, obwohl sie verlangt wird", user_row.get("username") + ) + + # ``outcome`` mitfuehren, nicht neu bauen: die Verzeichnisgruppen der + # laufenden Anmeldung stehen nur dort. Wer hier ein frisches AuthOutcome + # erzeugt, verliert sie lautlos -- und mit ihnen jede Freigabe, die daran + # haengt. + return AuthOutcome("ok", user_row=user_row, group_dns=outcome.group_dns) + + +def _verify_credentials( + conn: sqlite3.Connection, row: dict[str, Any] | None, name: str, password: str +) -> AuthOutcome: + from . import directory # lokaler Import: ldap3 ist optional + + directory_enabled = directory.is_enabled(conn) + + if row is not None and str(row.get("auth_source")) == "local": + if not passwords.verify_password(str(row.get("password_hash") or ""), password): + return AuthOutcome("invalid", GENERIC_FAILURE) + if passwords.needs_rehash(str(row.get("password_hash") or "")): + with core().database.transaction(conn): + conn.execute( + "UPDATE users SET password_hash=?, updated_at=? WHERE id=?", + (passwords.hash_password(password), _iso(_now()), row["id"]), + ) + return AuthOutcome("ok", user_row=row) + + if not directory_enabled: + passwords.dummy_verify() + return AuthOutcome("invalid", GENERIC_FAILURE) + + result = directory.authenticate(conn, name, password) + if result.status == "unavailable": + return AuthOutcome( + "directory_unavailable", + "Der Verzeichnisdienst ist derzeit nicht erreichbar. " + "Bitte versuchen Sie es spaeter erneut.", + ) + if result.status != "ok" or result.profile is None: + return AuthOutcome("invalid", GENERIC_FAILURE) + + user_row = directory.sync_account(conn, result.profile) + return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns) + + +def complete_login( + conn: sqlite3.Connection, + user_row: dict[str, Any], + *, + ip: str, + secret: str = "", + group_dns: tuple[str, ...] = (), +) -> User: + """Baut die Sitzung auf. Rotiert die Sitzungs-ID (Session-Fixation). + + ``secret`` ist das Passwort dieser Anmeldung. Es wird **nicht** gespeichert + und nicht in die Sitzung geschrieben; es wird ausschliesslich an die + angemeldeten Haken weitergereicht und ist danach weg. Gebraucht wird es + genau von einem: der Einbindung von Dateifreigaben, die sich mit der + Anmeldung des Benutzers am Fileserver anmeldet. + """ + from ..security import csrf + + with core().database.transaction(conn): + session_store.rotate(conn, session, reason="login") # type: ignore[arg-type] + conn.execute( + "UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"]) + ) + audit.log( + conn, + actor=str(user_row["username"]), + actor_id=int(user_row["id"]), + action="auth.login", + target=str(user_row["username"]), + detail=f"Quelle: {user_row.get('auth_source') or 'local'}", + ip=ip, + request_id=getattr(g, "request_id", ""), + ) + + session.clear() + session[SESSION_USER_KEY] = int(user_row["id"]) + session[SESSION_REAUTH_KEY] = _iso(_now()) + session["_ip"] = ip + session["_ua"] = (request.user_agent.string or "")[:200] + csrf.rotate_token() + + user = load_user(conn, int(user_row["id"])) + if user is None: # pragma: no cover - direkt nach Pruefung praktisch unmoeglich + raise RuntimeError("Benutzer konnte nach der Anmeldung nicht geladen werden") + g.user = user + _run_login_hooks(conn, user, secret=secret, group_dns=group_dns) + return user + + +def _run_login_hooks( + conn: sqlite3.Connection, user: User, *, secret: str, group_dns: tuple[str, ...] +) -> None: + """Ruft die angemeldeten Haken auf -- ohne die Anmeldung zu gefaehrden. + + Ein Haken, der scheitert, darf niemanden aussperren: was hier passiert, ist + Zusatzarbeit (Freigaben einbinden), nicht die Anmeldung selbst. Deshalb + wird jeder Fehler protokolliert und geschluckt. + """ + for hook in core().login_hooks: + try: + hook(conn, user, secret, group_dns) + except Exception: # pragma: no cover - haengt am Fileserver + current_app.logger.exception( + "Anmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook) + ) + + +def logout(conn: sqlite3.Connection) -> None: + user = getattr(g, "user", ANONYMOUS) + if user.is_authenticated: + # Vor dem Protokolleintrag, solange die Sitzung noch steht: die Haken + # raeumen auf, was zu ihr gehoert (z. B. eingebundene Freigaben). + for hook in core().logout_hooks: + try: + hook(conn, user) + except Exception: # pragma: no cover - haengt am Fileserver + current_app.logger.exception( + "Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook) + ) + with core().database.transaction(conn): + audit.log( + conn, + actor=user.username, + actor_id=user.id, + action="auth.logout", + target=user.username, + ip=client_ip(), + ) + session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined] + g.user = ANONYMOUS + + +# --------------------------------------------------------------------------- # +# Zweiter Faktor +# --------------------------------------------------------------------------- # + + +def start_totp_challenge(user_row: dict[str, Any]) -> None: + session[SESSION_PENDING_KEY] = int(user_row["id"]) + session["_pending_since"] = _iso(_now()) + + +def pending_totp_user(conn: sqlite3.Connection) -> dict[str, Any] | None: + user_id = session.get(SESSION_PENDING_KEY) + if not user_id: + return None + started = session.get("_pending_since") + if started: + try: + if _now() - datetime.fromisoformat(str(started)) > timedelta(minutes=5): + session.pop(SESSION_PENDING_KEY, None) + return None + except ValueError: + pass + return Database.one( + conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (int(user_id),) + ) + + +def verify_second_factor( + conn: sqlite3.Connection, user_row: dict[str, Any], code: str, *, ip: str +) -> AuthOutcome: + from ..security import totp + + security = core().config.security + subject = str(user_row["id"]) + verdict = ratelimit.check( + conn, BUCKET_TOTP, subject, limit=8, window_minutes=security.login_lockout_minutes + ) + if verdict.blocked: + return AuthOutcome( + "rate_limited", + "Zu viele Fehlversuche. Bitte warten Sie einen Moment.", + retry_after=verdict.retry_after_seconds, + ) + + cleaned = (code or "").strip() + if totp.verify_code(str(user_row.get("totp_secret") or ""), cleaned): + with core().database.transaction(conn): + ratelimit.clear(conn, BUCKET_TOTP, subject) + return AuthOutcome("ok", user_row=user_row) + + if _consume_recovery_code(conn, int(user_row["id"]), cleaned, ip=ip): + return AuthOutcome("ok", user_row=user_row) + + with core().database.transaction(conn): + ratelimit.record(conn, BUCKET_TOTP, subject) + audit.log( + conn, + actor=str(user_row["username"]), + actor_id=int(user_row["id"]), + action="auth.totp_failed", + target=str(user_row["username"]), + ip=ip, + severity="warning", + ) + return AuthOutcome("invalid", "Der Code ist nicht korrekt.") + + +def _consume_recovery_code( + conn: sqlite3.Connection, user_id: int, code: str, *, ip: str +) -> bool: + candidates = Database.all( + conn, + "SELECT id, code_hash FROM user_recovery_codes WHERE user_id=? AND used_at IS NULL", + (user_id,), + ) + normalized = code.strip().lower() + for entry in candidates: + if passwords.verify_password(str(entry["code_hash"]), normalized): + with core().database.transaction(conn): + conn.execute( + "UPDATE user_recovery_codes SET used_at=? WHERE id=?", + (_iso(_now()), entry["id"]), + ) + remaining = int( + Database.value( + conn, + "SELECT COUNT(*) FROM user_recovery_codes " + "WHERE user_id=? AND used_at IS NULL", + (user_id,), + default=0, + ) + or 0 + ) + audit.log( + conn, + actor=str( + Database.value(conn, "SELECT username FROM users WHERE id=?", (user_id,)) + or "" + ), + actor_id=user_id, + action="auth.recovery_code_used", + detail=f"Verbleibende Codes: {remaining}", + ip=ip, + severity="notice", + ) + return True + return False + + +# --------------------------------------------------------------------------- # +# Erneute Bestaetigung ("sudo-Modus") +# --------------------------------------------------------------------------- # + + +def reauth_is_fresh() -> bool: + stamp = session.get(SESSION_REAUTH_KEY) + if not stamp: + return False + try: + moment = datetime.fromisoformat(str(stamp)) + except ValueError: + return False + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + window = timedelta(minutes=core().config.security.reauth_valid_minutes) + return _now() - moment <= window + + +def mark_reauth() -> None: + session[SESSION_REAUTH_KEY] = _iso(_now()) + + +def confirm_password(conn: sqlite3.Connection, user: User, password: str) -> bool: + """Prueft das Passwort des angemeldeten Benutzers erneut. + + Fuer Verzeichniskonten laeuft die Pruefung gegen das Verzeichnis -- sonst + koennten LDAP-Nutzer den sudo-Modus nie erreichen. + """ + from . import directory + + row = rbac.get_user(conn, user.id) + if row is None: + return False + if str(row.get("auth_source")) == "local": + return passwords.verify_password(str(row.get("password_hash") or ""), password) + if not directory.is_enabled(conn): + return False + return directory.authenticate(conn, user.username, password).status == "ok" diff --git a/packages/tesm-core/src/tesm_core/cli_web.py b/packages/tesm-core/src/tesm_core/cli_web.py new file mode 100644 index 0000000..7d35f0f --- /dev/null +++ b/packages/tesm-core/src/tesm_core/cli_web.py @@ -0,0 +1,230 @@ +"""Der Befehl ``web-setup`` -- HTTPS einrichten, ohne nginx von Hand anzufassen. + +Beide Anwendungen registrieren ihn; die Logik steht darum genau einmal hier. + +Warum es diesen Befehl ueberhaupt gibt: die nginx-Site wird ausschliesslich aus +der Vorlage in :mod:`tesm_core.system.webserver` erzeugt -- das ist die +Konsequenz aus dem alias-Fehler des Vorgaengers, der jahrelang ueberlebte, weil +irgendwann jemand die Datei von Hand angelegt hatte. Ein Installationsskript, +das sich seine eigene TLS-Konfiguration zusammensetzt, waere derselbe Fehler in +neu. Stattdessen setzt dieser Befehl die Einstellungen und laesst die Anwendung +schreiben -- genau wie die Oberflaeche es tut. + +Aufruf aus ``install.sh``: + + tesm-admin web-setup --https --self-signed --hsts --apply + tesm-admin web-setup --https --domain tesm.example.org --acme-email a@b.c --apply +""" + +from __future__ import annotations + +import argparse +import sys +from typing import Any + +from .system import webserver + + +def _config_from_settings(extension: Any, conn: Any) -> webserver.WebConfig: + values = extension.settings.get_many( + conn, + [ + "web_domain", + "web_https_enabled", + "web_hsts_enabled", + "web_redirect_http", + "web_cert_path", + "web_key_path", + "web_acme_webroot", + "web_max_body_mb", + "web_http_port", + "web_https_port", + ], + ) + return webserver.WebConfig( + app_key=extension.config.site_key, + upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")), + static_root=str( + extension.registry.get("static_root", extension.config.base_dir / "static") + ), + domain=str(values["web_domain"] or ""), + https_enabled=bool(values["web_https_enabled"]), + hsts_enabled=bool(values["web_hsts_enabled"]), + redirect_http=bool(values["web_redirect_http"]), + cert_path=str(values["web_cert_path"] or ""), + key_path=str(values["web_key_path"] or ""), + acme_webroot=str( + values["web_acme_webroot"] or f"/var/www/{extension.config.site_key}-acme" + ), + websocket_path=str(extension.registry.get("websocket_path", "")), + max_body_mb=int(values["web_max_body_mb"] or 128), + http_port=int(values["web_http_port"] or 80), + https_port=int(values["web_https_port"] or 443), + ) + + +def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int: + """Setzt die Webserver-Einstellungen und schreibt optional die Site-Datei.""" + sysops = extension.sysops + with app.app_context(), extension.database.session() as conn: + values: dict[str, Any] = {} + + if args.domain is not None: + try: + values["web_domain"] = webserver.validate_domain(args.domain) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 2 + if args.acme_email: + values["web_acme_email"] = args.acme_email.strip() + if args.max_body_mb: + values["web_max_body_mb"] = int(args.max_body_mb) + for flag, key in (("http_port", "web_http_port"), ("https_port", "web_https_port")): + port = getattr(args, flag, None) + if port: + if not 1 <= int(port) <= 65535: + print(f"Ungueltiger Port: {port}", file=sys.stderr) + return 2 + values[key] = int(port) + + with extension.database.transaction(conn): + if values: + extension.settings.set_many(conn, values, actor="install") + + # -- Zertifikat --------------------------------------------------- # + config = _config_from_settings(extension, conn) + cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."} + + if args.acme: + if sysops is None or not sysops.available: + print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr) + return 2 + cert = webserver.request_certificate( + sysops, + domain=config.domain, + email=str(extension.settings.get(conn, "web_acme_email") or ""), + webroot=config.acme_webroot, + ) + if not cert["ok"] and args.self_signed: + # Kein oeffentliches Zertifikat bekommen -- lieber + # selbstsigniertes HTTPS als gar keins. Der Aufrufer erfaehrt + # beides. + print(f"Let's Encrypt fehlgeschlagen: {cert.get('detail', cert['message'])}") + print("Weiche auf ein selbstsigniertes Zertifikat aus.") + args.acme = False + elif not cert["ok"]: + print(cert.get("detail") or cert["message"], file=sys.stderr) + return 1 + + if args.self_signed and not args.acme: + if sysops is None or not sysops.available: + print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr) + return 2 + suggested = webserver.local_names() + cert = webserver.create_self_signed( + sysops, + common_name=config.domain or (suggested[0] if suggested else "localhost"), + alt_names=[*suggested, "localhost"], + ) + if not cert["ok"]: + print(cert.get("detail") or cert["message"], file=sys.stderr) + return 1 + + if cert.get("cert_path"): + with extension.database.transaction(conn): + extension.settings.set_many( + conn, + {"web_cert_path": cert["cert_path"], "web_key_path": cert["key_path"]}, + actor="install", + ) + print(cert["message"]) + + # -- HTTPS-Schalter ------------------------------------------------ # + config = _config_from_settings(extension, conn) + if args.https: + if not (config.cert_path and config.key_path): + print( + "HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed " + "oder --acme angeben.", + file=sys.stderr, + ) + return 2 + with extension.database.transaction(conn): + extension.settings.set_many( + conn, + { + "web_https_enabled": True, + "web_hsts_enabled": bool(args.hsts), + "web_redirect_http": not args.no_redirect, + }, + actor="install", + ) + + config = _config_from_settings(extension, conn) + rendered = webserver.render(config) + + if args.print_config: + print(rendered) + return 0 + + if not args.apply: + print("Einstellungen gespeichert. Mit --apply wird nginx neu geschrieben.") + return 0 + + if sysops is None or not sysops.available: + print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr) + return 2 + result = webserver.apply_config(sysops, rendered) + print(result["message"]) + if not result["ok"]: + print(result.get("detail", ""), file=sys.stderr) + return 1 + if config.https_enabled: + detail = f"HTTPS auf {config.https_port}, {config.http_port} leitet um" + else: + detail = f"HTTP auf {config.http_port}" + print(f"nginx: {config.server_name} -> {config.upstream} -- {detail}") + return 0 + + +def add_web_setup(sub: Any, runner: Any) -> None: + """Haengt ``web-setup`` an einen ``argparse``-Subparser.""" + parser = sub.add_parser( + "web-setup", + help="nginx und TLS einrichten (Port 80/443)", + description=( + "Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. " + "Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand " + "bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht." + ), + ) + parser.add_argument("--https", action="store_true", help="HTTPS auf Port 443 aktivieren") + parser.add_argument( + "--self-signed", + action="store_true", + help="Selbstsigniertes Zertifikat erzeugen (geschlossene Netze ohne Domain)", + ) + parser.add_argument( + "--acme", + action="store_true", + help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)", + ) + parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation") + parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt") + parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen") + parser.add_argument( + "--no-redirect", + action="store_true", + help="Port 80 nicht auf HTTPS umleiten (Sonderfall)", + ) + parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads") + parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)") + parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)") + parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden") + parser.add_argument( + "--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben" + ) + parser.set_defaults(func=runner) + + +__all__ = ["add_web_setup", "run_web_setup"] diff --git a/packages/tesm-core/src/tesm_core/config.py b/packages/tesm-core/src/tesm_core/config.py new file mode 100644 index 0000000..0d6fde9 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/config.py @@ -0,0 +1,205 @@ +"""Konfiguration aus Umgebungsvariablen -- typisiert, an einer Stelle. + +Im Vorgaengerprojekt waren Pfade und Konstanten quer durch eine mehrere +tausend Zeilen lange ``app.py`` verstreut. Hier gibt es genau ein Objekt, das +beim Start gebaut, validiert und danach nicht mehr veraendert wird. + +Namensraum: jede Anwendung bekommt ein eigenes Env-Praefix (``TESM_`` bzw. +``TESM_LICENSE_``), damit beide Dienste auf demselben Host ohne Kollisionen +laufen koennen -- inklusive der Pfade, die im Vorgaenger kollidierten +(netplan-Override!). +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field, replace +from pathlib import Path + + +def _env(prefix: str, name: str, default: str = "") -> str: + return os.environ.get(f"{prefix}{name}", default).strip() + + +def _env_bool(prefix: str, name: str, default: bool) -> bool: + raw = _env(prefix, name) + if not raw: + return default + return raw.lower() in {"1", "true", "yes", "on"} + + +def _env_int(prefix: str, name: str, default: int) -> int: + raw = _env(prefix, name) + try: + return int(raw) if raw else default + except ValueError: + return default + + +@dataclass(frozen=True, slots=True) +class SecurityConfig: + """Alles, was das Sicherheitsverhalten steuert. Defaults sind die sichere Wahl.""" + + #: Cookie nur ueber HTTPS ausliefern. In Produktion immer True (nginx terminiert TLS). + cookie_secure: bool = True + cookie_samesite: str = "Lax" + #: Leerlauf-Zeitfenster einer Sitzung. + session_idle_minutes: int = 60 + #: Harte Obergrenze unabhaengig von Aktivitaet. + session_absolute_hours: int = 12 + #: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt. + reauth_valid_minutes: int = 15 + #: Fehlversuche bis zur temporaeren Sperre eines Kontos. + login_max_attempts: int = 8 + login_lockout_minutes: int = 15 + #: Fehlversuche pro IP im gleichen Zeitfenster (unabhaengig vom Konto). + login_max_attempts_per_ip: int = 30 + #: Mindestlaenge lokaler Passwoerter. + password_min_length: int = 12 + #: 2FA fuer Administratoren erzwingen. + require_totp_for_admins: bool = False + #: HSTS-Header setzen (nur sinnvoll, wenn TLS tatsaechlich terminiert wird). + hsts_enabled: bool = False + hsts_max_age: int = 15552000 + #: Vertrauenswuerdige Proxy-Hops fuer X-Forwarded-For (0 = Header ignorieren). + trusted_proxy_hops: int = 1 + #: Maximale Groesse eines Uploads in Bytes (None = unbegrenzt). + max_content_length: int | None = 64 * 1024 * 1024 + + +@dataclass(frozen=True, slots=True) +class CoreConfig: + """Basiskonfiguration, die beide Anwendungen teilen.""" + + app_key: str + """Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units.""" + + site_key: str + """Name *dieser Installation* auf dem Host, z. B. ``tesm-opus``. + + Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite + Instanz laeuft. Alles, was ausserhalb von ``/srv/`` liegt und damit + zwischen Instanzen kollidieren koennte -- nginx-Site, netplan-Datei, + logrotate-Regel, Zertifikatsverzeichnis, Name des Sitzungscookies --, wird + ueber diesen Schluessel benannt und **nicht** ueber den ``app_key``. + + Ohne diese Trennung wuerde eine Testinstanz beim Speichern der + Webserver-Einstellungen die Site-Datei der Produktion ueberschreiben, und + beide Instanzen wuerden sich auf demselben Host dasselbe Sitzungscookie + teilen (Cookies unterscheiden keine Ports). + """ + + app_name: str + """Anzeigename in der Oberflaeche.""" + + env_prefix: str + base_dir: Path + data_dir: Path + log_dir: Path + db_path: Path + secret_key_file: Path + data_key_file: Path + license_path: Path + license_key_path: Path + #: Adresse, auf der der Anwendungsserver lauscht -- exakt das, was nginx + #: als ``proxy_pass``-Ziel braucht. Kommt aus derselben Umgebungsdatei wie + #: der ``--bind``-Parameter von gunicorn, damit beide nie auseinanderlaufen. + #: Vorher stand hier ein fest verdrahtetes ``127.0.0.1:5000``; eine zweite + #: Instanz auf Port 5100 erzeugte damit eine nginx-Site, die auf die + #: *andere* Installation zeigte. + bind: str = "127.0.0.1:5000" + debug: bool = False + #: Instanz laeuft als echter Webprozess (gesetzt durch die systemd-Unit). + web_process: bool = False + security: SecurityConfig = field(default_factory=SecurityConfig) + #: Zusaetzliche, app-spezifische Werte. + extra: dict[str, str] = field(default_factory=dict) + + @property + def is_production(self) -> bool: + return not self.debug + + def path_in_data(self, name: str) -> Path: + return self.data_dir / name + + +def load_core_config( + *, + app_key: str, + app_name: str, + env_prefix: str, + default_base: Path | None = None, + default_bind: str = "127.0.0.1:5000", +) -> CoreConfig: + """Liest die Konfiguration aus der Umgebung. + + Ohne gesetzte Env-Vars laeuft alles in einem Unterverzeichnis + ``instance/`` relativ zum aktuellen Arbeitsverzeichnis -- damit ist + die Anwendung ohne jede Vorbereitung lokal startbar, ohne je nach + ``/srv``/``/var/log`` zu greifen. + """ + base = Path(_env(env_prefix, "BASE_DIR") or (default_base or Path.cwd() / "instance" / app_key)) + base = base.expanduser().resolve() + data_dir = Path(_env(env_prefix, "DATA_DIR") or base / "data").expanduser() + log_dir = Path(_env(env_prefix, "LOG_DIR") or base / "logs").expanduser() + + security = SecurityConfig( + cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True), + cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax", + session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 60), + session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12), + reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15), + login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8), + login_lockout_minutes=_env_int(env_prefix, "LOGIN_LOCKOUT_MINUTES", 15), + login_max_attempts_per_ip=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS_PER_IP", 30), + password_min_length=_env_int(env_prefix, "PASSWORD_MIN_LENGTH", 12), + require_totp_for_admins=_env_bool(env_prefix, "REQUIRE_TOTP_FOR_ADMINS", False), + hsts_enabled=_env_bool(env_prefix, "HSTS_ENABLED", False), + hsts_max_age=_env_int(env_prefix, "HSTS_MAX_AGE", 15552000), + trusted_proxy_hops=_env_int(env_prefix, "TRUSTED_PROXY_HOPS", 1), + max_content_length=_env_int(env_prefix, "MAX_CONTENT_LENGTH", 64 * 1024 * 1024) or None, + ) + + debug = _env_bool(env_prefix, "DEBUG", False) + if debug: + # Lokale Entwicklung laeuft ueber http://127.0.0.1 -- ein Secure-Cookie + # wuerde dort nie gesetzt und die Anmeldung schlicht nicht funktionieren. + # dataclasses.replace, nicht __dict__: SecurityConfig hat slots und + # damit gar kein __dict__ -- der alte Ausdruck warf beim Start einen + # AttributeError, und zwar genau im Entwicklungsmodus, den das README + # empfiehlt. + security = replace( + security, cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", False) + ) + + bind = (_env(env_prefix, "BIND") or default_bind).strip() + if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind): + raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}") + + site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower() + if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key): + raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}") + + return CoreConfig( + app_key=app_key, + site_key=site_key, + bind=bind, + app_name=_env(env_prefix, "APP_NAME") or app_name, + env_prefix=env_prefix, + base_dir=base, + data_dir=data_dir, + log_dir=log_dir, + db_path=Path(_env(env_prefix, "DB_PATH") or data_dir / "app.db").expanduser(), + secret_key_file=Path( + _env(env_prefix, "SECRET_KEY_FILE") or data_dir / "secret.key" + ).expanduser(), + data_key_file=Path(_env(env_prefix, "DATA_KEY_FILE") or data_dir / "data.keys").expanduser(), + license_path=Path(_env(env_prefix, "LICENSE_PATH") or data_dir / "license.json").expanduser(), + license_key_path=Path( + _env(env_prefix, "LICENSE_KEY_PATH") or data_dir / "license_key.json" + ).expanduser(), + debug=debug, + web_process=_env(env_prefix, "WEB_PROCESS") == "1", + security=security, + ) diff --git a/packages/tesm-core/src/tesm_core/core_schema.py b/packages/tesm-core/src/tesm_core/core_schema.py new file mode 100644 index 0000000..245f5fa --- /dev/null +++ b/packages/tesm-core/src/tesm_core/core_schema.py @@ -0,0 +1,193 @@ +"""Migrationen der Komponente ``core``: Benutzer, Gruppen, Sitzungen, Audit, Settings. + +Diese Tabellen sind in beiden Anwendungen identisch. Neue Aenderungen kommen +als **neue** Migration mit neuer Versionsnummer dazu -- bestehende Migrationen +werden nie editiert (die Pruefsummenwarnung des Runners weist darauf hin). +""" + +from __future__ import annotations + +from .migrations import Migration + +V1_BASELINE = """ +-- Benutzerkonten. 'local' = eigenes Passwort, 'ldap' = Schattenkonto aus dem +-- Verzeichnisdienst (Passwort wird dort geprueft, nie hier gespeichert). +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL COLLATE NOCASE, + password_hash TEXT NOT NULL DEFAULT '', + is_admin INTEGER NOT NULL DEFAULT 0, + is_locked INTEGER NOT NULL DEFAULT 0, + auth_source TEXT NOT NULL DEFAULT 'local', + first_name TEXT NOT NULL DEFAULT '', + last_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + avatar_filename TEXT NOT NULL DEFAULT '', + totp_secret TEXT NOT NULL DEFAULT '', + totp_confirmed_at TEXT, + must_change_password INTEGER NOT NULL DEFAULT 0, + password_changed_at TEXT, + last_login_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT +); +-- Eindeutigkeit nur unter den *nicht* geloeschten Konten: ein Name im +-- Papierkorb blockiert die Neuanlage nicht dauerhaft, solange die Anwendung +-- den Konflikt bewusst behandelt. +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_live + ON users(username) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at); + +CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL COLLATE NOCASE, + description TEXT NOT NULL DEFAULT '', + is_system INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0, + preset_key TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_live + ON groups(name) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS group_permissions ( + group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + permission TEXT NOT NULL, + PRIMARY KEY (group_id, permission) +); + +CREATE TABLE IF NOT EXISTS user_groups ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, group_id) +); +CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(group_id); + +-- Einmal-Codes fuer den Verlust des Authenticators. Gespeichert wird nur ein +-- Argon2-Hash -- ein Datenbankleck gibt die Codes nicht her. +CREATE TABLE IF NOT EXISTS user_recovery_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + used_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_recovery_user ON user_recovery_codes(user_id); + +CREATE TABLE IF NOT EXISTS user_sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + idle_expires_at TEXT NOT NULL, + absolute_expires_at TEXT NOT NULL, + reauth_at TEXT, + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + data TEXT NOT NULL DEFAULT '{}', + revoked_at TEXT, + revoked_reason TEXT +); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id, revoked_at); +CREATE INDEX IF NOT EXISTS idx_sessions_idle ON user_sessions(idle_expires_at); + +-- Sliding-Window-Log der Ratenbegrenzung (Anmeldeversuche, Portal-Anfragen, API). +CREATE TABLE IF NOT EXISTS rate_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bucket TEXT NOT NULL, + subject TEXT NOT NULL, + ts TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_rate_lookup ON rate_events(bucket, subject, ts); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by TEXT NOT NULL DEFAULT '' +); + +-- Aenderungsprotokoll mit Hash-Kette: jede Zeile bindet den Hash ihrer +-- Vorgaengerin ein. Eine nachtraeglich geloeschte oder veraenderte Zeile bricht +-- die Kette und wird von der Pruefroutine gefunden. +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + actor TEXT NOT NULL, + actor_id INTEGER, + action TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + request_id TEXT NOT NULL DEFAULT '', + severity TEXT NOT NULL DEFAULT 'info', + prev_hash TEXT NOT NULL DEFAULT '', + hash TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts); +CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action); + +-- Dienstkonten (z. B. LDAP-Bind). Das Passwort liegt AES-GCM-verschluesselt vor, +-- gebunden an den Verwendungszweck als AAD. +CREATE TABLE IF NOT EXISTS service_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + purpose TEXT NOT NULL UNIQUE, + username TEXT NOT NULL DEFAULT '', + password_encrypted TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS ldap_group_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ad_group_dn TEXT NOT NULL UNIQUE, + ad_group_name TEXT NOT NULL, + app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + created_at TEXT NOT NULL +); +""" + +V2_TRUSTED_KEYS = """ +-- Gepinnte Aussteller-Schluessel der Lizenzpruefung (Trust on First Use bzw. +-- im Build mitgeliefert). Der Vorgaenger vertraute allein dem in der +-- Lizenzdatei eingebetteten Schluessel. +CREATE TABLE IF NOT EXISTS trusted_issuer_keys ( + key_id TEXT PRIMARY KEY, + public_key TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT 'tofu', + added_at TEXT NOT NULL, + added_by TEXT NOT NULL DEFAULT '' +); +""" + +V3_JOBS = """ +-- Persistente Auftragsverfolgung fuer Hintergrundlaeufe (Wartung, Import, +-- Berichte). Im Vorgaenger lebte dieser Zustand ausschliesslich im +-- Prozessspeicher und war nach einem Dienst-Neustart weg -- inklusive der +-- Information, ob ein Lauf ueberhaupt stattgefunden hat. +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + subject TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'pending', + progress INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', + output TEXT NOT NULL DEFAULT '', + started_by TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + finished_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_jobs_kind_state ON jobs(kind, state); +CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at); +""" + +CORE_MIGRATIONS: tuple[Migration, ...] = ( + Migration(version=1, name="baseline", sql=V1_BASELINE), + Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS), + Migration(version=3, name="jobs", sql=V3_JOBS), +) diff --git a/packages/tesm-core/src/tesm_core/db.py b/packages/tesm-core/src/tesm_core/db.py new file mode 100644 index 0000000..a8356a8 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/db.py @@ -0,0 +1,213 @@ +"""Datenbankzugriff auf SQLite -- eine Verbindung pro Request, Schreibsperren strukturell. + +Die schmerzhafteste Fehlerklasse des Vorgaengerprojekts war +``sqlite3.OperationalError: database is locked``: eine Hilfsfunktion oeffnete +still eine *zweite* Verbindung, waehrend die aeussere Funktion noch eine offene +Schreibtransaktion hielt. Das ist hier nicht mehr durch Disziplin, sondern +strukturell verhindert: + +* Pro Request existiert genau **eine** Verbindung (in ``flask.g``). +* ``Database.transaction()`` zaehlt die Verschachtelungstiefe. Wer waehrend + einer offenen Schreibtransaktion ``Database.connect()`` aufruft, bekommt + sofort einen ``NestedConnectionError`` mit Klartext-Erklaerung -- nicht erst + nach 15 Sekunden Busy-Timeout einen Deadlock. +* Verschachtelte ``transaction()``-Aufrufe auf derselben Verbindung sind + erlaubt und werden zu einem einzigen Commit zusammengefasst (Savepoints), + damit Service-Funktionen sich gefahrlos gegenseitig aufrufen koennen. + +Zusaetzlich: WAL-Modus (paralleles Lesen waehrend eines Schreibers), +``foreign_keys=ON`` (im Vorgaenger nie aktiviert -- Fremdschluessel waren +Dekoration) und ``busy_timeout=15000``. +""" + +from __future__ import annotations + +import os +import sqlite3 +import threading +from collections.abc import Iterator, Mapping, Sequence +from contextlib import contextmanager +from pathlib import Path +from typing import Any + + +class NestedConnectionError(RuntimeError): + """Zweite Verbindung waehrend einer offenen Schreibtransaktion.""" + + +class Row(sqlite3.Row): + """``sqlite3.Row`` mit ``dict``-artigem ``get`` und Mapping-Konvertierung.""" + + def get(self, key: str, default: Any = None) -> Any: + try: + return self[key] + except (IndexError, KeyError): + return default + + def as_dict(self) -> dict[str, Any]: + return {key: self[key] for key in self.keys()} + + +def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: + if row is None: + return None + return {key: row[key] for key in row.keys()} + + +def rows_to_dicts(rows: Sequence[sqlite3.Row]) -> list[dict[str, Any]]: + return [{key: row[key] for key in row.keys()} for row in rows] + + +class Database: + """Zugriffsobjekt fuer eine SQLite-Datei.""" + + def __init__(self, path: Path | str, *, timeout: float = 15.0) -> None: + self.path = Path(path) + self.timeout = timeout + self._state = threading.local() + + # ------------------------------------------------------------------ # + # Verbindungen + # ------------------------------------------------------------------ # + + def _depth(self) -> int: + return int(getattr(self._state, "depth", 0)) + + def _set_depth(self, value: int) -> None: + self._state.depth = value + + def connect(self) -> sqlite3.Connection: + """Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft.""" + if self._depth() > 0: + raise NestedConnectionError( + "Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. " + "Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen " + "(sonst blockiert SQLite sich selbst)." + ) + return self._raw_connect() + + def _raw_connect(self) -> sqlite3.Connection: + self.path.parent.mkdir(parents=True, exist_ok=True) + fresh = not self.path.exists() + conn = sqlite3.connect( + self.path, + timeout=self.timeout, + isolation_level=None, # Transaktionen steuern wir selbst, explizit. + check_same_thread=False, + ) + conn.row_factory = Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA busy_timeout=15000") + conn.execute("PRAGMA secure_delete=ON") + if fresh: + # Die Datei enthaelt Passwort-Hashes, verschluesselte Zugangsdaten und + # Sitzungen -- sie gehoert niemandem ausser dem Dienstbenutzer. + try: + os.chmod(self.path, 0o600) + except OSError: # pragma: no cover - z. B. Windows/NTFS + pass + return conn + + @contextmanager + def session(self) -> Iterator[sqlite3.Connection]: + """Kurzlebige Verbindung fuer Skripte und Hintergrund-Threads.""" + conn = self.connect() + try: + yield conn + finally: + conn.close() + + # ------------------------------------------------------------------ # + # Transaktionen + # ------------------------------------------------------------------ # + + @contextmanager + def transaction(self, conn: sqlite3.Connection | None = None) -> Iterator[sqlite3.Connection]: + """Schreibtransaktion mit ``BEGIN IMMEDIATE``. + + ``BEGIN IMMEDIATE`` nimmt die Schreibsperre sofort statt erst beim ersten + Schreibzugriff -- damit scheitert ein konkurrierender Schreiber sauber am + Busy-Timeout, statt mitten in der Transaktion in einen + ``SQLITE_BUSY_SNAPSHOT`` zu laufen. + """ + own_connection = conn is None + if own_connection: + conn = self._raw_connect() + assert conn is not None + + depth = self._depth() + savepoint = f"sp_{depth}" + self._set_depth(depth + 1) + try: + if depth == 0: + conn.execute("BEGIN IMMEDIATE") + else: + conn.execute(f"SAVEPOINT {savepoint}") + yield conn + except BaseException: + try: + if depth == 0: + conn.execute("ROLLBACK") + else: + conn.execute(f"ROLLBACK TO {savepoint}") + except sqlite3.Error: # pragma: no cover - Verbindung schon tot + pass + raise + else: + if depth == 0: + conn.execute("COMMIT") + else: + conn.execute(f"RELEASE {savepoint}") + finally: + self._set_depth(depth) + if own_connection: + conn.close() + + @property + def in_transaction(self) -> bool: + return self._depth() > 0 + + # ------------------------------------------------------------------ # + # Bequeme Abfragehelfer (immer parametrisiert -- kein String-Basteln) + # ------------------------------------------------------------------ # + + @staticmethod + def one( + conn: sqlite3.Connection, sql: str, params: Sequence[Any] | Mapping[str, Any] = () + ) -> dict[str, Any] | None: + return row_to_dict(conn.execute(sql, params).fetchone()) + + @staticmethod + def all( + conn: sqlite3.Connection, sql: str, params: Sequence[Any] | Mapping[str, Any] = () + ) -> list[dict[str, Any]]: + return rows_to_dicts(conn.execute(sql, params).fetchall()) + + @staticmethod + def value( + conn: sqlite3.Connection, + sql: str, + params: Sequence[Any] | Mapping[str, Any] = (), + default: Any = None, + ) -> Any: + row = conn.execute(sql, params).fetchone() + if row is None: + return default + return row[0] + + @staticmethod + def column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + return any(row["name"] == column for row in rows) + + @staticmethod + def table_exists(conn: sqlite3.Connection, table: str) -> bool: + return ( + conn.execute( + "SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name=?", + (table,), + ).fetchone() + is not None + ) diff --git a/packages/tesm-core/src/tesm_core/extension.py b/packages/tesm-core/src/tesm_core/extension.py new file mode 100644 index 0000000..b63aeb6 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/extension.py @@ -0,0 +1,120 @@ +"""Die zentrale Erweiterung: haelt alles zusammen, was eine App braucht. + +Statt globaler Modulvariablen (der Vorgaenger hatte ein Dutzend davon, u. a. +``_license_state`` und die DB-Pfade) haengt der gesamte Anwendungszustand an +einem Objekt in ``app.extensions["tesm"]``. Zugriff ueber ``core()``. +""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Sequence + +from flask import Flask, current_app, g + +from .config import CoreConfig +from .db import Database +from .keystore import Keystore +from .migrations import Migration, MigrationRunner +from .nav import NavTree +from .rbac.model import PermissionTree, RolePreset +from .security.passwords import PasswordPolicy +from .settings_store import SettingsStore + +if TYPE_CHECKING: # pragma: no cover + from .licensing_client import LicenseManager + from .sysops import SysOps + + +@dataclass +class TesmCore: + """Anwendungsweiter Zustand. Nach ``init_app`` unveraendert.""" + + config: CoreConfig + database: Database + keystore: Keystore + permissions: PermissionTree + nav: NavTree + settings: SettingsStore + role_presets: tuple[RolePreset, ...] = () + migrations: dict[str, Sequence[Migration]] = field(default_factory=dict) + license: "LicenseManager | None" = None + sysops: "SysOps | None" = None + #: App-spezifische Erweiterungen (z. B. Papierkorb-Register, Job-Registry). + registry: dict[str, Any] = field(default_factory=dict) + #: Wird nach einer erfolgreichen Anmeldung aufgerufen: + #: ``(conn, user, secret, group_dns)``. ``secret`` ist das Passwort dieser + #: Anmeldung -- es wird nirgends gespeichert und ist nach dem Aufruf weg. + #: Ein Haken, der scheitert, sperrt niemanden aus (siehe + #: ``auth.service._run_login_hooks``). + login_hooks: list[Callable[..., None]] = field(default_factory=list) + #: Wird vor dem Verwerfen der Sitzung aufgerufen: ``(conn, user)``. Was die + #: Anmeldung aufgebaut hat, wird hier wieder abgebaut -- sonst bliebe es + #: bestehen, bis eine Zeitgrenze greift. + logout_hooks: list[Callable[..., None]] = field(default_factory=list) + + @property + def password_policy(self) -> PasswordPolicy: + return PasswordPolicy(min_length=self.config.security.password_min_length) + + @property + def audit_archive_dir(self) -> Path: + return self.config.log_dir / "audit" + + def run_migrations(self) -> list[str]: + runner = MigrationRunner(self.database) + applied: list[str] = [] + for component, migrations in self.migrations.items(): + applied.extend(runner.run(component, migrations)) + return applied + + def migration_status(self) -> list[dict[str, object]]: + return MigrationRunner(self.database).status(list(self.migrations)) + + +def core(app: Flask | None = None) -> TesmCore: + target = app or current_app + try: + return target.extensions["tesm"] # type: ignore[return-value] + except KeyError as exc: # pragma: no cover - Programmierfehler + raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc + + +def db() -> sqlite3.Connection: + """Die Verbindung dieses Requests. Genau eine pro Request, nie eine zweite. + + Diese Funktion ist der einzige zugelassene Weg, im Request-Kontext an eine + Verbindung zu kommen. Wer stattdessen selbst ``Database.connect()`` aufruft, + riskiert genau den Deadlock, an dem der Vorgaenger mehrfach hing. + """ + connection = getattr(g, "_tesm_conn", None) + if connection is None: + connection = core().database.connect() + g._tesm_conn = connection + return connection + + +def close_db(_exception: BaseException | None = None) -> None: + connection = getattr(g, "_tesm_conn", None) + if connection is not None: + g._tesm_conn = None + connection.close() + + +def transaction() -> Any: + """Kurzform fuer ``core().database.transaction(db())``.""" + return core().database.transaction(db()) + + +def with_transaction(fn: Callable[..., Any]) -> Callable[..., Any]: + """Dekorator: fuehrt eine Funktion innerhalb einer Schreibtransaktion aus.""" + + def wrapper(*args: Any, **kwargs: Any) -> Any: + with transaction() as conn: + return fn(conn, *args, **kwargs) + + wrapper.__name__ = fn.__name__ + wrapper.__doc__ = fn.__doc__ + return wrapper diff --git a/packages/tesm-core/src/tesm_core/keystore.py b/packages/tesm-core/src/tesm_core/keystore.py new file mode 100644 index 0000000..90d8839 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/keystore.py @@ -0,0 +1,212 @@ +"""Schluesselverwaltung und Feldverschluesselung (AES-256-GCM, rotierbar). + +Der Vorgaenger nutzte Fernet mit einem einzigen, nie rotierbaren Schluessel in +``fernet.key``. Fernet ist in Ordnung, aber: + +* Ein Schluesselwechsel war nicht vorgesehen -- ein kompromittierter Schluessel + haette bedeutet, alle Zugangsdaten von Hand neu einzugeben. +* Es gab keine zugeordneten Daten (AAD), also keine kryptographische Bindung + eines Chiffrats an den Ort, an dem es steht. Ein Chiffrat aus Tabelle A liess + sich in Tabelle B einspielen. + +Hier: ein versionierter Schluesselbund (``data.keys``, Modus 0600) mit +AES-256-GCM. Jedes Chiffrat traegt die Schluessel-ID im Klartext-Praefix, damit +alte Werte nach einer Rotation weiter lesbar bleiben. ``aad`` bindet ein +Chiffrat an seinen Verwendungszweck (z. B. ``credentials.password:42``). + +Format: ``v1...`` +""" + +from __future__ import annotations + +import base64 +import json +import os +import secrets +import stat +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +_PREFIX = "v1" +_NONCE_BYTES = 12 +_KEY_BYTES = 32 + + +class DecryptionError(Exception): + """Chiffrat gehoert nicht zu diesem Schluesselbund oder wurde manipuliert.""" + + +def _b64e(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _b64d(text: str) -> bytes: + padded = text + "=" * (-len(text) % 4) + return base64.urlsafe_b64decode(padded.encode("ascii")) + + +def write_private_file(path: Path, content: str) -> None: + """Schreibt eine Datei mit Modus 0600, atomar (erst tmp, dann ersetzen).""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + handle = os.open(tmp, flags, 0o600) + try: + with os.fdopen(handle, "w", encoding="utf-8") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + except BaseException: + tmp.unlink(missing_ok=True) + raise + os.replace(tmp, path) + try: + os.chmod(path, 0o600) + except OSError: # pragma: no cover - Windows + pass + + +def check_private_file(path: Path) -> list[str]: + """Meldet zu weite Dateirechte -- die Diagnoseseite zeigt das an.""" + problems: list[str] = [] + try: + mode = path.stat().st_mode + except OSError: + return problems + if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH): + problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)") + return problems + + +@dataclass(frozen=True, slots=True) +class _Key: + key_id: str + material: bytes + created_at: str + + +class Keystore: + """Rotierbarer Schluesselbund fuer Feldverschluesselung.""" + + def __init__(self, path: Path) -> None: + self.path = Path(path) + self._keys: dict[str, _Key] = {} + self._active: str = "" + self._load_or_create() + + # ------------------------------------------------------------------ # + + def _load_or_create(self) -> None: + if self.path.exists(): + raw = json.loads(self.path.read_text(encoding="utf-8")) + for entry in raw.get("keys", []): + key = _Key( + key_id=str(entry["id"]), + material=_b64d(str(entry["key"])), + created_at=str(entry.get("created_at", "")), + ) + if len(key.material) != _KEY_BYTES: + raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge") + self._keys[key.key_id] = key + self._active = str(raw.get("active") or "") + if self._active not in self._keys: + raise ValueError("Aktiver Schluessel fehlt im Schluesselbund") + return + self._active = "1" + self._keys = { + "1": _Key( + key_id="1", + material=secrets.token_bytes(_KEY_BYTES), + created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"), + ) + } + self._persist() + + def _persist(self) -> None: + payload = { + "active": self._active, + "keys": [ + {"id": key.key_id, "key": _b64e(key.material), "created_at": key.created_at} + for key in sorted(self._keys.values(), key=lambda k: int(k.key_id)) + ], + } + write_private_file(self.path, json.dumps(payload, indent=2, sort_keys=True) + "\n") + + # ------------------------------------------------------------------ # + + @property + def active_key_id(self) -> str: + return self._active + + def key_ids(self) -> list[str]: + return sorted(self._keys, key=int) + + def rotate(self) -> str: + """Legt einen neuen aktiven Schluessel an. Alte bleiben zum Lesen erhalten.""" + new_id = str(max(int(k) for k in self._keys) + 1) + self._keys[new_id] = _Key( + key_id=new_id, + material=secrets.token_bytes(_KEY_BYTES), + created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"), + ) + self._active = new_id + self._persist() + return new_id + + def drop_key(self, key_id: str) -> None: + """Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung.""" + if key_id == self._active: + raise ValueError("Der aktive Schluessel kann nicht entfernt werden") + self._keys.pop(key_id, None) + self._persist() + + # ------------------------------------------------------------------ # + + def encrypt(self, plaintext: str, *, aad: str) -> str: + key = self._keys[self._active] + nonce = secrets.token_bytes(_NONCE_BYTES) + ciphertext = AESGCM(key.material).encrypt( + nonce, plaintext.encode("utf-8"), aad.encode("utf-8") + ) + return f"{_PREFIX}.{key.key_id}.{_b64e(nonce)}.{_b64e(ciphertext)}" + + def decrypt(self, token: str, *, aad: str) -> str: + try: + prefix, key_id, nonce_b64, ct_b64 = token.split(".", 3) + except ValueError as exc: + raise DecryptionError("Chiffrat hat ein unbekanntes Format") from exc + if prefix != _PREFIX: + raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}") + key = self._keys.get(key_id) + if key is None: + raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund") + try: + plain = AESGCM(key.material).decrypt( + _b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8") + ) + except (InvalidTag, ValueError) as exc: + raise DecryptionError( + "Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, " + "manipulierte Daten oder falscher Verwendungszweck)" + ) from exc + return plain.decode("utf-8") + + def needs_reencrypt(self, token: str) -> bool: + """True, wenn das Chiffrat noch unter einem alten Schluessel liegt.""" + parts = token.split(".", 3) + return len(parts) == 4 and parts[1] != self._active + + +def load_or_create_secret_key(path: Path) -> bytes: + """Flask-Secret-Key: 64 Byte, Datei mit Modus 0600, beim ersten Start erzeugt.""" + if path.exists(): + raw = path.read_text(encoding="utf-8").strip() + if raw: + return _b64d(raw) + material = secrets.token_bytes(64) + write_private_file(path, _b64e(material) + "\n") + return material diff --git a/packages/tesm-core/src/tesm_core/licensing_client.py b/packages/tesm-core/src/tesm_core/licensing_client.py new file mode 100644 index 0000000..f54de86 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/licensing_client.py @@ -0,0 +1,778 @@ +"""Lizenzverwaltung auf Client-Seite (TESM) und fuer die eigene Lizenz des Servers. + +Zustaendig fuer: Lizenzdatei laden und verifizieren, Aussteller pinnen, +Aktivierung/Deaktivierung/Heartbeat (online und offline), Modul-Freischaltung +und die Anzeige in der Topbar. + +Wesentliche Aenderungen gegenueber dem Vorgaenger: + +* **Vertrauensanker.** Die Signatur wird gegen gepinnte Aussteller-Schluessel + geprueft (Tabelle ``trusted_issuer_keys``). Beim ersten Import wird der + Schluessel per Trust-on-First-Use uebernommen und dabei protokolliert; jede + spaetere Lizenz eines *anderen* Ausstellers wird abgelehnt, bis ein + Administrator den neuen Schluessel ausdruecklich freigibt. Der Vorgaenger + pruefte ausschliesslich gegen den Schluessel *in derselben Datei* -- damit war + jede selbst signierte Datei "gueltig". +* **Trennung von Dokument und Client-Schluessel** (siehe ``tesm_licensing``): + ``license.json`` (0640) und ``license_key.json`` (0600). +* **Ein Gate, eine Wahrheit.** Der Vorgaenger hatte eine Docstring, die mehr + behauptete als der Code durchsetzte (LDAP/NGINX waren nur im Template + gegated). Hier gibt es genau eine Funktion ``require_license()`` und eine + Tabelle in ``LICENSED_FEATURES`` -- was dort steht, wird serverseitig + durchgesetzt, ohne Ausnahme. +""" + +from __future__ import annotations + +import json +import logging +import sqlite3 +import threading +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +import tesm_licensing as lic + +from . import audit +from .db import Database +from .keystore import write_private_file + +logger = logging.getLogger(__name__) + +SETTING_STATUS = "license_activation_status" +SETTING_PENDING = "license_pending_request" +SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at" +SETTING_HEARTBEAT_ERROR = "license_heartbeat_error" +SETTING_EXPORT_GRACE = "license_export_grace" + +STATUS_INACTIVE = "inactive" +STATUS_PENDING = "pending" +STATUS_ACTIVE = "active" + +API_PATHS = { + "activate": "/api/v1/licenses/activate", + "deactivate": "/api/v1/licenses/deactivate", + "heartbeat": "/api/v1/licenses/heartbeat", +} + +#: Funktionen, die eine gueltige, aktivierte Lizenz brauchen. Diese Tabelle ist +#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie. +LICENSED_FEATURES: dict[str, str] = { + "directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren", + "nginx": "Webserver-Einstellungen aendern", + "export": "Daten exportieren", + "trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen", + "monitor_interval": "Pruefintervall der Ueberwachung aendern", + "auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall", +} + + +def describe_transport_error(exc: BaseException, endpoint: str) -> str: + """Uebersetzt einen Netzfehler in etwas, mit dem man arbeiten kann. + + "War nicht erreichbar" ist bei einem Zertifikatsfehler schlicht falsch -- + der Server war sehr wohl erreichbar, nur nicht vertrauenswuerdig. Wer das + nicht weiss, sucht am falschen Ende. + """ + text = str(exc) + if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text: + detail = "Das Zertifikat konnte nicht geprueft werden" + if "self-signed" in text or "self signed" in text: + detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat" + return ( + f"{detail} ({endpoint}). Hinterlegen Sie sein Zertifikat unter " + "Einstellungen -> Lizenz, oder geben Sie dem Lizenzserver ein Zertifikat " + "einer anerkannten Stelle." + ) + if "Name or service not known" in text or "nodename nor servname" in text: + return f"Der Name im Endpunkt ({endpoint}) laesst sich nicht aufloesen." + if "Connection refused" in text: + return f"{endpoint} nimmt keine Verbindung an." + if "timed out" in text or "timeout" in text.lower(): + return f"{endpoint} hat nicht rechtzeitig geantwortet." + return f"Der Lizenzserver ({endpoint}) war nicht erreichbar: {text[:160]}" + + +class LicenseError(Exception): + """Fehler beim Import oder bei der Aktivierung -- Text ist fuer Anwender gedacht.""" + + +@dataclass(slots=True) +class LicenseState: + """Zwischengespeicherter Lizenzzustand. Wird bei jeder Aenderung neu gebaut.""" + + present: bool = False + valid: bool = False + issuer_pinned: bool = False + activated: bool = False + document: dict[str, Any] = field(default_factory=dict) + status: lic.LicenseStatus | None = None + error: str = "" + warnings: tuple[str, ...] = () + last_heartbeat_at: str = "" + heartbeat_error: str = "" + pending_action: str = "" + + @property + def role(self) -> str: + return self.status.role if self.status else "customer" + + @property + def modules(self) -> frozenset[str]: + return self.status.modules if self.status else frozenset() + + @property + def customer_name(self) -> str: + return str((self.status.customer if self.status else {}).get("name") or "") + + @property + def vendor(self) -> dict[str, Any]: + return dict(self.status.vendor) if self.status else {} + + +class LicenseManager: + """Haelt den Lizenzzustand und fuehrt die Handshakes durch.""" + + def __init__( + self, + *, + database: Database, + settings: Any, + license_path: Path, + key_path: Path, + app_key: str, + built_in_trusted_keys: tuple[str, ...] = (), + ) -> None: + self.database = database + self.settings = settings + self.license_path = Path(license_path) + self.key_path = Path(key_path) + self.app_key = app_key + self.built_in_trusted_keys = tuple(built_in_trusted_keys) + self._state = LicenseState() + self._lock = threading.RLock() + self._heartbeat_thread: threading.Thread | None = None + self._report_builder: Callable[[sqlite3.Connection], dict[str, Any]] | None = None + + # ------------------------------------------------------------------ # + # Zustand + # ------------------------------------------------------------------ # + + @property + def state(self) -> LicenseState: + return self._state + + def trusted_keys(self, conn: sqlite3.Connection) -> list[str]: + rows = Database.all(conn, "SELECT public_key FROM trusted_issuer_keys") + return [*self.built_in_trusted_keys, *(str(row["public_key"]) for row in rows)] + + def reload(self, conn: sqlite3.Connection) -> LicenseState: + """Liest Lizenzdatei und Status neu ein.""" + with self._lock: + state = LicenseState() + state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "") + state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "") + pending = self._pending(conn) + state.pending_action = str(pending.get("action") or "") if pending else "" + + if not self.license_path.is_file(): + state.error = "Es ist keine Lizenzdatei installiert." + self._state = state + return state + + try: + document = json.loads(self.license_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + state.error = f"Die Lizenzdatei ist unlesbar: {exc}" + self._state = state + return state + + if isinstance(document, dict) and "license" in document: + # Jemand hat das Auslieferungs-Bundle direkt hierher kopiert. + document, _ = lic.split_bundle(document) + + state.present = True + state.document = document + trusted = self.trusted_keys(conn) + try: + lic.verify_license(document, trusted_master_keys=trusted or None) + state.valid = True + state.issuer_pinned = bool(trusted) + except lic.UntrustedIssuer as exc: + state.error = str(exc) + except lic.LicensingError as exc: + state.error = str(exc) + + if state.valid: + state.status = lic.license_status( + document, issuer_pinned=state.issuer_pinned + ) + state.warnings = state.status.warnings + + state.activated = ( + str(self.settings.get(conn, SETTING_STATUS) or STATUS_INACTIVE) == STATUS_ACTIVE + ) + self._state = state + return state + + # ------------------------------------------------------------------ # + # Gates + # ------------------------------------------------------------------ # + + def active(self) -> bool: + """Grundvoraussetzung fuer alles Lizenzpflichtige.""" + state = self._state + return bool( + state.valid + and state.activated + and state.status is not None + and state.status.modules_active + ) + + def module_active(self, name: str) -> bool: + """Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?""" + return self.active() and name in self._state.modules + + def feature_allowed(self, feature: str) -> bool: + """Lizenzpflichtige Einzelfunktion. + + Ausnahme ``export``: bleibt erlaubt, wenn der lizenzlose Zustand durch + eine vom Kunden selbst ausgeloeste Deaktivierung entstanden ist + (Systemwechsel) -- sonst waeren die eigenen Daten eingeschlossen. + """ + if feature == "export" and not self.active(): + with self.database.session() as conn: + return str(self.settings.get(conn, SETTING_EXPORT_GRACE) or "") == "1" + return self.active() + + def require(self, feature: str) -> None: + if not self.feature_allowed(feature): + raise LicenseError( + f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, " + "aktivierte Lizenz nicht moeglich." + ) + + # ------------------------------------------------------------------ # + # Import + # ------------------------------------------------------------------ # + + def import_bundle( + self, + conn: sqlite3.Connection, + raw: bytes | str, + *, + actor: str, + allow_new_issuer: bool = False, + ) -> LicenseState: + """Nimmt eine hochgeladene Lizenzdatei an. + + ``allow_new_issuer`` ist nur fuer den ausdruecklichen Fall gedacht, dass + ein Administrator einen Aussteller-Wechsel bestaetigt (z. B. Wechsel des + Lizenzservers). Ohne dieses Kennzeichen wird eine Datei mit unbekanntem + Aussteller abgelehnt, sobald schon einer gepinnt ist. + """ + text = raw.decode("utf-8") if isinstance(raw, bytes) else raw + try: + payload = json.loads(text) + except ValueError as exc: + raise LicenseError("Die Datei ist kein gueltiges JSON.") from exc + + try: + document, client_key = lic.split_bundle(payload) + except lic.LicensingError as exc: + raise LicenseError(str(exc)) from exc + + try: + lic.verify_license(document) # innere Konsistenz + except lic.LicensingError as exc: + raise LicenseError(f"Die Lizenzdatei ist ungueltig: {exc}") from exc + + master_pubkey = str(document.get("master_pubkey") or "") + known = self.trusted_keys(conn) + if known and master_pubkey not in set(known) and not allow_new_issuer: + raise LicenseError( + "Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt " + f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den " + "Wechsel des Ausstellers ausdruecklich bestaetigen." + ) + + if not client_key and not self.key_path.is_file(): + raise LicenseError( + "Der Datei fehlt der Client-Schluessel. Bitte das vollstaendige " + "Lizenz-Bundle des Lizenzservers verwenden." + ) + + # Reihenfolge: erst Schluessel pinnen, dann Dateien schreiben, dann + # Aktivierungsstatus zuruecksetzen -- ein Abbruch dazwischen hinterlaesst + # keinen halb gueltigen Zustand. + if master_pubkey not in set(known): + conn.execute( + "INSERT INTO trusted_issuer_keys (key_id, public_key, label, source, added_at, added_by) " + "VALUES (?,?,?,?,?,?) ON CONFLICT(key_id) DO NOTHING", + ( + lic.key_id(master_pubkey), + master_pubkey, + str((document.get("vendor") or {}).get("name") or "Lizenzserver"), + "tofu" if not known else "admin-confirmed", + datetime.now(timezone.utc).isoformat(timespec="seconds"), + actor, + ), + ) + audit.log( + conn, + actor=actor, + action="license.issuer_pinned", + target=lic.key_id(master_pubkey), + detail="Trust on first use" if not known else "Aussteller-Wechsel bestaetigt", + severity="notice", + ) + + self.license_path.parent.mkdir(parents=True, exist_ok=True) + self.license_path.write_text( + json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + try: + self.license_path.chmod(0o640) + except OSError: + pass + if client_key: + write_private_file( + self.key_path, + json.dumps({"license_id": document.get("license_id"), "client_key": client_key}) + + "\n", + ) + + self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor) + self.settings.set(conn, SETTING_PENDING, "", actor=actor) + self.settings.set(conn, SETTING_EXPORT_GRACE, "", actor=actor) + audit.log( + conn, + actor=actor, + action="license.imported", + target=str(document.get("license_id") or ""), + detail=f"Typ {document.get('type')}, Rolle {document.get('role')}", + severity="notice", + ) + return self.reload(conn) + + def client_key(self) -> str: + if not self.key_path.is_file(): + return "" + try: + data = json.loads(self.key_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return "" + return str(data.get("client_key") or "") + + # ------------------------------------------------------------------ # + # Handshake + # ------------------------------------------------------------------ # + + def _pending(self, conn: sqlite3.Connection) -> dict[str, Any]: + raw = self.settings.get(conn, SETTING_PENDING) or "" + if not raw: + return {} + try: + return json.loads(raw) if isinstance(raw, str) else dict(raw) + except ValueError: + return {} + + def _endpoint(self, action: str) -> str: + base = str(self._state.document.get("master_endpoint") or "").rstrip("/") + if not base: + return "" + return base + API_PATHS[action] + + def build_request( + self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None + ) -> dict[str, Any]: + if not self._state.valid: + raise LicenseError("Es ist keine gueltige Lizenz installiert.") + key = self.client_key() + if not key: + raise LicenseError( + "Der Client-Schluessel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren." + ) + return lic.build_client_request( + action, self._state.document, key, hostname=hostname, report=report + ) + + def handshake( + self, + conn: sqlite3.Connection, + action: str, + *, + actor: str, + hostname: str = "", + report: dict[str, Any] | None = None, + timeout: int = 12, + ) -> dict[str, Any]: + """Fuehrt eine Aktion durch. Rueckgabe beschreibt das Ergebnis fuer die UI. + + ``{"ok": bool, "transport": "online"|"offline", "code": str, "message": str}`` + """ + request_payload = self.build_request(action, hostname=hostname, report=report) + endpoint = self._endpoint(action) + + reason = "" + if endpoint: + try: + response = self._post_json( + endpoint, + request_payload, + timeout=timeout, + ca_pem=str(self.settings.get(conn, "license_server_ca") or ""), + ) + except Exception as exc: # noqa: BLE001 - jeder Netzfehler fuehrt offline weiter + reason = describe_transport_error(exc, endpoint) + logger.info("Online-Handshake fehlgeschlagen (%s): %s", action, exc) + else: + return self._apply_response( + conn, action, request_payload, response, actor=actor, transport="online" + ) + else: + reason = ( + "In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur " + "ueber den Offline-Weg aktivieren." + ) + + # Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und + # Antwort liegen in der Praxis eine E-Mail und ein Arbeitstag; der Code + # muss ein Neuladen der Seite ueberstehen. + code = lic.encode_code(request_payload) + self.settings.set( + conn, + SETTING_PENDING, + json.dumps( + { + "action": action, + "nonce": request_payload["nonce"], + "fingerprint": request_payload["fingerprint"], + "license_id": request_payload["license_id"], + "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "code": code, + "reason": reason, + } + ), + actor=actor, + ) + if action == "activate": + self.settings.set(conn, SETTING_STATUS, STATUS_PENDING, actor=actor) + self.reload(conn) + return { + "ok": False, + "transport": "offline", + "code": code, + "message": ( + (reason + " " if reason else "") + + "Der Offline-Weg steht offen: uebermitteln Sie den angezeigten Code an " + "Ihren Ansprechpartner und tragen Sie die Antwort hier ein." + ), + } + + def submit_offline_response( + self, conn: sqlite3.Connection, code: str, *, actor: str + ) -> dict[str, Any]: + """Nimmt den Antwortcode des Lizenzservers an (Offline-Weg).""" + pending = self._pending(conn) + if not pending: + raise LicenseError("Es liegt keine offene Anfrage vor.") + try: + response = lic.decode_code(code) + except ValueError as exc: + raise LicenseError(f"Der Code ist unlesbar: {exc}") from exc + return self._apply_response( + conn, + str(pending.get("action") or ""), + { + "license_id": str(pending.get("license_id") or ""), + "fingerprint": str(pending.get("fingerprint") or ""), + "nonce": str(pending.get("nonce") or ""), + }, + response, + actor=actor, + transport="offline", + ) + + def _apply_response( + self, + conn: sqlite3.Connection, + action: str, + request_payload: dict[str, Any], + response: dict[str, Any], + *, + actor: str, + transport: str, + ) -> dict[str, Any]: + master_pubkey = str(self._state.document.get("master_pubkey") or "") + current_seq = int(self._state.document.get("seq") or 1) + try: + lic.verify_master_response( + response, + master_pubkey, + expected_license_id=str(request_payload["license_id"]), + expected_fingerprint=str(request_payload["fingerprint"]), + expected_action=action, + expected_nonce=str(request_payload["nonce"]), + current_seq=current_seq, + ) + except lic.LicensingError as exc: + raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gueltig: {exc}") from exc + + status = str(response.get("status") or "") + detail = str(response.get("detail") or "") + update = response.get("license_update") + + if isinstance(update, dict): + try: + lic.verify_license(update, trusted_master_keys=self.trusted_keys(conn) or None) + except lic.LicensingError as exc: + logger.warning("Mitgeliefertes Lizenz-Update verworfen: %s", exc) + else: + self.license_path.write_text( + json.dumps(update, indent=2, sort_keys=True, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + audit.log( + conn, + actor=actor, + action="license.updated_by_master", + target=str(update.get("license_id") or ""), + detail=f"Revision {update.get('seq')}", + severity="notice", + ) + + if status == "revoked": + self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor) + self.settings.set(conn, SETTING_PENDING, "", actor=actor) + audit.log( + conn, + actor=actor, + action="license.revoked_by_master", + target=str(request_payload["license_id"]), + severity="warning", + ) + self.reload(conn) + return { + "ok": False, + "transport": transport, + "code": "", + "message": "Diese Lizenz wurde vom Lizenzserver widerrufen.", + } + + if status != "ok": + self.reload(conn) + return { + "ok": False, + "transport": transport, + "code": "", + "message": detail or f"Der Lizenzserver hat die Anfrage abgelehnt ({status}).", + } + + if action == "activate": + self.settings.set(conn, SETTING_STATUS, STATUS_ACTIVE, actor=actor) + self.settings.set(conn, SETTING_EXPORT_GRACE, "", actor=actor) + message = "Die Lizenz ist jetzt aktiv." + elif action == "deactivate": + self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor) + # Der Kunde hat selbst deaktiviert (Systemwechsel): Export bleibt + # erlaubt, damit er seine Daten mitnehmen kann. + self.settings.set(conn, SETTING_EXPORT_GRACE, "1", actor=actor) + message = "Die Lizenz wurde deaktiviert und kann auf einem anderen System aktiviert werden." + else: + self.settings.set( + conn, + SETTING_LAST_HEARTBEAT, + datetime.now(timezone.utc).isoformat(timespec="seconds"), + actor=actor, + ) + self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor) + message = "Verbindung zum Lizenzserver bestaetigt." + + self.settings.set(conn, SETTING_PENDING, "", actor=actor) + audit.log( + conn, + actor=actor, + action=f"license.{action}", + target=str(request_payload["license_id"]), + detail=f"Transport: {transport}", + severity="notice", + ) + self.reload(conn) + return {"ok": True, "transport": transport, "code": "", "message": message} + + @staticmethod + def _tls_context(ca_pem: str) -> Any: + """Prueft gegen ein hinterlegtes Zertifikat -- oder gegen die Systemanker. + + Nie ohne Pruefung: das Lizenzprotokoll ist zwar selbst signiert und + gegen Wiedereinspielung geschuetzt, aber das ist kein Grund, den + Transport offen zu lassen. + """ + import ssl + + context = ssl.create_default_context() + pem = (ca_pem or "").strip() + if not pem: + return context + try: + # cadata nimmt PEM direkt entgegen -- keine temporaere Datei, die + # liegen bleiben oder mitgelesen werden koennte. + context.load_verify_locations(cadata=pem) + except (ssl.SSLError, ValueError) as exc: + raise ValueError( + "Das hinterlegte Zertifikat des Lizenzservers ist unlesbar: " + str(exc) + ) from exc + return context + + @staticmethod + def _post_json( + url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = "" + ) -> dict[str, Any]: + if not url.startswith(("https://", "http://")): + raise ValueError("Ungueltiger Endpunkt") + data = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( # noqa: S310 - Schema oben geprueft + url, + data=data, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + method="POST", + ) + context = LicenseManager._tls_context(ca_pem) if url.startswith("https://") else None + try: + with urllib.request.urlopen( # noqa: S310 + request, timeout=timeout, context=context + ) as response: + body = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(body) + except ValueError: + raise + if isinstance(parsed, dict) and "signature" in parsed: + return parsed # Auch abweisende Antworten sind signiert. + raise + return json.loads(body) + + # ------------------------------------------------------------------ # + # Heartbeat im Hintergrund + # ------------------------------------------------------------------ # + + def set_report_builder(self, builder: Callable[[sqlite3.Connection], dict[str, Any]]) -> None: + """Nur der Lizenzserver setzt das: Bericht an den eigenen Master.""" + self._report_builder = builder + + def start_heartbeat(self, *, hostname: str = "") -> None: + if self._heartbeat_thread is not None: + return + + def loop() -> None: + # Erster Lauf verzoegert: der Dienst soll erst sauber hochkommen. + time.sleep(90) + while True: + try: + self.run_heartbeat(hostname=hostname) + except Exception as exc: # noqa: BLE001 + logger.warning("Heartbeat fehlgeschlagen: %s", exc) + time.sleep(lic.HEARTBEAT_INTERVAL_SECONDS) + + thread = threading.Thread(target=loop, name="license-heartbeat", daemon=True) + thread.start() + self._heartbeat_thread = thread + logger.info("Lizenz-Heartbeat gestartet (Intervall %ss)", lic.HEARTBEAT_INTERVAL_SECONDS) + + def run_heartbeat(self, *, hostname: str = "") -> dict[str, Any]: + with self.database.session() as conn: + self.reload(conn) + if not self._state.valid or not self._state.activated: + return {"ok": False, "message": "Keine aktive Lizenz."} + report = None + if self._report_builder is not None and self._state.role == "license_server": + report = self._report_builder(conn) + try: + with self.database.transaction(conn): + return self.handshake( + conn, "heartbeat", actor="system", hostname=hostname, report=report + ) + except LicenseError as exc: + with self.database.transaction(conn): + self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system") + return {"ok": False, "message": str(exc)} + + # ------------------------------------------------------------------ # + # Anzeige + # ------------------------------------------------------------------ # + + def topbar_pill(self) -> dict[str, str] | None: + """Zustandsanzeige fuer die Topbar. Reihenfolge = Dringlichkeit.""" + state = self._state + if not state.present: + return { + "label": "Keine Lizenz", + "css_class": "status-pill--danger", + "dot": "1", + "dot_class": "status-pill__dot--pulse", + "title": "Es ist keine Lizenzdatei installiert.", + } + if not state.valid: + return { + "label": "Lizenz ungueltig", + "css_class": "status-pill--danger", + "dot": "1", + "dot_class": "status-pill__dot--pulse", + "title": state.error, + } + if not state.activated: + return { + "label": "Lizenz nicht aktiviert", + "css_class": "status-pill--warning", + "dot": "1", + "dot_class": "status-pill__dot--pulse", + "title": "Die Lizenz muss noch aktiviert werden.", + } + status = state.status + if status is None: + return None + if status.expired: + days = status.days_since_expiry or 0 + return { + "label": f"Lizenz abgelaufen (seit {days} Tagen)", + "css_class": "status-pill--danger", + "dot": "1", + "dot_class": "status-pill__dot--pulse", + "title": ( + "Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar." + if status.grace_active + else "Lizenzpflichtige Funktionen sind abgeschaltet." + ), + } + if status.expiring_soon: + return { + "label": f"Lizenz laeuft in {status.days_left} Tagen ab", + "css_class": "status-pill--warning", + "dot": "1", + "dot_class": "status-pill__dot--pulse", + "title": "Bitte rechtzeitig verlaengern.", + } + if state.last_heartbeat_at: + try: + last = lic.parse_iso(state.last_heartbeat_at) + days = (lic.utcnow() - last).days + except ValueError: + days = 0 + if days >= lic.HEARTBEAT_WARNING_DAYS: + return { + "label": f"Lizenzserver seit {days} Tagen nicht erreichbar", + "css_class": "status-pill--warning", + "dot": "1", + "dot_class": "", + "title": "Rein informativ -- es wird nichts abgeschaltet.", + } + return None diff --git a/packages/tesm-core/src/tesm_core/logging_setup.py b/packages/tesm-core/src/tesm_core/logging_setup.py new file mode 100644 index 0000000..60f2938 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/logging_setup.py @@ -0,0 +1,136 @@ +"""Protokollierung: strukturierte Zeilen, Vorgangsnummer, Rotation via logrotate. + +Jede Anfrage bekommt eine ``request_id``. Sie steht in jeder Logzeile, in +Fehlerseiten und im Audit-Log -- damit ein Anruf "bei mir kam Fehler 500" +in Sekunden auffindbar ist. Der Vorgaenger loggte ohne Korrelations-ID. + +Rotation macht ``logrotate`` mit ``copytruncate`` (siehe ``deploy/``), nicht +Python: mehrere Prozesse (Web, Monitor) schreiben in dasselbe Verzeichnis, und +keiner von ihnen kann ein Reopen-Signal verarbeiten. +""" + +from __future__ import annotations + +import json +import logging +import logging.handlers +import os +import sys +import time +import uuid +from pathlib import Path + +from flask import Flask, g, has_request_context, request + +ACCESS_LOGGER = "tesm.access" + + +class RequestFormatter(logging.Formatter): + """Textformat fuer Menschen, mit Vorgangsnummer und Benutzer.""" + + def format(self, record: logging.LogRecord) -> str: + if has_request_context(): + record.request_id = getattr(g, "request_id", "-") + record.actor = getattr(getattr(g, "user", None), "username", "-") or "-" + else: + record.request_id = getattr(record, "request_id", "-") + record.actor = getattr(record, "actor", "-") + return super().format(record) + + +class JsonFormatter(logging.Formatter): + """Maschinenlesbares Format -- fuer die Weiterverarbeitung in einem SIEM.""" + + def format(self, record: logging.LogRecord) -> str: + payload = { + "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(record.created)), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + if has_request_context(): + payload["request_id"] = getattr(g, "request_id", "") + payload["path"] = request.path + payload["method"] = request.method + payload["actor"] = getattr(getattr(g, "user", None), "username", "") or "" + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + for key, value in getattr(record, "extra_fields", {}).items(): + payload[key] = value + return json.dumps(payload, ensure_ascii=False) + + +def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False) -> None: + log_dir.mkdir(parents=True, exist_ok=True) + level = logging.DEBUG if debug else logging.INFO + + root = logging.getLogger() + root.setLevel(level) + for handler in list(root.handlers): + root.removeHandler(handler) + + formatter: logging.Formatter = ( + JsonFormatter() + if json_logs + else RequestFormatter( + "%(asctime)s %(levelname)-7s [%(request_id)s] %(actor)s %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + + stream = logging.StreamHandler(sys.stderr) + stream.setFormatter(formatter) + root.addHandler(stream) + + try: + app_log = log_dir / "app.log" + file_handler = logging.FileHandler(app_log, encoding="utf-8", delay=True) + file_handler.setFormatter(formatter) + file_handler.setLevel(level) + root.addHandler(file_handler) + if app_log.exists(): + try: + os.chmod(app_log, 0o640) + except OSError: + pass + except OSError as exc: # pragma: no cover - z. B. fehlende Rechte + root.warning("Konnte app.log nicht oeffnen: %s", exc) + + # Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces. + access = logging.getLogger(ACCESS_LOGGER) + access.propagate = False + access.setLevel(logging.INFO) + try: + access_handler = logging.FileHandler(log_dir / "access.log", encoding="utf-8", delay=True) + access_handler.setFormatter( + JsonFormatter() if json_logs else logging.Formatter("%(asctime)s %(message)s") + ) + access.addHandler(access_handler) + except OSError: # pragma: no cover + pass + + logging.getLogger("werkzeug").setLevel(logging.WARNING) + + @app.before_request + def _assign_request_id() -> None: + incoming = request.headers.get("X-Request-Id", "") + g.request_id = incoming[:64] if incoming.isascii() and incoming else uuid.uuid4().hex[:16] + g.request_started = time.perf_counter() + + @app.after_request + def _log_access(response): # type: ignore[no-untyped-def] + if request.path.startswith("/static/"): + return response + duration = (time.perf_counter() - getattr(g, "request_started", time.perf_counter())) * 1000 + actor = getattr(getattr(g, "user", None), "username", "") or "-" + access.info( + "%s %s %s %s %s %.1fms", + getattr(g, "request_id", "-"), + actor, + request.method, + request.full_path if request.query_string else request.path, + response.status_code, + duration, + ) + response.headers.setdefault("X-Request-Id", getattr(g, "request_id", "")) + return response diff --git a/packages/tesm-core/src/tesm_core/migrations.py b/packages/tesm-core/src/tesm_core/migrations.py new file mode 100644 index 0000000..c02e918 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/migrations.py @@ -0,0 +1,234 @@ +"""Versionierte Schema-Migrationen. + +Der Vorgaenger hatte eine ``_ensure_schema()``-Funktion, die bei jedem Start +rund 35 idempotente ``ALTER TABLE``-Blocks durchlief, plus Sentinel-Zeilen in +der ``settings``-Tabelle als Marker fuer Einmal-Migrationen. Das funktioniert, +ist aber nicht nachvollziehbar: welche Migration lief wann, in welcher +Reihenfolge, mit welchem Inhalt? + +Hier: eine ``schema_migrations``-Tabelle mit ``(component, version)``, einer +Pruefsumme des Migrationsinhalts und Zeitstempel. Jede Migration laeuft genau +einmal, in einer eigenen Transaktion, in Versionsreihenfolge. + +``component`` trennt die Namensraeume: ``core`` bringt Benutzer/Gruppen/ +Sitzungen/Audit mit, jede Anwendung ihre Fachtabellen. Dadurch koennen beide +Anwendungen dieselben Core-Migrationen nutzen, ohne dass sich Versionsnummern +in die Quere kommen. +""" + +from __future__ import annotations + +import hashlib +import logging +import sqlite3 +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from .db import Database + +logger = logging.getLogger(__name__) + +MigrationFn = Callable[[sqlite3.Connection], None] + + +@dataclass(frozen=True, slots=True) +class Migration: + """Eine Schemaaenderung. + + ``sql`` wird als Skript ausgefuehrt, danach ggf. ``fn`` fuer Datenmigrationen, + die sich in reinem SQL nicht sinnvoll ausdruecken lassen. + + ``allow_integrity_error``: fuer nachtraeglich eingefuehrte Unique-Indizes. + Trifft so ein Index auf bereits vorhandene, verletzende Altdaten, darf der + Anwendungsstart nicht crashen -- er loggt und laeuft weiter. Genau dieses + Muster hat sich im Vorgaengerprojekt bewaehrt. + """ + + version: int + name: str + sql: str = "" + fn: MigrationFn | None = None + allow_integrity_error: bool = False + tags: tuple[str, ...] = field(default=()) + + @property + def checksum(self) -> str: + payload = f"{self.version}:{self.name}:{self.sql}".encode("utf-8") + if self.fn is not None: + payload += f":fn={self.fn.__name__}".encode("utf-8") + return hashlib.sha256(payload).hexdigest()[:16] + + +def split_statements(script: str) -> list[str]: + """Zerlegt ein SQL-Skript in einzelne Anweisungen. + + Warum nicht ``executescript``: das setzt in Pythons sqlite3-Treiber + **implizit ein COMMIT** ab, bevor es laeuft. Innerhalb einer laufenden + Transaktion zerreisst das genau die Atomizitaet, die eine Migration + braucht. Also: selbst zerlegen und Anweisung fuer Anweisung ausfuehren. + + Der Splitter beruecksichtigt Zeichenketten, Zeilen- und Blockkommentare; + ``BEGIN ... END``-Bloecke (Trigger) werden zusammengehalten. + """ + statements: list[str] = [] + current: list[str] = [] + index = 0 + length = len(script) + depth = 0 # BEGIN/END-Tiefe fuer Trigger + + while index < length: + char = script[index] + nxt = script[index + 1] if index + 1 < length else "" + + if char == "-" and nxt == "-": + end = script.find("\n", index) + index = length if end == -1 else end + 1 + continue + if char == "/" and nxt == "*": + end = script.find("*/", index + 2) + index = length if end == -1 else end + 2 + continue + if char in "'\"": + quote = char + current.append(char) + index += 1 + while index < length: + current.append(script[index]) + if script[index] == quote: + if index + 1 < length and script[index + 1] == quote: + current.append(script[index + 1]) + index += 2 + continue + index += 1 + break + index += 1 + continue + + if char == ";" and depth == 0: + statement = "".join(current).strip() + if statement: + statements.append(statement) + current = [] + index += 1 + continue + + current.append(char) + upper_tail = "".join(current[-6:]).upper() + if upper_tail.endswith("BEGIN"): + depth += 1 + elif upper_tail.endswith("END") and depth > 0: + depth -= 1 + index += 1 + + tail = "".join(current).strip() + if tail: + statements.append(tail) + return statements + + +def run_script(conn: sqlite3.Connection, script: str) -> None: + for statement in split_statements(script): + conn.execute(statement) + + +_BOOTSTRAP = """ +CREATE TABLE IF NOT EXISTS schema_migrations ( + component TEXT NOT NULL, + version INTEGER NOT NULL, + name TEXT NOT NULL, + checksum TEXT NOT NULL, + applied_at TEXT NOT NULL, + PRIMARY KEY (component, version) +); +""" + + +class MigrationRunner: + """Fuehrt Migrationen mehrerer Komponenten gegen eine Datenbank aus.""" + + def __init__(self, database: Database) -> None: + self.database = database + + def applied(self, conn: sqlite3.Connection, component: str) -> dict[int, dict[str, str]]: + rows = Database.all( + conn, + "SELECT version, name, checksum, applied_at FROM schema_migrations WHERE component=?", + (component,), + ) + return {int(row["version"]): row for row in rows} + + def run(self, component: str, migrations: Sequence[Migration]) -> list[str]: + """Wendet fehlende Migrationen an. Rueckgabe: Namen der ausgefuehrten.""" + ordered = sorted(migrations, key=lambda m: m.version) + versions = [m.version for m in ordered] + if len(set(versions)) != len(versions): + duplicates = sorted({v for v in versions if versions.count(v) > 1}) + raise ValueError(f"Doppelte Migrationsversionen in {component}: {duplicates}") + + executed: list[str] = [] + with self.database.session() as conn: + run_script(conn, _BOOTSTRAP) + known = self.applied(conn, component) + + for migration in ordered: + record = known.get(migration.version) + if record is not None: + if record["checksum"] != migration.checksum: + logger.warning( + "Migration %s/%s (%s) wurde nachtraeglich geaendert " + "(Pruefsumme %s != %s). Bereits angewendete Migrationen werden " + "nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue " + "Versionsnummer.", + component, + migration.version, + migration.name, + record["checksum"], + migration.checksum, + ) + continue + + try: + with self.database.transaction(conn): + if migration.sql.strip(): + run_script(conn, migration.sql) + if migration.fn is not None: + migration.fn(conn) + conn.execute( + "INSERT INTO schema_migrations " + "(component, version, name, checksum, applied_at) VALUES (?,?,?,?,?)", + ( + component, + migration.version, + migration.name, + migration.checksum, + datetime.now(timezone.utc).isoformat(timespec="seconds"), + ), + ) + except sqlite3.IntegrityError: + if not migration.allow_integrity_error: + raise + logger.error( + "Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht " + "angewendet werden. Der Start laeuft weiter; die Daten muessen " + "manuell bereinigt werden.", + component, + migration.version, + migration.name, + ) + continue + executed.append(f"{component}/{migration.version} {migration.name}") + logger.info("Migration angewendet: %s/%s %s", component, migration.version, migration.name) + return executed + + def status(self, components: Sequence[str]) -> list[dict[str, object]]: + """Uebersicht fuer die Diagnoseseite.""" + with self.database.session() as conn: + run_script(conn, _BOOTSTRAP) + return Database.all( + conn, + "SELECT component, version, name, checksum, applied_at FROM schema_migrations " + f"WHERE component IN ({','.join('?' * len(components))}) " + "ORDER BY component, version", + tuple(components), + ) diff --git a/packages/tesm-core/src/tesm_core/nav.py b/packages/tesm-core/src/tesm_core/nav.py new file mode 100644 index 0000000..1cf5e63 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/nav.py @@ -0,0 +1,160 @@ +"""Navigation: Struktur, Sichtbarkeit, Reihenfolge. + +Uebernommen vom Vorgaenger (das Muster war gut), aber mit zwei Aenderungen: + +* Sichtbarkeit haengt an einer Liste von Rechten **plus** optional einer + Bedingung. Im Vorgaenger war das eine ``if/elif``-Kette in + ``_nav_key_visible()`` -- jede neue Seite bedeutete einen Eingriff in diese + Funktion. Hier deklariert jeder Eintrag selbst, was er braucht. +* "Sichtbar, wenn *irgendeines* der Rechte greift" ist der Default. Im + Vorgaenger war eine Seite mit reinem Export-Recht unerreichbar, weil die + Navigation nur auf ``.view`` prueft. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Iterable, Sequence + +VisibilityFn = Callable[[Any], bool] + + +@dataclass(frozen=True, slots=True) +class NavItem: + key: str + label: str + icon: str + endpoint: str = "" + #: Sichtbar, wenn eines dieser Rechte greift (leer = immer sichtbar). + permissions: tuple[str, ...] = () + #: Zusaetzliche Bedingung, z. B. Modul-Lizenz oder vorhandene Freigaben. + condition: VisibilityFn | None = field(default=None, compare=False) + children: tuple["NavItem", ...] = () + badge: Callable[[], str] | None = field(default=None, compare=False) + hint: str = "" + + @property + def is_group(self) -> bool: + return bool(self.children) + + +class NavTree: + """Navigationsbaum mit laufzeitkonfigurierbarer Reihenfolge.""" + + def __init__(self, items: Sequence[NavItem]) -> None: + self.items: tuple[NavItem, ...] = tuple(items) + self._by_key: dict[str, NavItem] = {} + for item in self.items: + self._by_key[item.key] = item + for child in item.children: + self._by_key[child.key] = child + + def item(self, key: str) -> NavItem | None: + return self._by_key.get(key) + + def default_order(self) -> list[str]: + return [item.key for item in self.items] + + def default_child_order(self, group_key: str) -> list[str]: + item = self._by_key.get(group_key) + return [child.key for child in item.children] if item else [] + + # ------------------------------------------------------------------ # + + def visible(self, item: NavItem, user: Any) -> bool: + if item.permissions and not user.has_any(item.permissions): + return False + if item.condition is not None and not item.condition(user): + return False + return True + + def resolve( + self, + user: Any, + *, + order: Sequence[str] | None = None, + child_order: dict[str, Sequence[str]] | None = None, + ) -> list[dict[str, Any]]: + """Liefert die gefilterte, sortierte Navigation fuer das Template.""" + child_order = child_order or {} + result: list[dict[str, Any]] = [] + + for item in self._ordered(self.items, order): + if item.is_group: + children = [ + child + for child in self._ordered(item.children, child_order.get(item.key)) + if self.visible(child, user) + ] + if not children: + continue + result.append( + { + "key": item.key, + "label": item.label, + "icon": item.icon, + "endpoint": "", + "hint": item.hint, + "children": [self._as_dict(child) for child in children], + } + ) + continue + if not self.visible(item, user): + continue + result.append(self._as_dict(item)) + return result + + def _as_dict(self, item: NavItem) -> dict[str, Any]: + return { + "key": item.key, + "label": item.label, + "icon": item.icon, + "endpoint": item.endpoint, + "hint": item.hint, + "badge": item.badge() if item.badge else "", + "children": [], + } + + @staticmethod + def _ordered( + items: Sequence[NavItem], order: Sequence[str] | None + ) -> list[NavItem]: + if not order: + return list(items) + index = {key: position for position, key in enumerate(order)} + # Unbekannte Schluessel (neu dazugekommene Seiten) landen hinten, statt + # zu verschwinden -- der Vorgaenger verlor sie stillschweigend. + return sorted(items, key=lambda item: (index.get(item.key, len(index)), item.label)) + + # ------------------------------------------------------------------ # + + def sanitize_order(self, keys: Iterable[str]) -> list[str]: + known = self.default_order() + chosen = [key for key in keys if key in known] + return chosen + [key for key in known if key not in chosen] + + def sanitize_child_order(self, group_key: str, keys: Iterable[str]) -> list[str]: + known = self.default_child_order(group_key) + chosen = [key for key in keys if key in known] + return chosen + [key for key in known if key not in chosen] + + def searchable(self, user: Any) -> list[dict[str, str]]: + """Flache Liste aller sichtbaren Seiten -- Datenbasis der Befehlspalette.""" + entries: list[dict[str, str]] = [] + for item in self.items: + if item.is_group: + for child in item.children: + if self.visible(child, user): + entries.append( + { + "label": child.label, + "group": item.label, + "endpoint": child.endpoint, + "icon": child.icon, + } + ) + elif self.visible(item, user): + entries.append( + {"label": item.label, "group": "", "endpoint": item.endpoint, "icon": item.icon} + ) + return entries diff --git a/packages/tesm-core/src/tesm_core/rbac/__init__.py b/packages/tesm-core/src/tesm_core/rbac/__init__.py new file mode 100644 index 0000000..f46527f --- /dev/null +++ b/packages/tesm-core/src/tesm_core/rbac/__init__.py @@ -0,0 +1,25 @@ +"""Rechtesystem: Modell (Bereiche/Ressourcen/Aktionen) und Verwaltungsdienste.""" + +from __future__ import annotations + +from .model import ( + ACTION_META, + IMPLIED_BY, + SENSITIVE_ACTIONS, + Action, + Area, + PermissionTree, + Resource, + RolePreset, +) + +__all__ = [ + "ACTION_META", + "Action", + "Area", + "IMPLIED_BY", + "PermissionTree", + "Resource", + "RolePreset", + "SENSITIVE_ACTIONS", +] diff --git a/packages/tesm-core/src/tesm_core/rbac/common.py b/packages/tesm-core/src/tesm_core/rbac/common.py new file mode 100644 index 0000000..47695e3 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/rbac/common.py @@ -0,0 +1,161 @@ +"""Ressourcen, die beide Anwendungen teilen. + +``tesm_core`` bringt Benutzer-, Gruppen-, Papierkorb-, Protokoll- und +Diagnoseseiten selbst mit. Damit deren Rechtepruefungen greifen, muessen die +zugehoerigen Ressourcenschluessel im Rechtebaum der Anwendung existieren -- +darum stehen sie hier und werden von beiden Apps eingebunden statt zweimal +getippt. +""" + +from __future__ import annotations + +from .model import Action, Area, Resource + +A = Action + +USERS = Resource( + key="users", + label="Benutzer", + actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), + hint="Konten anlegen, sperren, Gruppen zuordnen", + action_hints={ + A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung aendern.", + A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).", + }, +) + +GROUPS = Resource( + key="groups", + label="Gruppen und Rechte", + actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), + hint="Rechtematrix pflegen", + action_hints={ + A.EDIT: "Rechte vergeben -- nur Rechte, die man selbst besitzt.", + }, +) + +SETTINGS_SYSTEM = Resource( + key="settings_system", + label="Systemeinstellungen", + actions=(A.VIEW, A.EDIT, A.EXECUTE), + hint="Hostname, Zeitzone, Protokollrotation, Navigation", + action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."}, +) + +SETTINGS_LICENSE = Resource( + key="settings_license", + label="Lizenz", + actions=(A.VIEW, A.EDIT), + hint="Lizenzdatei einspielen, aktivieren, deaktivieren", +) + +SETTINGS_DIRECTORY = Resource( + key="settings_directory", + label="Verzeichnisdienst (LDAP/AD)", + actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS), + hint="Anbindung und Gruppen-Zuordnung", + action_hints={ + A.EXECUTE: "Verbindungstest ausfuehren.", + A.SECRETS: "Bind-Passwort des Dienstkontos setzen.", + }, +) + +SETTINGS_WEB = Resource( + key="settings_web", + label="Webserver und TLS", + actions=(A.VIEW, A.EDIT, A.EXECUTE), + hint="Domain, Zertifikat, HSTS", + action_hints={A.EXECUTE: "Konfiguration uebernehmen, Zertifikat anfordern."}, +) + +SETTINGS_NETWORK = Resource( + key="settings_network", + label="Netzwerkeinstellungen", + actions=(A.VIEW, A.EDIT), + hint="IP-Adresse, Gateway, DNS des Hosts", +) + +BACKUP = Resource( + key="backup", + label="Sicherung und Uebertragung", + actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS), + hint="Verschluesselter Export und Import", + action_hints={ + A.EXECUTE: "Eine Sicherung einspielen.", + A.EXPORT: "Sicherung erzeugen und herunterladen.", + A.SECRETS: "Zugangsdaten in die Sicherung einschliessen.", + }, +) + +TRASH = Resource( + key="trash", + label="Papierkorb", + actions=(A.VIEW, A.EDIT, A.DELETE), + hint="Geloeschtes wiederherstellen oder endgueltig entfernen", + action_hints={ + A.EDIT: "Eintraege wiederherstellen.", + A.DELETE: "Eintraege endgueltig entfernen.", + }, +) + +DIAGNOSTICS = Resource( + key="diagnostics", + label="Diagnose", + actions=(A.VIEW,), + hint="Schemastand, Protokollintegritaet, Schluesselrechte", +) + +LOGS_LIVE = Resource( + key="logs_live", + label="Laufendes Protokoll", + actions=(A.VIEW,), +) + +LOGS_HISTORY = Resource( + key="logs_history", + label="Protokollverlauf", + actions=(A.VIEW, A.DELETE, A.EXPORT), + hint="Rotierte und archivierte Protokolle", + action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."}, +) + +LOGS_AUDIT = Resource( + key="logs_audit", + label="Aenderungsprotokoll", + actions=(A.VIEW, A.EXPORT), + hint="Wer hat wann was geaendert", +) + + +def administration_area(*extra: Resource) -> Area: + """Bereich "Verwaltung" -- Basis fuer beide Anwendungen.""" + return Area( + key="administration", + label="Verwaltung", + icon="sliders", + hint="Benutzer, Rechte, Systemeinstellungen. Ohne diesen Zugang bleibt der " + "gesamte Verwaltungsbereich verborgen.", + resources=( + USERS, + GROUPS, + SETTINGS_SYSTEM, + SETTINGS_LICENSE, + SETTINGS_DIRECTORY, + SETTINGS_WEB, + SETTINGS_NETWORK, + BACKUP, + TRASH, + DIAGNOSTICS, + *extra, + ), + ) + + +def observability_area(*extra: Resource) -> Area: + """Bereich "Protokolle".""" + return Area( + key="observability", + label="Protokolle", + icon="terminal", + resources=(LOGS_LIVE, LOGS_HISTORY, LOGS_AUDIT, *extra), + ) diff --git a/packages/tesm-core/src/tesm_core/rbac/model.py b/packages/tesm-core/src/tesm_core/rbac/model.py new file mode 100644 index 0000000..0c5de71 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/rbac/model.py @@ -0,0 +1,246 @@ +"""Rechtemodell: Bereiche, Ressourcen, Aktionen. + +Das Vorgaengermodell kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern") +das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes +bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber: + +* Wer jemandem erlauben wollte, Geraete zu pflegen, musste ihm zwangslaeufig + auch das Loeschen erlauben. Das ist genau die Trennung, die man in einer + Verwaltungsanwendung am haeufigsten braucht. +* Dieselbe Spalte bedeutete in verschiedenen Zeilen Verschiedenes -- eine + Rechtematrix, die man erklaeren muss, statt sie zu lesen. + +Neues Modell: ein festes, ueberall gleich bedeutendes Aktionsvokabular. Welche +Aktionen eine Ressource ueberhaupt kennt, entscheidet die Ressource; die Matrix +rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen. + +Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein +``.view``-Recht. Ohne dieses greift kein einziges Recht innerhalb des +Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer +Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein +eingestellten Einzelrechte zu verlieren. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Iterable, Mapping, Sequence + + +class Action(str, Enum): + """Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen.""" + + VIEW = "view" + CREATE = "create" + EDIT = "edit" + DELETE = "delete" + EXECUTE = "execute" + EXPORT = "export" + SECRETS = "secrets" + + +#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix. +ACTION_META: dict[Action, tuple[str, str, str]] = { + Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."), + Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."), + Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."), + Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."), + Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."), + Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."), + Action.SECRETS: ( + "S", + "Geheimnisse", + "Passwoerter und Schluessel im Klartext sehen oder mitnehmen.", + ), +} + +#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus). +SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT}) + +#: ``view`` ist Voraussetzung fuer alles andere in derselben Zeile. +IMPLIED_BY: dict[Action, tuple[Action, ...]] = { + action: (Action.VIEW,) for action in Action if action is not Action.VIEW +} + + +@dataclass(frozen=True, slots=True) +class Resource: + """Eine Zeile der Rechtematrix.""" + + key: str + label: str + actions: tuple[Action, ...] + hint: str = "" + #: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint). + action_hints: Mapping[Action, str] = field(default_factory=dict) + + def permission(self, action: Action) -> str: + return f"{self.key}.{action.value}" + + def has(self, action: Action) -> bool: + return action in self.actions + + def permissions(self) -> tuple[str, ...]: + return tuple(self.permission(action) for action in self.actions) + + +@dataclass(frozen=True, slots=True) +class Area: + """Ein Bereich (Gruppe von Ressourcen) mit eigenem Kill-Switch.""" + + key: str + label: str + icon: str + resources: tuple[Resource, ...] + hint: str = "" + + @property + def view_key(self) -> str: + return f"{self.key}.view" + + def columns(self) -> tuple[Action, ...]: + """Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen.""" + present = {action for resource in self.resources for action in resource.actions} + return tuple(action for action in Action if action in present) + + def resource(self, key: str) -> Resource | None: + for resource in self.resources: + if resource.key == key: + return resource + return None + + +class PermissionTree: + """Aus Bereichen abgeleitete Nachschlagestrukturen. + + Wird beim Import einmal gebaut und danach nur gelesen. + """ + + def __init__(self, areas: Sequence[Area]) -> None: + self.areas: tuple[Area, ...] = tuple(areas) + self._by_key: dict[str, Area] = {} + self._parent: dict[str, str] = {} + self._all: list[str] = [] + self._resources: dict[str, tuple[Area, Resource]] = {} + + seen_keys: set[str] = set() + for area in self.areas: + if area.key in seen_keys: + raise ValueError(f"Doppelter Bereichsschluessel: {area.key}") + seen_keys.add(area.key) + self._by_key[area.key] = area + self._all.append(area.view_key) + for resource in area.resources: + if resource.key in seen_keys: + raise ValueError(f"Doppelter Ressourcenschluessel: {resource.key}") + seen_keys.add(resource.key) + if Action.VIEW not in resource.actions: + raise ValueError( + f"Ressource {resource.key} muss die Aktion 'view' anbieten -- " + "sie ist Voraussetzung fuer jede andere Aktion." + ) + self._resources[resource.key] = (area, resource) + for permission in resource.permissions(): + self._all.append(permission) + self._parent[permission] = area.view_key + + # ------------------------------------------------------------------ # + + def all_keys(self) -> tuple[str, ...]: + return tuple(self._all) + + def area(self, key: str) -> Area | None: + return self._by_key.get(key) + + def parent_view_key(self, permission: str) -> str | None: + return self._parent.get(permission) + + def resource(self, key: str) -> tuple[Area, Resource] | None: + return self._resources.get(key) + + def label_for(self, permission: str) -> str: + """Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs.""" + if permission.endswith(".view") and permission[:-5] in self._by_key: + return f"Bereich {self._by_key[permission[:-5]].label}: Zugang" + resource_key, _, action_value = permission.rpartition(".") + entry = self._resources.get(resource_key) + if entry is None: + return permission + _, resource = entry + try: + action = Action(action_value) + except ValueError: + return permission + return f"{resource.label}: {ACTION_META[action][1]}" + + def sanitize(self, permissions: Iterable[str]) -> list[str]: + """Wirft unbekannte Rechte weg und ergaenzt implizierte. + + Regeln: + * Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles + Weiterschleppen von Altrechten nach einem Refactoring). + * Jede Aktion impliziert ``view`` derselben Zeile. + * Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des + Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der + Bearbeiter wuerde sich wundern. + """ + known = set(self._all) + result: set[str] = {p for p in permissions if p in known} + for permission in list(result): + resource_key, _, action_value = permission.rpartition(".") + if resource_key in self._resources: + try: + action = Action(action_value) + except ValueError: + continue + for implied in IMPLIED_BY.get(action, ()): + candidate = f"{resource_key}.{implied.value}" + if candidate in known: + result.add(candidate) + for area in self.areas: + if any(p in result for resource in area.resources for p in resource.permissions()): + result.add(area.view_key) + return sorted(result) + + def effective(self, permissions: Iterable[str]) -> set[str]: + """Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen.""" + granted = set(permissions) + active = {key for key in granted if key.endswith(".view") and key[:-5] in self._by_key} + return { + key + for key in granted + if (parent := self._parent.get(key)) is None or parent in active + } + + def expand_preset(self, preset: "RolePreset") -> list[str]: + return self.sanitize(preset.permissions(self)) + + +@dataclass(frozen=True, slots=True) +class RolePreset: + """Benannte Rechtevorlage -- wird beim ersten Start als Gruppe angelegt. + + Der Vorgaenger kannte nur "Admin" und eine Standardgruppe "Benutzer"; jede + weitere Rolle musste jeder Kunde von Hand zusammenklicken. Vorlagen machen + aus der haeufigsten Aufgabe einen Startpunkt. + """ + + key: str + name: str + description: str + #: ``(ressource, aktionen)``-Paare, ``"*"`` fuer alle Aktionen der Ressource. + grants: tuple[tuple[str, tuple[Action, ...] | str], ...] + is_default: bool = False + """Standardgruppe: wird jedem neuen Nicht-Admin automatisch zugeordnet.""" + + def permissions(self, tree: PermissionTree) -> list[str]: + result: list[str] = [] + for resource_key, actions in self.grants: + entry = tree.resource(resource_key) + if entry is None: + continue + _, resource = entry + wanted = resource.actions if actions == "*" else tuple(a for a in actions if resource.has(a)) # type: ignore[arg-type] + result.extend(resource.permission(action) for action in wanted) + return result diff --git a/packages/tesm-core/src/tesm_core/rbac/service.py b/packages/tesm-core/src/tesm_core/rbac/service.py new file mode 100644 index 0000000..aee5f84 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/rbac/service.py @@ -0,0 +1,717 @@ +"""Benutzer- und Gruppenverwaltung inklusive Eskalationsschutz. + +Die wichtigste inhaltliche Korrektur gegenueber dem Vorgaenger steckt in +``guard_no_amplification``: + +Im Vorgaenger konnte jeder, der das Recht ``groups.edit`` besass, einer Gruppe +*beliebige* Rechte zuweisen -- also auch Rechte, die er selbst nicht hatte -- +und sich anschliessend selbst in diese Gruppe setzen. Das Rechtesystem war +damit fuer jeden Gruppenverwalter effektiv offen. Hier gilt: wer selbst kein +Recht X hat, kann Recht X auch nicht vergeben. Entziehen ist immer erlaubt. +Echte Administratoren sind davon ausgenommen (sie haben ohnehin alles). + +Weitere fest verdrahtete Grenzen, die kein Gruppenrecht aushebeln kann: + +* Das Admin-Kennzeichen setzt oder entfernt nur ein echter Administrator. +* Ein bestehendes Administratorkonto bearbeitet, sperrt oder loescht nur ein + echter Administrator. +* Es muss immer mindestens ein entsperrtes Administratorkonto uebrig bleiben. +* Niemand kann sich selbst das Admin-Kennzeichen entziehen oder sich selbst + sperren (haeufigster Weg, sich komplett auszusperren). +""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Iterable, Sequence + +from ..db import Database +from ..security import passwords +from .model import Action, PermissionTree, RolePreset + + +class PermissionDenied(Exception): + """Verstoss gegen eine feste Sicherheitsgrenze -- wird als Klartext angezeigt.""" + + +class ValidationError(Exception): + """Eingabefehler, der dem Benutzer erklaert wird.""" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +# --------------------------------------------------------------------------- # +# Gruppen +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class GroupRecord: + id: int + name: str + description: str + is_system: bool + is_default: bool + preset_key: str + permissions: tuple[str, ...] + member_count: int + deleted_at: str | None = None + + +def list_groups( + conn: sqlite3.Connection, *, include_deleted: bool = False +) -> list[GroupRecord]: + where = "" if include_deleted else "WHERE g.deleted_at IS NULL" + rows = Database.all( + conn, + f""" + SELECT g.*, + (SELECT COUNT(*) FROM user_groups ug + JOIN users u ON u.id = ug.user_id AND u.deleted_at IS NULL + WHERE ug.group_id = g.id) AS member_count + FROM groups g {where} + ORDER BY g.is_system DESC, g.name COLLATE NOCASE + """, + ) + perms = _permissions_by_group(conn) + return [ + GroupRecord( + id=int(row["id"]), + name=str(row["name"]), + description=str(row["description"] or ""), + is_system=bool(row["is_system"]), + is_default=bool(row["is_default"]), + preset_key=str(row["preset_key"] or ""), + permissions=tuple(perms.get(int(row["id"]), ())), + member_count=int(row["member_count"] or 0), + deleted_at=row["deleted_at"], + ) + for row in rows + ] + + +def _permissions_by_group(conn: sqlite3.Connection) -> dict[int, list[str]]: + result: dict[int, list[str]] = {} + for row in Database.all(conn, "SELECT group_id, permission FROM group_permissions"): + result.setdefault(int(row["group_id"]), []).append(str(row["permission"])) + for values in result.values(): + values.sort() + return result + + +def get_group(conn: sqlite3.Connection, group_id: int) -> GroupRecord | None: + for group in list_groups(conn, include_deleted=True): + if group.id == group_id: + return group + return None + + +def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]: + return [ + str(row["permission"]) + for row in Database.all( + conn, + "SELECT permission FROM group_permissions WHERE group_id=? ORDER BY permission", + (group_id,), + ) + ] + + +def guard_no_amplification( + actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht" +) -> None: + """Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt.""" + if getattr(actor, "is_admin", False): + return + missing = sorted(p for p in added if not actor.has(p)) + if missing: + labels = ", ".join(tree.label_for(p) for p in missing[:6]) + more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)" + raise PermissionDenied( + f"Sie koennen kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}." + ) + + +def create_group( + conn: sqlite3.Connection, + *, + name: str, + description: str, + permissions: Sequence[str], + tree: PermissionTree, + actor: Any, + preset_key: str = "", + is_system: bool = False, + is_default: bool = False, +) -> int: + clean_name = (name or "").strip() + if not clean_name: + raise ValidationError("Der Gruppenname darf nicht leer sein.") + if len(clean_name) > 64: + raise ValidationError("Der Gruppenname ist zu lang (maximal 64 Zeichen).") + existing = Database.one( + conn, "SELECT id, deleted_at FROM groups WHERE name=? COLLATE NOCASE", (clean_name,) + ) + if existing is not None: + if existing["deleted_at"]: + raise ValidationError( + f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. " + "Stellen Sie sie wieder her oder loeschen Sie sie endgueltig." + ) + raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.") + + wanted = tree.sanitize(permissions) + guard_no_amplification(actor, tree, wanted) + + cursor = conn.execute( + "INSERT INTO groups (name, description, is_system, is_default, preset_key, " + "created_at, updated_at) VALUES (?,?,?,?,?,?,?)", + (clean_name, (description or "").strip(), int(is_system), int(is_default), preset_key, _now(), _now()), + ) + group_id = int(cursor.lastrowid or 0) + _write_permissions(conn, group_id, wanted) + return group_id + + +def _write_permissions(conn: sqlite3.Connection, group_id: int, permissions: Sequence[str]) -> None: + conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) + conn.executemany( + "INSERT INTO group_permissions (group_id, permission) VALUES (?,?)", + [(group_id, permission) for permission in permissions], + ) + + +def update_group( + conn: sqlite3.Connection, + group_id: int, + *, + name: str | None = None, + description: str | None = None, + permissions: Sequence[str] | None = None, + tree: PermissionTree, + actor: Any, + unlock_system: bool = False, +) -> list[str]: + """Aendert eine Gruppe. Rueckgabe: Liste der Aenderungen fuer das Audit-Log.""" + group = get_group(conn, group_id) + if group is None or group.deleted_at: + raise ValidationError("Diese Gruppe existiert nicht (mehr).") + + changes: list[str] = [] + + if name is not None and name.strip() != group.name: + if group.is_system: + raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.") + clean = name.strip() + if not clean: + raise ValidationError("Der Gruppenname darf nicht leer sein.") + clash = Database.one( + conn, + "SELECT id FROM groups WHERE name=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL", + (clean, group_id), + ) + if clash is not None: + raise ValidationError(f"Eine Gruppe namens {clean!r} existiert bereits.") + conn.execute("UPDATE groups SET name=?, updated_at=? WHERE id=?", (clean, _now(), group_id)) + changes.append(f"Name: {group.name} -> {clean}") + + if description is not None and description.strip() != group.description: + conn.execute( + "UPDATE groups SET description=?, updated_at=? WHERE id=?", + (description.strip(), _now(), group_id), + ) + changes.append("Beschreibung geaendert") + + if permissions is not None: + if group.is_system and not unlock_system: + raise PermissionDenied( + "Die Rechte dieser Systemgruppe sind gesperrt. " + "Sie muessen sie zuerst ausdruecklich freischalten." + ) + wanted = tree.sanitize(permissions) + added = sorted(set(wanted) - set(group.permissions)) + removed = sorted(set(group.permissions) - set(wanted)) + guard_no_amplification(actor, tree, added) + if added or removed: + _write_permissions(conn, group_id, wanted) + conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id)) + if added: + changes.append("Rechte hinzugefuegt: " + ", ".join(tree.label_for(p) for p in added)) + if removed: + changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed)) + + return changes + + +def soft_delete_group(conn: sqlite3.Connection, group_id: int) -> None: + group = get_group(conn, group_id) + if group is None: + raise ValidationError("Diese Gruppe existiert nicht.") + if group.is_system: + raise PermissionDenied("Systemgruppen koennen nicht geloescht werden.") + conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id)) + + +def restore_group(conn: sqlite3.Connection, group_id: int) -> None: + group = get_group(conn, group_id) + if group is None: + raise ValidationError("Diese Gruppe existiert nicht.") + clash = Database.one( + conn, + "SELECT id FROM groups WHERE name=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL", + (group.name, group_id), + ) + if clash is not None: + raise ValidationError( + f"Es existiert bereits eine aktive Gruppe namens {group.name!r}. " + "Benennen Sie diese zuerst um." + ) + conn.execute("UPDATE groups SET deleted_at=NULL WHERE id=?", (group_id,)) + + +def purge_group(conn: sqlite3.Connection, group_id: int) -> None: + # Mitgliedschaften und Rechte haengen per ON DELETE CASCADE dran. + conn.execute("DELETE FROM groups WHERE id=?", (group_id,)) + + +def ensure_presets( + conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset] +) -> list[str]: + """Legt fehlende Vorlagen-Gruppen an. + + Wichtig -- Unterschied zum Vorgaenger: eine bereits existierende Gruppe wird + **nicht** bei jedem Start auf den Vorlagen-Rechtesatz zurueckgesetzt. Der + Vorgaenger tat das fuer die Standardgruppe "Benutzer", wodurch jede + Anpassung durch den Kunden beim naechsten Neustart verschwand. Wer die + Vorlage neu anwenden will, tut das ausdruecklich in der Oberflaeche. + """ + created: list[str] = [] + for preset in presets: + row = Database.one( + conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,) + ) + if row is not None: + continue + permissions = tree.expand_preset(preset) + cursor = conn.execute( + "INSERT INTO groups (name, description, is_system, is_default, preset_key, " + "created_at, updated_at) VALUES (?,?,?,?,?,?,?)", + ( + preset.name, + preset.description, + 1 if preset.is_default else 0, + 1 if preset.is_default else 0, + preset.key, + _now(), + _now(), + ), + ) + _write_permissions(conn, int(cursor.lastrowid or 0), permissions) + created.append(preset.name) + return created + + +def default_group_id(conn: sqlite3.Connection) -> int | None: + value = Database.value( + conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1" + ) + return int(value) if value is not None else None + + +# --------------------------------------------------------------------------- # +# Benutzer +# --------------------------------------------------------------------------- # + + +def granted_permissions(conn: sqlite3.Connection, user_id: int) -> list[str]: + """Vereinigung der Rechte aller (nicht geloeschten) Gruppen des Benutzers.""" + return [ + str(row["permission"]) + for row in Database.all( + conn, + """ + SELECT DISTINCT gp.permission + FROM user_groups ug + JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL + JOIN group_permissions gp ON gp.group_id = g.id + WHERE ug.user_id = ? + """, + (user_id,), + ) + ] + + +def user_groups(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]: + return Database.all( + conn, + "SELECT g.id, g.name, g.is_system, g.is_default FROM user_groups ug " + "JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL " + "WHERE ug.user_id=? ORDER BY g.name COLLATE NOCASE", + (user_id,), + ) + + +def list_users( + conn: sqlite3.Connection, *, include_deleted: bool = False +) -> list[dict[str, Any]]: + where = "" if include_deleted else "WHERE u.deleted_at IS NULL" + users = Database.all( + conn, + f""" + SELECT u.id, u.username, u.is_admin, u.is_locked, u.auth_source, u.first_name, + u.last_name, u.email, u.avatar_filename, u.last_login_at, u.created_at, + u.deleted_at, u.must_change_password, + CASE WHEN u.totp_secret <> '' THEN 1 ELSE 0 END AS totp_enabled + FROM users u {where} + ORDER BY u.username COLLATE NOCASE + """, + ) + memberships: dict[int, list[str]] = {} + for row in Database.all( + conn, + "SELECT ug.user_id, g.name FROM user_groups ug " + "JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL", + ): + memberships.setdefault(int(row["user_id"]), []).append(str(row["name"])) + for user in users: + user["group_names"] = sorted(memberships.get(int(user["id"]), [])) + return users + + +def get_user(conn: sqlite3.Connection, user_id: int) -> dict[str, Any] | None: + return Database.one(conn, "SELECT * FROM users WHERE id=?", (user_id,)) + + +def find_user_by_username( + conn: sqlite3.Connection, username: str, *, include_deleted: bool = False +) -> dict[str, Any] | None: + suffix = "" if include_deleted else " AND deleted_at IS NULL" + return Database.one( + conn, + f"SELECT * FROM users WHERE username=? COLLATE NOCASE{suffix}", + (username,), + ) + + +def count_active_admins(conn: sqlite3.Connection, *, exclude_id: int | None = None) -> int: + sql = "SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 AND deleted_at IS NULL" + params: tuple[Any, ...] = () + if exclude_id is not None: + sql += " AND id<>?" + params = (exclude_id,) + return int(Database.value(conn, sql, params, default=0) or 0) + + +def guard_target_account(actor: Any, target: dict[str, Any]) -> None: + """Ein Administratorkonto darf nur ein echter Administrator anfassen.""" + if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False): + raise PermissionDenied( + "Administratorkonten koennen nur von Administratoren bearbeitet werden." + ) + + +def guard_last_admin(conn: sqlite3.Connection, user_id: int, *, action: str) -> None: + row = get_user(conn, user_id) + if row is None or not row.get("is_admin"): + return + if count_active_admins(conn, exclude_id=user_id) == 0: + raise PermissionDenied( + f"{action} nicht moeglich: es muss mindestens ein entsperrtes " + "Administratorkonto uebrig bleiben." + ) + + +def _assignable_groups( + conn: sqlite3.Connection, actor: Any, tree: PermissionTree, group_ids: Sequence[int] +) -> list[int]: + """Prueft, ob der Bearbeiter diese Gruppen zuweisen darf (kein Rechtezuwachs).""" + if getattr(actor, "is_admin", False): + return [int(gid) for gid in group_ids] + result: list[int] = [] + for gid in group_ids: + permissions = group_permissions(conn, int(gid)) + guard_no_amplification(actor, tree, permissions, what="Gruppenrecht") + result.append(int(gid)) + return result + + +def set_user_groups( + conn: sqlite3.Connection, + user_id: int, + group_ids: Sequence[int], + *, + actor: Any, + tree: PermissionTree, +) -> None: + allowed = _assignable_groups(conn, actor, tree, group_ids) + conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) + conn.executemany( + "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)", + [(user_id, gid) for gid in allowed], + ) + + +def create_user( + conn: sqlite3.Connection, + *, + username: str, + password: str, + is_admin: bool, + first_name: str = "", + last_name: str = "", + email: str = "", + group_ids: Sequence[int] = (), + actor: Any, + tree: PermissionTree, + policy: passwords.PasswordPolicy, + auth_source: str = "local", + must_change_password: bool = False, +) -> int: + clean = (username or "").strip() + if not clean: + raise ValidationError("Der Benutzername darf nicht leer sein.") + if len(clean) > 64 or any(ch.isspace() for ch in clean): + raise ValidationError("Der Benutzername darf keine Leerzeichen enthalten (max. 64 Zeichen).") + + existing = find_user_by_username(conn, clean, include_deleted=True) + if existing is not None: + if existing["deleted_at"]: + raise ValidationError( + f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto " + "wieder her oder loeschen Sie es endgueltig." + ) + raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.") + + if is_admin and not getattr(actor, "is_admin", False): + raise PermissionDenied("Nur Administratoren koennen weitere Administratoren anlegen.") + + if auth_source == "local": + problems = policy.check(password, username=clean) + if problems: + raise ValidationError(" ".join(problems)) + password_hash = passwords.hash_password(password) + else: + password_hash = "" # Verzeichniskonto: Passwort wird nie hier geprueft. + + cursor = conn.execute( + "INSERT INTO users (username, password_hash, is_admin, auth_source, first_name, " + "last_name, email, must_change_password, password_changed_at, created_at, updated_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?)", + ( + clean, + password_hash, + int(is_admin), + auth_source, + first_name.strip(), + last_name.strip(), + email.strip(), + int(must_change_password), + _now() if auth_source == "local" else None, + _now(), + _now(), + ), + ) + user_id = int(cursor.lastrowid or 0) + + targets = list(group_ids) + if not is_admin and not targets: + fallback = default_group_id(conn) + if fallback is not None: + targets = [fallback] + set_user_groups(conn, user_id, targets, actor=actor, tree=tree) + return user_id + + +def update_user( + conn: sqlite3.Connection, + user_id: int, + *, + actor: Any, + tree: PermissionTree, + first_name: str | None = None, + last_name: str | None = None, + email: str | None = None, + is_admin: bool | None = None, + is_locked: bool | None = None, + group_ids: Sequence[int] | None = None, +) -> list[str]: + target = get_user(conn, user_id) + if target is None or target["deleted_at"]: + raise ValidationError("Dieses Konto existiert nicht (mehr).") + guard_target_account(actor, target) + + changes: list[str] = [] + directory = str(target.get("auth_source")) == "ldap" + + if directory and any(value is not None for value in (first_name, last_name, email)): + # Beim naechsten Login wuerden diese Felder ohnehin aus dem Verzeichnis + # ueberschrieben -- die Bearbeitung waere eine Illusion. + raise ValidationError( + "Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung " + "aus dem Verzeichnis uebernommen und koennen hier nicht geaendert werden." + ) + + fields: dict[str, Any] = {} + for column, value in ( + ("first_name", first_name), + ("last_name", last_name), + ("email", email), + ): + if value is not None and value.strip() != str(target.get(column) or ""): + fields[column] = value.strip() + changes.append(f"{column} geaendert") + + if is_admin is not None and bool(is_admin) != bool(target["is_admin"]): + if not getattr(actor, "is_admin", False): + raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator aendern.") + if int(target["id"]) == int(getattr(actor, "id", 0)): + raise PermissionDenied("Sie koennen Ihr eigenes Admin-Kennzeichen nicht aendern.") + if not is_admin: + guard_last_admin(conn, user_id, action="Herabstufen") + fields["is_admin"] = int(bool(is_admin)) + changes.append("Administrator: " + ("ja" if is_admin else "nein")) + + if is_locked is not None and bool(is_locked) != bool(target["is_locked"]): + if int(target["id"]) == int(getattr(actor, "id", 0)): + raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht sperren.") + if is_locked: + guard_last_admin(conn, user_id, action="Sperren") + fields["is_locked"] = int(bool(is_locked)) + changes.append("Gesperrt: " + ("ja" if is_locked else "nein")) + + if fields: + assignments = ", ".join(f"{column}=?" for column in fields) + conn.execute( + f"UPDATE users SET {assignments}, updated_at=? WHERE id=?", + (*fields.values(), _now(), user_id), + ) + + if group_ids is not None: + before = {int(row["id"]) for row in user_groups(conn, user_id)} + after = {int(gid) for gid in group_ids} + if before != after: + set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree) + changes.append("Gruppenzuordnung geaendert") + + return changes + + +def set_password( + conn: sqlite3.Connection, + user_id: int, + new_password: str, + *, + policy: passwords.PasswordPolicy, + must_change: bool = False, +) -> None: + row = get_user(conn, user_id) + if row is None: + raise ValidationError("Dieses Konto existiert nicht.") + if str(row.get("auth_source")) == "ldap": + raise ValidationError( + "Verzeichniskonten haben hier kein Passwort -- es wird im Verzeichnisdienst gepflegt." + ) + problems = policy.check(new_password, username=str(row["username"])) + if problems: + raise ValidationError(" ".join(problems)) + conn.execute( + "UPDATE users SET password_hash=?, must_change_password=?, password_changed_at=?, " + "updated_at=? WHERE id=?", + (passwords.hash_password(new_password), int(must_change), _now(), _now(), user_id), + ) + + +def soft_delete_user(conn: sqlite3.Connection, user_id: int, *, actor: Any) -> str: + target = get_user(conn, user_id) + if target is None or target["deleted_at"]: + raise ValidationError("Dieses Konto existiert nicht (mehr).") + guard_target_account(actor, target) + if int(target["id"]) == int(getattr(actor, "id", 0)): + raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht loeschen.") + guard_last_admin(conn, user_id, action="Loeschen") + + if str(target.get("auth_source")) == "ldap": + # Ein Verzeichniskonto ist nur ein Schattenkonto: es legt sich bei der + # naechsten gueltigen Anmeldung von selbst wieder an. Ein Papierkorb + # waere hier bedeutungslos. + conn.execute("DELETE FROM users WHERE id=?", (user_id,)) + return "endgueltig" + + conn.execute("UPDATE users SET deleted_at=?, updated_at=? WHERE id=?", (_now(), _now(), user_id)) + conn.execute( + "UPDATE user_sessions SET revoked_at=?, revoked_reason='user-deleted' " + "WHERE user_id=? AND revoked_at IS NULL", + (_now(), user_id), + ) + return "papierkorb" + + +def restore_user(conn: sqlite3.Connection, user_id: int) -> None: + row = get_user(conn, user_id) + if row is None: + raise ValidationError("Dieses Konto existiert nicht.") + clash = Database.one( + conn, + "SELECT id FROM users WHERE username=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL", + (row["username"], user_id), + ) + if clash is not None: + raise ValidationError( + f"Es existiert bereits ein aktives Konto namens {row['username']!r}." + ) + conn.execute("UPDATE users SET deleted_at=NULL, updated_at=? WHERE id=?", (_now(), user_id)) + + +def purge_user(conn: sqlite3.Connection, user_id: int) -> None: + conn.execute("DELETE FROM users WHERE id=?", (user_id,)) + + +def matrix_state( + tree: PermissionTree, permissions: Iterable[str] +) -> dict[str, dict[str, bool]]: + """Hilfsstruktur fuer das Rendering der Rechtematrix.""" + granted = set(permissions) + state: dict[str, dict[str, bool]] = {} + for area in tree.areas: + state[area.key] = {"view": area.view_key in granted} + for resource in area.resources: + state[resource.key] = { + action.value: resource.permission(action) in granted for action in resource.actions + } + return state + + +def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> list[str]: + """Kurzfassung fuer Listenansichten: ``Bereich: Ressource (RCUD)``.""" + granted = set(permissions) + lines: list[str] = [] + for area in tree.areas: + if area.view_key not in granted: + continue + parts: list[str] = [] + for resource in area.resources: + letters = "".join( + ACTION_LETTERS[action] + for action in resource.actions + if resource.permission(action) in granted + ) + if letters: + parts.append(f"{resource.label} ({letters})") + if parts: + lines.append(f"{area.label}: " + ", ".join(parts)) + return lines + + +ACTION_LETTERS: dict[Action, str] = { + Action.VIEW: "R", + Action.CREATE: "C", + Action.EDIT: "U", + Action.DELETE: "D", + Action.EXECUTE: "X", + Action.EXPORT: "E", + Action.SECRETS: "S", +} diff --git a/packages/tesm-core/src/tesm_core/security/__init__.py b/packages/tesm-core/src/tesm_core/security/__init__.py new file mode 100644 index 0000000..3b7ba8e --- /dev/null +++ b/packages/tesm-core/src/tesm_core/security/__init__.py @@ -0,0 +1,7 @@ +"""Sicherheitsbausteine: Passwoerter, Sitzungen, CSRF, Ratenbegrenzung, TOTP.""" + +from __future__ import annotations + +from . import csrf, passwords, ratelimit, sessions, totp + +__all__ = ["csrf", "passwords", "ratelimit", "sessions", "totp"] diff --git a/packages/tesm-core/src/tesm_core/security/csrf.py b/packages/tesm-core/src/tesm_core/security/csrf.py new file mode 100644 index 0000000..da82d7f --- /dev/null +++ b/packages/tesm-core/src/tesm_core/security/csrf.py @@ -0,0 +1,180 @@ +"""CSRF-Schutz und Header-Haertung. + +Der Vorgaenger hatte **keinen** CSRF-Schutz: jede Aenderung lief ueber ein +gewoehnliches POST-Formular ohne Token. Eine praeparierte Seite konnte damit im +Namen eines eingeloggten Admins Geraete loeschen oder Benutzer anlegen. + +Hier drei Schichten: + +1. ``SameSite=Lax`` am Sitzungscookie (blockt Cross-Site-POSTs bereits im Browser). +2. Ein Token pro Sitzung, das in jedem Formular mitgeschickt und in konstanter + Zeit verglichen wird. +3. Origin-/Referer-Pruefung als Rueckfallebene fuer Browser ohne SameSite. + +Alle drei sind unabhaengig voneinander wirksam. +""" + +from __future__ import annotations + +import hmac +import secrets +from urllib.parse import urlparse + +from flask import Flask, Response, abort, current_app, request, session + +SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"}) +FIELD_NAME = "csrf_token" +HEADER_NAME = "X-CSRF-Token" +_SESSION_KEY = "_csrf" + + +class CsrfError(Exception): + """Wird als HTTP 403 mit erklaerender Seite ausgeliefert.""" + + +def token() -> str: + """Aktuelles Token der Sitzung, bei Bedarf neu erzeugt.""" + value = session.get(_SESSION_KEY) + if not value: + value = secrets.token_urlsafe(32) + session[_SESSION_KEY] = value + return str(value) + + +def rotate_token() -> str: + """Neues Token -- nach Anmeldung/Abmeldung und Rechtewechsel.""" + session.pop(_SESSION_KEY, None) + return token() + + +def _submitted_token() -> str: + for source in (request.form, request.args): + value = source.get(FIELD_NAME) + if value: + return value + header = request.headers.get(HEADER_NAME) + if header: + return header + if request.is_json: + try: + payload = request.get_json(silent=True) or {} + except Exception: # pragma: no cover - defensiv + payload = {} + if isinstance(payload, dict) and payload.get(FIELD_NAME): + return str(payload[FIELD_NAME]) + return "" + + +def _origin_ok() -> bool: + """Prueft Origin bzw. Referer gegen den Host der Anfrage.""" + origin = request.headers.get("Origin") or "" + referer = request.headers.get("Referer") or "" + candidate = origin or referer + if not candidate: + # Kein Origin/Referer: manche Clients (curl, alte Proxies) senden keinen. + # Das Token allein muss dann reichen. + return True + parsed = urlparse(candidate) + if not parsed.netloc: + return False + + # Hinter einem Reverse Proxy kann der Host je nach Konfiguration mit oder + # ohne Port ankommen. Beide Formen gelten als derselbe Ursprung; ein + # *anderer* Host tut das nie. + expected: set[str] = {request.host} + forwarded_host = (request.headers.get("X-Forwarded-Host") or "").split(",")[0].strip() + if forwarded_host: + expected.add(forwarded_host) + forwarded_port = (request.headers.get("X-Forwarded-Port") or "").strip() + if forwarded_port: + expected.update(f"{host.split(':')[0]}:{forwarded_port}" for host in set(expected)) + # Zusaetzlich die portlose Schreibweise -- aber bewusst **keine** beliebigen + # Ports: ein anderer Dienst auf demselben Host waere ein anderer Ursprung. + expected.update(host.split(":")[0] for host in set(expected)) + # Und die Standardports, falls der Proxy sie weglaesst. + scheme_port = "443" if request.headers.get("X-Forwarded-Proto") == "https" else "80" + expected.update(f"{host.split(':')[0]}:{scheme_port}" for host in set(expected)) + + return parsed.netloc in expected + + +def protect() -> None: + """``before_request``-Hook.""" + if request.method in SAFE_METHODS: + return + view = current_app.view_functions.get(request.endpoint or "") + if getattr(view, "_csrf_exempt", False): + return + if not _origin_ok(): + abort(403, description="Die Anfrage kam von einer fremden Herkunft (Origin/Referer).") + expected = session.get(_SESSION_KEY) or "" + provided = _submitted_token() + if not expected or not provided: + abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.") + if not hmac.compare_digest(str(expected), str(provided)): + abort(403, description="Das Sicherheitstoken des Formulars ist ungueltig. Bitte Seite neu laden.") + + +def exempt(view): + """Dekorator fuer Endpunkte mit eigener Authentisierung (Maschinen-APIs). + + Bewusst sparsam einsetzen: nur fuer Routen, die *nicht* ueber Cookies + autorisiert werden. Eine cookie-autorisierte Route ohne CSRF-Schutz ist eine + Luecke. + """ + view._csrf_exempt = True # type: ignore[attr-defined] + return view + + +def init_app(app: Flask) -> None: + app.before_request(protect) + app.jinja_env.globals["csrf_token"] = token + app.jinja_env.globals["csrf_field_name"] = FIELD_NAME + + +# --------------------------------------------------------------------------- # +# Sicherheits-Header +# --------------------------------------------------------------------------- # + + +def content_security_policy(nonce: str) -> str: + """Strikte CSP. + + Kein ``unsafe-inline`` fuer Skripte -- jedes Inline-Script traegt den Nonce + dieses Requests. Styles brauchen ``unsafe-inline``, weil Jinja an einzelnen + Stellen berechnete Breiten (Fortschrittsbalken) als ``style``-Attribut + setzt; Style-Attribute sind kein Skriptausfuehrungsvektor. + """ + return "; ".join( + [ + "default-src 'self'", + f"script-src 'self' 'nonce-{nonce}'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "base-uri 'none'", + "object-src 'none'", + "worker-src 'self'", + "manifest-src 'self'", + ] + ) + + +def apply_security_headers(response: Response, *, nonce: str, hsts: str = "") -> Response: + response.headers.setdefault("Content-Security-Policy", content_security_policy(nonce)) + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "same-origin") + response.headers.setdefault( + "Permissions-Policy", + "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()", + ) + response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin") + response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin") + response.headers.setdefault("X-Permitted-Cross-Domain-Policies", "none") + if hsts: + response.headers.setdefault("Strict-Transport-Security", hsts) + return response diff --git a/packages/tesm-core/src/tesm_core/security/passwords.py b/packages/tesm-core/src/tesm_core/security/passwords.py new file mode 100644 index 0000000..9ff682d --- /dev/null +++ b/packages/tesm-core/src/tesm_core/security/passwords.py @@ -0,0 +1,153 @@ +"""Passwort-Hashing: Argon2id, mit Uebergangspfad von bcrypt. + +Der Vorgaenger nutzte bcrypt (via Flask-Bcrypt). bcrypt ist nicht kaputt, aber +Argon2id ist der aktuelle Stand (speicherhart, damit deutlich teurer auf GPUs) +und die Empfehlung des OWASP-Passwort-Cheatsheets. + +Bestehende bcrypt-Hashes werden weiter akzeptiert und beim naechsten +erfolgreichen Login transparent auf Argon2id migriert -- ein Import einer alten +Datenbank sperrt also niemanden aus. +""" + +from __future__ import annotations + +import hmac +import re +import secrets +import unicodedata +from dataclasses import dataclass + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError + +# Parameter nach OWASP-Empfehlung (m=64 MiB, t=3, p=2). Bei ~50 ms pro Hash auf +# der Ziel-VM bleibt der Login fluessig und Offline-Angriffe teuer. +_HASHER = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2, hash_len=32, salt_len=16) + +_BCRYPT_RE = re.compile(r"^\$2[aby]\$\d{2}\$") + +#: Passwoerter, die selbst mit 12 Zeichen nichts wert sind. Kurze, kuratierte +#: Liste -- eine vollstaendige Breach-Datenbank gehoert nicht in dieses Repo. +_FORBIDDEN = { + "passwort1234", + "password1234", + "administrator", + "qwertzuiop12", + "123456789012", + "tesmtesmtesm", + "willkommen12", + "geheim123456", +} + + +@dataclass(frozen=True, slots=True) +class PasswordPolicy: + min_length: int = 12 + require_classes: int = 2 + """Anzahl verschiedener Zeichenklassen (klein/gross/Ziffer/Sonderzeichen).""" + + def check(self, password: str, *, username: str = "") -> list[str]: + """Gibt eine Liste von Klartext-Problemen zurueck (leer = in Ordnung).""" + problems: list[str] = [] + normalized = normalize(password) + if len(normalized) < self.min_length: + problems.append(f"Mindestens {self.min_length} Zeichen erforderlich.") + classes = sum( + bool(pattern.search(normalized)) + for pattern in ( + # Kleinbuchstaben inkl. Latin-1-Supplement (Umlaute, Akzente). + re.compile("[a-zß-ÿ]"), + re.compile("[A-ZÀ-Þ]"), + re.compile(r"[0-9]"), + re.compile("[^A-Za-z0-9À-ÿ]"), + ) + ) + if classes < self.require_classes: + problems.append( + f"Mindestens {self.require_classes} verschiedene Zeichenarten " + "(Klein-/Grossbuchstaben, Ziffern, Sonderzeichen)." + ) + if normalized.lower() in _FORBIDDEN: + problems.append("Dieses Passwort ist zu haeufig und damit unsicher.") + if username and len(username) >= 3 and username.lower() in normalized.lower(): + problems.append("Das Passwort darf den Benutzernamen nicht enthalten.") + if len(set(normalized)) <= 3 and normalized: + problems.append("Das Passwort besteht aus zu wenigen verschiedenen Zeichen.") + return problems + + +def normalize(password: str) -> str: + """NFKC-Normalisierung. + + Ohne das schlaegt eine Anmeldung fehl, wenn ein Passwort mit Umlauten auf + einem macOS-Client (NFD) gesetzt und auf Windows (NFC) eingegeben wird. + """ + return unicodedata.normalize("NFKC", password or "") + + +def hash_password(password: str) -> str: + return _HASHER.hash(normalize(password)) + + +def is_legacy_hash(stored: str) -> bool: + return bool(_BCRYPT_RE.match(stored or "")) + + +def verify_password(stored: str, password: str) -> bool: + """Prueft ein Passwort gegen einen Argon2id- oder bcrypt-Hash. + + Laeuft bei leerem/kaputtem Hash bewusst in einen echten Argon2-Verify gegen + einen Dummy-Hash, damit die Antwortzeit nicht verraet, ob ein Konto + existiert (Timing-Orakel). + """ + stored = stored or "" + if is_legacy_hash(stored): + return _verify_bcrypt(stored, password) + try: + return _HASHER.verify(stored, normalize(password)) + except (VerifyMismatchError, VerificationError, InvalidHashError, ValueError): + return False + + +def _verify_bcrypt(stored: str, password: str) -> bool: + try: + import bcrypt # lokaler Import: nur fuer Altbestaende noetig + except ImportError: # pragma: no cover + return False + try: + return bcrypt.checkpw(normalize(password).encode("utf-8"), stored.encode("utf-8")) + except (ValueError, TypeError): + return False + + +def needs_rehash(stored: str) -> bool: + """True, wenn der Hash veraltet ist (bcrypt oder zu schwache Argon2-Parameter).""" + if is_legacy_hash(stored): + return True + try: + return _HASHER.check_needs_rehash(stored) + except (InvalidHashError, ValueError): + return True + + +def dummy_verify() -> None: + """Verbrennt dieselbe Rechenzeit wie ein echter Verify. + + Wird aufgerufen, wenn ein Benutzername nicht existiert -- so ist die + Antwortzeit von "Konto existiert nicht" nicht von "falsches Passwort" zu + unterscheiden. + """ + global _DUMMY_HASH + if not _DUMMY_HASH: + _DUMMY_HASH = _HASHER.hash(secrets.token_urlsafe(24)) + try: + _HASHER.verify(_DUMMY_HASH, "nicht-das-richtige-passwort") + except (VerifyMismatchError, VerificationError, InvalidHashError): + pass + + +_DUMMY_HASH = "" + + +def constant_time_equals(left: str, right: str) -> bool: + return hmac.compare_digest((left or "").encode("utf-8"), (right or "").encode("utf-8")) diff --git a/packages/tesm-core/src/tesm_core/security/ratelimit.py b/packages/tesm-core/src/tesm_core/security/ratelimit.py new file mode 100644 index 0000000..5fb35c9 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/security/ratelimit.py @@ -0,0 +1,84 @@ +"""Ratenbegrenzung und Kontosperre, in SQLite persistiert. + +Der Vorgaenger hatte keinerlei Bremse: ein Skript konnte beliebig schnell +Passwoerter gegen ``/login`` durchprobieren, und ein Neustart des Dienstes +haette einen In-Memory-Zaehler ohnehin geleert. + +Hier: ein Sliding-Window-Log in der Tabelle ``rate_events``. Persistent, damit +ein Dienst-Neustart die Bremse nicht aufhebt, und mit derselben Verbindung +nutzbar, die der Request schon offen hat (keine zweite Verbindung -> kein +Deadlock). +""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from ..db import Database + + +@dataclass(frozen=True, slots=True) +class RateVerdict: + allowed: bool + remaining: int + retry_after_seconds: int + + @property + def blocked(self) -> bool: + return not self.allowed + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(value: datetime) -> str: + return value.isoformat(timespec="seconds") + + +def record(conn: sqlite3.Connection, bucket: str, key: str) -> None: + """Vermerkt ein Ereignis (z. B. einen Fehlversuch).""" + conn.execute( + "INSERT INTO rate_events (bucket, subject, ts) VALUES (?,?,?)", + (bucket, key, _iso(_now())), + ) + + +def check( + conn: sqlite3.Connection, + bucket: str, + key: str, + *, + limit: int, + window_minutes: int, +) -> RateVerdict: + """Prueft, ob noch Versuche frei sind. Zaehlt selbst nichts hoch.""" + horizon = _iso(_now() - timedelta(minutes=window_minutes)) + rows = Database.all( + conn, + "SELECT ts FROM rate_events WHERE bucket=? AND subject=? AND ts>=? ORDER BY ts", + (bucket, key, horizon), + ) + used = len(rows) + if used < limit: + return RateVerdict(allowed=True, remaining=limit - used, retry_after_seconds=0) + + oldest = datetime.fromisoformat(rows[0]["ts"]) + if oldest.tzinfo is None: + oldest = oldest.replace(tzinfo=timezone.utc) + retry_after = int((oldest + timedelta(minutes=window_minutes) - _now()).total_seconds()) + return RateVerdict(allowed=False, remaining=0, retry_after_seconds=max(1, retry_after)) + + +def clear(conn: sqlite3.Connection, bucket: str, key: str) -> None: + """Loescht die Ereignisse eines Subjekts -- nach erfolgreicher Anmeldung.""" + conn.execute("DELETE FROM rate_events WHERE bucket=? AND subject=?", (bucket, key)) + + +def prune(conn: sqlite3.Connection, *, keep_hours: int = 48) -> int: + """Raeumt alte Ereignisse ab. Laeuft im Wartungslauf mit.""" + horizon = _iso(_now() - timedelta(hours=keep_hours)) + cursor = conn.execute("DELETE FROM rate_events WHERE ts < ?", (horizon,)) + return cursor.rowcount or 0 diff --git a/packages/tesm-core/src/tesm_core/security/sessions.py b/packages/tesm-core/src/tesm_core/security/sessions.py new file mode 100644 index 0000000..b9a01f1 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/security/sessions.py @@ -0,0 +1,286 @@ +"""Serverseitige Sitzungen in SQLite. + +Der Vorgaenger nutzte Flasks signierte Cookie-Sitzungen. Das ist bequem, hat +aber drei Nachteile, die fuer eine Verwaltungsanwendung mit Adminrechten +relevant sind: + +1. **Nicht widerrufbar.** Ein gestohlenes Cookie bleibt bis zum Ablauf gueltig; + "alle Sitzungen beenden" ist unmoeglich. +2. **Kein Leerlauf-Timeout.** Cookie-Sitzungen kennen nur ein absolutes Ablauf- + datum, keine Inaktivitaet. +3. **Sitzungsinhalt liegt beim Client.** Rotieren des Flask-Secret-Keys wirft + alle Nutzer heraus; ausserdem ist jede Sitzungsvariable im Browser sichtbar + (signiert, aber nicht verschluesselt). + +Hier: eine Zeile pro Sitzung in ``user_sessions``, der Cookie traegt nur eine +256-Bit-Zufalls-ID. Damit sind Widerruf, Leerlauf-Timeout, ein absolutes +Maximum, eine Uebersicht "meine aktiven Sitzungen" und Sitzungsrotation beim +Rechtewechsel moeglich. +""" + +from __future__ import annotations + +import json +import secrets +import sqlite3 +from datetime import datetime, timedelta, timezone +from typing import Any + +from flask import Flask, Request, Response +from flask.sessions import SessionInterface, SessionMixin +from werkzeug.datastructures import CallbackDict + +from ..db import Database + +SESSION_ID_BYTES = 32 + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(value: datetime) -> str: + return value.isoformat(timespec="seconds") + + +def _parse(value: str | None) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +class ServerSession(CallbackDict, SessionMixin): + """Sitzungsobjekt, das seine Aenderungen selbst als "dirty" markiert.""" + + def __init__(self, initial: dict[str, Any] | None = None, sid: str = "", *, new: bool = False): + def on_update(_self: Any) -> None: + self.modified = True + + super().__init__(initial or {}, on_update) + self.sid = sid + self.new = new + self.modified = False + self.destroyed = False + #: Von der Datenbank geladene Metadaten (nur lesend fuer den Aufrufer). + self.meta: dict[str, Any] = {} + + def destroy(self) -> None: + """Sitzung serverseitig verwerfen (Logout).""" + self.clear() + self.destroyed = True + self.modified = True + + +class SqliteSessionInterface(SessionInterface): + """Flask-Sitzungsschnittstelle gegen die Tabelle ``user_sessions``.""" + + session_class = ServerSession + pickle_based = False + + def __init__( + self, + database: Database, + *, + cookie_name: str, + idle_minutes: int, + absolute_hours: int, + ) -> None: + self.database = database + self.cookie_name = cookie_name + self.idle_minutes = idle_minutes + self.absolute_hours = absolute_hours + + # -- Flask-Schnittstelle ------------------------------------------- # + + def open_session(self, app: Flask, request: Request) -> ServerSession: + sid = request.cookies.get(self.cookie_name, "") + if not sid or len(sid) < 40: + return self.session_class(new=True) + + with self.database.session() as conn: + row = Database.one( + conn, + "SELECT * FROM user_sessions WHERE id=? AND revoked_at IS NULL", + (sid,), + ) + if row is None: + return self.session_class(new=True) + + now = _now() + idle_deadline = _parse(row.get("idle_expires_at")) + absolute_deadline = _parse(row.get("absolute_expires_at")) + if (idle_deadline and now > idle_deadline) or ( + absolute_deadline and now > absolute_deadline + ): + with self.database.transaction(conn): + conn.execute( + "UPDATE user_sessions SET revoked_at=?, revoked_reason='expired' " + "WHERE id=?", + (_iso(now), sid), + ) + return self.session_class(new=True) + + try: + data = json.loads(row.get("data") or "{}") + except ValueError: + data = {} + + session = self.session_class(data, sid) + session.meta = { + "user_id": row.get("user_id"), + "created_at": row.get("created_at"), + "last_seen_at": row.get("last_seen_at"), + "reauth_at": row.get("reauth_at"), + "absolute_expires_at": row.get("absolute_expires_at"), + "ip": row.get("ip"), + "user_agent": row.get("user_agent"), + } + # Leerlauffenster verlaengern, aber hoechstens einmal pro Minute + # schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang. + last_seen = _parse(row.get("last_seen_at")) + if last_seen is None or (now - last_seen) > timedelta(seconds=60): + with self.database.transaction(conn): + conn.execute( + "UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?", + (_iso(now), _iso(now + timedelta(minutes=self.idle_minutes)), sid), + ) + return session + + def save_session(self, app: Flask, session: ServerSession, response: Response) -> None: # type: ignore[override] + name = self.cookie_name + domain = self.get_cookie_domain(app) + path = self.get_cookie_path(app) + + if session.destroyed: + if session.sid: + with self.database.session() as conn, self.database.transaction(conn): + conn.execute( + "UPDATE user_sessions SET revoked_at=?, revoked_reason='logout' WHERE id=?", + (_iso(_now()), session.sid), + ) + response.delete_cookie(name, domain=domain, path=path) + return + + if not session: + # Leere Sitzung: nichts speichern, aber ein evtl. vorhandenes Cookie + # nicht anfassen (sonst wuerde jeder anonyme Request es loeschen). + if session.modified and session.sid: + response.delete_cookie(name, domain=domain, path=path) + return + + if not session.modified and not session.new: + return + + now = _now() + payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True) + with self.database.session() as conn, self.database.transaction(conn): + if session.new or not session.sid: + session.sid = secrets.token_urlsafe(SESSION_ID_BYTES) + conn.execute( + "INSERT INTO user_sessions " + "(id, user_id, created_at, last_seen_at, idle_expires_at, " + " absolute_expires_at, ip, user_agent, data) " + "VALUES (?,?,?,?,?,?,?,?,?)", + ( + session.sid, + session.get("user_id"), + _iso(now), + _iso(now), + _iso(now + timedelta(minutes=self.idle_minutes)), + _iso(now + timedelta(hours=self.absolute_hours)), + session.get("_ip", ""), + session.get("_ua", ""), + payload, + ), + ) + session.new = False + else: + conn.execute( + "UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, " + "idle_expires_at=?, reauth_at=? WHERE id=?", + ( + session.get("user_id"), + payload, + _iso(now), + _iso(now + timedelta(minutes=self.idle_minutes)), + session.get("_reauth_at"), + session.sid, + ), + ) + + response.set_cookie( + name, + session.sid, + httponly=True, + secure=self.get_cookie_secure(app), + samesite=self.get_cookie_samesite(app), + domain=domain, + path=path, + max_age=None, # Sitzungscookie: verschwindet beim Schliessen des Browsers. + ) + response.vary.add("Cookie") + + +# --------------------------------------------------------------------------- # +# Verwaltung +# --------------------------------------------------------------------------- # + + +def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "privilege-change") -> str: + """Erzeugt eine neue Sitzungs-ID und entwertet die alte (Session-Fixation). + + Wird bei jeder Anmeldung und bei jeder Rechteaenderung aufgerufen. + """ + old_sid = session.sid + if old_sid: + conn.execute( + "UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?", + (_iso(_now()), reason, old_sid), + ) + session.sid = "" + session.new = True + session.modified = True + return old_sid + + +def revoke(conn: sqlite3.Connection, sid: str, *, reason: str = "manual") -> None: + conn.execute( + "UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=? AND revoked_at IS NULL", + (_iso(_now()), reason, sid), + ) + + +def revoke_all_for_user( + conn: sqlite3.Connection, user_id: int, *, except_sid: str = "", reason: str = "manual" +) -> int: + cursor = conn.execute( + "UPDATE user_sessions SET revoked_at=?, revoked_reason=? " + "WHERE user_id=? AND revoked_at IS NULL AND id <> ?", + (_iso(_now()), reason, user_id, except_sid), + ) + return cursor.rowcount or 0 + + +def active_for_user(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]: + return Database.all( + conn, + "SELECT id, created_at, last_seen_at, absolute_expires_at, ip, user_agent " + "FROM user_sessions WHERE user_id=? AND revoked_at IS NULL " + "AND idle_expires_at > ? ORDER BY last_seen_at DESC", + (user_id, _iso(_now())), + ) + + +def prune(conn: sqlite3.Connection, *, keep_days: int = 30) -> int: + """Entfernt abgelaufene und widerrufene Sitzungen.""" + horizon = _iso(_now() - timedelta(days=keep_days)) + cursor = conn.execute( + "DELETE FROM user_sessions WHERE (revoked_at IS NOT NULL AND revoked_at < ?) " + "OR idle_expires_at < ?", + (horizon, horizon), + ) + return cursor.rowcount or 0 diff --git a/packages/tesm-core/src/tesm_core/security/totp.py b/packages/tesm-core/src/tesm_core/security/totp.py new file mode 100644 index 0000000..5b4df99 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/security/totp.py @@ -0,0 +1,84 @@ +"""TOTP (RFC 6238) und Wiederherstellungscodes -- ohne externe Abhaengigkeit. + +Zwei-Faktor-Authentisierung gab es im Vorgaengerprojekt nicht. Sie ist hier +optional pro Benutzer aktivierbar und kann fuer Administratoren erzwungen +werden (``SecurityConfig.require_totp_for_admins``). + +Implementiert mit ``hmac``/``hashlib`` aus der Standardbibliothek -- 60 Zeilen +gegenueber einer weiteren Abhaengigkeit im Deployment. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +import struct +import time +from urllib.parse import quote + +DIGITS = 6 +PERIOD = 30 +#: Toleranz in Zeitschritten (+/- 1 Schritt = +/- 30 s Uhrendrift). +WINDOW = 1 +SECRET_BYTES = 20 +RECOVERY_CODE_COUNT = 8 + + +def generate_secret() -> str: + """Base32-Geheimnis, wie es Authenticator-Apps erwarten (ohne Padding).""" + return base64.b32encode(secrets.token_bytes(SECRET_BYTES)).decode("ascii").rstrip("=") + + +def _decode_secret(secret: str) -> bytes: + cleaned = (secret or "").strip().replace(" ", "").upper() + cleaned += "=" * (-len(cleaned) % 8) + return base64.b32decode(cleaned, casefold=True) + + +def code_at(secret: str, counter: int) -> str: + digest = hmac.new(_decode_secret(secret), struct.pack(">Q", counter), hashlib.sha1).digest() + offset = digest[-1] & 0x0F + truncated = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF + return str(truncated % (10**DIGITS)).zfill(DIGITS) + + +def current_code(secret: str, *, now: float | None = None) -> str: + return code_at(secret, int((now if now is not None else time.time()) // PERIOD)) + + +def verify_code(secret: str, code: str, *, now: float | None = None) -> bool: + """Prueft einen Code mit Drift-Toleranz, in konstanter Zeit pro Kandidat.""" + cleaned = "".join(ch for ch in (code or "") if ch.isdigit()) + if len(cleaned) != DIGITS: + return False + counter = int((now if now is not None else time.time()) // PERIOD) + hit = False + for step in range(-WINDOW, WINDOW + 1): + candidate = counter + step + if candidate < 0: # nur bei Testzeitpunkten nahe der Epoche moeglich + continue + # Nicht fruehzeitig abbrechen: alle Kandidaten pruefen, damit die Laufzeit + # nicht verraet, welcher Zeitschritt gepasst hat. + hit |= hmac.compare_digest(code_at(secret, candidate), cleaned) + return hit + + +def provisioning_uri(secret: str, *, account: str, issuer: str) -> str: + """``otpauth://``-URI fuer den QR-Code.""" + label = quote(f"{issuer}:{account}", safe="") + return ( + f"otpauth://totp/{label}?secret={secret}&issuer={quote(issuer, safe='')}" + f"&algorithm=SHA1&digits={DIGITS}&period={PERIOD}" + ) + + +def format_secret(secret: str) -> str: + """Gruppiert das Geheimnis in Vierergruppen -- fuer die manuelle Eingabe.""" + return " ".join(secret[i : i + 4] for i in range(0, len(secret), 4)) + + +def generate_recovery_codes(count: int = RECOVERY_CODE_COUNT) -> list[str]: + """Einmal-Codes fuer den Fall eines verlorenen Authenticators.""" + return [f"{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}" for _ in range(count)] diff --git a/packages/tesm-core/src/tesm_core/settings_store.py b/packages/tesm-core/src/tesm_core/settings_store.py new file mode 100644 index 0000000..6e01881 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/settings_store.py @@ -0,0 +1,165 @@ +"""Typisierter Key/Value-Store fuer Laufzeiteinstellungen. + +Der Vorgaenger nutzte eine ``settings``-Tabelle als reinen String-Store; jeder +Aufrufer parste selbst und erfand seinen eigenen Default. Hier gibt es eine +Registry: jede Einstellung deklariert Typ, Default, Label und Bereich. Damit +sind Formulare, Import/Export und die Diagnoseseite generisch. +""" + +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Iterable, Literal, Sequence + +from .db import Database + +Kind = Literal["str", "int", "bool", "json", "secret"] + + +@dataclass(frozen=True, slots=True) +class SettingSpec: + key: str + default: Any = "" + kind: Kind = "str" + label: str = "" + hint: str = "" + section: str = "allgemein" + #: Wertebereich fuer ``int``. + minimum: int | None = None + maximum: int | None = None + #: Auswahlliste fuer ``str``: ``[(wert, label), ...]``. + choices: tuple[tuple[str, str], ...] = () + #: ``secret``-Werte werden in der Oberflaeche nie zurueckgegeben, nur ersetzt. + export: bool = True + + def parse(self, raw: str | None) -> Any: + if raw is None: + return self.default + if self.kind == "int": + try: + value = int(str(raw).strip()) + except (TypeError, ValueError): + return self.default + if self.minimum is not None: + value = max(self.minimum, value) + if self.maximum is not None: + value = min(self.maximum, value) + return value + if self.kind == "bool": + return str(raw).strip().lower() in {"1", "true", "yes", "on"} + if self.kind == "json": + try: + return json.loads(raw) if raw else self.default + except ValueError: + return self.default + return str(raw) + + def serialize(self, value: Any) -> str: + if self.kind == "bool": + return "1" if value else "0" + if self.kind == "json": + return json.dumps(value, separators=(",", ":"), sort_keys=True) + if self.kind == "int": + return str(int(value)) + return "" if value is None else str(value) + + +class SettingsStore: + """Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung.""" + + def __init__(self, specs: Iterable[SettingSpec]) -> None: + self._specs: dict[str, SettingSpec] = {spec.key: spec for spec in specs} + + # ------------------------------------------------------------------ # + + def register(self, specs: Iterable[SettingSpec]) -> None: + for spec in specs: + self._specs[spec.key] = spec + + def spec(self, key: str) -> SettingSpec: + return self._specs.get(key) or SettingSpec(key=key) + + def specs(self, section: str | None = None) -> list[SettingSpec]: + values = list(self._specs.values()) + if section is not None: + values = [spec for spec in values if spec.section == section] + return values + + # ------------------------------------------------------------------ # + + def get(self, conn: sqlite3.Connection, key: str, default: Any = None) -> Any: + spec = self.spec(key) + raw = Database.value(conn, "SELECT value FROM settings WHERE key=?", (key,)) + if raw is None: + return spec.default if default is None else default + return spec.parse(raw) + + def get_many(self, conn: sqlite3.Connection, keys: Sequence[str]) -> dict[str, Any]: + if not keys: + return {} + placeholders = ",".join("?" * len(keys)) + rows = Database.all( + conn, f"SELECT key, value FROM settings WHERE key IN ({placeholders})", tuple(keys) + ) + stored = {row["key"]: row["value"] for row in rows} + return {key: self.spec(key).parse(stored.get(key)) for key in keys} + + def section_values(self, conn: sqlite3.Connection, section: str) -> dict[str, Any]: + keys = [spec.key for spec in self.specs(section)] + return self.get_many(conn, keys) + + def set( + self, + conn: sqlite3.Connection, + key: str, + value: Any, + *, + actor: str = "", + ) -> None: + """Setzt einen Wert. Nutzt die uebergebene Verbindung -- niemals eine eigene. + + Genau hier lag im Vorgaenger eine Deadlock-Quelle: ``set_setting()`` + oeffnete intern eine zweite Verbindung und lief dadurch im Import-Pfad in + ``database is locked``. + """ + spec = self.spec(key) + conn.execute( + "INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?,?,?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value, " + "updated_at=excluded.updated_at, updated_by=excluded.updated_by", + ( + key, + spec.serialize(value), + datetime.now(timezone.utc).isoformat(timespec="seconds"), + actor, + ), + ) + + def set_many( + self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = "" + ) -> list[str]: + """Setzt mehrere Werte, gibt die tatsaechlich geaenderten Schluessel zurueck.""" + changed: list[str] = [] + current = self.get_many(conn, list(values)) + for key, value in values.items(): + spec = self.spec(key) + if spec.serialize(current.get(key)) == spec.serialize(value): + continue + self.set(conn, key, value, actor=actor) + changed.append(key) + return changed + + def delete(self, conn: sqlite3.Connection, key: str) -> None: + conn.execute("DELETE FROM settings WHERE key=?", (key,)) + + def all_values(self, conn: sqlite3.Connection) -> dict[str, Any]: + rows = Database.all(conn, "SELECT key, value FROM settings") + stored = {row["key"]: row["value"] for row in rows} + result = {key: spec.parse(stored.get(key)) for key, spec in self._specs.items()} + # Auch Schluessel ohne Spezifikation ausliefern (App-eigene Ad-hoc-Werte). + for key, raw in stored.items(): + result.setdefault(key, raw) + return result diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/css/base.css b/packages/tesm-core/src/tesm_core/static/tesm_core/css/base.css new file mode 100644 index 0000000..516a57f --- /dev/null +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/css/base.css @@ -0,0 +1,720 @@ +/* ========================================================================== + TESM Design-System -- Teil 1: Tokens, Reset, Layout + -------------------------------------------------------------------------- + Kein Bootstrap, kein Tailwind, kein Build-Schritt. Handgeschriebenes CSS in + Layern, damit die Spezifitaet vorhersehbar bleibt. + + Gegenueber dem Vorgaenger (eine 1550-Zeilen-Datei, flach, ohne Layer): + - Tokens sind konsequent semantisch (--surface-2 statt --bg-card-hover), + damit Themes ohne Umbenennen von Komponenten getauscht werden koennen. + - Drei Theme-Zustaende: System (Default), hell, dunkel. Der Vorgaenger kannte + nur "dunkel oder hell" und ignorierte die Systemeinstellung. + - Fokusringe sind ueberall sichtbar (:focus-visible), Kontraste erfuellen + WCAG AA, und prefers-reduced-motion wird respektiert. + ========================================================================== */ + +@layer tokens, reset, layout, components, utilities; + +@layer tokens { + :root { + /* -- Markenfarbe: WiS-Orange, in eine vollstaendige Skala aufgezogen ---- */ + --brand-050: #fff4ec; + --brand-100: #ffe3d1; + --brand-200: #ffc3a1; + --brand-300: #ff9f6b; + --brand-400: #ff8639; + --brand-500: #ff7100; + --brand-600: #e05f00; + --brand-700: #b84c00; + --brand-800: #8a3900; + + /* -- Neutrale, leicht kuehle Grauskala ---------------------------------- */ + --neutral-000: #ffffff; + --neutral-025: #fafafb; + --neutral-050: #f4f5f7; + --neutral-100: #eaecf0; + --neutral-200: #d7dae1; + --neutral-300: #b8bdc9; + --neutral-400: #8f96a6; + --neutral-500: #6b7383; + --neutral-600: #4d5462; + --neutral-700: #363c48; + --neutral-800: #23272f; + --neutral-850: #1a1d24; + --neutral-900: #14171c; + --neutral-950: #0d0f13; + + /* -- Semantische Statusfarben ------------------------------------------ */ + --green-400: #34d399; + --green-500: #16a34a; + --green-600: #15803d; + --red-400: #f87171; + --red-500: #dc2626; + --red-600: #b91c1c; + --amber-400: #fbbf24; + --amber-500: #d97706; + --blue-400: #60a5fa; + --blue-500: #2563eb; + --violet-400: #a78bfa; + + /* -- Abstaende (4er-Raster) -------------------------------------------- */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-8: 32px; + --sp-10: 40px; + --sp-12: 48px; + --sp-16: 64px; + + /* -- Radien ------------------------------------------------------------ */ + --r-xs: 5px; + --r-sm: 8px; + --r-md: 11px; + --r-lg: 16px; + --r-xl: 22px; + --r-full: 999px; + + /* -- Typografie -------------------------------------------------------- */ + --font-sans: "Inter var", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", + "Cascadia Mono", Consolas, "Liberation Mono", Menlo, monospace; + --fs-xs: 0.6875rem; /* 11px */ + --fs-sm: 0.8125rem; /* 13px */ + --fs-base: 0.875rem; /* 14px */ + --fs-md: 0.9375rem; /* 15px */ + --fs-lg: 1.0625rem; /* 17px */ + --fs-xl: 1.3125rem; /* 21px */ + --fs-2xl: 1.625rem; /* 26px */ + --fs-3xl: 2rem; + --lh-tight: 1.2; + --lh-normal: 1.5; + --lh-relaxed: 1.65; + + /* -- Struktur ---------------------------------------------------------- */ + --sidebar-w: 258px; + --sidebar-rail-w: 68px; + --topbar-h: 60px; + --content-max: 1680px; + + /* -- Bewegung ---------------------------------------------------------- */ + --ease: cubic-bezier(0.32, 0.72, 0, 1); + --dur-fast: 120ms; + --dur: 180ms; + --dur-slow: 300ms; + + --z-sticky: 30; + --z-drawer: 60; + --z-modal: 80; + --z-toast: 100; + } + + /* -- Helles Theme (Basis) ------------------------------------------------ */ + :root, + :root[data-theme="light"] { + color-scheme: light; + + --canvas: var(--neutral-050); + --surface-1: var(--neutral-000); + --surface-2: var(--neutral-025); + --surface-3: var(--neutral-100); + --surface-inset: var(--neutral-050); + --surface-overlay: rgba(20, 23, 28, 0.45); + + --border: #e2e5ea; + --border-strong: #cdd2db; + --border-subtle: #eef0f4; + + --text: #171a20; + --text-muted: #5b6371; + --text-faint: #868d9b; + --text-inverse: #ffffff; + + --accent: var(--brand-600); + + /* Marke. Eigene Token statt --accent: das Zeichen hat seine eigenen + Farbwerte und soll sich nicht mitverschieben, wenn der Akzent sich + aendert. */ + --logo-plate: #edf1f2; + --logo-plate-line: #c1cbce; + --logo-mark: #b5750e; + --logo-text: #172026; + --accent-hover: var(--brand-700); + --accent-text: #ffffff; + --accent-soft: var(--brand-050); + --accent-soft-text: var(--brand-700); + --accent-ring: rgba(224, 95, 0, 0.32); + + --success: var(--green-600); + --success-soft: #e8f7ee; + --danger: var(--red-600); + --danger-soft: #fdecec; + --warning: var(--amber-500); + --warning-soft: #fdf3e2; + --info: var(--blue-500); + --info-soft: #eaf1fe; + --neutral-pill: #eef0f4; + --neutral-pill-text: #5b6371; + + --shadow-xs: 0 1px 2px rgba(16, 20, 28, 0.06); + --shadow-sm: 0 2px 6px rgba(16, 20, 28, 0.07), 0 1px 2px rgba(16, 20, 28, 0.05); + --shadow-md: 0 8px 20px -6px rgba(16, 20, 28, 0.14), 0 2px 6px rgba(16, 20, 28, 0.06); + --shadow-lg: 0 22px 48px -16px rgba(16, 20, 28, 0.24), 0 4px 12px rgba(16, 20, 28, 0.08); + --shadow-accent: 0 6px 18px -6px rgba(224, 95, 0, 0.45); + } + + /* -- Dunkles Theme ------------------------------------------------------- */ + @media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + /* Marke, dunkle Fassung. */ + --logo-plate: #10151b; + --logo-plate-line: transparent; + --logo-mark: #e2a63c; + --logo-text: #e7ecee; + + color-scheme: dark; + + --canvas: #0b0d11; + --surface-1: #14171c; + --surface-2: #191d23; + --surface-3: #21262e; + --surface-inset: #101318; + --surface-overlay: rgba(5, 6, 9, 0.66); + + --border: #262b34; + --border-strong: #333944; + --border-subtle: #1e222a; + + --text: #eceef2; + --text-muted: #a0a8b6; + --text-faint: #6f7787; + --text-inverse: #14171c; + + --accent: var(--brand-500); + --accent-hover: var(--brand-400); + --accent-text: #1a0d02; + --accent-soft: rgba(255, 113, 0, 0.14); + --accent-soft-text: var(--brand-300); + --accent-ring: rgba(255, 113, 0, 0.42); + + --success: var(--green-400); + --success-soft: rgba(52, 211, 153, 0.14); + --danger: var(--red-400); + --danger-soft: rgba(248, 113, 113, 0.15); + --warning: var(--amber-400); + --warning-soft: rgba(251, 191, 36, 0.15); + --info: var(--blue-400); + --info-soft: rgba(96, 165, 250, 0.15); + --neutral-pill: rgba(160, 168, 182, 0.14); + --neutral-pill-text: #a0a8b6; + + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.45); + --shadow-md: 0 10px 26px -8px rgba(0, 0, 0, 0.6); + --shadow-lg: 0 26px 56px -18px rgba(0, 0, 0, 0.72); + --shadow-accent: 0 6px 20px -8px rgba(255, 113, 0, 0.5); + } + } + + :root[data-theme="dark"] { + /* Marke, dunkle Fassung. */ + --logo-plate: #10151b; + --logo-plate-line: transparent; + --logo-mark: #e2a63c; + --logo-text: #e7ecee; + + color-scheme: dark; + + --canvas: #0b0d11; + --surface-1: #14171c; + --surface-2: #191d23; + --surface-3: #21262e; + --surface-inset: #101318; + --surface-overlay: rgba(5, 6, 9, 0.66); + + --border: #262b34; + --border-strong: #333944; + --border-subtle: #1e222a; + + --text: #eceef2; + --text-muted: #a0a8b6; + --text-faint: #6f7787; + --text-inverse: #14171c; + + --accent: var(--brand-500); + --accent-hover: var(--brand-400); + --accent-text: #1a0d02; + --accent-soft: rgba(255, 113, 0, 0.14); + --accent-soft-text: var(--brand-300); + --accent-ring: rgba(255, 113, 0, 0.42); + + --success: var(--green-400); + --success-soft: rgba(52, 211, 153, 0.14); + --danger: var(--red-400); + --danger-soft: rgba(248, 113, 113, 0.15); + --warning: var(--amber-400); + --warning-soft: rgba(251, 191, 36, 0.15); + --info: var(--blue-400); + --info-soft: rgba(96, 165, 250, 0.15); + --neutral-pill: rgba(160, 168, 182, 0.14); + --neutral-pill-text: #a0a8b6; + + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.45); + --shadow-md: 0 10px 26px -8px rgba(0, 0, 0, 0.6); + --shadow-lg: 0 26px 56px -18px rgba(0, 0, 0, 0.72); + --shadow-accent: 0 6px 20px -8px rgba(255, 113, 0, 0.5); + } +} + +@layer reset { + *, + *::before, + *::after { + box-sizing: border-box; + } + + html { + -webkit-text-size-adjust: 100%; + tab-size: 4; + } + + body { + margin: 0; + min-height: 100dvh; + background: var(--canvas); + color: var(--text); + font: var(--fs-base) / var(--lh-normal) var(--font-sans); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + + h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd, ul, ol { + margin: 0; + } + + ul, ol { + padding: 0; + list-style: none; + } + + h1, h2, h3, h4 { + line-height: var(--lh-tight); + font-weight: 640; + letter-spacing: -0.011em; + text-wrap: balance; + } + + h1 { font-size: var(--fs-2xl); } + h2 { font-size: var(--fs-xl); } + h3 { font-size: var(--fs-lg); } + h4 { font-size: var(--fs-md); } + + a { + color: var(--accent); + text-decoration: none; + } + a:hover { text-decoration: underline; } + + img, svg, video, canvas { + display: block; + max-width: 100%; + } + + button, input, select, textarea { + font: inherit; + color: inherit; + } + + code, kbd, pre, samp { + font-family: var(--font-mono); + font-size: 0.92em; + } + + hr { + height: 1px; + margin: var(--sp-5) 0; + border: 0; + background: var(--border); + } + + :where(a, button, input, select, textarea, summary, [tabindex]):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--r-xs); + } + + ::selection { + background: var(--accent-soft); + color: var(--accent-soft-text); + } + + ::placeholder { color: var(--text-faint); opacity: 1; } + + [hidden] { display: none !important; } + + /* Scrollbars: dezent, aber sichtbar */ + * { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; + } + *::-webkit-scrollbar { width: 10px; height: 10px; } + *::-webkit-scrollbar-track { background: transparent; } + *::-webkit-scrollbar-thumb { + background: var(--border-strong); + border: 3px solid transparent; + border-radius: var(--r-full); + background-clip: content-box; + } + + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } + } + + @media print { + .sidebar, .topbar, .toast-stack, .no-print { display: none !important; } + .main { margin: 0 !important; } + body { background: #fff; color: #000; } + } +} + +@layer layout { + .app-shell { + display: flex; + min-height: 100dvh; + } + + /* -- Seitenleiste ------------------------------------------------------- */ + .sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: var(--z-drawer); + display: flex; + flex-direction: column; + width: var(--sidebar-w); + background: var(--surface-1); + border-right: 1px solid var(--border); + transition: width var(--dur) var(--ease), transform var(--dur) var(--ease); + } + + .sidebar__brand { + display: flex; + align-items: center; + gap: var(--sp-3); + height: var(--topbar-h); + padding: 0 var(--sp-4); + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; + } + + .sidebar__logo { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + /* Die Wortmarke bestimmt die Breite, nicht die Seitenleiste: sonst + zerlaeuft der Schriftzug bei schmalem Fenster. */ + max-width: 168px; + } + .sidebar__logo svg { width: 100%; height: auto; display: block; } + .sidebar__edition { + font-size: var(--fs-xs); + color: var(--text-faint); + letter-spacing: 0.02em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-left: 2px; + } + + .sidebar__title { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.25; + } + .sidebar__title strong { + font-size: var(--fs-md); + font-weight: 650; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .sidebar__title span { + font-size: var(--fs-xs); + color: var(--text-faint); + white-space: nowrap; + } + + .sidebar__nav { + flex: 1; + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--sp-3) var(--sp-2) var(--sp-4); + } + + .nav-group__label { + padding: var(--sp-4) var(--sp-3) var(--sp-2); + font-size: var(--fs-xs); + font-weight: 620; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--text-faint); + } + + .nav-item { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: 8px var(--sp-3); + margin-bottom: 2px; + border-radius: var(--r-sm); + color: var(--text-muted); + font-size: var(--fs-base); + font-weight: 500; + white-space: nowrap; + transition: background var(--dur-fast), color var(--dur-fast); + } + .nav-item:hover { + background: var(--surface-3); + color: var(--text); + text-decoration: none; + } + .nav-item[aria-current="page"] { + background: var(--accent-soft); + color: var(--accent-soft-text); + font-weight: 600; + } + .nav-item__icon { + flex-shrink: 0; + width: 18px; + height: 18px; + stroke-width: 1.75; + } + .nav-item__label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + .nav-item__badge { + flex-shrink: 0; + padding: 1px 7px; + border-radius: var(--r-full); + background: var(--danger-soft); + color: var(--danger); + font-size: var(--fs-xs); + font-weight: 700; + } + + .nav-tree > summary { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: 8px var(--sp-3); + margin-bottom: 2px; + border-radius: var(--r-sm); + color: var(--text-muted); + font-size: var(--fs-base); + font-weight: 500; + cursor: pointer; + list-style: none; + transition: background var(--dur-fast); + } + .nav-tree > summary::-webkit-details-marker { display: none; } + .nav-tree > summary:hover { background: var(--surface-3); color: var(--text); } + .nav-tree__chevron { + margin-left: auto; + width: 15px; + height: 15px; + transition: transform var(--dur) var(--ease); + } + .nav-tree[open] > summary > .nav-tree__chevron { transform: rotate(90deg); } + .nav-tree[open] > summary { color: var(--text); } + .nav-tree__children { + margin: 2px 0 var(--sp-2) 0; + padding-left: calc(var(--sp-3) + 9px); + border-left: 1px solid var(--border-subtle); + margin-left: calc(var(--sp-3) + 9px); + } + .nav-tree__children .nav-item { padding-left: var(--sp-3); } + + .sidebar__footer { + flex-shrink: 0; + border-top: 1px solid var(--border-subtle); + padding: var(--sp-3); + } + + .user-chip { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-2); + border-radius: var(--r-md); + min-width: 0; + } + .user-chip:hover { background: var(--surface-3); text-decoration: none; } + .user-chip__meta { min-width: 0; flex: 1; } + .user-chip__name { + display: block; + font-size: var(--fs-base); + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .user-chip__role { + display: block; + font-size: var(--fs-xs); + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* -- Schmale Leiste (eingeklappt) --------------------------------------- */ + body[data-sidebar="rail"] .sidebar { width: var(--sidebar-rail-w); } + body[data-sidebar="rail"] .main { margin-left: var(--sidebar-rail-w); } + body[data-sidebar="rail"] .sidebar__title, + body[data-sidebar="rail"] .nav-item__label, + body[data-sidebar="rail"] .nav-item__badge, + body[data-sidebar="rail"] .nav-group__label, + body[data-sidebar="rail"] .nav-tree__chevron, + body[data-sidebar="rail"] .user-chip__meta, + body[data-sidebar="rail"] .sidebar__actions-label { display: none; } + body[data-sidebar="rail"] .nav-tree__children { + margin-left: 0; + padding-left: 0; + border-left: 0; + } + body[data-sidebar="rail"] .nav-item, + body[data-sidebar="rail"] .nav-tree > summary { justify-content: center; padding: 9px; } + + /* -- Hauptbereich ------------------------------------------------------- */ + .main { + flex: 1; + min-width: 0; + margin-left: var(--sidebar-w); + display: flex; + flex-direction: column; + transition: margin-left var(--dur) var(--ease); + } + + .topbar { + position: sticky; + top: 0; + z-index: var(--z-sticky); + display: flex; + align-items: center; + gap: var(--sp-3); + height: var(--topbar-h); + padding: 0 var(--sp-5); + background: color-mix(in srgb, var(--canvas) 82%, transparent); + backdrop-filter: blur(12px) saturate(1.4); + border-bottom: 1px solid var(--border); + } + + .topbar__heading { min-width: 0; flex: 1; } + .topbar__title { + font-size: var(--fs-lg); + font-weight: 640; + letter-spacing: -0.012em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .topbar__sub { + font-size: var(--fs-xs); + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .topbar__right { + display: flex; + align-items: center; + gap: var(--sp-2); + flex-shrink: 0; + } + + .content { + flex: 1; + width: 100%; + max-width: var(--content-max); + padding: var(--sp-6) var(--sp-5) var(--sp-12); + display: flex; + flex-direction: column; + gap: var(--sp-5); + } + + .page-footer { + padding: var(--sp-4) var(--sp-5); + border-top: 1px solid var(--border-subtle); + color: var(--text-faint); + font-size: var(--fs-xs); + display: flex; + flex-wrap: wrap; + gap: var(--sp-4); + justify-content: space-between; + } + + /* -- Raster ------------------------------------------------------------- */ + .grid { display: grid; gap: var(--sp-4); } + .grid--2 { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } + .grid--3 { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); } + .grid--4 { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } + .grid--tiles { grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); } + .grid--split { grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); align-items: start; } + + .row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-3); + } + .row--between { justify-content: space-between; } + .row--end { justify-content: flex-end; } + .row--tight { gap: var(--sp-2); } + .stack { display: flex; flex-direction: column; gap: var(--sp-4); } + .stack--tight { gap: var(--sp-2); } + + /* -- Mobil -------------------------------------------------------------- */ + .sidebar__scrim { + position: fixed; + inset: 0; + z-index: calc(var(--z-drawer) - 1); + background: var(--surface-overlay); + opacity: 0; + pointer-events: none; + transition: opacity var(--dur) var(--ease); + } + + @media (max-width: 1000px) { + .sidebar { + transform: translateX(-100%); + box-shadow: var(--shadow-lg); + } + body[data-sidebar="open"] .sidebar { transform: none; } + body[data-sidebar="open"] .sidebar__scrim { opacity: 1; pointer-events: auto; } + .main, body[data-sidebar="rail"] .main { margin-left: 0; } + body[data-sidebar="rail"] .sidebar { width: var(--sidebar-w); } + body[data-sidebar="rail"] .sidebar__title, + body[data-sidebar="rail"] .nav-item__label, + body[data-sidebar="rail"] .nav-group__label, + body[data-sidebar="rail"] .nav-tree__chevron, + body[data-sidebar="rail"] .user-chip__meta { display: revert; } + .content { padding: var(--sp-4) var(--sp-4) var(--sp-10); } + .topbar { padding: 0 var(--sp-4); } + .grid--split { grid-template-columns: 1fr; } + } +} diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css b/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css new file mode 100644 index 0000000..62e3c1d --- /dev/null +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css @@ -0,0 +1,1040 @@ +/* ========================================================================== + TESM Design-System -- Teil 2: Komponenten + ========================================================================== */ + +@layer components { + /* -- Karten ------------------------------------------------------------- */ + .card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--r-lg); + box-shadow: var(--shadow-xs); + } + .card__header { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-4) var(--sp-5); + border-bottom: 1px solid var(--border-subtle); + } + .card__header > .row { flex: 1; min-width: 0; } + .card__title { + font-size: var(--fs-md); + font-weight: 640; + letter-spacing: -0.008em; + display: flex; + align-items: center; + gap: var(--sp-2); + } + .card__sub { + font-size: var(--fs-xs); + color: var(--text-faint); + margin-top: 2px; + } + .card__body { padding: var(--sp-5); } + .card__body--flush { padding: 0; } + .card__footer { + display: flex; + gap: var(--sp-2); + justify-content: flex-end; + padding: var(--sp-3) var(--sp-5); + border-top: 1px solid var(--border-subtle); + background: var(--surface-2); + border-radius: 0 0 var(--r-lg) var(--r-lg); + } + .card--accent { border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); } + .card--danger { border-color: color-mix(in srgb, var(--danger) 42%, var(--border)); } + + /* -- Schaltflaechen ----------------------------------------------------- */ + .btn { + --btn-bg: var(--surface-1); + --btn-fg: var(--text); + --btn-border: var(--border-strong); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: 7px 14px; + min-height: 34px; + border: 1px solid var(--btn-border); + border-radius: var(--r-sm); + background: var(--btn-bg); + color: var(--btn-fg); + font-size: var(--fs-base); + font-weight: 570; + line-height: 1.2; + white-space: nowrap; + cursor: pointer; + text-decoration: none; + transition: background var(--dur-fast), border-color var(--dur-fast), + color var(--dur-fast), box-shadow var(--dur-fast), transform var(--dur-fast); + } + .btn:hover { background: var(--surface-3); text-decoration: none; } + .btn:active { transform: translateY(1px); } + .btn[disabled], .btn[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } + .btn svg { width: 16px; height: 16px; flex-shrink: 0; stroke-width: 1.9; } + + .btn--primary { + --btn-bg: var(--accent); + --btn-fg: var(--accent-text); + --btn-border: transparent; + box-shadow: var(--shadow-accent); + } + .btn--primary:hover { --btn-bg: var(--accent-hover); } + + .btn--danger { + --btn-bg: var(--danger); + --btn-fg: #fff; + --btn-border: transparent; + } + .btn--danger:hover { filter: brightness(1.08); background: var(--danger); } + + .btn--ghost { + --btn-bg: transparent; + --btn-border: transparent; + --btn-fg: var(--text-muted); + } + .btn--ghost:hover { --btn-fg: var(--text); background: var(--surface-3); } + + .btn--subtle { + --btn-bg: var(--surface-3); + --btn-border: transparent; + } + + .btn--sm { padding: 4px 10px; min-height: 28px; font-size: var(--fs-sm); } + .btn--sm svg { width: 14px; height: 14px; } + .btn--lg { padding: 10px 20px; min-height: 42px; font-size: var(--fs-md); } + .btn--icon { padding: 7px; min-width: 34px; } + .btn--icon.btn--sm { padding: 5px; min-width: 28px; } + .btn--block { width: 100%; } + + .btn-group { + display: inline-flex; + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + overflow: hidden; + } + .btn-group .btn { + border: 0; + border-radius: 0; + box-shadow: none; + } + .btn-group .btn + .btn { border-left: 1px solid var(--border); } + .btn-group .btn[aria-pressed="true"] { + background: var(--accent-soft); + color: var(--accent-soft-text); + } + + /* -- Formulare ---------------------------------------------------------- */ + .field { display: flex; flex-direction: column; gap: 5px; min-width: 0; } + .field__label { + font-size: var(--fs-sm); + font-weight: 570; + color: var(--text-muted); + display: flex; + align-items: center; + gap: var(--sp-2); + } + .field__label .req { color: var(--danger); } + .field__hint { font-size: var(--fs-xs); color: var(--text-faint); } + .field__error { font-size: var(--fs-xs); color: var(--danger); font-weight: 550; } + .field--inline { flex-direction: row; align-items: center; gap: var(--sp-3); } + + .input, .select, .textarea { + width: 100%; + padding: 8px 11px; + min-height: 36px; + background: var(--surface-1); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + color: var(--text); + font-size: var(--fs-base); + transition: border-color var(--dur-fast), box-shadow var(--dur-fast); + } + .input:hover, .select:hover, .textarea:hover { border-color: var(--text-faint); } + .input:focus, .select:focus, .textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-ring); + } + .input[readonly], .input[disabled], .select[disabled], .textarea[disabled] { + background: var(--surface-inset); + color: var(--text-muted); + cursor: not-allowed; + } + .input[aria-invalid="true"] { border-color: var(--danger); } + .textarea { min-height: 96px; resize: vertical; line-height: var(--lh-normal); } + .input--mono { font-family: var(--font-mono); font-size: var(--fs-sm); } + .select { + appearance: none; + padding-right: 32px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238f96a6' stroke-width='2.2' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 9px center; + background-size: 15px; + } + + .input-group { display: flex; align-items: stretch; } + .input-group .input { border-radius: var(--r-sm) 0 0 var(--r-sm); } + .input-group .btn { border-radius: 0 var(--r-sm) var(--r-sm) 0; border-left: 0; } + + .search-input { + position: relative; + display: flex; + align-items: center; + min-width: 180px; + } + .search-input svg { + position: absolute; + left: 10px; + width: 15px; + height: 15px; + color: var(--text-faint); + pointer-events: none; + } + .search-input .input { padding-left: 32px; } + + .check { + display: inline-flex; + align-items: flex-start; + gap: var(--sp-2); + cursor: pointer; + font-size: var(--fs-base); + line-height: 1.35; + } + .check input[type="checkbox"], .check input[type="radio"] { + appearance: none; + flex-shrink: 0; + width: 17px; + height: 17px; + margin: 1px 0 0; + border: 1.5px solid var(--border-strong); + border-radius: 5px; + background: var(--surface-1); + cursor: pointer; + transition: background var(--dur-fast), border-color var(--dur-fast); + } + .check input[type="radio"] { border-radius: 50%; } + .check input:checked { + background: var(--accent); + border-color: var(--accent); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); + background-size: 13px; + background-position: center; + background-repeat: no-repeat; + } + .check input[type="radio"]:checked { + background-image: none; + box-shadow: inset 0 0 0 4px var(--surface-1); + } + .check input:disabled { opacity: 0.42; cursor: not-allowed; } + .check input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + .check--disabled { color: var(--text-faint); cursor: not-allowed; } + + .switch { display: inline-flex; align-items: center; gap: var(--sp-3); cursor: pointer; } + .switch input { + appearance: none; + width: 38px; + height: 22px; + border-radius: var(--r-full); + background: var(--border-strong); + position: relative; + cursor: pointer; + transition: background var(--dur) var(--ease); + flex-shrink: 0; + } + .switch input::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: var(--shadow-xs); + transition: transform var(--dur) var(--ease); + } + .switch input:checked { background: var(--accent); } + .switch input:checked::after { transform: translateX(16px); } + .switch input:disabled { opacity: 0.45; cursor: not-allowed; } + + fieldset.fieldset { + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: var(--sp-4); + margin: 0; + } + fieldset.fieldset > legend { + padding: 0 var(--sp-2); + font-size: var(--fs-sm); + font-weight: 620; + color: var(--text-muted); + } + + .form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: var(--sp-4); + } + .form-grid--wide { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } + .form-grid > .field--full { grid-column: 1 / -1; } + + /* -- Tabellen ----------------------------------------------------------- */ + .table-wrap { + overflow-x: auto; + border-radius: var(--r-lg); + } + .table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: var(--fs-base); + } + .table thead th { + position: sticky; + top: 0; + z-index: 1; + padding: 9px var(--sp-4); + background: var(--surface-2); + border-bottom: 1px solid var(--border); + color: var(--text-faint); + font-size: var(--fs-xs); + font-weight: 650; + letter-spacing: 0.05em; + text-transform: uppercase; + text-align: left; + white-space: nowrap; + } + .table tbody td { + padding: 10px var(--sp-4); + border-bottom: 1px solid var(--border-subtle); + vertical-align: middle; + } + .table tbody tr:last-child td { border-bottom: 0; } + .table tbody tr:hover { background: var(--surface-2); } + .table th.sortable { cursor: pointer; user-select: none; } + .table th.sortable:hover { color: var(--text); } + .table th.sortable::after { + content: "\2195"; + margin-left: 5px; + opacity: 0.4; + font-size: 1.05em; + } + .table th[aria-sort="ascending"]::after { content: "\2191"; opacity: 1; color: var(--accent); } + .table th[aria-sort="descending"]::after { content: "\2193"; opacity: 1; color: var(--accent); } + .table td.numeric, .table th.numeric { text-align: right; font-variant-numeric: tabular-nums; } + .table td.actions { text-align: right; white-space: nowrap; } + .table td.mono { font-family: var(--font-mono); font-size: var(--fs-sm); } + .table--compact tbody td { padding: 6px var(--sp-3); } + .table--fixed { table-layout: fixed; } + + .table-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--border-subtle); + } + .table-toolbar__spacer { flex: 1; } + + /* -- Statusanzeigen ----------------------------------------------------- */ + .pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: var(--r-full); + background: var(--neutral-pill); + color: var(--neutral-pill-text); + font-size: var(--fs-xs); + font-weight: 650; + white-space: nowrap; + line-height: 1.5; + } + .pill::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; + } + .pill--plain::before { display: none; } + .pill--online, .pill--ok, .pill--success, .pill--green { + background: var(--success-soft); + color: var(--success); + } + .pill--offline, .pill--error, .pill--danger, .pill--red { + background: var(--danger-soft); + color: var(--danger); + } + .pill--warning, .pill--unknown, .pill--orange { + background: var(--warning-soft); + color: var(--warning); + } + .pill--info, .pill--blue { background: var(--info-soft); color: var(--info); } + .pill--accent, .pill--admin { background: var(--accent-soft); color: var(--accent-soft-text); } + .pill--lg { padding: 5px 12px; font-size: var(--fs-sm); } + + .status-pill { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 12px; + border-radius: var(--r-full); + background: var(--surface-1); + border: 1px solid var(--border); + font-size: var(--fs-sm); + font-weight: 570; + color: var(--text-muted); + white-space: nowrap; + } + .status-pill__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--success); + flex-shrink: 0; + } + .status-pill__dot--pulse { animation: pulse-ring 2.4s var(--ease) infinite; } + .status-pill--danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); color: var(--danger); } + .status-pill--danger .status-pill__dot { background: var(--danger); } + .status-pill--warning { border-color: color-mix(in srgb, var(--warning) 45%, var(--border)); color: var(--warning); } + .status-pill--warning .status-pill__dot { background: var(--warning); } + + @keyframes pulse-ring { + 0% { box-shadow: 0 0 0 0 currentColor; opacity: 1; } + 70% { box-shadow: 0 0 0 6px transparent; opacity: 0.85; } + 100% { box-shadow: 0 0 0 0 transparent; opacity: 1; } + } + + .badge { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: var(--r-xs); + background: var(--surface-3); + color: var(--text-muted); + font-size: var(--fs-xs); + font-weight: 620; + font-family: var(--font-mono); + } + + .avatar { + width: 32px; + height: 32px; + flex-shrink: 0; + border-radius: 50%; + display: grid; + place-items: center; + background: var(--accent-soft); + color: var(--accent-soft-text); + font-size: var(--fs-sm); + font-weight: 700; + object-fit: cover; + overflow: hidden; + } + .avatar--lg { width: 56px; height: 56px; font-size: var(--fs-lg); } + .avatar--sm { width: 24px; height: 24px; font-size: var(--fs-xs); } + + /* -- Kennzahlen --------------------------------------------------------- */ + .stat { + display: flex; + flex-direction: column; + gap: 3px; + padding: var(--sp-4); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--r-md); + transition: border-color var(--dur-fast), transform var(--dur-fast); + } + a.stat:hover, button.stat:hover { + border-color: var(--accent); + text-decoration: none; + transform: translateY(-1px); + } + .stat[aria-pressed="true"] { + border-color: var(--accent); + background: var(--accent-soft); + } + .stat__label { + font-size: var(--fs-xs); + font-weight: 620; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-faint); + } + .stat__value { + font-size: var(--fs-2xl); + font-weight: 680; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + line-height: 1.1; + } + .stat__hint { font-size: var(--fs-xs); color: var(--text-faint); } + .stat--ok .stat__value { color: var(--success); } + .stat--danger .stat__value { color: var(--danger); } + .stat--warning .stat__value { color: var(--warning); } + .stat--muted .stat__value { color: var(--text-faint); } + button.stat { cursor: pointer; text-align: left; font: inherit; } + + .progress { + height: 6px; + border-radius: var(--r-full); + background: var(--surface-3); + overflow: hidden; + } + .progress__bar { + height: 100%; + border-radius: inherit; + background: var(--accent); + transition: width var(--dur-slow) var(--ease); + } + .progress--success .progress__bar { background: var(--success); } + .progress--danger .progress__bar { background: var(--danger); } + + /* -- Hinweise ----------------------------------------------------------- */ + .alert { + display: flex; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + border-radius: var(--r-md); + border: 1px solid var(--border); + background: var(--surface-2); + font-size: var(--fs-base); + line-height: var(--lh-normal); + } + .alert svg { width: 18px; height: 18px; flex-shrink: 0; margin-top: 1px; } + .alert__body { min-width: 0; flex: 1; } + .alert__title { font-weight: 640; margin-bottom: 2px; } + .alert--info { background: var(--info-soft); border-color: transparent; color: var(--text); } + .alert--info svg { color: var(--info); } + .alert--success { background: var(--success-soft); border-color: transparent; } + .alert--success svg { color: var(--success); } + .alert--warning { background: var(--warning-soft); border-color: transparent; } + .alert--warning svg { color: var(--warning); } + .alert--danger { background: var(--danger-soft); border-color: transparent; } + .alert--danger svg { color: var(--danger); } + + .empty { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-12) var(--sp-5); + text-align: center; + color: var(--text-muted); + } + .empty svg { width: 34px; height: 34px; color: var(--text-faint); stroke-width: 1.4; } + .empty__title { font-size: var(--fs-md); font-weight: 620; color: var(--text); } + .empty p { max-width: 46ch; font-size: var(--fs-base); } + + /* -- Toasts ------------------------------------------------------------- */ + .toast-stack { + position: fixed; + top: var(--sp-4); + right: var(--sp-4); + z-index: var(--z-toast); + display: flex; + flex-direction: column; + gap: var(--sp-2); + width: min(380px, calc(100vw - var(--sp-8))); + pointer-events: none; + } + .toast { + display: flex; + align-items: flex-start; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + background: var(--surface-1); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--r-md); + box-shadow: var(--shadow-lg); + font-size: var(--fs-base); + pointer-events: auto; + animation: toast-in var(--dur) var(--ease); + } + .toast--success { border-left-color: var(--success); } + .toast--danger { border-left-color: var(--danger); } + .toast--warning { border-left-color: var(--warning); } + .toast--info { border-left-color: var(--info); } + .toast__body { flex: 1; min-width: 0; line-height: var(--lh-normal); } + .toast__close { + flex-shrink: 0; + padding: 0; + width: 20px; + height: 20px; + border: 0; + background: none; + color: var(--text-faint); + cursor: pointer; + line-height: 1; + font-size: 17px; + } + .toast__close:hover { color: var(--text); } + .toast.is-leaving { animation: toast-out 160ms var(--ease) forwards; } + + @keyframes toast-in { + from { opacity: 0; transform: translateX(14px) scale(0.98); } + to { opacity: 1; transform: none; } + } + @keyframes toast-out { + to { opacity: 0; transform: translateX(14px) scale(0.98); } + } + + /* -- Dialoge ------------------------------------------------------------ */ + dialog.modal { + padding: 0; + border: 1px solid var(--border); + border-radius: var(--r-lg); + background: var(--surface-1); + color: var(--text); + box-shadow: var(--shadow-lg); + width: min(560px, calc(100vw - var(--sp-8))); + max-height: calc(100dvh - var(--sp-12)); + overflow: visible; + } + dialog.modal--wide { width: min(880px, calc(100vw - var(--sp-8))); } + dialog.modal--narrow { width: min(420px, calc(100vw - var(--sp-8))); } + dialog.modal::backdrop { + background: var(--surface-overlay); + backdrop-filter: blur(3px); + } + dialog.modal[open] { animation: modal-in var(--dur) var(--ease); } + @keyframes modal-in { + from { opacity: 0; transform: translateY(8px) scale(0.985); } + to { opacity: 1; transform: none; } + } + .modal__header { + display: flex; + align-items: flex-start; + gap: var(--sp-3); + padding: var(--sp-4) var(--sp-5); + border-bottom: 1px solid var(--border-subtle); + } + .modal__title { font-size: var(--fs-md); font-weight: 640; flex: 1; } + .modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; } + .modal__body { + padding: var(--sp-5); + overflow-y: auto; + max-height: min(64dvh, 640px); + display: flex; + flex-direction: column; + gap: var(--sp-4); + } + .modal__footer { + display: flex; + gap: var(--sp-2); + justify-content: flex-end; + padding: var(--sp-3) var(--sp-5); + border-top: 1px solid var(--border-subtle); + background: var(--surface-2); + border-radius: 0 0 var(--r-lg) var(--r-lg); + } + .modal__footer--split { justify-content: space-between; } + + /* -- Tabs --------------------------------------------------------------- */ + .tabs { + display: flex; + gap: var(--sp-1); + border-bottom: 1px solid var(--border); + overflow-x: auto; + } + .tab { + padding: 9px var(--sp-3); + border: 0; + border-bottom: 2px solid transparent; + background: none; + color: var(--text-muted); + font-size: var(--fs-base); + font-weight: 570; + white-space: nowrap; + cursor: pointer; + } + .tab:hover { color: var(--text); text-decoration: none; } + .tab[aria-selected="true"], .tab.is-active { + color: var(--accent); + border-bottom-color: var(--accent); + } + + /* -- Befehlspalette ----------------------------------------------------- */ + dialog.palette { + padding: 0; + border: 1px solid var(--border); + border-radius: var(--r-lg); + background: var(--surface-1); + box-shadow: var(--shadow-lg); + width: min(560px, calc(100vw - var(--sp-6))); + margin-top: 10vh; + overflow: hidden; + } + dialog.palette::backdrop { background: var(--surface-overlay); backdrop-filter: blur(3px); } + .palette__input { + width: 100%; + padding: var(--sp-4) var(--sp-5); + border: 0; + border-bottom: 1px solid var(--border-subtle); + background: none; + color: var(--text); + font-size: var(--fs-md); + } + .palette__input:focus { outline: none; } + .palette__list { max-height: 46dvh; overflow-y: auto; padding: var(--sp-2); } + .palette__item { + display: flex; + align-items: center; + gap: var(--sp-3); + width: 100%; + padding: 9px var(--sp-3); + border: 0; + border-radius: var(--r-sm); + background: none; + color: var(--text); + font-size: var(--fs-base); + text-align: left; + cursor: pointer; + } + .palette__item svg { width: 16px; height: 16px; color: var(--text-faint); flex-shrink: 0; } + .palette__item:hover, .palette__item.is-active { background: var(--accent-soft); color: var(--accent-soft-text); } + .palette__item.is-active svg { color: currentColor; } + .palette__group { margin-left: auto; font-size: var(--fs-xs); color: var(--text-faint); } + .palette__hint { + padding: var(--sp-2) var(--sp-5); + border-top: 1px solid var(--border-subtle); + background: var(--surface-2); + font-size: var(--fs-xs); + color: var(--text-faint); + display: flex; + gap: var(--sp-4); + } + + kbd { + display: inline-block; + padding: 1px 5px; + border: 1px solid var(--border-strong); + border-bottom-width: 2px; + border-radius: var(--r-xs); + background: var(--surface-2); + color: var(--text-muted); + font-size: var(--fs-xs); + font-family: var(--font-mono); + line-height: 1.5; + } + + /* -- Rechtematrix ------------------------------------------------------- */ + .matrix { width: 100%; border-collapse: separate; border-spacing: 0; } + .matrix th, .matrix td { padding: 7px var(--sp-3); border-bottom: 1px solid var(--border-subtle); } + .matrix thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--surface-2); + font-size: var(--fs-xs); + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-faint); + text-align: center; + } + .matrix thead th:first-child { text-align: left; } + .matrix td:not(:first-child), .matrix th:not(:first-child) { width: 62px; text-align: center; } + .matrix tr.matrix__area > td { + background: var(--surface-2); + font-weight: 640; + } + .matrix tr.matrix__area > td:first-child { border-radius: var(--r-sm) 0 0 var(--r-sm); } + .matrix tr.is-locked td:not(.matrix__killswitch) { opacity: 0.42; } + /* Was es fuer eine Zeile nicht gibt: dunkler und eingesenkt statt heller. + Vorher war es ein natives Kaestchen -- im dunklen Thema ein helles Quadrat, + das kraeftiger wirkte als ein echtes, nicht gesetztes Recht. */ + .matrix__na { + display: inline-block; + width: 17px; + height: 17px; + border-radius: 5px; + background: var(--surface-0); + border: 1px solid var(--border-subtle); + box-shadow: inset 0 1px 2px rgb(0 0 0 / 28%); + vertical-align: -3px; + opacity: 0.55; + } + .matrix__resource-label { display: flex; flex-direction: column; gap: 1px; } + .matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); } + .matrix__legend { + display: flex; + flex-wrap: wrap; + gap: var(--sp-4); + font-size: var(--fs-xs); + color: var(--text-muted); + } + .matrix__legend b { color: var(--text); font-family: var(--font-mono); } + + /* -- Sonstiges ---------------------------------------------------------- */ + .code-block { + margin: 0; + padding: var(--sp-3) var(--sp-4); + background: var(--surface-inset); + border: 1px solid var(--border-subtle); + border-radius: var(--r-sm); + font-size: var(--fs-sm); + line-height: 1.55; + overflow-x: auto; + white-space: pre; + tab-size: 2; + } + .code-block--wrap { white-space: pre-wrap; word-break: break-all; } + + .log-view { + margin: 0; + padding: var(--sp-3) var(--sp-4); + max-height: 62dvh; + overflow: auto; + background: var(--surface-inset); + border-radius: 0 0 var(--r-lg) var(--r-lg); + font-family: var(--font-mono); + font-size: var(--fs-sm); + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + } + .log-view mark { background: var(--warning-soft); color: var(--warning); border-radius: 3px; } + + .kv { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-4); font-size: var(--fs-base); } + .kv dt { color: var(--text-faint); white-space: nowrap; } + .kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } + + .breadcrumb { display: flex; align-items: center; gap: 6px; font-size: var(--fs-xs); color: var(--text-faint); } + .breadcrumb a { color: var(--text-muted); } + .breadcrumb span::after { content: "/"; margin-left: 6px; opacity: 0.5; } + .breadcrumb > :last-child::after { content: none; } + + .divider { height: 1px; background: var(--border-subtle); margin: var(--sp-2) 0; } + + .hint-icon { + display: inline-grid; + place-items: center; + width: 15px; + height: 15px; + border-radius: 50%; + background: var(--surface-3); + color: var(--text-faint); + font-size: 10px; + font-weight: 700; + cursor: help; + flex-shrink: 0; + } + .hint-icon:hover { background: var(--accent-soft); color: var(--accent-soft-text); } + + .skeleton { + border-radius: var(--r-sm); + background: linear-gradient(90deg, var(--surface-3) 25%, var(--surface-2) 50%, var(--surface-3) 75%); + background-size: 200% 100%; + animation: shimmer 1.4s linear infinite; + min-height: 1em; + } + @keyframes shimmer { + from { background-position: 200% 0; } + to { background-position: -200% 0; } + } + + .spin { animation: spin 900ms linear infinite; } + @keyframes spin { to { transform: rotate(360deg); } } + + /* -- Anmeldeseite ------------------------------------------------------- */ + .auth-page { + min-height: 100dvh; + display: grid; + place-items: center; + padding: var(--sp-6); + background: + radial-gradient(1100px 520px at 12% -10%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 70%), + radial-gradient(900px 480px at 100% 110%, color-mix(in srgb, var(--info) 10%, transparent), transparent 70%), + var(--canvas); + } + /* Die Wortmarke liegt hinter allem (z-index 0), die Anmeldebox davor. + Sie ist absichtlich nicht vertikal zentriert: die Box sitzt hoeher und + steht dadurch vor dem oberen Teil des Motivs -- so war es im Vorgaenger, + und so bleibt die Funktionszeile darunter lesbar. */ + /* ---------------------------------------------------------------------- # + Geraetekachel der Statusuebersicht + + Der Zustand traegt die Kachel: er faerbt die Kante links, das Symbolfeld + und die Plakette. Die Beschriftung steht trotzdem daneben -- Farbe allein + unterscheidet nicht fuer jeden. Der Name steht in der Monoschrift, weil + Geraetenamen Kennungen sind und in einer Liste untereinander lesbar + bleiben muessen (FRM02 / FRM03 / FRM032). + ---------------------------------------------------------------------- */ + .tile { + --tile-accent: var(--text-faint); + position: relative; + overflow: hidden; + padding: var(--sp-4); + padding-left: calc(var(--sp-4) + 3px); + border: 1px solid var(--border-subtle); + border-radius: var(--r-lg); + background: + linear-gradient(180deg, + color-mix(in srgb, var(--tile-accent) 6%, transparent), + transparent 62%), + var(--surface-1); + } + .tile::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--tile-accent); + } + .tile--online { --tile-accent: var(--success); } + .tile--restarting { --tile-accent: var(--warning); } + .tile--offline { --tile-accent: var(--danger); } + .tile--unknown { --tile-accent: var(--warning); } + .tile--disabled { --tile-accent: var(--text-faint); } + + .tile__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + } + .tile__badge { + display: grid; + place-items: center; + width: 34px; + height: 34px; + flex-shrink: 0; + border-radius: var(--r-md); + background: color-mix(in srgb, var(--tile-accent) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--tile-accent) 30%, transparent); + color: var(--tile-accent); + } + .tile__name { + margin-top: var(--sp-3); + font-family: var(--font-mono); + font-weight: 800; + font-size: var(--fs-md); + letter-spacing: 0.3px; + } + .tile__ip { + font-family: var(--font-mono); + font-size: var(--fs-xs); + color: color-mix(in srgb, var(--tile-accent) 72%, var(--text-faint)); + } + .tile__meta { + display: flex; + align-items: center; + gap: 4px; + margin-top: 2px; + font-size: var(--fs-xs); + color: var(--text-faint); + } + + .auth-page { position: relative; overflow: hidden; align-content: center; } + .auth-backdrop { + position: absolute; + top: 4vh; + left: 50%; + transform: translateX(-50%); + width: min(72vw, 880px); + pointer-events: none; + z-index: 0; + opacity: 0.5; + } + .auth-backdrop svg { width: 100%; height: auto; display: block; } + @media (max-width: 640px) { + .auth-backdrop { width: min(90vw, 460px); opacity: 0.32; } + } + /* Wer wenig Bewegung/Reize moechte, bekommt das Motiv dezenter. */ + @media (prefers-contrast: more) { + .auth-backdrop { opacity: 0.18; } + } + + .auth-card { + position: relative; + z-index: 1; + width: min(410px, 100%); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--r-xl); + box-shadow: var(--shadow-lg); + padding: var(--sp-8) var(--sp-6); + display: flex; + flex-direction: column; + gap: var(--sp-5); + } + .auth-card--wide { width: min(520px, 100%); } + .auth-brand { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-3); + text-align: center; + } + .auth-brand__mark { + width: 46px; + height: 46px; + border-radius: var(--r-md); + display: grid; + place-items: center; + background: var(--accent); + color: var(--accent-text); + font-weight: 800; + font-size: var(--fs-lg); + } + .auth-brand h1 { font-size: var(--fs-xl); } + .auth-brand p { color: var(--text-muted); font-size: var(--fs-sm); } + .auth-footnote { text-align: center; font-size: var(--fs-xs); color: var(--text-faint); } +} + +@layer utilities { + .muted { color: var(--text-muted); } + .faint { color: var(--text-faint); } + .danger-text { color: var(--danger); } + .success-text { color: var(--success); } + .warning-text { color: var(--warning); } + .accent-text { color: var(--accent); } + .mono { font-family: var(--font-mono); font-size: 0.94em; } + .nowrap { white-space: nowrap; } + .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } + .break-all { overflow-wrap: anywhere; } + .tabular { font-variant-numeric: tabular-nums; } + .fs-xs { font-size: var(--fs-xs); } + .fs-sm { font-size: var(--fs-sm); } + .fs-lg { font-size: var(--fs-lg); } + .fw-600 { font-weight: 620; } + .center { text-align: center; } + .right { text-align: right; } + .flex-1 { flex: 1; min-width: 0; } + .mt-0 { margin-top: 0; } + .mt-2 { margin-top: var(--sp-2); } + .mt-3 { margin-top: var(--sp-3); } + .mt-4 { margin-top: var(--sp-4); } + .mb-2 { margin-bottom: var(--sp-2); } + .mb-3 { margin-bottom: var(--sp-3); } + .mb-4 { margin-bottom: var(--sp-4); } + .gap-2 { gap: var(--sp-2); } + .gap-4 { gap: var(--sp-4); } + .w-full { width: 100%; } + .hide-sm { } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; + } + @media (max-width: 760px) { + .hide-sm { display: none !important; } + } +} diff --git a/srv/tesm/static/images/icon-dark.svg b/packages/tesm-core/src/tesm_core/static/tesm_core/img/favicon.svg similarity index 54% rename from srv/tesm/static/images/icon-dark.svg rename to packages/tesm-core/src/tesm_core/static/tesm_core/img/favicon.svg index 0802b3d..998f6a3 100644 --- a/srv/tesm/static/images/icon-dark.svg +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/img/favicon.svg @@ -1,12 +1,16 @@ - - - + + + - + + fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round" + stroke-linejoin="round" opacity="0.85"/> TESM - + diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js b/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js new file mode 100644 index 0000000..cd3adad --- /dev/null +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js @@ -0,0 +1,842 @@ +/* ========================================================================== + TESM -- Frontend-Verhalten + -------------------------------------------------------------------------- + Ein ES-Modul, keine Abhaengigkeiten, kein Build-Schritt. Alles laeuft ueber + deklarative data-Attribute, damit Templates kein eigenes Inline-Skript + brauchen -- Voraussetzung fuer die strikte CSP (kein 'unsafe-inline'). + + Konventionen: + data-behavior="..." ein Verhalten an ein Element binden + data-* Parameter des Verhaltens + ========================================================================== */ + +"use strict"; + +const STORAGE = { + theme: "tesm.theme", + sidebar: "tesm.sidebar", + sections: "tesm.sections", +}; + +/* -- Kleine Helfer -------------------------------------------------------- */ + +const $ = (selector, scope = document) => scope.querySelector(selector); +const $$ = (selector, scope = document) => Array.from(scope.querySelectorAll(selector)); + +function store(key, value) { + try { + if (value === null) localStorage.removeItem(key); + else localStorage.setItem(key, value); + } catch { + /* Privatmodus o. Ä. -- Einstellungen sind dann nur fluechtig. */ + } +} + +function read(key, fallback = null) { + try { + const value = localStorage.getItem(key); + return value === null ? fallback : value; + } catch { + return fallback; + } +} + +function csrfToken() { + return document.body?.dataset.csrf || ""; +} + +/** fetch mit CSRF-Header und JSON-Erwartung. */ +export async function api(url, options = {}) { + const opts = { credentials: "same-origin", ...options }; + opts.headers = { + "X-Requested-With": "fetch", + "X-CSRF-Token": csrfToken(), + ...(options.headers || {}), + }; + const response = await fetch(url, opts); + const type = response.headers.get("content-type") || ""; + const payload = type.includes("application/json") ? await response.json() : await response.text(); + if (!response.ok) { + const message = + (payload && typeof payload === "object" && (payload.error || payload.message)) || + `Anfrage fehlgeschlagen (HTTP ${response.status})`; + throw new Error(message); + } + return payload; +} + +/* -- Toasts --------------------------------------------------------------- */ + +let toastStack = null; + +export function showToast(message, type = "info", { timeout = 6000 } = {}) { + if (!message) return; + if (!toastStack) { + toastStack = document.createElement("div"); + toastStack.className = "toast-stack"; + toastStack.setAttribute("role", "status"); + toastStack.setAttribute("aria-live", "polite"); + document.body.appendChild(toastStack); + } + const toast = document.createElement("div"); + toast.className = `toast toast--${type}`; + const body = document.createElement("div"); + body.className = "toast__body"; + body.textContent = message; + const close = document.createElement("button"); + close.type = "button"; + close.className = "toast__close"; + close.setAttribute("aria-label", "Meldung schliessen"); + close.textContent = "×"; + toast.append(body, close); + toastStack.appendChild(toast); + + let timer = 0; + const dismiss = () => { + window.clearTimeout(timer); + toast.classList.add("is-leaving"); + window.setTimeout(() => toast.remove(), 180); + }; + close.addEventListener("click", dismiss); + if (timeout > 0) timer = window.setTimeout(dismiss, timeout); + return dismiss; +} + +function initFlashes() { + const node = $("#flashed-messages"); + if (!node) return; + let entries = []; + try { + entries = JSON.parse(node.textContent || "[]"); + } catch { + entries = []; + } + entries.forEach(([category, message]) => { + const type = { error: "danger", danger: "danger", success: "success", warning: "warning" }[ + category + ] || "info"; + showToast(message, type, { timeout: type === "danger" ? 0 : 6000 }); + }); +} + +/* -- Theme (System / hell / dunkel) --------------------------------------- */ + +function applyTheme(mode) { + const root = document.documentElement; + if (mode === "light" || mode === "dark") root.dataset.theme = mode; + else delete root.dataset.theme; + $$('[data-behavior="theme-toggle"] [data-theme-option]').forEach((btn) => { + btn.setAttribute("aria-pressed", String(btn.dataset.themeOption === mode)); + }); +} + +function initTheme() { + const mode = read(STORAGE.theme, "system"); + applyTheme(mode); + $$('[data-behavior="theme-toggle"]').forEach((group) => { + group.addEventListener("click", (event) => { + const button = event.target.closest("[data-theme-option]"); + if (!button) return; + const next = button.dataset.themeOption; + store(STORAGE.theme, next === "system" ? null : next); + applyTheme(next); + }); + }); +} + +/* -- Seitenleiste --------------------------------------------------------- */ + +function initSidebar() { + const body = document.body; + const desktop = () => window.matchMedia("(min-width: 1001px)").matches; + const saved = read(STORAGE.sidebar, "full"); + if (desktop() && saved === "rail") body.dataset.sidebar = "rail"; + + $$('[data-behavior="sidebar-toggle"]').forEach((button) => { + button.addEventListener("click", () => { + if (desktop()) { + const rail = body.dataset.sidebar === "rail"; + body.dataset.sidebar = rail ? "full" : "rail"; + store(STORAGE.sidebar, rail ? "full" : "rail"); + } else { + body.dataset.sidebar = body.dataset.sidebar === "open" ? "closed" : "open"; + } + }); + }); + + const scrim = $(".sidebar__scrim"); + scrim?.addEventListener("click", () => { + body.dataset.sidebar = "closed"; + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && body.dataset.sidebar === "open") body.dataset.sidebar = "closed"; + }); +} + +/* -- Aufklappbare Bereiche (Zustand bleibt erhalten) ---------------------- */ + +function initCollapsibles() { + let state = {}; + try { + state = JSON.parse(read(STORAGE.sections, "{}")) || {}; + } catch { + state = {}; + } + $$("details[data-persist]").forEach((element) => { + const key = element.dataset.persist; + if (key in state) element.open = Boolean(state[key]); + element.addEventListener("toggle", () => { + state[key] = element.open; + store(STORAGE.sections, JSON.stringify(state)); + }); + }); +} + +/* -- Dialoge -------------------------------------------------------------- */ + +function openDialog(dialog) { + if (!dialog) return; + if (typeof dialog.showModal === "function") dialog.showModal(); + else dialog.setAttribute("open", ""); + const focusable = dialog.querySelector( + "[autofocus], input:not([type=hidden]):not([disabled]), select, textarea, button" + ); + focusable?.focus({ preventScroll: true }); +} + +function initDialogs() { + document.addEventListener("click", (event) => { + const opener = event.target.closest("[data-dialog-open]"); + if (opener) { + event.preventDefault(); + const dialog = document.getElementById(opener.dataset.dialogOpen); + if (dialog) { + // Felder aus data-fill-* des Auslösers in das Formular uebernehmen. + Object.entries(opener.dataset).forEach(([key, value]) => { + if (!key.startsWith("fill")) return; + const name = key.slice(4); + const target = dialog.querySelector(`[data-fill-target="${name}"]`); + if (!target) return; + if ("value" in target) target.value = value; + else target.textContent = value; + }); + openDialog(dialog); + } + return; + } + const closer = event.target.closest("[data-dialog-close]"); + if (closer) { + event.preventDefault(); + closer.closest("dialog")?.close(); + } + }); + + // Klick auf den Hintergrund schliesst -- aber nur, wenn wirklich daneben + // geklickt wurde (nicht bei einem Drag aus dem Dialog heraus). + $$("dialog").forEach((dialog) => { + dialog.addEventListener("mousedown", (event) => { + dialog.dataset.outside = String(event.target === dialog); + }); + dialog.addEventListener("click", (event) => { + if (event.target === dialog && dialog.dataset.outside === "true") dialog.close(); + }); + }); +} + +/* -- Bestaetigungsdialog (ersetzt window.confirm) ------------------------- */ + +function initConfirmations() { + const dialog = $("#confirm-dialog"); + if (!dialog) return; + const titleNode = $("[data-confirm-title]", dialog); + const textNode = $("[data-confirm-text]", dialog); + const okButton = $("[data-confirm-ok]", dialog); + let pending = null; + + document.addEventListener( + "click", + (event) => { + const trigger = event.target.closest("[data-confirm]"); + if (!trigger || trigger.dataset.confirmed === "1") return; + event.preventDefault(); + event.stopPropagation(); + pending = trigger; + titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestaetigen"; + textNode.textContent = trigger.dataset.confirm; + okButton.textContent = trigger.dataset.confirmOk || "Fortfahren"; + okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`; + openDialog(dialog); + }, + true + ); + + okButton.addEventListener("click", () => { + dialog.close(); + if (!pending) return; + const trigger = pending; + pending = null; + trigger.dataset.confirmed = "1"; + unsavedGuard.disable(); + if (trigger.form && trigger.type === "submit") trigger.click(); + else if (trigger.tagName === "FORM") trigger.submit(); + else trigger.click(); + delete trigger.dataset.confirmed; + }); +} + +/* -- Schutz vor ungespeicherten Aenderungen ------------------------------ */ + +const unsavedGuard = { + dirty: false, + armed: true, + disable() { + this.armed = false; + }, + init() { + const forms = $$("form:not([data-no-guard])").filter((form) => + form.querySelector("input:not([type=hidden]):not([type=search]), select, textarea") + ); + forms.forEach((form) => { + const snapshot = new FormData(form); + form.addEventListener("input", () => { + this.dirty = !sameForm(snapshot, new FormData(form)); + }); + form.addEventListener("submit", () => { + this.dirty = false; + this.armed = false; + }); + }); + window.addEventListener("beforeunload", (event) => { + if (!this.dirty || !this.armed) return; + event.preventDefault(); + event.returnValue = ""; + }); + }, +}; + +function sameForm(a, b) { + const left = Array.from(a.entries()).map(([k, v]) => `${k}=${v}`).sort().join("&"); + const right = Array.from(b.entries()).map(([k, v]) => `${k}=${v}`).sort().join("&"); + return left === right; +} + +/* -- Tabellen: Sortieren und Filtern ------------------------------------- */ + +function cellValue(row, index) { + const cell = row.cells[index]; + if (!cell) return ""; + return (cell.dataset.sortValue ?? cell.textContent ?? "").trim(); +} + +function initTables() { + $$('table[data-behavior="sortable"]').forEach((table) => { + const body = table.tBodies[0]; + if (!body) return; + $$("thead th.sortable", table).forEach((header, index) => { + header.tabIndex = 0; + const activate = () => { + const ascending = header.getAttribute("aria-sort") !== "ascending"; + $$("thead th", table).forEach((other) => other.removeAttribute("aria-sort")); + header.setAttribute("aria-sort", ascending ? "ascending" : "descending"); + const rows = Array.from(body.rows); + const numeric = rows.every((row) => { + const value = cellValue(row, index).replace(/[^\d,.\-]/g, "").replace(",", "."); + return value === "" || !Number.isNaN(Number(value)); + }); + rows.sort((rowA, rowB) => { + const a = cellValue(rowA, index); + const b = cellValue(rowB, index); + const result = numeric + ? Number(a.replace(/[^\d,.\-]/g, "").replace(",", ".") || 0) - + Number(b.replace(/[^\d,.\-]/g, "").replace(",", ".") || 0) + : a.localeCompare(b, "de", { numeric: true, sensitivity: "base" }); + return ascending ? result : -result; + }); + rows.forEach((row) => body.appendChild(row)); + }; + header.addEventListener("click", activate); + header.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + activate(); + } + }); + }); + }); + + $$("[data-filter-target]").forEach((input) => { + const apply = () => { + const needle = input.value.trim().toLowerCase(); + const target = document.getElementById(input.dataset.filterTarget); + if (!target) return; + const rows = target.tagName === "TABLE" ? Array.from(target.tBodies[0]?.rows || []) : $$("[data-filter-item]", target); + let visible = 0; + rows.forEach((row) => { + const haystack = (row.dataset.filterText || row.textContent || "").toLowerCase(); + const match = !needle || haystack.includes(needle); + row.hidden = !match; + if (match) visible += 1; + }); + const counter = input.dataset.filterCount + ? document.getElementById(input.dataset.filterCount) + : null; + if (counter) counter.textContent = String(visible); + const emptyNode = input.dataset.filterEmpty + ? document.getElementById(input.dataset.filterEmpty) + : null; + if (emptyNode) emptyNode.hidden = visible !== 0; + }; + input.addEventListener("input", apply); + if (input.value) apply(); + }); +} + +/* -- Rechtematrix: Kill-Switch und Zeilenlogik --------------------------- */ + +function initPermissionMatrix() { + $$('[data-behavior="permission-matrix"]').forEach((table) => { + const syncArea = (areaKey) => { + const master = $(`[data-area-switch="${areaKey}"]`, table); + if (!master) return; + $$(`tr[data-area="${areaKey}"]`, table).forEach((row) => { + row.classList.toggle("is-locked", !master.checked); + // Nur echte Rechte-Felder: alles ohne name-Attribut ist Darstellung, + // kein Bedienelement, und darf hier nicht freigeschaltet werden. + $$('input[type="checkbox"][name]', row).forEach((box) => { + box.disabled = !master.checked; + }); + }); + }; + + $$("[data-area-switch]", table).forEach((master) => { + const areaKey = master.dataset.areaSwitch; + syncArea(areaKey); + master.addEventListener("change", () => syncArea(areaKey)); + }); + + table.addEventListener("change", (event) => { + const box = event.target; + if (!(box instanceof HTMLInputElement) || !box.dataset.action) return; + const row = box.closest("tr"); + if (!row) return; + const viewBox = $('input[data-action="view"]', row); + if (box.dataset.action === "view") { + // "Ansehen" abwaehlen entfernt alles andere dieser Zeile. + if (!box.checked) { + $$('input[type="checkbox"][name]', row).forEach((other) => { + if (other !== box) other.checked = false; + }); + } + } else if (box.checked && viewBox) { + viewBox.checked = true; // Jede Aktion setzt "Ansehen" voraus. + } + const areaKey = row.dataset.area; + const master = areaKey ? $(`[data-area-switch="${areaKey}"]`, table) : null; + if (master && box.checked && !master.checked) { + master.checked = true; + master.dispatchEvent(new Event("change")); + } + }); + }); +} + +/* -- Automatische Aktualisierung von Teilbereichen ----------------------- */ + +function initAutoRefresh() { + + // -- Verzeichnisgruppe suchen ------------------------------------------- # + // + // Ein AD-Gruppen-DN wird nicht getippt: er ist lang, enthaelt Kommas und + // Leerzeichen, und ein Tippfehler faellt erst bei der naechsten Anmeldung + // auf -- als stille Nicht-Zuordnung, die niemand mit dem Formular in + // Verbindung bringt. + $$("[data-behavior='group-picker']").forEach((picker) => { + const query = picker.querySelector("[data-group-query]"); + const button = picker.querySelector("[data-group-search]"); + const results = picker.querySelector("[data-group-results]"); + const status = picker.querySelector("[data-group-status]"); + const nameField = document.querySelector(picker.dataset.nameTarget); + const dnField = document.querySelector(picker.dataset.dnTarget); + if (!query || !button || !results || !nameField || !dnField) return; + + const show = (text) => { if (status) status.textContent = text; }; + + const pick = (entry) => { + nameField.value = entry.name || ""; + dnField.value = entry.dn || ""; + results.hidden = true; + show(`Uebernommen: ${entry.name || entry.dn}`); + }; + + const search = async () => { + const term = query.value.trim(); + if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; } + button.disabled = true; + show("Suche laeuft ..."); + results.innerHTML = ""; + try { + const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`; + const data = await api(url); + const entries = (data && data.results) || []; + if (!entries.length) { + results.hidden = true; + show("Keine Gruppe gefunden. Ist ein Dienstkonto hinterlegt?"); + return; + } + entries.forEach((entry) => { + const row = document.createElement("button"); + row.type = "button"; + row.className = "btn btn--sm btn--ghost"; + row.style.justifyContent = "flex-start"; + row.style.textAlign = "left"; + const name = document.createElement("strong"); + name.textContent = entry.name || "(ohne Namen)"; + const dn = document.createElement("span"); + dn.className = "fs-xs faint break-all"; + dn.style.marginLeft = "8px"; + dn.textContent = entry.dn || ""; + row.append(name, dn); + row.addEventListener("click", () => pick(entry)); + results.appendChild(row); + }); + results.hidden = false; + show(`${entries.length} Treffer -- zum Uebernehmen anklicken.`); + } catch (error) { + results.hidden = true; + show("Die Suche ist fehlgeschlagen. Laeuft die Verbindung zum Verzeichnis?"); + } finally { + button.disabled = false; + } + }; + + button.addEventListener("click", search); + query.addEventListener("keydown", (event) => { + if (event.key === "Enter") { event.preventDefault(); search(); } + }); + }); + + // -- Transport bestimmt den Port ----------------------------------------- # + // + // 636 fuer LDAPS, 389 sonst. Wer den Transport umstellt und den Port stehen + // laesst, bekommt eine Verbindung, die auf gut Glueck scheitert -- und der + // Fehler ("Zeitlimit") sagt nichts ueber die Ursache. + $$("[data-behavior='transport-port']").forEach((select) => { + const port = document.querySelector(select.dataset.portTarget); + if (!port) return; + const standard = { ldaps: "636", starttls: "389", none: "389" }; + select.addEventListener("change", () => { + const expected = standard[select.value]; + if (!expected) return; + // Einen abweichenden, absichtlich gesetzten Port nicht ueberschreiben -- + // nur einen, der zum vorherigen Transport gehoerte. + const known = Object.values(standard); + if (port.value === "" || known.includes(port.value)) port.value = expected; + }); + }); + + $$("[data-refresh-url]").forEach((region) => { + // Number(x || 15000) haette "0" zu 15000 gemacht -- eine Null ist falsy. + // Bei einer Zahl aus einem Attribut ist || die falsche Vorgabe; gemeint + // war "kein Wert angegeben", nicht "Wert ist 0". + const raw = region.dataset.refreshInterval; + const interval = raw === undefined || raw === "" ? 15000 : Number(raw); + if (!Number.isFinite(interval) || interval <= 0) return; // ausdruecklich aus + let timer = 0; + let stopped = false; + + const tick = async () => { + if (stopped || document.hidden) return schedule(); + try { + const html = await api(region.dataset.refreshUrl, { headers: { Accept: "text/html" } }); + // Eine vollstaendige Seite gehoert nie in einen Ausschnitt. Zeigt die + // URL versehentlich auf eine ganze Seite, waere das Ergebnis eine + // Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer. + if (typeof html === "string" && /<(!doctype|html|body)\b/i.test(html)) { + console.warn("Aktualisierung geliefert: ganze Seite statt Ausschnitt --", region.id); + stopped = true; + return; + } + if (typeof html === "string") { + // Gescrollt wird selten der Bereich selbst, sondern das Element + // darin (bei Protokollen das
). Beim Ersetzen
+          // von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
+          // deshalb sprang ein laufendes Auftragsprotokoll bei jeder
+          // Aktualisierung an den Anfang zurueck.
+          const scrollerOf = (el) => el.querySelector(".log-view, [data-scroll]") || el;
+          const before = scrollerOf(region);
+          const position = before.scrollTop;
+          // "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
+          // werden, ohne exakt ganz unten stehen zu muessen.
+          const following =
+            before.scrollHeight - before.scrollTop - before.clientHeight < 48;
+
+          region.innerHTML = html;
+
+          const after = scrollerOf(region);
+          after.scrollTop = following ? after.scrollHeight : position;
+          region.dispatchEvent(new CustomEvent("tesm:refreshed", { bubbles: true }));
+          initRelativeTimes(region);
+          initCountdowns(region);
+        }
+      } catch {
+        /* Netzfehler: beim naechsten Intervall erneut versuchen. */
+      }
+      schedule();
+    };
+
+    const schedule = () => {
+      window.clearTimeout(timer);
+      timer = window.setTimeout(tick, interval);
+    };
+
+    document.addEventListener("visibilitychange", () => {
+      if (!document.hidden) schedule();
+    });
+    schedule();
+    region.addEventListener("tesm:stop-refresh", () => {
+      stopped = true;
+      window.clearTimeout(timer);
+    });
+  });
+}
+
+/* -- Zeitangaben ---------------------------------------------------------- */
+
+const RELATIVE = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
+const UNITS = [
+  ["year", 31536000],
+  ["month", 2592000],
+  ["day", 86400],
+  ["hour", 3600],
+  ["minute", 60],
+  ["second", 1],
+];
+
+function relativeTime(iso) {
+  const target = new Date(iso);
+  if (Number.isNaN(target.getTime())) return "";
+  const seconds = (target.getTime() - Date.now()) / 1000;
+  for (const [unit, size] of UNITS) {
+    if (Math.abs(seconds) >= size || unit === "second") {
+      return RELATIVE.format(Math.round(seconds / size), unit);
+    }
+  }
+  return "";
+}
+
+function initRelativeTimes(scope = document) {
+  $$("[data-relative-time]", scope).forEach((node) => {
+    const iso = node.dataset.relativeTime;
+    const text = relativeTime(iso);
+    if (text) {
+      node.textContent = text;
+      if (!node.title) node.title = new Date(iso).toLocaleString("de-DE");
+    }
+  });
+}
+
+function initCountdowns(scope = document) {
+  $$("[data-countdown-to]", scope).forEach((node) => {
+    const target = new Date(node.dataset.countdownTo).getTime();
+    if (Number.isNaN(target)) return;
+    const render = () => {
+      const remaining = Math.max(0, Math.floor((target - Date.now()) / 1000));
+      const hours = Math.floor(remaining / 3600);
+      const minutes = Math.floor((remaining % 3600) / 60);
+      const seconds = remaining % 60;
+      node.textContent =
+        hours > 0
+          ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
+          : `${minutes}:${String(seconds).padStart(2, "0")}`;
+      if (remaining === 0) {
+        window.clearInterval(handle);
+        node.textContent = node.dataset.countdownDone || "0:00";
+      }
+    };
+    render();
+    const handle = window.setInterval(render, 1000);
+  });
+}
+
+/* -- In die Zwischenablage ----------------------------------------------- */
+
+function initClipboard() {
+  document.addEventListener("click", async (event) => {
+    const button = event.target.closest("[data-copy]");
+    if (!button) return;
+    event.preventDefault();
+    const source = button.dataset.copy.startsWith("#")
+      ? document.querySelector(button.dataset.copy)
+      : null;
+    const text = source ? source.value ?? source.textContent : button.dataset.copy;
+    try {
+      await navigator.clipboard.writeText((text || "").trim());
+      showToast(button.dataset.copyMessage || "In die Zwischenablage kopiert.", "success", {
+        timeout: 2500,
+      });
+    } catch {
+      showToast("Kopieren nicht moeglich -- bitte manuell markieren.", "warning");
+    }
+  });
+}
+
+/* -- Befehlspalette (Strg+K) --------------------------------------------- */
+
+function initPalette() {
+  const dialog = $("#command-palette");
+  if (!dialog) return;
+  const input = $(".palette__input", dialog);
+  const list = $(".palette__list", dialog);
+  let entries = [];
+  try {
+    entries = JSON.parse($("#palette-data")?.textContent || "[]");
+  } catch {
+    entries = [];
+  }
+  let active = 0;
+
+  const render = () => {
+    const needle = input.value.trim().toLowerCase();
+    const matches = entries
+      .filter((entry) => !needle || `${entry.label} ${entry.group}`.toLowerCase().includes(needle))
+      .slice(0, 40);
+    active = 0;
+    list.innerHTML = "";
+    if (!matches.length) {
+      const empty = document.createElement("div");
+      empty.className = "palette__item faint";
+      empty.textContent = "Keine Treffer";
+      list.appendChild(empty);
+      return;
+    }
+    matches.forEach((entry, index) => {
+      const item = document.createElement("a");
+      item.className = `palette__item${index === 0 ? " is-active" : ""}`;
+      item.href = entry.url;
+      item.innerHTML = `${escapeHtml(entry.label)}`;
+      if (entry.group) {
+        const group = document.createElement("span");
+        group.className = "palette__group";
+        group.textContent = entry.group;
+        item.appendChild(group);
+      }
+      list.appendChild(item);
+    });
+  };
+
+  const move = (delta) => {
+    const items = $$(".palette__item[href]", list);
+    if (!items.length) return;
+    items[active]?.classList.remove("is-active");
+    active = (active + delta + items.length) % items.length;
+    items[active].classList.add("is-active");
+    items[active].scrollIntoView({ block: "nearest" });
+  };
+
+  document.addEventListener("keydown", (event) => {
+    if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
+      event.preventDefault();
+      input.value = "";
+      render();
+      openDialog(dialog);
+    }
+  });
+  $$('[data-behavior="open-palette"]').forEach((button) =>
+    button.addEventListener("click", () => {
+      input.value = "";
+      render();
+      openDialog(dialog);
+    })
+  );
+  input.addEventListener("input", render);
+  input.addEventListener("keydown", (event) => {
+    if (event.key === "ArrowDown") {
+      event.preventDefault();
+      move(1);
+    } else if (event.key === "ArrowUp") {
+      event.preventDefault();
+      move(-1);
+    } else if (event.key === "Enter") {
+      event.preventDefault();
+      $$(".palette__item[href]", list)[active]?.click();
+    }
+  });
+}
+
+function escapeHtml(value) {
+  const node = document.createElement("span");
+  node.textContent = value;
+  return node.innerHTML;
+}
+
+/* -- Formulare gegen Doppelabsenden -------------------------------------- */
+
+function initSubmitGuards() {
+  document.addEventListener("submit", (event) => {
+    const form = event.target;
+    if (!(form instanceof HTMLFormElement) || form.dataset.allowMultiSubmit) return;
+    const buttons = $$('button[type="submit"], input[type="submit"]', form);
+    window.setTimeout(() => {
+      buttons.forEach((button) => {
+        button.disabled = true;
+        if (button.dataset.busyLabel) button.textContent = button.dataset.busyLabel;
+      });
+    }, 0);
+    // Falls die Navigation scheitert (Validierungsfehler serverseitig),
+    // Buttons nach kurzer Zeit wieder freigeben.
+    window.setTimeout(() => buttons.forEach((button) => (button.disabled = false)), 12000);
+  });
+}
+
+/* -- Kachelfilter (Dashboard) -------------------------------------------- */
+
+function initTileFilters() {
+  $$('[data-behavior="tile-filter"]').forEach((group) => {
+    const targetId = group.dataset.filterScope;
+    const scope = targetId ? document.getElementById(targetId) : document;
+    const apply = (value) => {
+      $$("[data-tile-section]", scope).forEach((section) => {
+        section.hidden = Boolean(value) && section.dataset.tileSection !== value;
+      });
+      $$("[data-filter-value]", group).forEach((button) => {
+        button.setAttribute("aria-pressed", String(button.dataset.filterValue === value));
+      });
+    };
+    group.addEventListener("click", (event) => {
+      const button = event.target.closest("[data-filter-value]");
+      if (!button) return;
+      const value = button.getAttribute("aria-pressed") === "true" ? "" : button.dataset.filterValue;
+      apply(value);
+    });
+  });
+}
+
+/* -- Start ---------------------------------------------------------------- */
+
+function boot() {
+  initTheme();
+  initSidebar();
+  initFlashes();
+  initCollapsibles();
+  initDialogs();
+  initConfirmations();
+  initTables();
+  initPermissionMatrix();
+  initAutoRefresh();
+  initRelativeTimes();
+  initCountdowns();
+  initClipboard();
+  initPalette();
+  initSubmitGuards();
+  initTileFilters();
+  unsavedGuard.init();
+}
+
+if (document.readyState === "loading") {
+  document.addEventListener("DOMContentLoaded", boot, { once: true });
+} else {
+  boot();
+}
+
+window.tesm = { showToast, api, openDialog, relativeTime };
diff --git a/packages/tesm-core/src/tesm_core/sysops.py b/packages/tesm-core/src/tesm_core/sysops.py
new file mode 100644
index 0000000..fc41469
--- /dev/null
+++ b/packages/tesm-core/src/tesm_core/sysops.py
@@ -0,0 +1,310 @@
+"""Privilegierte Systemaktionen ueber einen einzigen, engen Helfer.
+
+Der Vorgaenger lief als ``root`` -- inklusive Flask, Jinja, Template-Rendering,
+Datei-Uploads und LDAP-Client. Eine einzige Luecke in irgendeinem dieser Pfade
+haette vollstaendige Kontrolle ueber den Host bedeutet. Die eigene
+Dokumentation vermerkte das als "bewusst, mit Verbesserungshinweis".
+
+Hier ist der Verbesserungshinweis umgesetzt:
+
+* Der Webprozess laeuft als unprivilegierter Dienstbenutzer.
+* Alles, was Root braucht (systemctl, nginx, netplan, hostnamectl, mount.cifs,
+  apt, certbot), laeuft ueber **ein** Skript: ``tesm-helper``. Es gehoert root,
+  ist per ``sudoers`` mit ``NOPASSWD`` nur fuer genau diesen Pfad erlaubt und
+  validiert jedes Argument gegen eine Positivliste.
+* Die Sicherheitsgrenze ist damit klein, an einer Stelle und pruefbar -- statt
+  ueber Tausende Zeilen Anwendungscode verteilt.
+
+Lesende Systemabfragen (``systemctl is-active``, ``ip addr``) brauchen keine
+Rechte und laufen direkt. Nur Aenderungen gehen ueber den Helfer.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import shutil
+import subprocess
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Sequence
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_HELPER = Path("/usr/local/lib/tesm/tesm-helper")
+#: Verben, die der Helfer kennt. Muss mit dem Skript in ``deploy/`` uebereinstimmen.
+HELPER_VERBS = (
+    "service-status",
+    "service-start",
+    "service-stop",
+    "service-restart",
+    "service-enable",
+    "service-disable",
+    "nginx-test",
+    "nginx-reload",
+    "nginx-write",
+    "nginx-apply",
+    "netplan-write",
+    "netplan-apply",
+    "hostname-set",
+    "timezone-set",
+    "kea-write",
+    "logrotate-write",
+    "mount-cifs",
+    "umount",
+    "apt-install",
+    "certbot-issue",
+    "selfsigned-issue",
+    "file-read",
+)
+
+
+@dataclass(frozen=True, slots=True)
+class Result:
+    ok: bool
+    stdout: str = ""
+    stderr: str = ""
+    code: int = 0
+
+    @property
+    def output(self) -> str:
+        return (self.stdout + ("\n" + self.stderr if self.stderr else "")).strip()
+
+    @property
+    def message(self) -> str:
+        return self.stderr.strip() or self.stdout.strip()
+
+
+UNAVAILABLE = Result(
+    ok=False,
+    stderr=(
+        "Systemaktionen sind in dieser Umgebung nicht verfuegbar "
+        "(Helfer nicht installiert oder kein Linux-Host)."
+    ),
+    code=127,
+)
+
+
+class SysOps:
+    """Zugang zu privilegierten Aktionen. In der Entwicklung ein No-Op."""
+
+    def __init__(
+        self,
+        *,
+        app_key: str,
+        site_key: str = "",
+        helper: Path | None = None,
+        timeout: int = 120,
+        enabled: bool = True,
+    ) -> None:
+        self.app_key = app_key
+        #: Name dieser Installation auf dem Host. Alles, was ausserhalb von
+        #: /srv/ liegt, wird darueber benannt -- sonst schreibt eine
+        #: Testinstanz in die Systemdateien der Produktion.
+        self.site_key = site_key or app_key
+        self.helper = Path(helper or os.environ.get("TESM_HELPER", DEFAULT_HELPER))
+        self.timeout = timeout
+        self.enabled = enabled
+
+    # ------------------------------------------------------------------ #
+
+    @property
+    def available(self) -> bool:
+        """Ist der Helfer *vorhanden*? Sagt noch nichts darueber, ob er laeuft."""
+        return self.enabled and self.helper.is_file() and os.name == "posix"
+
+    def selftest(self) -> Result:
+        """Ruft den Helfer wirklich auf -- die einzige ehrliche Auskunft.
+
+        Dass die Datei existiert, hat nichts zu bedeuten. Auf beiden Testhosts
+        stand monatelang "Helfer erreichbar", waehrend jeder Aufruf an
+        ``NoNewPrivileges`` scheiterte und damit jede Systemaktion tot war.
+        Geprueft wird ``service-status nginx``: der Helfer beantwortet das
+        immer, egal wie es dem Dienst geht. ``nginx-test`` waere der falsche
+        Kandidat -- sein Fehlschlag hiesse "Konfiguration kaputt", nicht
+        "Helfer kaputt", und die Seite meldete dann das Falsche.
+        """
+        if not self.available:
+            return Result(False, "", f"Der Helfer {self.helper} fehlt.", 1)
+        result = self.run("service-status", "nginx")
+        if not result.ok and "no new privileges" in result.output.lower():
+            return Result(
+                False,
+                "",
+                "sudo wird durch 'NoNewPrivileges' der systemd-Unit blockiert. Ohne sudo "
+                "kann der Dienst den Helfer nicht aufrufen -- keine Systemaktion "
+                "funktioniert. In der Unit 'NoNewPrivileges=yes' entfernen und "
+                "'systemctl daemon-reload && systemctl restart' ausfuehren.",
+                result.code,
+            )
+        if not result.ok and "a password is required" in result.output.lower():
+            return Result(
+                False,
+                "",
+                "sudo verlangt ein Passwort. Die sudoers-Regel fuer den Helfer fehlt oder "
+                "greift nicht.",
+                result.code,
+            )
+        return result
+
+    def _sudo_prefix(self) -> list[str]:
+        if os.geteuid() == 0 if hasattr(os, "geteuid") else False:
+            return []
+        sudo = shutil.which("sudo")
+        return [sudo, "-n"] if sudo else []
+
+    def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
+        """Ruft den Helfer auf. Argumente werden nie durch eine Shell geschickt."""
+        if verb not in HELPER_VERBS:
+            raise ValueError(f"Unbekanntes Helfer-Verb: {verb!r}")
+        if not self.available:
+            return UNAVAILABLE
+        command = [*self._sudo_prefix(), str(self.helper), verb, *[str(a) for a in args]]
+        try:
+            completed = subprocess.run(  # noqa: S603 - feste Argumentliste, keine Shell
+                command,
+                input=stdin,
+                capture_output=True,
+                text=True,
+                timeout=timeout or self.timeout,
+                check=False,
+            )
+        except subprocess.TimeoutExpired:
+            return Result(False, stderr=f"Zeitlimit ueberschritten ({verb}).", code=124)
+        except OSError as exc:
+            return Result(False, stderr=str(exc), code=126)
+        return Result(
+            ok=completed.returncode == 0,
+            stdout=completed.stdout or "",
+            stderr=completed.stderr or "",
+            code=completed.returncode,
+        )
+
+    # ------------------------------------------------------------------ #
+    # Lesende Abfragen -- ohne Rechte, direkt
+    # ------------------------------------------------------------------ #
+
+    @staticmethod
+    def read_command(command: Sequence[str], *, timeout: int = 10) -> Result:
+        """Fuehrt eine rein lesende Systemabfrage aus."""
+        if not command:
+            return Result(False, stderr="Leeres Kommando", code=2)
+        binary = shutil.which(command[0])
+        if binary is None:
+            return Result(False, stderr=f"{command[0]} ist nicht installiert.", code=127)
+        try:
+            completed = subprocess.run(  # noqa: S603
+                [binary, *command[1:]],
+                capture_output=True,
+                text=True,
+                timeout=timeout,
+                check=False,
+            )
+        except subprocess.TimeoutExpired:
+            return Result(False, stderr="Zeitlimit ueberschritten.", code=124)
+        except OSError as exc:
+            return Result(False, stderr=str(exc), code=126)
+        return Result(
+            ok=completed.returncode == 0,
+            stdout=completed.stdout or "",
+            stderr=completed.stderr or "",
+            code=completed.returncode,
+        )
+
+    def service_state(self, unit: str) -> dict[str, str]:
+        """``{"active": ..., "enabled": ..., "installed": ...}`` fuer eine Unit."""
+        installed = shutil.which("systemctl") is not None
+        if not installed:
+            return {"active": "unbekannt", "enabled": "unbekannt", "installed": "nein"}
+        active = self.read_command(["systemctl", "is-active", unit]).stdout.strip() or "unknown"
+        enabled = self.read_command(["systemctl", "is-enabled", unit]).stdout.strip() or "unknown"
+        # ``list-unit-files`` erwartet ein Muster **mit** Endung: fuer
+        # "kea-dhcp4-server" liefert es nichts, fuer
+        # "kea-dhcp4-server.service" die Zeile. Ohne diese Ergaenzung stand
+        # ueberall "installiert: nein" -- auch bei laufendem Dienst, was zum
+        # Installieren-Knopf verleitet hat.
+        pattern = unit if "." in unit.rsplit("/", 1)[-1] else f"{unit}.service"
+        exists = self.read_command(
+            ["systemctl", "list-unit-files", pattern, "--no-legend"]
+        ).stdout.strip()
+        return {
+            "active": active,
+            "enabled": enabled,
+            "installed": "ja" if exists else "nein",
+        }
+
+    # ------------------------------------------------------------------ #
+    # Bequeme Wrapper
+    # ------------------------------------------------------------------ #
+
+    def service(self, action: str, unit: str) -> Result:
+        verb = f"service-{action}"
+        return self.run(verb, unit)
+
+    def nginx_test(self) -> Result:
+        return self.run("nginx-test")
+
+    def nginx_reload(self) -> Result:
+        return self.run("nginx-reload")
+
+    def write_nginx(self, content: str) -> Result:
+        return self.run("nginx-write", self.site_key, stdin=content)
+
+    def apply_nginx(self, content: str) -> Result:
+        """Schreiben, pruefen und neu laden -- als eine Operation mit Ruecknahme.
+
+        Bewusst nicht als drei Aufrufe: ``nginx-write`` aktiviert die Site
+        sofort. Zwischen Schreiben und einem fehlgeschlagenen ``nginx -t`` lag
+        sonst ein Zeitfenster, in dem eine kaputte Konfiguration aktiviert war
+        -- der naechste Reload durch irgendwen haette den ganzen Webserver
+        lahmgelegt, auch fuer alle anderen Sites.
+        """
+        return self.run("nginx-apply", self.site_key, stdin=content, timeout=60)
+
+    def write_netplan(self, content: str) -> Result:
+        return self.run("netplan-write", self.site_key, stdin=content)
+
+    def apply_netplan(self) -> Result:
+        return self.run("netplan-apply", timeout=90)
+
+    def set_hostname(self, hostname: str) -> Result:
+        return self.run("hostname-set", hostname)
+
+    def set_timezone(self, timezone: str) -> Result:
+        return self.run("timezone-set", timezone)
+
+    def write_kea_config(self, content: str) -> Result:
+        return self.run("kea-write", stdin=content)
+
+    def write_logrotate(self, content: str) -> Result:
+        return self.run("logrotate-write", self.site_key, stdin=content)
+
+    def mount_cifs(self, unc: str, target: str, username: str, password: str, domain: str = "") -> Result:
+        # Das Passwort geht ueber stdin an den Helfer, der es in eine
+        # 0600-Credentials-Datei schreibt. Nie als Argument (Prozessliste!),
+        # nie ueber die Umgebung (/proc//environ).
+        payload = f"username={username}\npassword={password}\n" + (
+            f"domain={domain}\n" if domain else ""
+        )
+        return self.run("mount-cifs", unc, target, stdin=payload, timeout=45)
+
+    def umount(self, target: str) -> Result:
+        return self.run("umount", target, timeout=45)
+
+    def issue_self_signed(self, common_name: str, *alt_names: str) -> Result:
+        """Erzeugt ein selbstsigniertes Zertifikat fuer diese Installation.
+
+        Fuer Geraete in geschlossenen Netzen ohne oeffentliche Domain -- dort
+        gibt es kein Let's Encrypt, und ohne Zertifikat gaebe es kein HTTPS.
+        """
+        return self.run("selfsigned-issue", self.site_key, common_name, *alt_names, timeout=90)
+
+    def apt_install(self, *packages: str) -> Result:
+        return self.run("apt-install", *packages, timeout=600)
+
+    def certbot_issue(self, domain: str, email: str, webroot: str) -> Result:
+        return self.run("certbot-issue", domain, email, webroot, timeout=300)
+
+    def read_privileged_file(self, path: str) -> Result:
+        """Liest eine Datei, die dem Dienstbenutzer nicht gehoert (z. B. Kea-Log)."""
+        return self.run("file-read", path)
diff --git a/packages/tesm-core/src/tesm_core/system/__init__.py b/packages/tesm-core/src/tesm_core/system/__init__.py
new file mode 100644
index 0000000..c814b2b
--- /dev/null
+++ b/packages/tesm-core/src/tesm_core/system/__init__.py
@@ -0,0 +1,12 @@
+"""Host-Ebene: Netzwerkeinstellungen und Webserver-Konfiguration.
+
+Beide Anwendungen brauchen das identisch. Die Namensraeume sind ueber
+``app_key`` getrennt -- der Vorgaenger schrieb aus beiden Apps in dieselbe
+netplan-Override-Datei und ueberschrieb sich damit gegenseitig.
+"""
+
+from __future__ import annotations
+
+from . import hostnet, webserver
+
+__all__ = ["hostnet", "webserver"]
diff --git a/packages/tesm-core/src/tesm_core/system/hostnet.py b/packages/tesm-core/src/tesm_core/system/hostnet.py
new file mode 100644
index 0000000..84258e8
--- /dev/null
+++ b/packages/tesm-core/src/tesm_core/system/hostnet.py
@@ -0,0 +1,208 @@
+"""Netzwerkeinstellungen des Hosts -- mit Sicherheitsnetz.
+
+Eine falsche IP-Adresse auf einem Host, den man nur ueber das Netz erreicht,
+ist ein Ausflug ins Rechenzentrum. Darum gilt hier dasselbe Muster wie im
+Vorgaenger, nur sauberer umgesetzt:
+
+1. Aenderung wird geschrieben und angewandt.
+2. Ein Zeitgeber laeuft (``REVERT_SECONDS``).
+3. Bestaetigt der Administrator nicht rechtzeitig **von der neuen Adresse aus**,
+   wird automatisch zurueckgerollt.
+
+Zwei Aenderungen gegenueber dem Vorgaenger:
+
+* **Eigener Dateiname pro Anwendung.** Der Vorgaenger schrieb aus *beiden*
+  Anwendungen nach ``/etc/netplan/90-tesm.yaml``. Liefen sie auf demselben Host,
+  ueberschrieb eine die Konfiguration der anderen. Hier heisst die Datei
+  ``90-.yaml``.
+* **DNS-Anzeige ueber ``resolvectl``**, nicht ``/etc/resolv.conf`` -- unter
+  systemd-resolved steht dort immer nur der Stub 127.0.0.53.
+"""
+
+from __future__ import annotations
+
+import ipaddress
+import logging
+import re
+import shutil
+from dataclasses import dataclass, field
+from typing import Any
+
+from ..sysops import SysOps
+
+logger = logging.getLogger(__name__)
+
+REVERT_SECONDS = 45
+
+BACKEND_NETPLAN = "netplan"
+BACKEND_NETWORK_MANAGER = "NetworkManager"
+BACKEND_DHCPCD = "dhcpcd"
+BACKEND_UNKNOWN = "unbekannt"
+
+
+@dataclass(slots=True)
+class InterfaceInfo:
+    name: str
+    addresses: list[str] = field(default_factory=list)
+    state: str = "unbekannt"
+    mac: str = ""
+    gateway: str = ""
+    dns: list[str] = field(default_factory=list)
+    method: str = "unbekannt"
+
+
+def detect_backend(sysops: SysOps) -> str:
+    """Erkennt das aktive Netzwerk-Backend -- rein lesend."""
+    if shutil.which("netplan"):
+        return BACKEND_NETPLAN
+    if sysops.read_command(["systemctl", "is-active", "NetworkManager"]).stdout.strip() == "active":
+        return BACKEND_NETWORK_MANAGER
+    if sysops.read_command(["systemctl", "is-active", "dhcpcd"]).stdout.strip() == "active":
+        return BACKEND_DHCPCD
+    return BACKEND_UNKNOWN
+
+
+def list_interfaces(sysops: SysOps) -> list[InterfaceInfo]:
+    result: dict[str, InterfaceInfo] = {}
+    addr = sysops.read_command(["ip", "-o", "addr", "show"])
+    for line in addr.stdout.splitlines():
+        parts = line.split()
+        if len(parts) < 4:
+            continue
+        name = parts[1]
+        entry = result.setdefault(name, InterfaceInfo(name=name))
+        if parts[2] in {"inet", "inet6"}:
+            entry.addresses.append(parts[3])
+
+    link = sysops.read_command(["ip", "-o", "link", "show"])
+    for line in link.stdout.splitlines():
+        parts = line.split()
+        if len(parts) < 2:
+            continue
+        name = parts[1].rstrip(":").split("@")[0]
+        entry = result.setdefault(name, InterfaceInfo(name=name))
+        if "state" in parts:
+            entry.state = parts[parts.index("state") + 1]
+        if "link/ether" in parts:
+            entry.mac = parts[parts.index("link/ether") + 1]
+
+    routes = sysops.read_command(["ip", "route", "show", "default"])
+    for line in routes.stdout.splitlines():
+        parts = line.split()
+        if "via" in parts and "dev" in parts:
+            gateway = parts[parts.index("via") + 1]
+            device = parts[parts.index("dev") + 1]
+            if device in result:
+                result[device].gateway = gateway
+
+    for entry in result.values():
+        entry.dns = read_dns(sysops, entry.name)
+        entry.method = "unbekannt"
+
+    return [entry for name, entry in sorted(result.items()) if name != "lo"]
+
+
+def read_dns(sysops: SysOps, interface: str) -> list[str]:
+    """Liest die DNS-Server per ``resolvectl dns ``.
+
+    Mehrzeilige Ausgabe beachten: IPv6-Fortsetzungszeilen enthalten selbst
+    Doppelpunkte -- ein naives ``split(":")`` zerlegt sie falsch.
+    """
+    if not shutil.which("resolvectl"):
+        return []
+    result = sysops.read_command(["resolvectl", "dns", interface])
+    if not result.ok:
+        return []
+    servers: list[str] = []
+    for line in result.stdout.splitlines():
+        text = line.strip()
+        if not text:
+            continue
+        if text.lower().startswith("link"):
+            _, _, tail = text.partition(":")
+            text = tail.strip()
+        for token in text.split():
+            try:
+                ipaddress.ip_address(token)
+            except ValueError:
+                continue
+            servers.append(token)
+    return servers
+
+
+def validate_static(
+    *, address: str, prefix: Any, gateway: str, dns: str
+) -> tuple[str, int, str, list[str]]:
+    try:
+        prefix_length = int(prefix)
+    except (TypeError, ValueError) as exc:
+        raise ValueError("Die Praefixlaenge muss eine Zahl sein (z. B. 24).") from exc
+    if not 1 <= prefix_length <= 32:
+        raise ValueError("Die Praefixlaenge muss zwischen 1 und 32 liegen.")
+    try:
+        interface_ip = ipaddress.ip_interface(f"{address.strip()}/{prefix_length}")
+    except ValueError as exc:
+        raise ValueError(f"{address!r} ist keine gueltige IPv4-Adresse.") from exc
+
+    gateway_clean = (gateway or "").strip()
+    if gateway_clean:
+        try:
+            gateway_ip = ipaddress.ip_address(gateway_clean)
+        except ValueError as exc:
+            raise ValueError(f"{gateway_clean!r} ist keine gueltige Gateway-Adresse.") from exc
+        if gateway_ip not in interface_ip.network:
+            raise ValueError(
+                f"Das Gateway {gateway_clean} liegt nicht im Netz {interface_ip.network}. "
+                "So waere der Host nach der Aenderung nicht mehr erreichbar."
+            )
+
+    servers: list[str] = []
+    for token in re.split(r"[\s,;]+", dns or ""):
+        if not token:
+            continue
+        try:
+            ipaddress.ip_address(token)
+        except ValueError as exc:
+            raise ValueError(f"{token!r} ist keine gueltige DNS-Adresse.") from exc
+        servers.append(token)
+
+    return str(interface_ip.ip), prefix_length, gateway_clean, servers
+
+
+def render_netplan(
+    *,
+    interface: str,
+    dhcp: bool,
+    address: str = "",
+    prefix: int = 24,
+    gateway: str = "",
+    dns: list[str] | None = None,
+) -> str:
+    """Erzeugt eine Override-Datei fuer genau **ein** Interface.
+
+    Nur der Block des betroffenen Interfaces steht darin. Ein vollstaendiger
+    Neuaufbau der Netzwerkkonfiguration wuerde andere, unveraenderte Interfaces
+    zuruecksetzen -- ein realer Fehler des Vorgaengerprojekts.
+    """
+    lines = [
+        "# Von TESM verwaltet. Diese Datei enthaelt ausschliesslich das unten",
+        "# genannte Interface; alle anderen bleiben bei ihrer",
+        "# Distributionskonfiguration.",
+        "network:",
+        "  version: 2",
+        "  ethernets:",
+        f"    {interface}:",
+    ]
+    if dhcp:
+        lines.append("      dhcp4: true")
+    else:
+        lines.append("      dhcp4: false")
+        lines.append(f"      addresses: [{address}/{prefix}]")
+        if gateway:
+            lines.append("      routes:")
+            lines.append(f"        - to: default")
+            lines.append(f"          via: {gateway}")
+        if dns:
+            lines.append("      nameservers:")
+            lines.append(f"        addresses: [{', '.join(dns)}]")
+    return "\n".join(lines) + "\n"
diff --git a/packages/tesm-core/src/tesm_core/system/webserver.py b/packages/tesm-core/src/tesm_core/system/webserver.py
new file mode 100644
index 0000000..30b5a54
--- /dev/null
+++ b/packages/tesm-core/src/tesm_core/system/webserver.py
@@ -0,0 +1,379 @@
+"""nginx-Konfiguration und TLS-Zertifikate.
+
+Der Vorgaenger schrieb die Site-Datei nur, wenn sie **noch nicht existierte**.
+Das hat produktiv realen Schaden verursacht: eine sehr alte Datei mit einem
+falschen ``alias``-Pfad blieb ueber beliebig viele Updates bestehen, obwohl das
+Repo-Template den Fehler laengst behoben hatte -- Ergebnis: alle statischen
+Dateien 404, Oberflaeche komplett ohne Design.
+
+Loesung hier: die Datei wird **immer** aus der Vorlage erzeugt. Alles, was ein
+Administrator sonst per Hand hineinschrieb (Domain, Ports, Zertifikate, HSTS),
+ist Einstellung in der Anwendung und fliesst in die Vorlage ein. Damit gibt es
+keinen Grund mehr, eine erzeugte Datei per Hand zu bearbeiten -- und keinen
+Grund, sie beim Update in Ruhe zu lassen.
+
+Zielzustand im Betrieb: Port 80 leitet auf 443 um (ausser dem ACME-Pfad),
+Port 443 terminiert TLS, HSTS wird gesetzt.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+import socket
+from collections.abc import Sequence
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from ..sysops import SysOps
+
+logger = logging.getLogger(__name__)
+
+REVERT_SECONDS = 45
+DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}$")
+
+
+@dataclass(frozen=True, slots=True)
+class WebConfig:
+    app_key: str
+    upstream: str
+    static_root: str
+    domain: str = ""
+    https_enabled: bool = False
+    hsts_enabled: bool = False
+    redirect_http: bool = True
+    cert_path: str = ""
+    key_path: str = ""
+    acme_webroot: str = "/var/www/tesm-acme"
+    websocket_path: str = ""
+    max_body_mb: int = 512
+    #: Abweichende Ports braucht nur eine Testinstanz neben einer laufenden
+    #: Produktion. Der Regelfall -- und das Ziel jeder echten Installation --
+    #: ist 80 und 443.
+    http_port: int = 80
+    https_port: int = 443
+    #: ``http2 on;`` gibt es erst ab nginx 1.25.1. Aeltere Versionen kennen nur
+    #: ``listen ... ssl http2;`` und weisen die neue Direktive mit
+    #: "unknown directive" ab -- der ganze Webserver startet dann nicht mehr.
+    #: Auf POETEST (nginx 1.24) genau so passiert. Vorgabe ist deshalb die alte
+    #: Schreibweise: sie funktioniert ueberall und erzeugt auf neuen Versionen
+    #: nur eine Warnung.
+    http2_directive: bool = False
+
+    @property
+    def server_name(self) -> str:
+        return self.domain or "_"
+
+    @property
+    def default_marker(self) -> str:
+        """``default_server`` fuer die Site ohne eigenen Namen.
+
+        ``server_name _;`` ist bei nginx kein Sonderfall -- es ist nur ein Name,
+        auf den kein echter Host-Header passt. Ohne ``default_server`` wird die
+        *zuerst geladene* Site zur Vorgabe, und das ist die alphabetisch erste
+        Datei in ``sites-enabled``. Auf einem Host mit zwei Anwendungen
+        entscheidet dann der Zufall des Dateinamens, wer Anfragen ohne
+        passenden Namen bekommt.
+
+        Mit Domain gibt es keinen Grund, Vorgabe zu sein -- dann greift der
+        Name. Zwei namenlose Sites auf demselben Port weist nginx ab; das ist
+        richtig so und wird beim Uebernehmen sichtbar.
+        """
+        return "" if self.domain else " default_server"
+
+    @property
+    def https_suffix(self) -> str:
+        """``:8443`` bei abweichendem Port, sonst leer -- fuer die Umleitung."""
+        return "" if self.https_port == 443 else f":{self.https_port}"
+
+
+def validate_domain(value: str) -> str:
+    text = (value or "").strip().lower().rstrip(".")
+    if not text:
+        return ""
+    if not DOMAIN_RE.match(text):
+        raise ValueError(f"{text!r} ist kein gueltiger Domainname.")
+    return text
+
+
+def render(config: WebConfig) -> str:
+    """Erzeugt die vollstaendige Site-Konfiguration."""
+    body = f"{max(1, config.max_body_mb)}m"
+    # Kopfzeilen und proxy_pass -- ohne Zeitlimits. Die kommen je Block dazu,
+    # denn nginx lehnt eine doppelte Direktive im selben Block ab (auf POETEST
+    # real passiert: der Websocket-Block erbte 120s und setzte noch einmal
+    # 3600s, "proxy_read_timeout directive is duplicate", nginx startete nicht).
+    common_proxy = [
+        # $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
+        # abweichendem Port schlaegt die Origin-Pruefung der Anwendung sonst fehl.
+        "        proxy_set_header Host $http_host;",
+        "        proxy_set_header X-Real-IP $remote_addr;",
+        "        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;",
+        "        proxy_set_header X-Forwarded-Proto $scheme;",
+        "        proxy_set_header X-Forwarded-Host $http_host;",
+        "        proxy_set_header X-Forwarded-Port $server_port;",
+        f"        proxy_pass http://{config.upstream};",
+        "        proxy_http_version 1.1;",
+    ]
+
+    lines: list[str] = [
+        f"# Von TESM erzeugt ({config.app_key}). Aenderungen bitte in der Anwendung",
+        "# vornehmen -- diese Datei wird bei jeder Uebernahme neu geschrieben.",
+        "",
+    ]
+
+    # ---- Port 80 --------------------------------------------------------- #
+    lines += [
+        "server {",
+        f"    listen {config.http_port}{config.default_marker};",
+        f"    listen [::]:{config.http_port}{config.default_marker};",
+        f"    server_name {config.server_name};",
+        "",
+        "    # ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar --",
+        "    # sonst kann Let's Encrypt nie erneuern.",
+        "    location /.well-known/acme-challenge/ {",
+        f"        alias {config.acme_webroot.rstrip('/')}/.well-known/acme-challenge/;",
+        "        default_type \"text/plain\";",
+        "    }",
+        "",
+    ]
+    if config.https_enabled and config.redirect_http:
+        lines += [
+            "    location / {",
+            f"        return 308 https://$host{config.https_suffix}$request_uri;",
+            "    }",
+            "}",
+            "",
+        ]
+    else:
+        lines += _site_body(config, common_proxy, body, tls=False)
+        lines += ["}", ""]
+
+    # ---- Port 443 -------------------------------------------------------- #
+    if config.https_enabled:
+        lines += [
+            "server {",
+            f"    listen {config.https_port} ssl"
+            f"{config.default_marker}{'' if config.http2_directive else ' http2'};",
+            f"    listen [::]:{config.https_port} ssl"
+            f"{config.default_marker}{'' if config.http2_directive else ' http2'};",
+            *(["    http2 on;"] if config.http2_directive else []),
+            f"    server_name {config.server_name};",
+            "",
+            f"    ssl_certificate     {config.cert_path};",
+            f"    ssl_certificate_key {config.key_path};",
+            "    ssl_protocols TLSv1.2 TLSv1.3;",
+            "    ssl_prefer_server_ciphers off;",
+            "    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:"
+            "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:"
+            "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;",
+            "    ssl_session_cache shared:TESM:10m;",
+            "    ssl_session_timeout 1d;",
+            "    ssl_session_tickets off;",
+        ]
+        # OCSP-Stapling nur mit einem Zertifikat einer echten CA. Ein
+        # selbstsigniertes hat keinen OCSP-Responder; nginx warnt dann bei
+        # jedem Start ("ssl_stapling ignored") -- eine Warnung, die niemand
+        # abstellen kann und die echte Warnungen untergehen laesst.
+        if config.domain:
+            lines += ["    ssl_stapling on;", "    ssl_stapling_verify on;"]
+        lines += [""]
+        if config.hsts_enabled:
+            lines.append(
+                '    add_header Strict-Transport-Security "max-age=15552000; '
+                'includeSubDomains" always;'
+            )
+            lines.append("")
+        lines += _site_body(config, common_proxy, body, tls=True)
+        lines += ["}", ""]
+
+    return "\n".join(lines)
+
+
+def _site_body(config: WebConfig, common_proxy: list[str], body: str, *, tls: bool) -> list[str]:
+    lines = [
+        f"    client_max_body_size {body};",
+        "    # Groesser als das Limit der Anwendung, damit deren eigene, erklaerende",
+        "    # Fehlerseite gewinnt statt der generischen 413 von nginx.",
+        "",
+        "    location /assets/ {",
+        f"        alias {config.static_root.rstrip('/')}/;",
+        "        expires 7d;",
+        "        access_log off;",
+        "        add_header Cache-Control \"public, max-age=604800, immutable\";",
+        "    }",
+        "",
+        "    location /core-assets/ {",
+        f"        alias {config.static_root.rstrip('/')}-core/;",
+        "        expires 7d;",
+        "        access_log off;",
+        "    }",
+        "",
+    ]
+    if config.websocket_path:
+        # Eine SSH-Sitzung im Browser steht minutenlang still, ohne tot zu sein.
+        lines += [
+            f"    location {config.websocket_path} {{",
+            "        proxy_set_header Upgrade $http_upgrade;",
+            '        proxy_set_header Connection "upgrade";',
+            *common_proxy,
+            "        proxy_read_timeout 3600s;",
+            "        proxy_send_timeout 3600s;",
+            "        proxy_buffering off;",
+            "    }",
+            "",
+        ]
+    lines += [
+        "    location / {",
+        *common_proxy,
+        "        proxy_read_timeout 120s;",
+        "        proxy_send_timeout 120s;",
+        "    }",
+    ]
+    return lines
+
+
+def apply_config(sysops: SysOps, content: str) -> dict[str, Any]:
+    """Schreibt die Konfiguration, prueft sie und laedt nginx neu.
+
+    Ein einziger Helferaufruf, nicht drei. Der Helfer nimmt die Aenderung bei
+    einem fehlgeschlagenen ``nginx -t`` selbst zurueck; danach ist der Zustand
+    auf der Platte garantiert derselbe wie vorher.
+
+    Das war ein echter Fehler in der ersten Fassung: ``nginx-write`` verlinkt
+    die Datei sofort nach ``sites-enabled``. Wurde sie anschliessend abgelehnt,
+    blieb sie aktiviert liegen -- der laufende nginx merkte nichts, aber der
+    naechste Reload (auch fuer eine ganz andere Site) scheiterte. Aus einer
+    abgelehnten Eingabe wurde ein Ausfall des Webservers.
+    """
+    result = sysops.apply_nginx(content)
+    if not result.ok:
+        return {
+            "ok": False,
+            "message": "nginx hat die Konfiguration abgelehnt -- der alte Stand gilt weiter.",
+            "detail": result.output,
+        }
+    return {"ok": True, "message": "Konfiguration uebernommen.", "detail": result.output}
+
+
+def request_certificate(
+    sysops: SysOps, *, domain: str, email: str, webroot: str
+) -> dict[str, Any]:
+    """Fordert ein Let's-Encrypt-Zertifikat per Webroot an.
+
+    Bewusst ``certbot certonly --webroot`` und nicht das nginx-Plugin: das
+    Plugin bearbeitet die Site-Datei selbst und wuerde die von der Anwendung
+    erzeugte Konfiguration verbiegen.
+    """
+    if not domain:
+        return {"ok": False, "message": "Es ist keine Domain konfiguriert."}
+    if not email:
+        return {"ok": False, "message": "Fuer Let's Encrypt ist eine E-Mail-Adresse noetig."}
+    result = sysops.certbot_issue(domain, email, webroot)
+    if not result.ok:
+        return {"ok": False, "message": "certbot ist fehlgeschlagen.", "detail": result.output}
+    return {
+        "ok": True,
+        "message": f"Zertifikat fuer {domain} ausgestellt.",
+        "detail": result.output,
+        "cert_path": f"/etc/letsencrypt/live/{domain}/fullchain.pem",
+        "key_path": f"/etc/letsencrypt/live/{domain}/privkey.pem",
+    }
+
+
+def create_self_signed(
+    sysops: SysOps, *, common_name: str, alt_names: Sequence[str] = ()
+) -> dict[str, Any]:
+    """Erzeugt ein selbstsigniertes Zertifikat fuer diese Installation.
+
+    Der Regelfall im Zielumfeld ist ein Geraet in einem geschlossenen Netz:
+    keine oeffentliche Domain, kein Let's Encrypt, oft nicht einmal ein
+    aufloesbarer Name -- nur eine IP. Ohne diesen Weg gaebe es dort ueberhaupt
+    kein HTTPS, und Port 443 bliebe leer.
+
+    Der Browser zeigt eine Warnung; das ist der ehrliche Preis. Verschluesselt
+    ist die Verbindung trotzdem, und das Sitzungscookie darf endlich
+    ``Secure`` tragen.
+    """
+    name = (common_name or "").strip().lower().rstrip(".")
+    if not name:
+        return {"ok": False, "message": "Es fehlt ein Name fuer das Zertifikat."}
+    if not re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name):
+        return {"ok": False, "message": f"{name!r} ist als Zertifikatsname nicht zulaessig."}
+
+    cleaned: list[str] = []
+    for entry in alt_names:
+        value = (entry or "").strip().lower().rstrip(".")
+        if not value or value == name or value in cleaned:
+            continue
+        if re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", value):
+            cleaned.append(value)
+
+    result = sysops.issue_self_signed(name, *cleaned)
+    if not result.ok:
+        return {
+            "ok": False,
+            "message": "Das Zertifikat konnte nicht erzeugt werden.",
+            "detail": result.output,
+        }
+    paths = [line.strip() for line in result.stdout.splitlines() if line.strip()]
+    if len(paths) != 2:
+        return {"ok": False, "message": "Unerwartete Antwort des Helfers.", "detail": result.output}
+    return {
+        "ok": True,
+        "message": (
+            f"Selbstsigniertes Zertifikat fuer {name} erzeugt. Browser zeigen dafuer eine "
+            "Warnung -- fuer ein oeffentlich erreichbares System bitte Let's Encrypt nutzen."
+        ),
+        "cert_path": paths[0],
+        "key_path": paths[1],
+    }
+
+
+def local_names() -> list[str]:
+    """Hostname und eigene IP-Adressen -- Vorschlag fuer die Namen im Zertifikat."""
+    names: list[str] = []
+    try:
+        hostname = socket.gethostname().strip().lower()
+    except OSError:  # pragma: no cover - defensiv
+        hostname = ""
+    for candidate in (hostname, f"{hostname}.local" if hostname else ""):
+        if candidate and candidate not in names:
+            names.append(candidate)
+    try:
+        for info in socket.getaddrinfo(hostname or "localhost", None, socket.AF_INET):
+            address = info[4][0]
+            if address and not address.startswith("127.") and address not in names:
+                names.append(address)
+    except OSError:  # pragma: no cover - defensiv
+        pass
+    return names
+
+
+def certificate_info(cert_path: str) -> dict[str, Any]:
+    """Liest Aussteller und Ablauf eines Zertifikats -- ohne externes Werkzeug."""
+    path = Path(cert_path or "")
+    try:
+        if not path.is_file():
+            return {"present": False}
+        from cryptography import x509
+
+        certificate = x509.load_pem_x509_certificate(path.read_bytes())
+    except PermissionError:
+        # Kein Grund, die ganze Seite abzuwerfen: das Zertifikat kann
+        # ausserhalb der Reichweite des Dienstbenutzers liegen. nginx liest es
+        # trotzdem -- nur die Anzeige von Aussteller und Ablauf entfaellt.
+        return {"present": True, "error": "Die Anwendung darf diese Datei nicht lesen."}
+    except OSError as exc:
+        return {"present": True, "error": str(exc)}
+    except Exception as exc:  # noqa: BLE001
+        return {"present": True, "error": str(exc)}
+    return {
+        "present": True,
+        "subject": certificate.subject.rfc4514_string(),
+        "issuer": certificate.issuer.rfc4514_string(),
+        "not_valid_after": certificate.not_valid_after_utc.isoformat(timespec="seconds"),
+        "not_valid_before": certificate.not_valid_before_utc.isoformat(timespec="seconds"),
+        "serial": format(certificate.serial_number, "x"),
+    }
diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/_group_picker.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/_group_picker.html
new file mode 100644
index 0000000..d396fe1
--- /dev/null
+++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/_group_picker.html
@@ -0,0 +1,46 @@
+{# Auswahlfeld fuer eine Verzeichnisgruppe.
+
+   Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthaelt
+   Kommas und Leerzeichen und ein Tippfehler faellt erst bei der naechsten
+   Anmeldung auf -- als stille Nicht-Zuordnung. Deshalb gesucht statt getippt.
+
+   Das Suchergebnis kommt aus dem Verzeichnis selbst; die Felder werden dabei
+   beide gefuellt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
+   Umgebung ohne Dienstkonto ist die Suche nicht moeglich, und dann ist ein
+   eingefuegter DN besser als gar keine Zuordnung.
+
+   Erwartet: name_field, dn_field (Feldnamen), scope (eindeutiges Kuerzel),
+   optional name_value / dn_value und required.
+#}
+{% macro group_picker(name_field, dn_field, scope, name_value='', dn_value='',
+                      required=False, label='Verzeichnisgruppe') %}
+  
+ {{ label }} +
+ + +
+
+ + +
+ + +
+
+{% endmacro %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/_log_lines.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/_log_lines.html new file mode 100644 index 0000000..4ce031f --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/_log_lines.html @@ -0,0 +1,7 @@ +{% if lines %} +
{% for line in lines %}{{ line }}
+{% endfor %}
+{% else %} +
Keine Zeilen
+

Es liegen (noch) keine passenden Protokollzeilen vor.

+{% endif %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/account.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/account.html new file mode 100644 index 0000000..c330720 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/account.html @@ -0,0 +1,121 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, alert, pill, empty_state %} + +{% block page_title %}Mein Konto{% endblock %} +{% block page_sub %}{{ current_user.username }} · {{ current_user.role_label }}{% endblock %} + +{% block topbar_extra %} +
{{ icon('lock', size=15) }} Sicherheit +{% endblock %} + +{% block content %} +
+
+
+
+
{{ icon('users', size=17) }} Stammdaten
+
+
+ +
+ {% if current_user.is_directory_account %} + {{ alert('Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder + Anmeldung uebernommen.', 'info') }} + {% endif %} +
+ {{ field('first_name', 'Vorname', value=profile.first_name, + disabled=current_user.is_directory_account) }} + {{ field('last_name', 'Nachname', value=profile.last_name, + disabled=current_user.is_directory_account) }} + {{ field('email', 'E-Mail', value=profile.email, type='email', + disabled=current_user.is_directory_account, extra_class='field--full') }} +
+
+ {% if not current_user.is_directory_account %} + + {% endif %} +
+
+ +
+
+
+
{{ icon('shield', size=17) }} Meine wirksamen Rechte
+
+ Vereinigung aller Gruppen, nach Anwendung der Bereichsschalter +
+
+
+
+ {% if current_user.is_admin %} + {{ alert('Sie sind Administrator und duerfen unabhaengig von Gruppen alles.', 'warning') }} + {% elif permissions %} +
+ {% for permission in permissions %} + {{ tree.label_for(permission) }} + {% endfor %} +
+ {% else %} + {{ empty_state('Keine Rechte', 'Sie sehen nur diese Kontoseite.', 'lock') }} + {% endif %} +
+
+
+ +
+
+
+
{{ icon('grid', size=17) }} Profilbild
+
+
+
+ {% if profile.avatar_filename %} + + {% else %} + + {% endif %} +
PNG, JPG, WEBP oder GIF, maximal 2 MB.
+
+
+ + + +
+
+
+ +
+
+
{{ icon('shield-users', size=17) }} Gruppen
+
+
+ {% if groups %} +
+ {% for group in groups %}{{ pill(group.name, 'accent' if group.is_default else '') }}{% endfor %} +
+ {% else %} +

Keiner Gruppe zugeordnet.

+ {% endif %} +
+
+ +
+
+
{{ icon('info', size=17) }} Konto
+
+
+
+
Benutzername
{{ current_user.username }}
+
Quelle
{{ 'Verzeichnisdienst' if current_user.is_directory_account else 'Lokal' }}
+
Zwei-Faktor
+
{% if profile.totp_secret %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('inaktiv', 'warning') }}{% endif %}
+
Angelegt
{{ profile.created_at | dt }}
+
Letzte Anmeldung
{{ profile.last_login_at | dt }}
+
+
+
+
+
+{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/account_security.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/account_security.html new file mode 100644 index 0000000..74fbc0e --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/account_security.html @@ -0,0 +1,181 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, alert, pill, empty_state %} + +{% block page_title %}Sicherheit{% endblock %} +{% block page_sub %}Passwort, zweiter Faktor und aktive Sitzungen{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block content %} +{% if recovery_codes %} +
+
+
+
{{ icon('key', size=17) }} Wiederherstellungscodes
+
+ Jetzt sichern – sie werden nur dieses eine Mal angezeigt. Jeder Code + funktioniert genau einmal. +
+
+ +
+
+
{% for code in recovery_codes %}{{ code }}
+{% endfor %}
+
+
+{% endif %} + +
+
+
+
+
{{ icon('lock', size=17) }} Passwort aendern
+
+ {% if current_user.is_directory_account %} +
+ {{ alert('Ihr Passwort wird im Verzeichnisdienst gepflegt und dort geaendert.', 'info') }} +
+ {% else %} +
+ +
+ {{ field('current_password', 'Aktuelles Passwort', type='password', required=True, + autocomplete='current-password') }} + {{ field('new_password', 'Neues Passwort', type='password', required=True, + autocomplete='new-password', + hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen und zwei verschiedene Zeichenarten.') }} + {{ field('repeat_password', 'Neues Passwort wiederholen', type='password', required=True, + autocomplete='new-password') }} + {{ alert('Nach der Aenderung werden alle anderen Sitzungen beendet.', 'info') }} +
+ +
+ {% endif %} +
+ +
+
+
+
{{ icon('shield', size=17) }} Zwei-Faktor-Authentisierung + {% if totp_enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('inaktiv', 'warning') }}{% endif %} +
+
Zeitbasierte Einmalcodes (TOTP), kompatibel mit jeder Authenticator-App
+
+
+
+ {% if pending_secret %} + {{ alert('Richten Sie den Eintrag in Ihrer App ein und bestaetigen Sie mit einem aktuellen Code. Erst dann wird die Anmeldung umgestellt.', 'warning') }} +
+
Geheimnis
+
{{ pending_secret_grouped }} +
+
Konto
{{ current_user.username }}
+
Verfahren
TOTP, SHA-1, 6 Stellen, 30 Sekunden
+
+
+ otpauth-URI anzeigen +
{{ pending_uri }}
+
+
+ + + +
+ {% elif totp_enabled %} +
+
Aktiviert am
{{ profile.totp_confirmed_at | dt }}
+
Unbenutzte Wiederherstellungscodes
+
{{ recovery_remaining }}{% if recovery_remaining <= 2 %} +  – bitte erneuern{% endif %}
+
+
+
+ + +
+
+ + +
+
+ {% else %} + {% if require_totp_for_admins and current_user.is_admin %} + {{ alert('In dieser Installation ist ein zweiter Faktor fuer Administratoren verpflichtend. Bitte jetzt einrichten.', 'danger') }} + {% endif %} +

+ Ein zweiter Faktor schuetzt Ihr Konto auch dann, wenn Ihr Passwort bekannt wird. +

+
+ + +
+ {% endif %} +
+
+
+ +
+
+
+
{{ icon('activity', size=17) }} Meine Sitzungen
+
{{ sessions | length }} aktiv
+
+ {% if sessions | length > 1 %} +
+ + +
+ {% endif %} +
+
+ {% if sessions %} + + + {% for entry in sessions %} + + + + + {% endfor %} + +
+
+ {{ entry.ip or 'unbekannte Adresse' }} + {% if entry.id == current_sid %}{{ pill('diese Sitzung', 'online') }}{% endif %} +
+
{{ entry.user_agent or '-' }}
+
+ aktiv {{ entry.last_seen_at | dt }} +
+
+ {% if entry.id != current_sid %} +
+ + +
+ {% endif %} +
+ {% else %} + {{ empty_state('Keine Sitzungen', '', 'activity') }} + {% endif %} +
+
+
+{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/base.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/base.html new file mode 100644 index 0000000..9b98231 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/base.html @@ -0,0 +1,190 @@ +{# ========================================================================== + Grundgeruest aller angemeldeten Seiten. + Bloecke: extra_head, page_title, page_sub, breadcrumb, topbar_extra, + content, dialogs, scripts + ========================================================================== #} + + + + + + + + {% block title %}{{ self.page_title() }} · {{ app_name }}{% endblock %} + + + + {% block extra_head %}{% endblock %} + + + +
+ + + + +
+
+ + +
+ {% block breadcrumb %}{% endblock %} +
{% block page_title %}{{ app_name }}{% endblock %}
+ {% set sub %}{% block page_sub %}{% endblock %}{% endset %} + {% if sub | trim %}
{{ sub }}
{% endif %} +
+ +
+ {% block topbar_extra %}{% endblock %} + {% for pill in status_pills %} + + {% if pill.dot %}{% endif %} + {{ pill.label }} + + {% endfor %} + +
+
+ +
+ {% block content %}{% endblock %} +
+ +
+ {{ app_name }} {{ app_version }} · © {{ current_year }} WiS GmbH + + {% if migration_warning %} + Schemawarnung + {% endif %} + Angemeldet als {{ current_user.username or 'Gast' }} + +
+
+
+ +{# -- Flash-Meldungen: als JSON, von app.js in Toasts verwandelt ----------- #} +{% with messages = get_flashed_messages(with_categories=true) %} + +{% endwith %} + +{# -- Befehlspalette ------------------------------------------------------- #} + + +
+
+ waehlen + Enter oeffnen + Esc schliessen +
+
+ + +{# -- Bestaetigungsdialog (ersetzt window.confirm) ------------------------- #} + + + + + + +{% block dialogs %}{% endblock %} + + +{% block scripts %}{% endblock %} + + diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/diagnostics.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/diagnostics.html new file mode 100644 index 0000000..30b3f62 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/diagnostics.html @@ -0,0 +1,190 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import alert, pill, stat %} + +{% block page_title %}Diagnose{% endblock %} +{% block page_sub %}Schemastand, Protokollintegritaet, Schluessel und Systemzugriff{% endblock %} + +{% block content %} +
+ {{ stat('Benutzer', counts['Benutzer']) }} + {{ stat('Gruppen', counts['Gruppen']) }} + {{ stat('Aktive Sitzungen', counts['Aktive Sitzungen']) }} + {{ stat('Protokollzeilen', counts['Protokollzeilen']) }} +
+ +
+
+
+
+
+ {{ icon('shield', size=17) }} Aenderungsprotokoll + {% if chain.ok %}{{ pill('unversehrt', 'online') }}{% else %}{{ pill('Kette gebrochen', 'danger') }}{% endif %} +
+
{{ chain.checked }} Zeilen geprueft (Hash-Kette)
+
+
+
+ {% if chain.ok %} +

+ Jede Zeile bindet den Hash ihrer Vorgaengerin ein. Eine nachtraeglich entfernte oder + geaenderte Zeile wuerde hier auffallen. +

+ {% else %} + {{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger', + 'Manipulation oder Datenverlust') }} + {% endif %} +
+
+ +
+
+
+
+ {{ icon('key', size=17) }} Schluessel und Dateirechte + {% if key_problems %}{{ pill('Problem', 'danger') }}{% else %}{{ pill('in Ordnung', 'online') }}{% endif %} +
+
Aktiver Datenschluessel: {{ keystore_active }} (vorhanden: {{ keystore_keys | join(', ') }})
+
+ {% if current_user.is_admin %} +
+ + +
+ {% endif %} +
+
+ {% if key_problems %} +
    + {% for problem in key_problems %}
  • {{ problem }}
  • {% endfor %} +
+ {% else %} +

+ Alle Schluesseldateien liegen mit Modus 0600. Feldverschluesselung ist AES-256-GCM mit + rotierbarem Schluesselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden. +

+ {% endif %} +
+
+
+ +
+
+
+
{{ icon('database', size=17) }} Schemastand
+
+ {{ db_path }} · {{ db_size | bytes }} · {{ migrations | length }} angewendete Migrationen +
+
+
+
+ + + + + + + + + + + + {% for entry in migrations %} + + + + + + + + {% endfor %} + +
KomponenteVersionMigrationPruefsummeAngewendet
{{ entry.component }}{{ entry.version }}{{ entry.name }}{{ entry.checksum }}{{ entry.applied_at | dt }}
+
+
+ +
+
+
+
+
+ {{ icon('server', size=17) }} Systemzugriff + {% if not sysops_available %}{{ pill('nicht verfuegbar', 'warning') }} + {% elif sysops_selftest and sysops_selftest.ok %}{{ pill('Helfer antwortet', 'online') }} + {% else %}{{ pill('Helfer antwortet nicht', 'danger') }}{% endif %} +
+
{{ sysops_helper or 'kein Pfad konfiguriert' }}
+
+
+
+

+ Der Webprozess laeuft unprivilegiert. Alles, was Root-Rechte braucht, geht durch genau + ein Helferskript mit Positivliste – nicht durch die Anwendung selbst. +

+ {% if not sysops_available %} + {{ alert('Ohne Helfer sind Systemaktionen (Dienste, Netzwerk, Zertifikate) deaktiviert. + Alles andere funktioniert normal -- das ist der erwartete Zustand in einer + Entwicklungsumgebung.', 'info') }} + {% elif sysops_selftest and not sysops_selftest.ok %} + {# Geprueft wird ein echter Aufruf, nicht die blosse Existenz der Datei. + Genau daran lag es einmal: die Seite meldete "erreichbar", waehrend + jeder Aufruf scheiterte. #} + {{ alert(sysops_selftest.message or 'Der Helfer laesst sich nicht aufrufen.', + 'danger', 'Systemaktionen funktionieren nicht') }} + {% endif %} +
+
+ +
+
+
{{ icon('info', size=17) }} Laufzeit
+ {% if current_user.is_admin %} +
+ + +
+ {% endif %} +
+
+
+ {% for label, value in runtime.items() %} +
{{ label }}
{{ value }}
+ {% endfor %} +
Protokollverzeichnis
{{ log_dir }}
+
+
+
+
+ +{% if license_state %} +
+
+
+
+ {{ icon('key', size=17) }} Lizenzzustand + {% if license_state.valid and license_state.activated %}{{ pill('aktiv', 'online') }} + {% elif license_state.valid %}{{ pill('nicht aktiviert', 'warning') }} + {% else %}{{ pill('ungueltig', 'danger') }}{% endif %} +
+
+
+
+
+
Datei vorhanden
{{ license_state.present | yes_no }}
+
Signatur gueltig
{{ license_state.valid | yes_no }}
+
Aussteller gepinnt
{{ license_state.issuer_pinned | yes_no }}
+
Rolle
{{ license_state.role }}
+
Module
{{ license_state.modules | sort | join(', ') or '–' | safe }}
+
Letzter Heartbeat
{{ license_state.last_heartbeat_at | dt }}
+ {% if license_state.error %}
Fehler
{{ license_state.error }}
{% endif %} + {% if license_state.warnings %} +
Hinweise
{{ license_state.warnings | join('; ') }}
+ {% endif %} +
+
+
+{% endif %} +{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/error.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/error.html new file mode 100644 index 0000000..ebca1bb --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/error.html @@ -0,0 +1,17 @@ +{% extends "tesm_core/standalone.html" %} +{% block heading %}{{ code }} – {{ name }}{% endblock %} + +{% block content %} +
+ {{ icon('alert', size=18) }} +
{{ description }}
+
+{% if request_id %} +

Vorgangsnummer fuer die Fehlersuche: {{ request_id }}

+{% endif %} + +{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html new file mode 100644 index 0000000..aacb4d3 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html @@ -0,0 +1,122 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, textarea_field, pill, alert, permission_matrix, empty_state %} + +{% block page_title %}{{ group.name }}{% endblock %} +{% block page_sub %}{{ group.member_count }} Mitglied{{ '' if group.member_count == 1 else 'er' }} · {{ group.permissions | length }} Rechte{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block content %} +{% set locked = group.is_system and not unlocked %} + +{% if group.is_system %} + {% if locked %} +
+ {{ icon('lock', size=18) }} +
+
Systemgruppe
+ Name und Rechte sind gesperrt. Anders als im Vorgaenger werden die Rechte + nicht bei jedem Neustart auf die Vorlage zurueckgesetzt – + Anpassungen bleiben erhalten. + {% if current_user.has('groups.edit') %} + + {{ icon('unlock', size=14) }} Rechte freischalten + + {% endif %} +
+
+ {% else %} + {{ alert('Die Rechte dieser Systemgruppe sind zur Bearbeitung freigeschaltet.', 'warning') }} + {% endif %} +{% endif %} + +
+ + {% if not locked %}{% endif %} + {% if unlocked %}{% endif %} + +
+
+
+
+
{{ icon('settings', size=17) }} Stammdaten
+
+ {% if group.preset_key and current_user.is_admin %} + + {% endif %} +
+
+
+ {{ field('name', 'Name', value=group.name, required=True, disabled=group.is_system, + hint_text='Systemgruppen koennen nicht umbenannt werden.' if group.is_system else '') }} + {{ textarea_field('description', 'Beschreibung', value=group.description, rows=2, + extra_class='field--full') }} +
+
+
+ +
+
+
+
{{ icon('shield', size=17) }} Rechtematrix
+
+ Der Bereichsschalter ist ein Hauptschalter: ohne ihn greift kein Recht des Bereichs. + Jede Aktion setzt „Ansehen“ derselben Zeile voraus. +
+
+
+
+ {{ permission_matrix(tree, action_meta, state, disabled=locked or not can_edit) }} +
+ {% if can_edit and not locked %} + + {% endif %} +
+
+
+ +
+
+
+
{{ icon('users', size=17) }} Mitglieder
+
Zuordnung erfolgt in der Benutzerverwaltung
+
+ {% if current_user.has('users.view') %} + Benutzerverwaltung + {% endif %} +
+ {% if members %} +
+ + + + {% for member in members %} + + + + + + {% endfor %} + +
BenutzerNameQuelle
{{ member.username }} + {% if member.is_admin %}{{ pill('Admin', 'accent') }}{% endif %} + {{ (member.first_name ~ ' ' ~ member.last_name) | trim or '–' | safe }}{{ 'Verzeichnis' if member.auth_source == 'ldap' else 'Lokal' }}
+
+ {% else %} + {{ empty_state('Keine Mitglieder', 'Diese Gruppe ist noch niemandem zugeordnet.', 'users') }} + {% endif %} +
+{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/groups.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/groups.html new file mode 100644 index 0000000..e4204d9 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/groups.html @@ -0,0 +1,85 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, textarea_field, pill, modal, alert, permission_matrix, empty_state %} + +{% block page_title %}Gruppen und Rechte{% endblock %} +{% block page_sub %}{{ groups | length }} Gruppen · Rechte addieren sich ueber alle Gruppen eines Benutzers{% endblock %} + +{% block topbar_extra %} + {% if current_user.has('groups.create') %} + + {% endif %} +{% endblock %} + +{% block content %} +{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt fuer +alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter ueber eine +neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }} + +
+ {% for group in groups %} +
+
+
+
+ {{ icon('shield-users', size=17) }} + {{ group.name }} + {% if group.is_system %}{{ pill('System', 'accent') }}{% endif %} + {% if group.is_default %}{{ pill('Standard', 'info') }}{% endif %} +
+
+ {{ group.description or 'Ohne Beschreibung' }} · + {{ group.member_count }} Mitglied{{ '' if group.member_count == 1 else 'er' }} +
+
+ {% if current_user.has('groups.delete') and not group.is_system %} +
+ + +
+ {% endif %} +
+
+ {% if summaries[group.id] %} +
    + {% for line in summaries[group.id] %}
  • {{ line }}
  • {% endfor %} +
+ {% else %} +

Keine Rechte vergeben – Mitglieder sehen nur ihr eigenes Konto.

+ {% endif %} +
+ +
+ {% endfor %} +
+{% if not groups %} + {{ empty_state('Keine Gruppen', 'Ohne Gruppen hat niemand ausser Administratoren Rechte.', 'shield-users') }} +{% endif %} +{% endblock %} + +{% block dialogs %} +{% if current_user.has('groups.create') %} + {% call modal('group-new', 'Gruppe anlegen', 'Rechte koennen anschliessend feiner eingestellt werden', 'wide') %} +
+ + + +
+ {% endcall %} +{% endif %} +{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/login.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/login.html new file mode 100644 index 0000000..5797220 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/login.html @@ -0,0 +1,29 @@ +{% extends "tesm_core/standalone.html" %} +{% block heading %}Anmelden{% endblock %} +{% block subheading %}{{ app_name }} – {{ app_edition }}{% endblock %} + +{% block content %} +
+ + + + +
+{% if directory_enabled %} +

+ Verzeichniskonten melden sich mit ihrem gewohnten Anmeldenamen und Netzwerkpasswort an. +

+{% endif %} +{% if public_dashboard_url %} +

Zur oeffentlichen Statusuebersicht

+{% endif %} +{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_archive.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_archive.html new file mode 100644 index 0000000..6df3dab --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_archive.html @@ -0,0 +1,30 @@ +{% extends "tesm_core/base.html" %} +{% block page_title %}Archiv {{ day }}{% endblock %} +{% block page_sub %}{{ rows | length }} archivierte Aenderungen{% endblock %} +{% block breadcrumb %} + +{% endblock %} +{% block content %} +
+
+ + + + + + + {% for row in rows %} + + + + + + + + + {% endfor %} + +
ZeitBenutzerAktionZielDetailHash
{{ row.ts | dt }}{{ row.actor }}{{ row.action }}{{ row.target }}{{ row.detail }}{{ row.hash[:12] }}
+
+
+{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html new file mode 100644 index 0000000..c2bb4cf --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html @@ -0,0 +1,94 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import pill, empty_state, alert %} + +{% block page_title %}Aenderungsprotokoll{% endblock %} +{% block page_sub %}{{ total }} Eintraege · Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %} + +{% block topbar_extra %} + {% if current_user.has('logs_audit.export') %} + {{ icon('download', size=15) }} CSV + {% endif %} +{% endblock %} + +{% block content %} +{% if not chain.ok %} + {{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger', + 'Das Protokoll wurde veraendert') }} +{% else %} + {{ alert('Jeder Eintrag bindet den Hash seines Vorgaengers ein. Eine nachtraeglich entfernte + oder geaenderte Zeile faellt bei der Pruefung auf -- ' ~ chain.checked ~ ' Zeilen geprueft.', + 'success') }} +{% endif %} + +
+
+ + {{ icon('search', size=15) }} + + + + + + + Zuruecksetzen +
+ +
+ + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + {% endfor %} + +
ZeitBenutzerAktionZielDetailStufeHerkunft
{{ row.ts | dt }}{{ row.actor }}{{ row.action }}{{ row.target }}{{ row.detail }} + {% if row.severity == 'critical' %}{{ pill('kritisch', 'danger') }} + {% elif row.severity == 'warning' %}{{ pill('Warnung', 'warning') }} + {% elif row.severity == 'notice' %}{{ pill('Hinweis', 'info') }} + {% else %}{{ pill('Info', '') }}{% endif %} + {{ row.ip or '–' | safe }}
+
+ {% if not rows %}{{ empty_state('Keine Eintraege', 'Zu diesem Filter gibt es nichts.', 'history') }}{% endif %} + + +
+{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_file.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_file.html new file mode 100644 index 0000000..eec3add --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_file.html @@ -0,0 +1,12 @@ +{% extends "tesm_core/base.html" %} +{% block page_title %}{{ name }}{% endblock %} +{% block page_sub %}Rotierte Protokolldatei · letzte {{ lines | length }} Zeilen{% endblock %} +{% block breadcrumb %} + +{% endblock %} +{% block content %} +
+
{{ icon('file-text', size=17) }} Inhalt
+ {% include "tesm_core/_log_lines.html" %} +
+{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_history.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_history.html new file mode 100644 index 0000000..986a30a --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_history.html @@ -0,0 +1,84 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import empty_state, alert %} + +{% block page_title %}Protokollverlauf{% endblock %} +{% block page_sub %}{{ rotated | length }} rotierte Dateien · {{ archived | length }} archivierte Audit-Tage{% endblock %} + +{% block content %} +{{ alert('Rotiert wird per logrotate mit copytruncate -- keiner der schreibenden Prozesse +(Ueberwachung, Web, Kea) kann ein Reopen-Signal verarbeiten.', 'info') }} + +
+
+
+
+
{{ icon('file-text', size=17) }} Rotierte Dateien
+
{{ log_dir }}
+
+
+ {% if rotated %} +
+ + + + {% for entry in rotated %} + + + + + + {% endfor %} + +
DateiGroesseGeaendert
{{ entry.name }}{{ entry.size | bytes }}{{ entry.modified | int | string }}
+
+ {% else %} + {{ empty_state('Noch nichts rotiert', 'logrotate hat bisher keine Kopie angelegt.', 'clock') }} + {% endif %} +
+ +
+
+
+
{{ icon('history', size=17) }} Archivierte Aenderungen
+
Aus der Datenbank ausgelagerte Kalendertage
+
+ {% if archived and current_user.has('logs_history.export') %} +
+ + +
+ {% endif %} +
+ {% if archived %} +
+ + + + {% for entry in archived %} + + + + + + {% endfor %} + +
TagGroesse
{{ entry.day }}{{ entry.size | bytes }}{{ entry.modified | dt }}
+
+ {% if current_user.has('logs_history.delete') %} + + {% endif %} + {% else %} + {{ empty_state('Kein Archiv', 'Es wurden noch keine Audit-Tage ausgelagert.', 'history') }} + {% endif %} +
+
+{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_live.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_live.html new file mode 100644 index 0000000..a632454 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_live.html @@ -0,0 +1,33 @@ +{% extends "tesm_core/base.html" %} +{% block page_title %}Laufendes Protokoll{% endblock %} +{% block page_sub %}{{ path }} · letzte {{ line_count }} Zeilen{% endblock %} + +{% block topbar_extra %} +
+ + {{ icon('search', size=15) }} + + + + +
+{% endblock %} + +{% block content %} +
+
+
+
{{ icon('terminal', size=17) }} Erreichbarkeit und PoE
+
Aktualisiert sich automatisch alle 10 Sekunden
+
+
+
+ {% include "tesm_core/_log_lines.html" %} +
+
+{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/macros.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/macros.html new file mode 100644 index 0000000..09c976f --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/macros.html @@ -0,0 +1,222 @@ +{# ========================================================================== + Wiederverwendbare Bausteine. Import in Templates: + {% from "tesm_core/macros.html" import field, card_header, pill, ... %} + ========================================================================== #} + +{% macro csrf(token) -%} + +{%- endmacro %} + +{% macro hint(text) -%} +{% if text %}?{% endif %} +{%- endmacro %} + +{% macro field(name, label, value='', type='text', required=False, hint_text='', placeholder='', + autocomplete='', readonly=False, disabled=False, error='', extra_class='', attrs='') -%} + +{%- endmacro %} + +{% macro select_field(name, label, options, selected='', hint_text='', required=False, disabled=False, extra_class='', attrs='') -%} + +{%- endmacro %} + +{% macro textarea_field(name, label, value='', rows=4, hint_text='', placeholder='', extra_class='', mono=False) -%} + +{%- endmacro %} + +{% macro checkbox(name, label, checked=False, value='1', hint_text='', disabled=False) -%} + +{%- endmacro %} + +{% macro switch(name, label, checked=False, value='1', disabled=False) -%} + +{%- endmacro %} + +{% macro pill(label, kind='') -%} +{{ label }} +{%- endmacro %} + +{% macro badge(label) -%}{{ label }}{%- endmacro %} + +{% macro card_header(title, sub='', icon_name='') -%} +
+
+
{% if icon_name %}{{ icon(icon_name, size=17) }}{% endif %}{{ title }}
+ {% if sub %}
{{ sub }}
{% endif %} +
+ {{ caller() if caller is defined else '' }} +
+{%- endmacro %} + +{% macro empty_state(title, text='', icon_name='info', node_id='') -%} +
+ {{ icon(icon_name, size=34) }} +
{{ title }}
+ {% if text %}

{{ text }}

{% endif %} + {{ caller() if caller is defined else '' }} +
+{%- endmacro %} + +{% macro alert(text, kind='info', title='') -%} +
+ {{ icon({'info': 'info', 'success': 'check', 'warning': 'alert', 'danger': 'alert'}[kind], size=18) }} +
+ {% if title %}
{{ title }}
{% endif %} + {{ text }} +
+
+{%- endmacro %} + +{% macro search_box(target_id, placeholder='Suchen ...', count_id='', empty_id='') -%} + + {{ icon('search', size=15) }} + + +{%- endmacro %} + +{% macro stat(label, value, kind='', hint_text='', href='', filter_value='') -%} +{% if href %} + +{% elif filter_value %} +{% else %}
{% endif %} +{%- endmacro %} + +{% macro modal(node_id, title, sub='', size='') -%} + + + {{ caller() }} + +{%- endmacro %} + +{% macro kv_row(label, value) -%} +
{{ label }}
{{ value if value not in (None, '') else '–' | safe }}
+{%- endmacro %} + +{#- Rechtematrix: einmal geschrieben, in beiden Anwendungen benutzt -#} +{% macro permission_matrix(tree, action_meta, state, disabled=False, field_prefix='permissions') -%} +
+ + {% for area in tree.areas %} + {% set columns = area.columns() %} + + + + {% for action in columns %} + + {% endfor %} + + + + + + {% for action in columns %}{% endfor %} + + {% for resource in area.resources %} + + + {% for action in columns %} + + {% endfor %} + + {% endfor %} + + {% endfor %} +
{{ area.label }} + {{ action_meta[action][0] }} +
+ +
+ + {{ resource.label }} + {% if resource.hint %}{{ resource.hint }}{% endif %} + + + {% if resource.has(action) %} + + {% else %} + {# Bewusst *kein* Eingabefeld: ein deaktiviertes Kaestchen hat das + JS beim Einschalten des Bereichs wieder freigegeben, es liess + sich anhaken, hatte aber kein name-Attribut -- es sah erteilt + aus und war nach dem Speichern weg. Was es nicht gibt, ist + hier deshalb gar kein Bedienelement. #} + + nicht vorgesehen + {% endif %} +
+
+
+ {% for action, meta in action_meta.items() %} + {{ meta[0] }} {{ meta[1] }} – {{ meta[2] }} + {% endfor %} + Fuer diese Zeile nicht vorgesehen – + die Spalte gehoert zu einer anderen Zeile desselben Bereichs. +
+{%- endmacro %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/reauth.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/reauth.html new file mode 100644 index 0000000..0ffcdc9 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/reauth.html @@ -0,0 +1,25 @@ +{% extends "tesm_core/standalone.html" %} +{% block heading %}Bitte bestaetigen{% endblock %} +{% block subheading %}Diese Aktion verlangt eine erneute Eingabe Ihres Passworts{% endblock %} + +{% block content %} +
+ {{ icon('info', size=18) }} +
+ Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein uebernommenes + Sitzungscookie allein reicht dafuer nicht aus – die Bestaetigung gilt + danach {{ reauth_minutes }} Minuten. +
+
+
+ + +
+ Abbrechen + +
+
+{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/second_factor.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/second_factor.html new file mode 100644 index 0000000..a4b6e68 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/second_factor.html @@ -0,0 +1,20 @@ +{% extends "tesm_core/standalone.html" %} +{% block heading %}Zweiter Faktor{% endblock %} +{% block subheading %}Code aus Ihrer Authenticator-App{% endblock %} + +{% block content %} +
+ + + +
+

Abbrechen und zurueck zur Anmeldung

+{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/sessions.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/sessions.html new file mode 100644 index 0000000..b5f7a34 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/sessions.html @@ -0,0 +1,55 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import empty_state, alert, search_box %} + +{% block page_title %}Aktive Sitzungen{% endblock %} +{% block page_sub %}{{ sessions | length }} laufende Sitzungen · serverseitig gespeichert und jederzeit widerrufbar{% endblock %} + +{% block topbar_extra %}{{ search_box('sessions-table', 'Benutzer oder IP ...') }}{% endblock %} + +{% block content %} +{{ alert('Sitzungen liegen in der Datenbank, nicht im Cookie. Ein widerrufener Eintrag ist sofort +ungueltig -- auch wenn das Cookie noch existiert.', 'info') }} + +
+
+ + + + + + + + + + + + + + {% for entry in sessions %} + + + + + + + + + + {% endfor %} + +
BenutzerAngemeldetLetzte AktivitaetLaeuft abIPClientAktion
{{ entry.username or 'anonym' }}{{ entry.created_at | dt }} + {{ entry.last_seen_at | dt }} + {{ entry.absolute_expires_at | dt }}{{ entry.ip or '–' | safe }} + {{ entry.user_agent or '–' | safe }} + +
+ + +
+
+
+ {% if not sessions %}{{ empty_state('Keine aktiven Sitzungen', '', 'activity') }}{% endif %} +
+{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_directory.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_directory.html new file mode 100644 index 0000000..578f724 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_directory.html @@ -0,0 +1,167 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/_group_picker.html" import group_picker %} +{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill, empty_state %} + +{% block page_title %}Verzeichnisdienst{% endblock %} +{% block page_sub %} + LDAP / Active Directory · + {% if values.ldap_enabled %}aktiv{% else %}nicht aktiv{% endif %} +{% endblock %} + +{% block content %} +{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %} + +{{ alert('Same Sign-On, nicht Single Sign-On: der Benutzer tippt sein Verzeichnispasswort auf der +normalen Anmeldeseite ein. Es wird bei jeder Anmeldung live gegen das Verzeichnis geprueft und +niemals hier gespeichert.', 'info') }} + +{% if values.ldap_security == 'none' %} + {{ alert('Die Verbindung ist unverschluesselt. Das Passwort geht dabei im Klartext ueber das + Netz -- nur fuer Testumgebungen vertretbar.', 'danger', 'Unverschluesselt') }} +{% elif not values.ldap_verify_cert %} + {{ alert('Das Serverzertifikat wird nicht geprueft. Damit schuetzt die Verschluesselung nur + gegen passives Mitlesen, nicht gegen einen Man-in-the-Middle.', 'warning', + 'Zertifikat ungeprueft') }} +{% endif %} + +
+
+
+
{{ icon('users', size=17) }} Verbindung
+ {% if current_user.has('settings_directory.execute') %} +
+ + +
+ {% endif %} +
+
+ +
+ {{ checkbox('ldap_enabled', 'Verzeichnisanmeldung aktiv', checked=values.ldap_enabled, + disabled=bool(license_hint)) }} +
+ {{ field('ldap_host', 'Server', value=values.ldap_host, disabled=bool(license_hint), + placeholder='dc01.firma.local') }} + {{ field('ldap_port', 'Port', value=values.ldap_port, type='number', + disabled=bool(license_hint), attrs='id="ldap-port"') }} + {# Der Transport setzt den Port mit. Wer umstellt und den Port stehen + laesst, bekommt eine Verbindung, die auf gut Glueck scheitert -- + und die Meldung ("Zeitlimit") sagt nichts ueber die Ursache. Ein + absichtlich abweichender Port bleibt erhalten. #} + {{ select_field('ldap_security', 'Transport', specs['ldap_security'].choices, + selected=values.ldap_security, disabled=bool(license_hint), + attrs='data-behavior="transport-port" data-port-target="#ldap-port"') }} + {{ field('ldap_timeout', 'Zeitlimit (s)', value=values.ldap_timeout, type='number', + disabled=bool(license_hint)) }} + {{ field('ldap_ca_file', 'CA-Zertifikat (Pfad)', value=values.ldap_ca_file, + disabled=bool(license_hint), extra_class='field--full', + hint_text='Leer = Systemvertrauensspeicher.') }} + {{ field('ldap_base_dn', 'Basis-DN', value=values.ldap_base_dn, + disabled=bool(license_hint), extra_class='field--full', + hint_text='Leer = automatisch per RootDSE ermitteln.') }} + {{ field('ldap_user_attribute', 'Anmeldeattribut', value=values.ldap_user_attribute, + disabled=bool(license_hint)) }} + {{ field('ldap_email_attribute', 'E-Mail-Attribut', value=values.ldap_email_attribute, + disabled=bool(license_hint)) }} +
+
+ {{ checkbox('ldap_verify_cert', 'Serverzertifikat pruefen', checked=values.ldap_verify_cert, + disabled=bool(license_hint)) }} + {{ checkbox('ldap_group_sync', 'Gruppen aus dem Verzeichnis uebernehmen', + checked=values.ldap_group_sync, disabled=bool(license_hint)) }} + {{ checkbox('ldap_allow_insecure', 'Unverschluesselte Verbindung ausdruecklich erlauben', + checked=values.ldap_allow_insecure, disabled=bool(license_hint), + hint_text=specs['ldap_allow_insecure'].hint) }} +
+ +
+ Dienstkonto fuer die Suche (nur Lesezugriff noetig) +
+ {{ field('bind_username', 'Benutzer', value=bind_username, autocomplete='off', + disabled=not current_user.has('settings_directory.secrets')) }} + {{ field('bind_password', 'Passwort (leer = unveraendert)', type='password', + autocomplete='new-password', + disabled=not current_user.has('settings_directory.secrets')) }} +
+ {% if not current_user.has('settings_directory.secrets') %} +

+ Zum Setzen des Bind-Passworts fehlt Ihnen das Recht „Geheimnisse“. +

+ {% endif %} +
+
+ +
+
+ +
+
+
+
{{ icon('shield-users', size=17) }} Gruppen-Zuordnung
+
+ Verzeichnisgruppe → Gruppe in dieser Anwendung, bei jeder Anmeldung neu ausgewertet +
+
+
+ {% if mappings %} +
+ + + + {% for entry in mappings %} + + + + + + {% endfor %} + +
VerzeichnisgruppeGruppe hier
{{ entry.ad_group_name }} +
{{ entry.ad_group_dn }}
{{ pill(entry.group_name or 'unbekannt', 'accent') }} +
+ + +
+
+
+ {% else %} + {{ empty_state('Keine Zuordnungen', + 'Ohne Zuordnung erhalten Verzeichniskonten nur die Standardgruppe.', 'shield-users') }} + {% endif %} + + {% if current_user.has('settings_directory.edit') %} +
+ +
+
+ {{ group_picker('ad_group_name', 'ad_group_dn', 'mapping', required=True) }} + {# Ohne Ziel ist die Zuordnung sinnlos -- deshalb Pflichtfeld. #} + +
+
+ +
+ {% endif %} +
+
+{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_network.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_network.html new file mode 100644 index 0000000..ae819a7 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_network.html @@ -0,0 +1,108 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, alert, pill, empty_state %} + +{% block page_title %}Netzwerkeinstellungen{% endblock %} +{% block page_sub %}Backend: {{ backend }} · Sicherheitsnetz {{ revert_seconds }} s{% endblock %} + +{% block content %} +{{ alert('Nach einer Aenderung laeuft ein Zeitgeber. Bestaetigen Sie nicht innerhalb von +' ~ revert_seconds ~ ' Sekunden von der neuen Adresse aus, wird automatisch +zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning', +'Automatischer Rueckfall') }} + +{% if pending %} +
+
+
+
{{ icon('alert', size=17) }} Aenderung wartet auf Bestaetigung
+
Interface {{ pending.interface }}
+
+
+
+
+ + +
+
+
+{% endif %} + +{% if not sysops_available %} + {{ alert('Systemhelfer nicht verfuegbar -- die Anzeige ist rein lesend.', 'info') }} +{% endif %} + +
+
+
{{ icon('network', size=17) }} Interfaces
+
+ {% if interfaces %} +
+ + + + {% for entry in interfaces %} + + + + + + + + + {% endfor %} + +
NameZustandAdressenGatewayDNSMAC
{{ entry.name }}{% if entry.state == 'UP' %}{{ pill('UP', 'online') }}{% else %}{{ pill(entry.state, '') }}{% endif %}{{ entry.addresses | join(', ') or '–' | safe }}{{ entry.gateway or '–' | safe }}{{ entry.dns | join(', ') or '–' | safe }}{{ entry.mac }}
+
+ {% else %} + {{ empty_state('Keine Interfaces gelesen', 'Das System gibt hier nichts zurueck.', 'network') }} + {% endif %} +
+ +{% if current_user.has('settings_network.edit') and interfaces %} +
+
+
+
{{ icon('sliders', size=17) }} Aendern
+
+ Es wird nur der Block des gewaehlten Interfaces geschrieben (Datei + {{ netplan_path }}) – andere Interfaces bleiben + unberuehrt. +
+
+
+
+ +
+
+ + + {{ field('address', 'IP-Adresse', placeholder='192.168.1.10') }} + {{ field('prefix', 'Praefixlaenge', value='24', type='number') }} + {{ field('gateway', 'Gateway', placeholder='192.168.1.1') }} + {{ field('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }} +
+ {{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst waere der Host nach der + Aenderung nicht mehr erreichbar. Das wird vor dem Schreiben geprueft.', 'info') }} +
+ +
+
+{% endif %} +{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_web.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_web.html new file mode 100644 index 0000000..837b1ac --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_web.html @@ -0,0 +1,180 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, checkbox, alert, pill %} + +{% block page_title %}Webserver und TLS{% endblock %} +{% block page_sub %} + {{ config.domain or 'ohne Domain' }} · + {% if config.https_enabled %}HTTPS aktiv{% else %}nur HTTP{% endif %} +{% endblock %} + +{% block content %} +{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %} +{% if not sysops_available %} + {{ alert('Systemhelfer nicht verfuegbar -- die Konfiguration kann hier erzeugt und angesehen, + aber nicht angewandt werden.', 'info') }} +{% endif %} + +{{ alert('Die Site-Datei wird bei jeder Uebernahme vollstaendig neu geschrieben. +Alles, was ein Administrator sonst per Hand hineinschreiben wuerde, ist hier Einstellung. Der +Vorgaenger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad +blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info', +'Warum die Datei immer neu geschrieben wird') }} + +
+
+
+
+
{{ icon('server', size=17) }} Einstellungen
+
+
+ +
+
+ {{ field('web_domain', 'Domain', value=config.domain, + placeholder='tesm.firma.de', disabled=bool(license_hint), + hint_text='Leer = Zugriff ueber IP-Adresse.') }} + {{ field('web_acme_email', 'E-Mail fuer Let\'s Encrypt', value=acme_email, type='email', + disabled=bool(license_hint)) }} + {{ field('web_cert_path', 'Zertifikat (Pfad)', value=config.cert_path, + disabled=bool(license_hint), extra_class='field--full') }} + {{ field('web_key_path', 'Privater Schluessel (Pfad)', value=config.key_path, + disabled=bool(license_hint), extra_class='field--full') }} + {{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot, + disabled=bool(license_hint), extra_class='field--full') }} +
+
+ {{ checkbox('web_https_enabled', 'HTTPS auf Port 443 anbieten', + checked=config.https_enabled, disabled=bool(license_hint)) }} + {{ checkbox('web_redirect_http', 'HTTP (Port 80) auf HTTPS umleiten', + checked=config.redirect_http, disabled=bool(license_hint), + hint_text='Der ACME-Pfad bleibt dabei erreichbar -- sonst koennte Let\'s + Encrypt nie erneuern.') }} + {{ checkbox('web_hsts_enabled', 'HSTS-Header senden', + checked=config.hsts_enabled, disabled=bool(license_hint), + hint_text='Nur mit funktionierendem HTTPS aktivieren: Browser merken sich + die Vorgabe und verweigern danach jeden HTTP-Zugriff.') }} +
+
+ +
+
+ +
+ +
+
{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration
+
Vorschau der Datei, die geschrieben wuerde
+
+ {{ icon('chevron-down', size=16) }} +
+
{{ preview }}
+
+
+ +
+
+
+
+
{{ icon('shield', size=17) }} Zertifikat
+
+ {% if certificate.present %}vorhanden{% else %}nicht vorhanden{% endif %} +
+
+
+
+ {% if certificate.present and not certificate.get('error') %} +
+
Inhaber
{{ certificate.subject }}
+
Aussteller
{{ certificate.issuer }}
+
Gueltig bis
{{ certificate.not_valid_after | dt }}
+
Seriennummer
{{ certificate.serial }}
+
+ {% elif certificate.get('error') %} + {{ alert('Zertifikat nicht lesbar: ' ~ certificate.error, 'danger') }} + {% else %} +

+ Fordern Sie ein Zertifikat per Let's Encrypt an oder hinterlegen Sie eigene Pfade. +

+ {% endif %} + + {% if current_user.has('settings_web.execute') %} +
+ + +
+ {% if not certbot_available %} +

certbot ist auf diesem Host nicht installiert.

+ {% elif not config.domain %} +

+ Fuer Let's Encrypt wird oben eine Domain gebraucht, die oeffentlich auf + diesen Host zeigt. +

+ {% endif %} + +
+

+ Kein oeffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser + zeigen dafuer eine Warnung, die Verbindung ist aber verschluesselt -- + und das Sitzungscookie darf endlich Secure tragen. +

+ {% if local_names %} +

+ Wird ausgestellt auf: + {{ (config.domain or local_names[0]) }} + {% if local_names|length > 1 %} + (zusaetzlich {{ local_names[1:] | join(', ') }}) + {% endif %} +

+ {% endif %} +
+ + +
+ {% endif %} +
+
+ + {% if current_user.has('settings_web.execute') %} +
+
+
+
{{ icon('upload', size=17) }} Uebernehmen
+
Schreiben, nginx -t, dann neu laden
+
+
+
+
+ + +
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/standalone.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/standalone.html new file mode 100644 index 0000000..5bd2f55 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/standalone.html @@ -0,0 +1,80 @@ +{# ========================================================================== + Grundgeruest fuer Seiten ohne Anwendungsrahmen: Anmeldung, zweiter Faktor, + Kundenportal, Self-Service. + + Wichtiger Unterschied zu base.html: Flash-Meldungen werden hier **sichtbar + serverseitig** gerendert, nicht ueber die JSON-Bruecke. Der Vorgaenger hatte + dieselbe Trennung; sie ist wichtig, weil diese Seiten auch ohne JavaScript + nutzbar bleiben muessen (und weil Tests hier direkt auf Text pruefen). + ========================================================================== #} + + + + + + + + {% block title %}{{ self.heading() }} · {{ app_name }}{% endblock %} + + + + {% block extra_head %}{% endblock %} + + +
+ {% if auth_tagline %} + {# Wortmarke mit Funktionszeile als tatsaechlicher Seitenhintergrund -- + uebernommen vom Vorgaenger. Bewusst inline und mit Themen-Tokens statt + zweier Dateien fuer hell und dunkel: bei der Einstellung "System" und + beim Umschalten von Hand waere die versteckte Variante die falsche. + ``aria-hidden``, weil der Name schon in der Ueberschrift steht. #} + + {% endif %} +
+
+ +

{% block heading %}{{ app_name }}{% endblock %}

+ {% set sub %}{% block subheading %}{% endblock %}{% endset %} + {% if sub | trim %}

{{ sub }}

{% endif %} +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + {% set kind = {'error': 'danger', 'danger': 'danger', 'success': 'success', + 'warning': 'warning'}.get(category, 'info') %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} + +

{{ app_name }} {{ app_version }} · © {{ current_year }} WiS GmbH

+
+
+ +{% block scripts %}{% endblock %} + + diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/trash.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/trash.html new file mode 100644 index 0000000..ecec0f3 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/trash.html @@ -0,0 +1,73 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import empty_state, alert, pill %} + +{% block page_title %}Papierkorb{% endblock %} +{% block page_sub %}{{ total }} Eintraege · Aufbewahrung {{ retention_days }} Tage{% endblock %} + +{% block content %} +{{ alert('Ansehen ist immer moeglich. Wiederherstellen und endgueltiges Loeschen erfordern eine +gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Seite entfernt.', +'info') }} + +{% for section in sections %} +
+
+
+
{{ icon(section.icon, size=17) }} {{ section.label }} + {{ pill(section.entries | length, '') }} +
+ {% if section.hint %}
{{ section.hint }}
{% endif %} +
+ {% if section.entries and current_user.has('trash.delete') %} +
+ + + +
+ {% endif %} +
+ {% if section.entries %} +
+ + + + {% for entry in section.entries %} + + + + + + + {% endfor %} + +
EintragDetailGeloeschtAktion
{{ entry.label }}{{ entry.detail }}{{ entry.deleted_at | dt }} + {% if current_user.has('trash.edit') %} +
+ + +
+ {% endif %} + {% if current_user.has('trash.delete') %} +
+ + +
+ {% endif %} +
+
+ {% else %} + {{ empty_state('Nichts im Papierkorb', '', section.icon) }} + {% endif %} +
+{% endfor %} +{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html new file mode 100644 index 0000000..cc6bc6e --- /dev/null +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html @@ -0,0 +1,238 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, select_field, checkbox, pill, search_box, modal, empty_state, alert %} + +{% block page_title %}Benutzer{% endblock %} +{% block page_sub %}{{ users | length }} Konten · Rechte kommen ausschliesslich aus Gruppen{% endblock %} + +{% block topbar_extra %} + {{ search_box('users-table', 'Benutzer suchen ...', count_id='users-visible') }} + {% if current_user.has('users.create') %} + + {% endif %} +{% endblock %} + +{% block content %} +
+
+ {{ users | length }} sichtbar + + {% if directory_enabled and current_user.has('users.create') %} + + {% endif %} + {% if current_user.is_admin %} + {{ icon('activity', size=15) }} Sitzungen + {% endif %} +
+ +
+ + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + {% endfor %} + +
BenutzerNameQuelleGruppenStatusLetzte AnmeldungAktion
+
+ +
+
{{ user.username }}
+ {% if user.email %}
{{ user.email }}
{% endif %} +
+
+
{{ (user.first_name ~ ' ' ~ user.last_name) | trim or '–' | safe }} + {% if user.auth_source == 'ldap' %} + {{ pill('Verzeichnis', 'info') }} + {% else %} + {{ pill('Lokal', '') }} + {% endif %} + + {% if user.is_admin %}{{ pill('Administrator', 'accent') }}{% endif %} + {{ user.group_names | join(', ') or ('–' | safe if not user.is_admin else '') }} + + {% if user.is_locked %}{{ pill('Gesperrt', 'danger') }} + {% else %}{{ pill('Aktiv', 'online') }}{% endif %} + {% if user.totp_enabled %}{{ icon('lock', size=14) }}{% endif %} + {% if user.must_change_password %}{{ pill('Passwortwechsel', 'warning') }}{% endif %} + + {% if user.last_login_at %} + {{ user.last_login_at | dt }} + {% else %}noch nie{% endif %} + + {% if current_user.has('users.edit') %} + + {% endif %} + {% if current_user.has('users.delete') %} +
+ + +
+ {% endif %} +
+
+ {% if not users %} + {{ empty_state('Keine Konten vorhanden', 'Legen Sie das erste Konto an.', 'users') }} + {% endif %} +
+ +{{ alert('Rechte werden ausschliesslich ueber Gruppen vergeben. Das Admin-Kennzeichen ist davon +unabhaengig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes +Administratorkonto muss immer bestehen bleiben.', 'info') }} +{% endblock %} + +{% block dialogs %} +{% if current_user.has('users.create') %} + {% call modal('user-new', 'Konto anlegen', 'Lokales Konto mit eigenem Passwort') %} +
+ + + +
+ {% endcall %} + + {% if directory_enabled %} + {% call modal('user-directory', 'Aus Verzeichnis uebernehmen', + 'Konto vorbereiten, damit Gruppen vor der ersten Anmeldung sitzen') %} +
+ + + +
+ {% endcall %} + {% endif %} +{% endif %} + +{% if current_user.has('users.edit') %} + {% for user in users %} + {% call modal('user-edit-' ~ user.id, 'Konto ' ~ user.username, + 'Verzeichniskonto' if user.auth_source == 'ldap' else 'Lokales Konto') %} +
+ + + +
+ {% endcall %} + {% endfor %} +{% endif %} +{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/transfer.py b/packages/tesm-core/src/tesm_core/transfer.py new file mode 100644 index 0000000..68d148c --- /dev/null +++ b/packages/tesm-core/src/tesm_core/transfer.py @@ -0,0 +1,233 @@ +"""Verschluesselter Umschlag fuer Sicherungen -- gemeinsam von beiden Anwendungen. + +Format 3. Was hier liegt, ist der kryptographische Teil: Passphrase ableiten, +verschluesseln, entschluesseln, Zwischenstand halten. *Welche* Kategorien es +gibt und wie sie gelesen/geschrieben werden, entscheidet jede Anwendung selbst. + +Argon2id statt PBKDF2 (der Vorgaenger nutzte 390.000 PBKDF2-Iterationen -- +ordentlich, aber nicht speicherhart) und AES-256-GCM statt Fernet, mit dem +Umschlagkopf als zugeordneten Daten: wer Salz oder Kategorienliste manipuliert, +bricht die Entschluesselung. +""" + +from __future__ import annotations + +import base64 +import json +import secrets +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any + +from argon2.low_level import Type, hash_secret_raw +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from .db import Database + +FORMAT_VERSION = 3 + +KDF_TIME_COST = 4 +KDF_MEMORY_COST = 131072 # 128 MiB +KDF_PARALLELISM = 2 +KDF_SALT_BYTES = 16 +KEY_BYTES = 32 +NONCE_BYTES = 12 + +MIN_PASSPHRASE_LENGTH = 12 +PREVIEW_JOB_KIND = "backup.preview" +PREVIEW_TTL_MINUTES = 30 + + +class TransferError(Exception): + """Fehler mit Klartextmeldung fuer die Oberflaeche.""" + + +@dataclass(frozen=True, slots=True) +class Category: + """Eine waehlbare Kategorie einer Sicherung.""" + + key: str + label: str + hint: str = "" + #: Enthaelt Geheimnisse im Klartext (verlangt ``backup.secrets``). + sensitive: bool = False + #: Nur echte Administratoren duerfen sie sichern/einspielen. + admin_only: bool = False + + +def _b64e(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _b64d(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _derive_key(passphrase: str, salt: bytes, params: dict[str, int]) -> bytes: + return hash_secret_raw( + secret=passphrase.encode("utf-8"), + salt=salt, + time_cost=int(params["t"]), + memory_cost=int(params["m"]), + parallelism=int(params["p"]), + hash_len=KEY_BYTES, + type=Type.ID, + ) + + +def seal(payload: dict[str, Any], passphrase: str, *, app_tag: str) -> str: + if len(passphrase or "") < MIN_PASSPHRASE_LENGTH: + raise TransferError( + f"Die Passphrase muss mindestens {MIN_PASSPHRASE_LENGTH} Zeichen haben -- sie ist " + "der einzige Schutz dieser Datei." + ) + salt = secrets.token_bytes(KDF_SALT_BYTES) + params = {"t": KDF_TIME_COST, "m": KDF_MEMORY_COST, "p": KDF_PARALLELISM} + header = { + "app": app_tag, + "format": FORMAT_VERSION, + "created_at": _now().isoformat(timespec="seconds"), + "categories": sorted(payload.keys()), + "kdf": {"name": "argon2id", "salt": _b64e(salt), **params}, + } + key = _derive_key(passphrase, salt, params) + nonce = secrets.token_bytes(NONCE_BYTES) + aad = json.dumps(header, sort_keys=True, separators=(",", ":")).encode("utf-8") + ciphertext = AESGCM(key).encrypt( + nonce, json.dumps(payload, ensure_ascii=False).encode("utf-8"), aad + ) + return json.dumps( + {**header, "nonce": _b64e(nonce), "payload": _b64e(ciphertext)}, + ensure_ascii=False, + indent=2, + ) + + +def unseal( + raw: bytes | str, passphrase: str, *, app_tag: str +) -> tuple[dict[str, Any], dict[str, Any]]: + text = raw.decode("utf-8") if isinstance(raw, bytes) else raw + try: + envelope = json.loads(text) + except ValueError as exc: + raise TransferError("Die Datei ist kein gueltiges JSON.") from exc + if not isinstance(envelope, dict): + raise TransferError("Die Datei enthaelt kein Objekt.") + if envelope.get("app") != app_tag: + raise TransferError( + f"Diese Sicherung gehoert zu einer anderen Anwendung ({envelope.get('app')!r})." + ) + if int(envelope.get("format") or 0) != FORMAT_VERSION: + raise TransferError( + f"Nicht unterstuetztes Sicherungsformat ({envelope.get('format')!r}, " + f"erwartet {FORMAT_VERSION})." + ) + kdf = envelope.get("kdf") or {} + if kdf.get("name") != "argon2id": + raise TransferError("Unbekanntes Schluesselableitungsverfahren.") + + header = { + key: envelope[key] + for key in ("app", "format", "created_at", "categories", "kdf") + if key in envelope + } + aad = json.dumps(header, sort_keys=True, separators=(",", ":")).encode("utf-8") + try: + key = _derive_key( + passphrase, + _b64d(str(kdf["salt"])), + { + "t": int(kdf.get("t") or KDF_TIME_COST), + "m": int(kdf.get("m") or KDF_MEMORY_COST), + "p": int(kdf.get("p") or KDF_PARALLELISM), + }, + ) + plain = AESGCM(key).decrypt( + _b64d(str(envelope["nonce"])), _b64d(str(envelope["payload"])), aad + ) + except (InvalidTag, KeyError, ValueError) as exc: + raise TransferError( + "Die Datei konnte nicht geoeffnet werden. Passphrase falsch oder Datei beschaedigt." + ) from exc + return json.loads(plain.decode("utf-8")), header + + +# --------------------------------------------------------------------------- # +# Zwischenstand des Imports +# --------------------------------------------------------------------------- # + + +def store_preview(conn: sqlite3.Connection, payload: dict[str, Any], *, actor: str) -> str: + """Legt die geoeffnete Sicherung in der Datenbank ab. + + Der Vorgaenger hielt diesen Zwischenstand in einer Modulvariable -- einer der + Gruende, warum die Anwendung mit genau einem Worker laufen *musste*. + """ + token = secrets.token_urlsafe(18) + conn.execute("DELETE FROM jobs WHERE kind=? AND started_by=?", (PREVIEW_JOB_KIND, actor)) + conn.execute( + "INSERT INTO jobs (id, kind, subject, state, message, output, started_by, created_at, " + "updated_at) VALUES (?,?,?,'pending','',?,?,?,?)", + ( + token, + PREVIEW_JOB_KIND, + "Import-Vorschau", + json.dumps(payload, ensure_ascii=False), + actor, + _now().isoformat(timespec="seconds"), + _now().isoformat(timespec="seconds"), + ), + ) + return token + + +def load_preview(conn: sqlite3.Connection, token: str, *, actor: str) -> dict[str, Any]: + row = Database.one( + conn, + "SELECT output, created_at FROM jobs WHERE id=? AND kind=? AND started_by=?", + (token, PREVIEW_JOB_KIND, actor), + ) + if row is None: + raise TransferError("Der Zwischenstand ist nicht mehr vorhanden. Bitte neu hochladen.") + created = datetime.fromisoformat(str(row["created_at"])) + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + if _now() - created > timedelta(minutes=PREVIEW_TTL_MINUTES): + conn.execute("DELETE FROM jobs WHERE id=?", (token,)) + raise TransferError("Der Zwischenstand ist abgelaufen. Bitte neu hochladen.") + return json.loads(str(row["output"])) + + +def drop_preview(conn: sqlite3.Connection, token: str) -> None: + conn.execute("DELETE FROM jobs WHERE id=? AND kind=?", (token, PREVIEW_JOB_KIND)) + + +def summarize(payload: dict[str, Any], categories: tuple[Category, ...]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for category in categories: + if category.key not in payload: + continue + data = payload[category.key] + if isinstance(data, list): + count = len(data) + elif isinstance(data, dict): + count = sum(len(value) if isinstance(value, list) else 1 for value in data.values()) + else: + count = 1 + summary.append( + { + "key": category.key, + "label": category.label, + "hint": category.hint, + "count": count, + "sensitive": category.sensitive, + "admin_only": category.admin_only, + } + ) + return summary diff --git a/packages/tesm-core/src/tesm_core/ui.py b/packages/tesm-core/src/tesm_core/ui.py new file mode 100644 index 0000000..d9038b5 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/ui.py @@ -0,0 +1,238 @@ +"""Darstellungshelfer: Icons, Formatierung, Template-Globals. + +Die Icons sind Pfadfragmente eines 24x24-Rasters mit ``stroke="currentColor"`` +-- kein Icon-Font, kein externes Sprite, keine zusaetzliche HTTP-Anfrage und +nichts, was die CSP aufweichen muesste. +""" + +from __future__ import annotations + +import math +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from flask import Flask, url_for +from markupsafe import Markup + +ICONS: dict[str, str] = { + "grid": '', + "cpu": '', + "switch": '', + "network": '', + "key": '', + "wrench": '', + "users": '', + "shield-users": '', + "folder": '', + "sliders": '', + "server": '', + "trash": '', + "terminal": '', + "clock": '', + "history": '', + "transfer": '', + "shield": '', + "search": '', + "menu": '', + "chevron-right": '', + "chevron-down": '', + "sun": '', + "moon": '', + "monitor": '', + "log-out": '', + "settings": '', + "plus": '', + "pencil": '', + "x": '', + "check": '', + "alert": '', + "info": '', + "download": '', + "upload": '', + "refresh": '', + "play": '', + "power": '', + "lock": '', + "unlock": '', + "mail": '', + "building": '', + "ticket": '', + "file-text": '', + "activity": '', + "database": '', + "eye": '', + "copy": '', + "external": '', + "filter": '', + "calendar": '', + "link": '', + "puzzle": '', + "credit-card": '', + "map-pin": '', + "share": '', + "hard-drive": '', +} + + +def icon(name: str, *, size: int = 18, extra_class: str = "") -> Markup: + """Rendert ein Icon als Inline-SVG.""" + path = ICONS.get(name) or ICONS["info"] + classes = f"icon icon--{name}" + (f" {extra_class}" if extra_class else "") + return Markup( + f'' + ) + + +# --------------------------------------------------------------------------- # +# Formatierung +# --------------------------------------------------------------------------- # + + +def format_bytes(value: Any) -> str: + try: + size = float(value) + except (TypeError, ValueError): + return "-" + if size < 1024: + return f"{int(size)} B" + units = ("KB", "MB", "GB", "TB") + index = min(int(math.log(size, 1024)) - 1, len(units) - 1) + return f"{size / (1024 ** (index + 1)):.1f} {units[index]}".replace(".", ",") + + +def format_datetime(value: Any, *, with_time: bool = True) -> str: + if not value: + return "-" + if isinstance(value, datetime): + moment = value + else: + text = str(value).strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + moment = datetime.fromisoformat(text) + except ValueError: + return str(value) + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + local = moment.astimezone() + return local.strftime("%d.%m.%Y %H:%M") if with_time else local.strftime("%d.%m.%Y") + + +def format_date(value: Any) -> str: + return format_datetime(value, with_time=False) + + +def format_duration(seconds: Any) -> str: + try: + total = int(seconds) + except (TypeError, ValueError): + return "-" + if total < 60: + return f"{total} s" + minutes, secs = divmod(total, 60) + if minutes < 60: + return f"{minutes} min {secs} s" if secs else f"{minutes} min" + hours, minutes = divmod(minutes, 60) + if hours < 24: + return f"{hours} h {minutes} min" if minutes else f"{hours} h" + days, hours = divmod(hours, 24) + return f"{days} d {hours} h" if hours else f"{days} d" + + +def format_number(value: Any) -> str: + try: + return f"{int(value):n}".replace(",", ".") + except (TypeError, ValueError): + return str(value) + + +def yes_no(value: Any) -> str: + return "ja" if value else "nein" + + +def from_json(value: Any) -> Any: + """Parst einen JSON-String aus der Datenbank -- fuer Modul-/Rechtelisten. + + Wirft nie: kaputte Daten fuehren zu einer leeren Liste, nicht zu einer + Fehlerseite. + """ + if value in (None, ""): + return [] + if isinstance(value, (list, dict)): + return value + import json as _json + + try: + return _json.loads(str(value)) + except ValueError: + return [] + + +# --------------------------------------------------------------------------- # +# Registrierung +# --------------------------------------------------------------------------- # + + +def asset_url(app: Flask) -> Any: + """``asset_url('css/base.css')`` mit Cache-Buster aus der Dateizeit.""" + cache: dict[str, str] = {} + + def build(filename: str, blueprint: str = "tesm_core") -> str: + if filename in cache: + return cache[filename] + endpoint = f"{blueprint}.static" if blueprint else "static" + try: + url = url_for(endpoint, filename=filename) + except Exception: # noqa: BLE001 - Blueprint ohne static-Ordner + url = url_for("static", filename=filename) + version = "" + try: + static_folder = app.blueprints[blueprint].static_folder if blueprint else app.static_folder + if static_folder: + path = Path(static_folder) / filename + if path.is_file(): + version = str(int(path.stat().st_mtime)) + except (KeyError, OSError): + version = "" + result = f"{url}?v={version}" if version else url + if not app.debug: + cache[filename] = result + return result + + return build + + +def init_app(app: Flask) -> None: + app.jinja_env.globals.update( + icon=icon, + asset_url=asset_url(app), + # Kleine Python-Builtins, die in Templates immer wieder gebraucht werden. + # Jinja bringt sie nicht mit; ohne sie muesste jedes Template den Umweg + # ueber eigene Filter gehen. + bool=bool, + int=int, + str=str, + len=len, + min=min, + max=max, + sorted=sorted, + dict=dict, + list=list, + set=set, + enumerate=enumerate, + ) + app.jinja_env.filters.update( + bytes=format_bytes, + dt=format_datetime, + date=format_date, + duration=format_duration, + number=format_number, + yes_no=yes_no, + from_json=from_json, + ) + app.jinja_env.trim_blocks = True + app.jinja_env.lstrip_blocks = True diff --git a/packages/tesm-core/src/tesm_core/views/__init__.py b/packages/tesm-core/src/tesm_core/views/__init__.py new file mode 100644 index 0000000..4e9991f --- /dev/null +++ b/packages/tesm-core/src/tesm_core/views/__init__.py @@ -0,0 +1,7 @@ +"""Blueprints, die beide Anwendungen teilen.""" + +from __future__ import annotations + +from . import account, admin, diagnostics, trash + +__all__ = ["account", "admin", "diagnostics", "trash"] diff --git a/packages/tesm-core/src/tesm_core/views/account.py b/packages/tesm-core/src/tesm_core/views/account.py new file mode 100644 index 0000000..fa0e9ae --- /dev/null +++ b/packages/tesm-core/src/tesm_core/views/account.py @@ -0,0 +1,367 @@ +"""Eigenes Konto: Stammdaten, Passwort, zweiter Faktor, eigene Sitzungen. + +Bewusst getrennt von der Benutzerverwaltung: hier aendert jeder nur sich +selbst, ohne irgendein Recht aus der Rechtematrix. +""" + +from __future__ import annotations + +import secrets +from datetime import datetime, timezone +from pathlib import Path + +from flask import ( + Blueprint, + abort, + flash, + redirect, + render_template, + request, + send_from_directory, + session, + url_for, +) +from werkzeug.utils import secure_filename + +from .. import audit +from ..auth.models import current_user, login_required, reauth_required +from ..auth.service import client_ip, confirm_password +from ..db import Database +from ..extension import core, db, transaction +from ..rbac import service as rbac +from ..security import passwords, sessions as session_store, totp + +bp = Blueprint("account", __name__, url_prefix="/konto") + +ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"} +MAX_AVATAR_BYTES = 2 * 1024 * 1024 + +#: Magische Bytes je erlaubtem Bildtyp. Der Vorgaenger prueft nur die Endung -- +#: damit laesst sich beliebiger Inhalt als ".png" hochladen. +_MAGIC = { + b"\x89PNG\r\n\x1a\n": "png", + b"\xff\xd8\xff": "jpeg", + b"GIF87a": "gif", + b"GIF89a": "gif", + b"RIFF": "webp", +} + + +def _avatar_dir() -> Path: + path = core().config.data_dir / "avatars" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action=action, + target=current_user.username, + detail=detail, + ip=client_ip(), + severity=severity, + ) + + +@bp.get("/") +@login_required +def overview(): + conn = db() + row = rbac.get_user(conn, current_user.id) or {} + return render_template( + "tesm_core/account.html", + profile=row, + groups=rbac.user_groups(conn, current_user.id), + permissions=sorted(current_user.effective_permissions), + tree=core().permissions, + ) + + +@bp.post("/stammdaten") +@login_required +def update_profile(): + if current_user.is_directory_account: + flash( + "Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder " + "Anmeldung uebernommen.", + "info", + ) + return redirect(url_for("account.overview")) + with transaction() as conn: + conn.execute( + "UPDATE users SET first_name=?, last_name=?, email=?, updated_at=? WHERE id=?", + ( + (request.form.get("first_name") or "").strip()[:64], + (request.form.get("last_name") or "").strip()[:64], + (request.form.get("email") or "").strip()[:190], + datetime.now(timezone.utc).isoformat(timespec="seconds"), + current_user.id, + ), + ) + _audit(conn, "account.profile_updated") + flash("Stammdaten gespeichert.", "success") + return redirect(url_for("account.overview")) + + +@bp.post("/profilbild") +@login_required +def upload_avatar(): + upload = request.files.get("avatar") + if upload is None or not upload.filename: + flash("Keine Datei ausgewaehlt.", "danger") + return redirect(url_for("account.overview")) + + suffix = Path(upload.filename).suffix.lower() + if suffix not in ALLOWED_AVATAR_SUFFIXES: + flash("Erlaubt sind PNG, JPG, WEBP und GIF.", "danger") + return redirect(url_for("account.overview")) + + head = upload.stream.read(16) + upload.stream.seek(0) + if not any(head.startswith(magic) for magic in _MAGIC): + flash("Die Datei ist kein Bild.", "danger") + return redirect(url_for("account.overview")) + + data = upload.stream.read(MAX_AVATAR_BYTES + 1) + if len(data) > MAX_AVATAR_BYTES: + flash("Das Bild ist zu gross (maximal 2 MB).", "danger") + return redirect(url_for("account.overview")) + + filename = f"{current_user.id}-{secrets.token_hex(8)}{suffix}" + target = _avatar_dir() / secure_filename(filename) + target.write_bytes(data) + + with transaction() as conn: + previous = Database.value( + conn, "SELECT avatar_filename FROM users WHERE id=?", (current_user.id,) + ) + conn.execute( + "UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?", + (target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id), + ) + _audit(conn, "account.avatar_updated") + if previous: + (_avatar_dir() / str(previous)).unlink(missing_ok=True) + flash("Profilbild aktualisiert.", "success") + return redirect(url_for("account.overview")) + + +@bp.get("/profilbild/") +@login_required +def avatar(filename: str): + safe = secure_filename(filename) + if safe != filename or Path(safe).suffix.lower() not in ALLOWED_AVATAR_SUFFIXES: + abort(404) + return send_from_directory(_avatar_dir(), safe, max_age=3600) + + +# --------------------------------------------------------------------------- # +# Sicherheit +# --------------------------------------------------------------------------- # + + +@bp.get("/sicherheit") +@login_required +def security(): + conn = db() + row = rbac.get_user(conn, current_user.id) or {} + pending_secret = session.get("_totp_setup", "") + return render_template( + "tesm_core/account_security.html", + profile=row, + policy=core().password_policy, + sessions=session_store.active_for_user(conn, current_user.id), + current_sid=getattr(session, "sid", ""), + totp_enabled=bool(row.get("totp_secret")), + pending_secret=pending_secret, + pending_uri=( + totp.provisioning_uri( + pending_secret, account=current_user.username, issuer=core().config.app_name + ) + if pending_secret + else "" + ), + pending_secret_grouped=totp.format_secret(pending_secret) if pending_secret else "", + recovery_remaining=int( + Database.value( + conn, + "SELECT COUNT(*) FROM user_recovery_codes WHERE user_id=? AND used_at IS NULL", + (current_user.id,), + default=0, + ) + or 0 + ), + recovery_codes=session.pop("_recovery_codes", []), + require_totp_for_admins=core().config.security.require_totp_for_admins, + ) + + +@bp.post("/passwort") +@login_required +def change_password(): + conn = db() + if current_user.is_directory_account: + flash("Verzeichniskonten aendern ihr Passwort im Verzeichnisdienst.", "info") + return redirect(url_for("account.security")) + + current = request.form.get("current_password") or "" + new = request.form.get("new_password") or "" + repeat = request.form.get("repeat_password") or "" + + if not confirm_password(conn, current_user, current): + flash("Das aktuelle Passwort ist nicht korrekt.", "danger") + return redirect(url_for("account.security")) + if new != repeat: + flash("Die beiden neuen Passwoerter stimmen nicht ueberein.", "danger") + return redirect(url_for("account.security")) + if passwords.constant_time_equals(current, new): + flash("Das neue Passwort muss sich vom alten unterscheiden.", "danger") + return redirect(url_for("account.security")) + + try: + with transaction(): + rbac.set_password(conn, current_user.id, new, policy=core().password_policy) + # Alle anderen Sitzungen beenden: nach einem Passwortwechsel soll + # eine ggf. uebernommene Sitzung nicht weiterlaufen. + revoked = session_store.revoke_all_for_user( + conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change" + ) + _audit( + conn, + "account.password_changed", + f"{revoked} weitere Sitzungen beendet", + severity="notice", + ) + except rbac.ValidationError as exc: + flash(str(exc), "danger") + return redirect(url_for("account.security")) + + flash("Passwort geaendert. Andere Sitzungen wurden beendet.", "success") + return redirect(url_for("account.security")) + + +@bp.post("/zwei-faktor/starten") +@login_required +@reauth_required +def totp_start(): + session["_totp_setup"] = totp.generate_secret() + flash("Scannen Sie den Code und bestaetigen Sie mit einem gueltigen Zahlencode.", "info") + return redirect(url_for("account.security")) + + +@bp.post("/zwei-faktor/bestaetigen") +@login_required +@reauth_required +def totp_confirm(): + secret = session.get("_totp_setup", "") + if not secret: + flash("Es laeuft keine Einrichtung. Bitte neu starten.", "danger") + return redirect(url_for("account.security")) + if not totp.verify_code(secret, request.form.get("code") or ""): + flash("Der Code ist nicht korrekt. Bitte erneut versuchen.", "danger") + return redirect(url_for("account.security")) + + codes = totp.generate_recovery_codes() + with transaction() as conn: + conn.execute( + "UPDATE users SET totp_secret=?, totp_confirmed_at=?, updated_at=? WHERE id=?", + ( + secret, + datetime.now(timezone.utc).isoformat(timespec="seconds"), + datetime.now(timezone.utc).isoformat(timespec="seconds"), + current_user.id, + ), + ) + conn.execute("DELETE FROM user_recovery_codes WHERE user_id=?", (current_user.id,)) + conn.executemany( + "INSERT INTO user_recovery_codes (user_id, code_hash, created_at) VALUES (?,?,?)", + [ + ( + current_user.id, + passwords.hash_password(code), + datetime.now(timezone.utc).isoformat(timespec="seconds"), + ) + for code in codes + ], + ) + _audit(conn, "account.totp_enabled", severity="notice") + + session.pop("_totp_setup", None) + session["_recovery_codes"] = codes + flash("Zwei-Faktor-Authentisierung aktiviert. Bitte die Wiederherstellungscodes sichern.", "success") + return redirect(url_for("account.security")) + + +@bp.post("/zwei-faktor/deaktivieren") +@login_required +@reauth_required +def totp_disable(): + if core().config.security.require_totp_for_admins and current_user.is_admin: + flash( + "Fuer Administratoren ist die Zwei-Faktor-Authentisierung in dieser " + "Installation verpflichtend.", + "danger", + ) + return redirect(url_for("account.security")) + with transaction() as conn: + conn.execute( + "UPDATE users SET totp_secret='', totp_confirmed_at=NULL, updated_at=? WHERE id=?", + (datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id), + ) + conn.execute("DELETE FROM user_recovery_codes WHERE user_id=?", (current_user.id,)) + _audit(conn, "account.totp_disabled", severity="warning") + flash("Zwei-Faktor-Authentisierung deaktiviert.", "info") + return redirect(url_for("account.security")) + + +@bp.post("/zwei-faktor/codes-erneuern") +@login_required +@reauth_required +def recovery_codes(): + codes = totp.generate_recovery_codes() + with transaction() as conn: + conn.execute("DELETE FROM user_recovery_codes WHERE user_id=?", (current_user.id,)) + conn.executemany( + "INSERT INTO user_recovery_codes (user_id, code_hash, created_at) VALUES (?,?,?)", + [ + ( + current_user.id, + passwords.hash_password(code), + datetime.now(timezone.utc).isoformat(timespec="seconds"), + ) + for code in codes + ], + ) + _audit(conn, "account.recovery_codes_renewed", severity="notice") + session["_recovery_codes"] = codes + flash("Neue Wiederherstellungscodes erzeugt. Die alten gelten nicht mehr.", "success") + return redirect(url_for("account.security")) + + +@bp.post("/sitzungen//beenden") +@login_required +def revoke_session(sid: str): + conn = db() + owner = Database.value(conn, "SELECT user_id FROM user_sessions WHERE id=?", (sid,)) + if owner is None or int(owner) != current_user.id: + abort(404) + with transaction(): + session_store.revoke(conn, sid, reason="self") + _audit(conn, "account.session_revoked", sid[:12]) + flash("Sitzung beendet.", "success") + return redirect(url_for("account.security")) + + +@bp.post("/sitzungen/alle-beenden") +@login_required +def revoke_other_sessions(): + with transaction() as conn: + count = session_store.revoke_all_for_user( + conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self" + ) + _audit(conn, "account.sessions_revoked", f"{count} Sitzungen") + flash(f"{count} weitere Sitzung(en) beendet.", "success") + return redirect(url_for("account.security")) diff --git a/packages/tesm-core/src/tesm_core/views/admin.py b/packages/tesm-core/src/tesm_core/views/admin.py new file mode 100644 index 0000000..a670f32 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/views/admin.py @@ -0,0 +1,440 @@ +"""Benutzer- und Gruppenverwaltung -- identisch in beiden Anwendungen.""" + +from __future__ import annotations + +from typing import Any + +from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for + +from .. import audit +from ..auth import directory +from ..auth.models import admin_required, current_user, login_required, permission_required +from ..auth.service import client_ip +from ..db import Database +from ..extension import core, db, transaction +from ..rbac import service as rbac +from ..rbac.model import ACTION_META, Action +from ..security import sessions as session_store + +bp = Blueprint("admin", __name__, url_prefix="/verwaltung") + + +def _audit(conn: Any, action: str, target: str = "", detail: str = "", severity: str = "info") -> None: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action=action, + target=target, + detail=detail, + ip=client_ip(), + severity=severity, + ) + + +def _int_list(name: str) -> list[int]: + values: list[int] = [] + for raw in request.form.getlist(name): + try: + values.append(int(raw)) + except (TypeError, ValueError): + continue + return values + + +# --------------------------------------------------------------------------- # +# Benutzer +# --------------------------------------------------------------------------- # + + +@bp.get("/benutzer") +@permission_required("users.view") +def users(): + conn = db() + return render_template( + "tesm_core/users.html", + users=rbac.list_users(conn), + groups=[group for group in rbac.list_groups(conn)], + directory_enabled=directory.is_enabled(conn), + policy=core().password_policy, + ) + + +@bp.post("/benutzer/neu") +@permission_required("users.create") +def user_create(): + conn = db() + try: + with transaction(): + user_id = rbac.create_user( + conn, + username=request.form.get("username", ""), + password=request.form.get("password", ""), + is_admin=request.form.get("is_admin") == "1", + first_name=request.form.get("first_name", ""), + last_name=request.form.get("last_name", ""), + email=request.form.get("email", ""), + group_ids=_int_list("group_ids"), + actor=current_user, + tree=core().permissions, + policy=core().password_policy, + must_change_password=request.form.get("must_change_password") == "1", + ) + _audit( + conn, + "user.created", + request.form.get("username", ""), + "Administrator" if request.form.get("is_admin") == "1" else "Benutzer", + severity="notice", + ) + flash(f"Konto {request.form.get('username')} wurde angelegt.", "success") + return redirect(url_for("admin.users", highlight=user_id)) + except (rbac.ValidationError, rbac.PermissionDenied) as exc: + flash(str(exc), "danger") + return redirect(url_for("admin.users")) + + +@bp.post("/benutzer/") +@permission_required("users.edit") +def user_update(user_id: int): + conn = db() + try: + with transaction(): + changes = rbac.update_user( + conn, + user_id, + actor=current_user, + tree=core().permissions, + first_name=request.form.get("first_name"), + last_name=request.form.get("last_name"), + email=request.form.get("email"), + is_admin=request.form.get("is_admin") == "1", + is_locked=request.form.get("is_locked") == "1", + group_ids=_int_list("group_ids") if "group_ids" in request.form else None, + ) + if request.form.get("new_password"): + rbac.set_password( + conn, + user_id, + request.form["new_password"], + policy=core().password_policy, + must_change=True, + ) + changes.append("Passwort zurueckgesetzt") + session_store.revoke_all_for_user(conn, user_id, reason="password-reset") + target = rbac.get_user(conn, user_id) or {} + if changes: + _audit( + conn, + "user.updated", + str(target.get("username") or user_id), + "; ".join(changes), + severity="notice", + ) + flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") + except (rbac.ValidationError, rbac.PermissionDenied) as exc: + flash(str(exc), "danger") + return redirect(url_for("admin.users")) + + +@bp.post("/benutzer//loeschen") +@permission_required("users.delete") +def user_delete(user_id: int): + conn = db() + try: + with transaction(): + target = rbac.get_user(conn, user_id) or {} + mode = rbac.soft_delete_user(conn, user_id, actor=current_user) + _audit( + conn, + "user.deleted", + str(target.get("username") or user_id), + f"Modus: {mode}", + severity="warning", + ) + flash( + "Konto endgueltig entfernt (Verzeichniskonto)." + if mode == "endgueltig" + else "Konto in den Papierkorb verschoben.", + "success", + ) + except (rbac.ValidationError, rbac.PermissionDenied) as exc: + flash(str(exc), "danger") + return redirect(url_for("admin.users")) + + +@bp.post("/benutzer//sitzungen-beenden") +@permission_required("users.edit") +def user_revoke_sessions(user_id: int): + conn = db() + target = rbac.get_user(conn, user_id) + if target is None: + abort(404) + rbac.guard_target_account(current_user, target) + with transaction(): + count = session_store.revoke_all_for_user(conn, user_id, reason="admin") + _audit( + conn, + "user.sessions_revoked", + str(target["username"]), + f"{count} Sitzungen", + severity="notice", + ) + flash(f"{count} Sitzung(en) beendet.", "success") + return redirect(url_for("admin.users")) + + +@bp.get("/benutzer/verzeichnis-suche") +@permission_required("users.create") +def directory_search(): + conn = db() + query = (request.args.get("q") or "").strip() + existing = { + str(row["username"]).lower() for row in rbac.list_users(conn, include_deleted=True) + } + results = [ + entry + for entry in directory.search_directory_users(conn, query) + if entry["username"].lower() not in existing + ] + return jsonify({"results": results}) + + +@bp.post("/benutzer/verzeichnis-uebernehmen") +@permission_required("users.create") +def directory_adopt(): + """Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen.""" + conn = db() + username = (request.form.get("username") or "").strip() + if not username: + flash("Kein Benutzername angegeben.", "danger") + return redirect(url_for("admin.users")) + try: + with transaction(): + user_id = rbac.create_user( + conn, + username=username, + password="", + is_admin=False, + first_name=request.form.get("first_name", ""), + last_name=request.form.get("last_name", ""), + email=request.form.get("email", ""), + group_ids=_int_list("group_ids"), + actor=current_user, + tree=core().permissions, + policy=core().password_policy, + auth_source="ldap", + ) + _audit(conn, "user.directory_adopted", username, severity="notice") + flash(f"Verzeichniskonto {username} vorbereitet.", "success") + return redirect(url_for("admin.users", highlight=user_id)) + except (rbac.ValidationError, rbac.PermissionDenied) as exc: + flash(str(exc), "danger") + return redirect(url_for("admin.users")) + + +# --------------------------------------------------------------------------- # +# Gruppen +# --------------------------------------------------------------------------- # + + +@bp.get("/gruppen") +@permission_required("groups.view") +def groups(): + conn = db() + tree = core().permissions + entries = rbac.list_groups(conn) + return render_template( + "tesm_core/groups.html", + groups=entries, + summaries={ + group.id: rbac.summarize_permissions(tree, group.permissions) for group in entries + }, + tree=tree, + ) + + +@bp.get("/gruppen/") +@permission_required("groups.view") +def group_detail(group_id: int): + conn = db() + group = rbac.get_group(conn, group_id) + if group is None or group.deleted_at: + abort(404) + tree = core().permissions + return render_template( + "tesm_core/group_detail.html", + group=group, + tree=tree, + state=rbac.matrix_state(tree, group.permissions), + members=Database.all( + conn, + "SELECT u.id, u.username, u.first_name, u.last_name, u.is_admin, u.auth_source " + "FROM user_groups ug JOIN users u ON u.id = ug.user_id " + "WHERE ug.group_id=? AND u.deleted_at IS NULL ORDER BY u.username COLLATE NOCASE", + (group_id,), + ), + can_edit=current_user.has("groups.edit"), + unlocked=request.args.get("freischalten") == "1", + ) + + +@bp.post("/gruppen/neu") +@permission_required("groups.create") +def group_create(): + conn = db() + try: + with transaction(): + group_id = rbac.create_group( + conn, + name=request.form.get("name", ""), + description=request.form.get("description", ""), + permissions=request.form.getlist("permissions"), + tree=core().permissions, + actor=current_user, + ) + _audit(conn, "group.created", request.form.get("name", ""), severity="notice") + flash("Gruppe angelegt.", "success") + return redirect(url_for("admin.group_detail", group_id=group_id)) + except (rbac.ValidationError, rbac.PermissionDenied) as exc: + flash(str(exc), "danger") + return redirect(url_for("admin.groups")) + + +@bp.post("/gruppen/") +@permission_required("groups.edit") +def group_update(group_id: int): + conn = db() + try: + with transaction(): + changes = rbac.update_group( + conn, + group_id, + name=request.form.get("name"), + description=request.form.get("description"), + permissions=request.form.getlist("permissions") + if "permissions_submitted" in request.form + else None, + tree=core().permissions, + actor=current_user, + unlock_system=request.form.get("unlock_system") == "1", + ) + group = rbac.get_group(conn, group_id) + if changes: + _audit( + conn, + "group.updated", + group.name if group else str(group_id), + "; ".join(changes), + severity="notice", + ) + flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") + except (rbac.ValidationError, rbac.PermissionDenied) as exc: + flash(str(exc), "danger") + return redirect(url_for("admin.group_detail", group_id=group_id)) + + +@bp.post("/gruppen//loeschen") +@permission_required("groups.delete") +def group_delete(group_id: int): + conn = db() + try: + with transaction(): + group = rbac.get_group(conn, group_id) + rbac.soft_delete_group(conn, group_id) + _audit( + conn, + "group.deleted", + group.name if group else str(group_id), + severity="warning", + ) + flash("Gruppe in den Papierkorb verschoben.", "success") + except (rbac.ValidationError, rbac.PermissionDenied) as exc: + flash(str(exc), "danger") + return redirect(url_for("admin.groups")) + + +@bp.post("/gruppen//vorlage-anwenden") +@admin_required +def group_apply_preset(group_id: int): + """Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch.""" + conn = db() + group = rbac.get_group(conn, group_id) + if group is None: + abort(404) + preset = next((p for p in core().role_presets if p.key == group.preset_key), None) + if preset is None: + flash("Fuer diese Gruppe ist keine Vorlage hinterlegt.", "info") + return redirect(url_for("admin.group_detail", group_id=group_id)) + tree = core().permissions + with transaction(): + rbac.update_group( + conn, + group_id, + permissions=tree.expand_preset(preset), + tree=tree, + actor=current_user, + unlock_system=True, + ) + _audit(conn, "group.preset_applied", group.name, preset.key, severity="notice") + flash(f"Rechte der Vorlage {preset.name} uebernommen.", "success") + return redirect(url_for("admin.group_detail", group_id=group_id)) + + +# --------------------------------------------------------------------------- # +# Sitzungen +# --------------------------------------------------------------------------- # + + +@bp.get("/sitzungen") +@admin_required +def sessions(): + conn = db() + rows = Database.all( + conn, + "SELECT s.*, u.username FROM user_sessions s LEFT JOIN users u ON u.id = s.user_id " + "WHERE s.revoked_at IS NULL AND s.idle_expires_at > datetime('now') " + "ORDER BY s.last_seen_at DESC LIMIT 500", + ) + return render_template("tesm_core/sessions.html", sessions=rows) + + +@bp.post("/sitzungen//beenden") +@admin_required +def session_revoke(sid: str): + with transaction() as conn: + session_store.revoke(conn, sid, reason="admin") + _audit(conn, "session.revoked", sid[:12], severity="notice") + flash("Sitzung beendet.", "success") + return redirect(url_for("admin.sessions")) + + +# --------------------------------------------------------------------------- # +# Navigationsreihenfolge (nur echte Administratoren) +# --------------------------------------------------------------------------- # + + +@bp.post("/navigation") +@admin_required +def nav_order(): + extension = core() + order = extension.nav.sanitize_order(request.form.getlist("order")) + child_order: dict[str, list[str]] = {} + for item in extension.nav.items: + if not item.children: + continue + submitted = request.form.getlist(f"child_order_{item.key}") + if submitted: + child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted) + with transaction() as conn: + extension.settings.set(conn, "nav_order", order, actor=current_user.username) + extension.settings.set(conn, "nav_child_order", child_order, actor=current_user.username) + _audit(conn, "system.nav_order_changed", severity="info") + flash("Reihenfolge der Navigation gespeichert.", "success") + return redirect(request.referrer or url_for("index")) + + +#: Kurzbezeichnungen der Aktionen -- von Templates und Audit-Texten genutzt. +ACTION_LABELS: dict[Action, str] = { + action: meta[1] for action, meta in ACTION_META.items() +} diff --git a/packages/tesm-core/src/tesm_core/views/diagnostics.py b/packages/tesm-core/src/tesm_core/views/diagnostics.py new file mode 100644 index 0000000..1d6adae --- /dev/null +++ b/packages/tesm-core/src/tesm_core/views/diagnostics.py @@ -0,0 +1,150 @@ +"""Diagnoseseite: Schemastand, Protokollintegritaet, Dateirechte, Lizenzzustand. + +Diese Seite gab es im Vorgaenger nicht. Sie beantwortet die Fragen, die bei +jedem Zwischenfall zuerst gestellt werden -- laeuft das Schema auf dem +erwarteten Stand, ist das Aenderungsprotokoll unversehrt, liegen die +Schluesseldateien mit korrekten Rechten, ist der privilegierte Helfer +erreichbar. +""" + +from __future__ import annotations + +import os +import platform +import sys +from datetime import datetime, timezone + +from flask import Blueprint, flash, redirect, render_template, url_for + +from .. import audit +from ..auth.models import admin_required, current_user, permission_required +from ..db import Database +from ..extension import core, db, transaction +from ..keystore import check_private_file +from ..security import ratelimit +from ..security import sessions as session_store + +bp = Blueprint("diagnostics", __name__, url_prefix="/diagnose") + + +@bp.get("/") +@permission_required("diagnostics.view") +def overview(): + extension = core() + conn = db() + config = extension.config + + chain = audit.verify_chain(conn, limit=5000) + key_problems: list[str] = [] + for path in (config.data_key_file, config.secret_key_file, config.license_key_path): + key_problems.extend(check_private_file(path)) + + counts = { + "Benutzer": Database.value(conn, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL", default=0), + "Gruppen": Database.value(conn, "SELECT COUNT(*) FROM groups WHERE deleted_at IS NULL", default=0), + "Aktive Sitzungen": Database.value( + conn, + "SELECT COUNT(*) FROM user_sessions WHERE revoked_at IS NULL " + "AND idle_expires_at > datetime('now')", + default=0, + ), + "Protokollzeilen": audit.count(conn), + } + + try: + db_size = config.db_path.stat().st_size + except OSError: + db_size = 0 + + return render_template( + "tesm_core/diagnostics.html", + migrations=extension.migration_status(), + chain=chain, + key_problems=key_problems, + counts=counts, + db_size=db_size, + db_path=str(config.db_path), + log_dir=str(config.log_dir), + keystore_active=extension.keystore.active_key_id, + keystore_keys=extension.keystore.key_ids(), + sysops_available=bool(extension.sysops and extension.sysops.available), + sysops_helper=str(extension.sysops.helper) if extension.sysops else "", + # Nicht "liegt die Datei da", sondern "laesst er sich aufrufen". Der + # Unterschied hat einmal gekostet: die Seite meldete "erreichbar", + # waehrend jeder Aufruf an NoNewPrivileges scheiterte. + sysops_selftest=(extension.sysops.selftest() if extension.sysops else None), + license_state=extension.license.state if extension.license else None, + runtime={ + "Python": sys.version.split()[0], + "Plattform": platform.platform(), + "Prozess": f"PID {os.getpid()}", + "Webprozess-Kennzeichen": "gesetzt" if config.web_process else "nicht gesetzt", + "Debug": "ja" if config.debug else "nein", + "Cookie Secure": "ja" if config.security.cookie_secure else "nein", + "HSTS": "ja" if config.security.hsts_enabled else "nein", + "2FA-Pflicht fuer Admins": "ja" if config.security.require_totp_for_admins else "nein", + }, + now=datetime.now(timezone.utc), + ) + + +@bp.post("/aufraeumen") +@admin_required +def housekeeping(): + """Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle.""" + extension = core() + with transaction() as conn: + sessions_removed = session_store.prune(conn) + rate_removed = ratelimit.prune(conn) + archived = audit.archive_old_rows(conn, extension.audit_archive_dir) + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="system.housekeeping", + detail=( + f"{sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, " + f"{len(archived)} Protokolltage archiviert" + ), + severity="notice", + ) + flash( + f"Aufgeraeumt: {sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, " + f"{len(archived)} archivierte Protokolltage.", + "success", + ) + return redirect(url_for("diagnostics.overview")) + + +@bp.post("/schluessel-rotieren") +@admin_required +def rotate_keys(): + """Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu. + + Der Vorgaenger hatte genau einen, nie wechselbaren Fernet-Schluessel. Hier + bleibt der alte Schluessel zum Lesen erhalten, bis alle Werte umgezogen sind. + """ + extension = core() + keystore = extension.keystore + new_id = keystore.rotate() + migrated = 0 + + reencrypt = extension.registry.get("reencrypt_secrets") + with transaction() as conn: + if callable(reencrypt): + migrated = int(reencrypt(conn, keystore)) + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="system.data_key_rotated", + target=new_id, + detail=f"{migrated} Werte neu verschluesselt", + severity="notice", + ) + flash( + f"Neuer Datenschluessel {new_id} aktiv, {migrated} gespeicherte Geheimnisse " + "wurden neu verschluesselt.", + "success", + ) + return redirect(url_for("diagnostics.overview")) diff --git a/packages/tesm-core/src/tesm_core/views/hostsettings.py b/packages/tesm-core/src/tesm_core/views/hostsettings.py new file mode 100644 index 0000000..7a3e8a9 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/views/hostsettings.py @@ -0,0 +1,529 @@ +"""Einstellungen auf Host-Ebene: Netzwerk, Webserver/TLS, Verzeichnisdienst. + +In beiden Anwendungen identisch -- darum genau einmal hier. Jede App bringt +zusaetzlich nur ihre eigene Systemseite mit. + +Lizenzpflicht wird serverseitig durchgesetzt (``license.require``). Der +Vorgaenger pruefte Verzeichnisdienst und Webserver nur im Template; ein direkter +POST kam trotz fehlender Lizenz durch, obwohl die Dokumentation das Gegenteil +behauptete. +""" + +from __future__ import annotations + +import shutil +import threading +import time +from typing import Any + +from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for + +from .. import audit +from ..auth import directory +from ..auth.models import current_user, permission_required +from ..auth.service import client_ip +from ..db import Database +from ..extension import core, db, transaction +from ..licensing_client import LicenseError +from ..rbac import service as rbac +from ..system import hostnet, webserver + +bp = Blueprint("hostsettings", __name__, url_prefix="/einstellungen") + +PENDING_NETWORK_KEY = "pending_network_revert" + + +def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None: + with transaction() as conn: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action=action, + target=target, + detail=detail, + ip=client_ip(), + severity=severity, + ) + + +def _license_hint(feature: str) -> str: + manager = core().license + if manager is None: + return "" + try: + manager.require(feature) + except LicenseError as exc: + return str(exc) + return "" + + +# --------------------------------------------------------------------------- # +# Netzwerk +# --------------------------------------------------------------------------- # + + +@bp.get("/netzwerk") +@permission_required("settings_network.view") +def network(): + extension = core() + sysops = extension.sysops + return render_template( + "tesm_core/settings_network.html", + interfaces=hostnet.list_interfaces(sysops) if sysops else [], + backend=hostnet.detect_backend(sysops) if sysops else hostnet.BACKEND_UNKNOWN, + revert_seconds=hostnet.REVERT_SECONDS, + pending=extension.registry.get(PENDING_NETWORK_KEY), + sysops_available=bool(sysops and sysops.available), + netplan_path=f"/etc/netplan/90-{extension.config.site_key}.yaml", + ) + + +@bp.post("/netzwerk") +@permission_required("settings_network.edit") +def network_apply(): + extension = core() + sysops = extension.sysops + if sysops is None or not sysops.available: + flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") + return redirect(url_for("hostsettings.network")) + + interface = (request.form.get("interface") or "").strip() + if interface not in {entry.name for entry in hostnet.list_interfaces(sysops)}: + flash("Unbekanntes Interface.", "danger") + return redirect(url_for("hostsettings.network")) + + dhcp = request.form.get("mode") == "dhcp" + try: + if dhcp: + content = hostnet.render_netplan(interface=interface, dhcp=True) + else: + address, prefix, gateway, dns = hostnet.validate_static( + address=request.form.get("address", ""), + prefix=request.form.get("prefix", "24"), + gateway=request.form.get("gateway", ""), + dns=request.form.get("dns", ""), + ) + content = hostnet.render_netplan( + interface=interface, + dhcp=False, + address=address, + prefix=prefix, + gateway=gateway, + dns=dns, + ) + except ValueError as exc: + flash(str(exc), "danger") + return redirect(url_for("hostsettings.network")) + + previous = sysops.run("file-read", f"/etc/netplan/90-{extension.config.site_key}.yaml") + write = sysops.write_netplan(content) + if not write.ok: + flash(f"Schreiben fehlgeschlagen: {write.message}", "danger") + return redirect(url_for("hostsettings.network")) + applied = sysops.apply_netplan() + if not applied.ok: + sysops.write_netplan(previous.stdout if previous.ok else "") + sysops.apply_netplan() + flash( + f"Uebernahme fehlgeschlagen, alter Zustand wiederhergestellt: {applied.message}", + "danger", + ) + return redirect(url_for("hostsettings.network")) + + extension.registry[PENDING_NETWORK_KEY] = { + "interface": interface, + "previous": previous.stdout if previous.ok else "", + "deadline": time.time() + hostnet.REVERT_SECONDS, + } + _start_revert_timer(extension) + _audit("settings.network_applied", interface, severity="warning") + flash( + f"Uebernommen. Bitte innerhalb von {hostnet.REVERT_SECONDS} Sekunden bestaetigen -- " + "sonst wird automatisch zurueckgerollt.", + "warning", + ) + return redirect(url_for("hostsettings.network")) + + +@bp.post("/netzwerk/bestaetigen") +@permission_required("settings_network.edit") +def network_confirm(): + if core().registry.pop(PENDING_NETWORK_KEY, None) is None: + flash("Es liegt keine Aenderung zur Bestaetigung vor.", "info") + else: + _audit("settings.network_confirmed", severity="notice") + flash("Netzwerkeinstellungen bestaetigt.", "success") + return redirect(url_for("hostsettings.network")) + + +def _start_revert_timer(extension: Any) -> None: + def worker() -> None: + time.sleep(hostnet.REVERT_SECONDS + 2) + pending = extension.registry.pop(PENDING_NETWORK_KEY, None) + if not pending: + return + sysops = extension.sysops + if sysops is not None: + sysops.write_netplan(pending.get("previous") or "") + sysops.apply_netplan() + with extension.database.session() as conn, extension.database.transaction(conn): + audit.log( + conn, + actor="system", + action="settings.network_auto_reverted", + detail="Keine Bestaetigung innerhalb der Frist -- automatisch zurueckgerollt.", + severity="warning", + ) + + threading.Thread(target=worker, name="network-revert", daemon=True).start() + + +# --------------------------------------------------------------------------- # +# Webserver und TLS +# --------------------------------------------------------------------------- # + + +def _web_config() -> webserver.WebConfig: + extension = core() + conn = db() + values = extension.settings.get_many( + conn, + [ + "web_domain", + "web_https_enabled", + "web_hsts_enabled", + "web_redirect_http", + "web_cert_path", + "web_key_path", + "web_acme_webroot", + "web_max_body_mb", + "web_http_port", + "web_https_port", + ], + ) + return webserver.WebConfig( + app_key=extension.config.site_key, + upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")), + static_root=str(extension.registry.get("static_root", extension.config.base_dir / "static")), + domain=str(values["web_domain"] or ""), + https_enabled=bool(values["web_https_enabled"]), + hsts_enabled=bool(values["web_hsts_enabled"]), + redirect_http=bool(values["web_redirect_http"]), + cert_path=str(values["web_cert_path"] or ""), + key_path=str(values["web_key_path"] or ""), + acme_webroot=str(values["web_acme_webroot"] or f"/var/www/{extension.config.site_key}-acme"), + websocket_path=str(extension.registry.get("websocket_path", "")), + max_body_mb=int(values["web_max_body_mb"] or 128), + http_port=int(values["web_http_port"] or 80), + https_port=int(values["web_https_port"] or 443), + ) + + +@bp.get("/webserver") +@permission_required("settings_web.view") +def web(): + extension = core() + config = _web_config() + return render_template( + "tesm_core/settings_web.html", + config=config, + preview=webserver.render(config), + certificate=webserver.certificate_info(config.cert_path), + acme_email=str(extension.settings.get(db(), "web_acme_email") or ""), + license_hint=_license_hint("nginx"), + certbot_available=bool(shutil.which("certbot")), + sysops_available=bool(extension.sysops and extension.sysops.available), + local_names=webserver.local_names(), + ) + + +@bp.post("/webserver") +@permission_required("settings_web.edit") +def web_save(): + problem = _license_hint("nginx") + if problem: + flash(problem, "danger") + return redirect(url_for("hostsettings.web")) + + extension = core() + conn = db() + try: + domain = webserver.validate_domain(request.form.get("web_domain", "")) + except ValueError as exc: + flash(str(exc), "danger") + return redirect(url_for("hostsettings.web")) + + https_enabled = request.form.get("web_https_enabled") == "1" + cert_path = (request.form.get("web_cert_path") or "").strip() + key_path = (request.form.get("web_key_path") or "").strip() + if https_enabled and not (cert_path and key_path): + flash("Fuer HTTPS werden Zertifikat und privater Schluessel gebraucht.", "danger") + return redirect(url_for("hostsettings.web")) + + values = { + "web_domain": domain, + "web_https_enabled": https_enabled, + "web_hsts_enabled": request.form.get("web_hsts_enabled") == "1" and https_enabled, + "web_redirect_http": request.form.get("web_redirect_http") == "1", + "web_cert_path": cert_path, + "web_key_path": key_path, + "web_acme_email": (request.form.get("web_acme_email") or "").strip(), + "web_acme_webroot": (request.form.get("web_acme_webroot") or "").strip() + or f"/var/www/{extension.config.site_key}-acme", + } + with transaction(): + changed = extension.settings.set_many(conn, values, actor=current_user.username) + _audit("settings.web_saved", domain, ", ".join(changed), severity="notice") + flash("Gespeichert. Uebernehmen Sie die Konfiguration, damit sie wirksam wird.", "success") + return redirect(url_for("hostsettings.web")) + + +@bp.post("/webserver/uebernehmen") +@permission_required("settings_web.execute") +def web_apply(): + problem = _license_hint("nginx") + if problem: + flash(problem, "danger") + return redirect(url_for("hostsettings.web")) + sysops = core().sysops + if sysops is None or not sysops.available: + flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") + return redirect(url_for("hostsettings.web")) + + result = webserver.apply_config(sysops, webserver.render(_web_config())) + _audit( + "settings.web_applied", + detail=result.get("message", ""), + severity="notice" if result["ok"] else "warning", + ) + flash( + result["message"] + (f" {result.get('detail', '')}" if not result["ok"] else ""), + "success" if result["ok"] else "danger", + ) + return redirect(url_for("hostsettings.web")) + + +@bp.post("/webserver/zertifikat") +@permission_required("settings_web.execute") +def web_certificate(): + problem = _license_hint("nginx") + if problem: + flash(problem, "danger") + return redirect(url_for("hostsettings.web")) + extension = core() + sysops = extension.sysops + conn = db() + if sysops is None or not sysops.available: + flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") + return redirect(url_for("hostsettings.web")) + + config = _web_config() + result = webserver.request_certificate( + sysops, + domain=config.domain, + email=str(extension.settings.get(conn, "web_acme_email") or ""), + webroot=config.acme_webroot, + ) + if result["ok"]: + with transaction(): + extension.settings.set_many( + conn, + { + "web_cert_path": result["cert_path"], + "web_key_path": result["key_path"], + "web_https_enabled": True, + }, + actor=current_user.username, + ) + _audit("settings.certificate_issued", config.domain, severity="notice") + flash(result["message"], "success" if result["ok"] else "danger") + return redirect(url_for("hostsettings.web")) + + +@bp.post("/webserver/selbstsigniert") +@permission_required("settings_web.execute") +def web_self_signed(): + """Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain. + + Danach wird HTTPS aktiviert, aber **nicht** automatisch uebernommen: der + Administrator soll die erzeugte Konfiguration erst sehen. Ein Reload, der + die eigene Sitzung ueber einen anderen Port wegwirft, kommt nie ungefragt. + """ + problem = _license_hint("nginx") + if problem: + flash(problem, "danger") + return redirect(url_for("hostsettings.web")) + extension = core() + sysops = extension.sysops + conn = db() + if sysops is None or not sysops.available: + flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") + return redirect(url_for("hostsettings.web")) + + config = _web_config() + suggested = webserver.local_names() + common_name = config.domain or (suggested[0] if suggested else "") + result = webserver.create_self_signed( + sysops, common_name=common_name, alt_names=[*suggested, "localhost"] + ) + if result["ok"]: + with transaction(): + extension.settings.set_many( + conn, + { + "web_cert_path": result["cert_path"], + "web_key_path": result["key_path"], + "web_https_enabled": True, + }, + actor=current_user.username, + ) + _audit("settings.self_signed_issued", common_name, severity="notice") + flash( + result["message"] + (f" {result.get('detail', '')}" if not result["ok"] else ""), + "success" if result["ok"] else "danger", + ) + return redirect(url_for("hostsettings.web")) + + +# --------------------------------------------------------------------------- # +# Verzeichnisdienst +# --------------------------------------------------------------------------- # + + +@bp.get("/verzeichnisdienst") +@permission_required("settings_directory.view") +def directory_settings(): + extension = core() + conn = db() + bind_username, _ = directory.bind_credentials(conn) + return render_template( + "tesm_core/settings_directory.html", + values=extension.settings.get_many(conn, [spec.key for spec in directory.SETTINGS]), + specs={spec.key: spec for spec in directory.SETTINGS}, + bind_username=bind_username, + mappings=Database.all( + conn, + "SELECT m.*, g.name AS group_name FROM ldap_group_mappings m " + "LEFT JOIN groups g ON g.id = m.app_group_id ORDER BY m.ad_group_name", + ), + groups=rbac.list_groups(conn), + license_hint=_license_hint("directory"), + ) + + +@bp.post("/verzeichnisdienst") +@permission_required("settings_directory.edit") +def directory_save(): + problem = _license_hint("directory") + if problem: + flash(problem, "danger") + return redirect(url_for("hostsettings.directory_settings")) + + extension = core() + conn = db() + values: dict[str, Any] = {} + for spec in directory.SETTINGS: + if spec.kind == "bool": + values[spec.key] = request.form.get(spec.key) == "1" + elif spec.key in request.form: + values[spec.key] = request.form.get(spec.key) + + if values.get("ldap_security") == "none" and not values.get("ldap_allow_insecure"): + flash( + "Eine unverschluesselte Verbindung muss ausdruecklich erlaubt werden -- das Passwort " + "geht dabei im Klartext ueber das Netz.", + "danger", + ) + return redirect(url_for("hostsettings.directory_settings")) + + with transaction(): + changed = extension.settings.set_many(conn, values, actor=current_user.username) + if current_user.has("settings_directory.secrets"): + directory.store_bind_credentials( + conn, + request.form.get("bind_username", ""), + request.form.get("bind_password") or None, + ) + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="settings.directory_saved", + detail=", ".join(changed), + ip=client_ip(), + severity="notice", + ) + flash("Verzeichniseinstellungen gespeichert.", "success") + return redirect(url_for("hostsettings.directory_settings")) + + +@bp.post("/verzeichnisdienst/test") +@permission_required("settings_directory.execute") +def directory_test(): + result = directory.test_connection(db()) + flash(result["message"], "success" if result["ok"] else "danger") + return redirect(url_for("hostsettings.directory_settings")) + + +@bp.get("/verzeichnisdienst/gruppensuche") +@permission_required("settings_directory.edit", "fileshare.edit", mode="any") +def directory_group_search(): + """Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder. + + Auch mit ``fileshare.edit`` erreichbar: wer Freigaben zuordnen darf, muss + die Gruppen finden koennen, ohne zugleich den Verzeichnisdienst + konfigurieren zu duerfen. Gelesen wird nur, und nur mit dem hinterlegten + Dienstkonto -- es entsteht kein Zugriff, den der Server nicht ohnehin hat. + """ + return jsonify( + {"results": directory.search_directory_groups(db(), request.args.get("q") or "")} + ) + + +@bp.post("/verzeichnisdienst/zuordnung") +@permission_required("settings_directory.edit") +def directory_mapping_add(): + conn = db() + dn = (request.form.get("ad_group_dn") or "").strip() + name = (request.form.get("ad_group_name") or "").strip() + group_id = request.form.get("app_group_id") + if not dn or not group_id: + flash("Verzeichnisgruppe und Zielgruppe sind beide erforderlich.", "danger") + return redirect(url_for("hostsettings.directory_settings")) + with transaction(): + conn.execute( + "INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id, created_at) " + "VALUES (?,?,?,datetime('now')) ON CONFLICT(ad_group_dn) DO UPDATE SET " + "ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id", + (dn, name or dn, int(group_id)), + ) + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="settings.directory_mapping_added", + target=name or dn, + ip=client_ip(), + severity="notice", + ) + flash("Zuordnung gespeichert.", "success") + return redirect(url_for("hostsettings.directory_settings")) + + +@bp.post("/verzeichnisdienst/zuordnung//loeschen") +@permission_required("settings_directory.edit") +def directory_mapping_delete(mapping_id: int): + with transaction() as conn: + conn.execute("DELETE FROM ldap_group_mappings WHERE id=?", (mapping_id,)) + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="settings.directory_mapping_deleted", + target=str(mapping_id), + ip=client_ip(), + severity="notice", + ) + flash("Zuordnung entfernt.", "success") + return redirect(url_for("hostsettings.directory_settings")) diff --git a/packages/tesm-core/src/tesm_core/views/logs.py b/packages/tesm-core/src/tesm_core/views/logs.py new file mode 100644 index 0000000..6a272f0 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/views/logs.py @@ -0,0 +1,239 @@ +"""Protokollansichten, die beide Anwendungen teilen. + +* **Laufend** -- die Textdatei ``live.log``. +* **Verlauf** -- die von logrotate rotierten Kopien plus die aus der Datenbank + ausgelagerten Audit-Tage. +* **Aenderungen** -- die Tabelle ``audit_log`` mit pruefbarer Hash-Kette. + +Was ``live.log`` fuellt, entscheidet die Anwendung: TESM schreibt dort +Erreichbarkeit und PoE-Vorgaenge; der Lizenzserver spiegelt dorthin seinen +Audit-Trail, weil es bei ihm keinen Telemetrie-Prozess gibt. Beides landet in +derselben Ansicht -- der Vorgaenger hatte dafuer zwei getrennte Umsetzungen. +""" + +from __future__ import annotations + +import gzip +import io +import zipfile +from pathlib import Path + +from flask import Blueprint, Response, abort, flash, redirect, render_template, request, url_for + +from .. import audit +from ..auth.models import current_user, permission_required +from ..auth.service import client_ip +from ..extension import core, db, transaction + +bp = Blueprint("logs", __name__, url_prefix="/protokolle") + +MAX_TAIL_LINES = 2000 + + +def _live_log(): + log = core().registry.get("live_log") + if log is None: # pragma: no cover - jede App registriert eines + abort(404) + return log + + +@bp.get("/laufend") +@permission_required("logs_live.view") +def live(): + lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES) + needle = (request.args.get("q") or "").strip() + return render_template( + "tesm_core/logs_live.html", + lines=_live_log().tail(lines, needle=needle), + path=str(_live_log().path), + query=needle, + line_count=lines, + ) + + +@bp.get("/laufend/inhalt") +@permission_required("logs_live.view") +def live_fragment(): + lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES) + needle = (request.args.get("q") or "").strip() + return render_template( + "tesm_core/_log_lines.html", lines=_live_log().tail(lines, needle=needle) + ) + + +@bp.get("/verlauf") +@permission_required("logs_history.view", "logs_history.export", mode="any") +def history(): + extension = core() + rotated = [] + for path in sorted(extension.config.log_dir.glob("*.log.*"), reverse=True): + try: + stat = path.stat() + except OSError: + continue + rotated.append({"name": path.name, "size": stat.st_size, "modified": stat.st_mtime}) + return render_template( + "tesm_core/logs_history.html", + rotated=rotated, + archived=audit.archived_days(extension.audit_archive_dir), + log_dir=str(extension.config.log_dir), + ) + + +@bp.get("/verlauf/datei/") +@permission_required("logs_history.view") +def history_file(name: str): + extension = core() + target = (extension.config.log_dir / name).resolve() + if extension.config.log_dir.resolve() not in target.parents or not target.is_file(): + abort(404) + try: + if target.suffix == ".gz": + content = gzip.decompress(target.read_bytes()).decode("utf-8", errors="replace") + else: + content = target.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + abort(500, description=f"Datei nicht lesbar: {exc}") + return render_template( + "tesm_core/logs_file.html", name=name, lines=content.splitlines()[-MAX_TAIL_LINES:] + ) + + +@bp.get("/verlauf/archiv/") +@permission_required("logs_history.view") +def history_archive(day: str): + if len(day) != 10 or day.count("-") != 2: + abort(404) + rows = audit.read_archived_day(core().audit_archive_dir, day) + if not rows: + abort(404) + return render_template("tesm_core/logs_archive.html", day=day, rows=rows) + + +@bp.post("/verlauf/archiv-export") +@permission_required("logs_history.export") +def history_export(): + """Packt die archivierten Audit-Tage in ein ZIP. + + Anders als im Vorgaenger loescht der Export die Dateien **nicht**: ein + Download, der Beweismittel vom Server entfernt, ist keine gute Idee. Das + Loeschen ist ein eigener, ausdruecklicher Schritt. + """ + extension = core() + entries = audit.archived_days(extension.audit_archive_dir) + if not entries: + flash("Es sind keine Archivdateien vorhanden.", "info") + return redirect(url_for("logs.history")) + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for entry in entries: + archive.write(entry["path"], arcname=Path(entry["path"]).name) + buffer.seek(0) + + with transaction() as conn: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="logs.archive_exported", + detail=f"{len(entries)} Tage", + ip=client_ip(), + severity="notice", + ) + return Response( + buffer.getvalue(), + mimetype="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{extension.config.app_key}-auditarchiv.zip"' + }, + ) + + +@bp.post("/verlauf/archiv-loeschen") +@permission_required("logs_history.delete") +def history_purge(): + removed = 0 + for entry in audit.archived_days(core().audit_archive_dir): + try: + Path(entry["path"]).unlink() + removed += 1 + except OSError: + continue + with transaction() as conn: + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="logs.archive_deleted", + detail=f"{removed} Dateien", + ip=client_ip(), + severity="warning", + ) + flash(f"{removed} Archivdatei(en) entfernt.", "success") + return redirect(url_for("logs.history")) + + +@bp.get("/aenderungen") +@permission_required("logs_audit.view", "logs_audit.export", mode="any") +def audit_view(): + conn = db() + page = max(1, int(request.args.get("seite") or 1)) + per_page = 100 + return render_template( + "tesm_core/logs_audit.html", + rows=audit.search( + conn, + query=(request.args.get("q") or "").strip(), + action=(request.args.get("aktion") or "").strip(), + actor=(request.args.get("benutzer") or "").strip(), + severity=(request.args.get("stufe") or "").strip(), + limit=per_page, + offset=(page - 1) * per_page, + ), + total=audit.count(conn), + actions=audit.distinct_actions(conn), + chain=audit.verify_chain(conn, limit=2000), + page=page, + per_page=per_page, + filters={ + "q": request.args.get("q") or "", + "aktion": request.args.get("aktion") or "", + "benutzer": request.args.get("benutzer") or "", + "stufe": request.args.get("stufe") or "", + }, + ) + + +@bp.get("/aenderungen/export") +@permission_required("logs_audit.export") +def audit_export(): + conn = db() + rows = audit.search(conn, limit=100000) + lines = ["ts;actor;action;target;detail;ip;severity;hash"] + for row in rows: + lines.append( + ";".join( + str(row.get(key) or "").replace(";", ",").replace("\n", " ") + for key in ("ts", "actor", "action", "target", "detail", "ip", "severity", "hash") + ) + ) + with transaction(): + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="logs.audit_exported", + detail=f"{len(rows)} Zeilen", + ip=client_ip(), + severity="notice", + ) + return Response( + "\n".join(lines).encode("utf-8-sig"), + mimetype="text/csv; charset=utf-8", + headers={ + "Content-Disposition": ( + f'attachment; filename="{core().config.app_key}-aenderungsprotokoll.csv"' + ) + }, + ) diff --git a/packages/tesm-core/src/tesm_core/views/trash.py b/packages/tesm-core/src/tesm_core/views/trash.py new file mode 100644 index 0000000..b2559a2 --- /dev/null +++ b/packages/tesm-core/src/tesm_core/views/trash.py @@ -0,0 +1,282 @@ +"""Papierkorb -- generisch ueber angemeldete Anbieter. + +Der Vorgaenger hatte den Papierkorb nur in TESM und dort fuer fuenf fest +verdrahtete Tabellen; der Lizenzserver-Fork hatte gar keinen, obwohl die +``deleted_at``-Spalten noch existierten (ein Artefakt, das die eigene +Spezifikation als "nicht nachbauen" markiert). + +Hier ist der Papierkorb ein Kern-Feature mit einem Register: jede Anwendung +meldet ihre loeschbaren Ressourcen an, die Oberflaeche ist fuer alle gleich. +Benutzer und Gruppen registriert der Kern selbst -- damit haben **beide** +Anwendungen einen funktionierenden Papierkorb, statt einer von beiden gar +keinen. +""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Callable + +from flask import Blueprint, abort, flash, redirect, render_template, request, url_for + +from .. import audit +from ..auth.models import current_user, permission_required +from ..auth.service import client_ip +from ..db import Database +from ..extension import core, db, transaction +from ..rbac import service as rbac + +bp = Blueprint("trash", __name__, url_prefix="/papierkorb") + + +@dataclass(frozen=True, slots=True) +class TrashProvider: + """Eine im Papierkorb verwaltbare Ressourcenart.""" + + key: str + label: str + icon: str + list_fn: Callable[[sqlite3.Connection], list[dict[str, Any]]] + restore_fn: Callable[[sqlite3.Connection, str], None] + purge_fn: Callable[[sqlite3.Connection, str], None] + hint: str = "" + + +def register(provider: TrashProvider) -> None: + """Meldet einen Anbieter an. Wird beim App-Aufbau aufgerufen.""" + providers: dict[str, TrashProvider] = core().registry.setdefault("trash_providers", {}) + providers[provider.key] = provider + + +def providers() -> dict[str, TrashProvider]: + return core().registry.get("trash_providers", {}) + + +# --------------------------------------------------------------------------- # +# Kern-Anbieter: Benutzer und Gruppen +# --------------------------------------------------------------------------- # + + +def _users_in_trash(conn: sqlite3.Connection) -> list[dict[str, Any]]: + return [ + { + "id": str(row["id"]), + "label": str(row["username"]), + "detail": f"{row['first_name']} {row['last_name']}".strip() or "-", + "deleted_at": row["deleted_at"], + } + for row in Database.all( + conn, + "SELECT id, username, first_name, last_name, deleted_at FROM users " + "WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC", + ) + ] + + +def _groups_in_trash(conn: sqlite3.Connection) -> list[dict[str, Any]]: + return [ + { + "id": str(row["id"]), + "label": str(row["name"]), + "detail": str(row["description"] or "-"), + "deleted_at": row["deleted_at"], + } + for row in Database.all( + conn, + "SELECT id, name, description, deleted_at FROM groups " + "WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC", + ) + ] + + +def register_core_providers() -> None: + register( + TrashProvider( + key="users", + label="Benutzerkonten", + icon="users", + list_fn=_users_in_trash, + restore_fn=lambda conn, ident: rbac.restore_user(conn, int(ident)), + purge_fn=lambda conn, ident: rbac.purge_user(conn, int(ident)), + hint="Verzeichniskonten landen nie hier -- sie legen sich bei der naechsten " + "Anmeldung von selbst wieder an.", + ) + ) + register( + TrashProvider( + key="groups", + label="Gruppen", + icon="shield-users", + list_fn=_groups_in_trash, + restore_fn=lambda conn, ident: rbac.restore_group(conn, int(ident)), + purge_fn=lambda conn, ident: rbac.purge_group(conn, int(ident)), + hint="Beim endgueltigen Loeschen verschwinden auch Mitgliedschaften und Rechte.", + ) + ) + + +# --------------------------------------------------------------------------- # +# Ansicht +# --------------------------------------------------------------------------- # + + +def _retention_days() -> int: + return int(core().settings.get(db(), "trash_retention_days") or 30) + + +def _purge_expired(conn: sqlite3.Connection) -> int: + """Entfernt abgelaufene Eintraege beim Seitenaufruf. + + Bewusst ohne eigenen Scheduler: ein zusaetzlicher Timer waere ein weiterer + Prozess, der schiefgehen kann. Die Frist ist eine Obergrenze, kein + sekundengenauer Vorgang. + """ + horizon = (datetime.now(timezone.utc) - timedelta(days=_retention_days())).isoformat( + timespec="seconds" + ) + removed = 0 + for provider in providers().values(): + for entry in provider.list_fn(conn): + if str(entry.get("deleted_at") or "") and str(entry["deleted_at"]) < horizon: + provider.purge_fn(conn, str(entry["id"])) + removed += 1 + return removed + + +@bp.get("/") +@permission_required("trash.view") +def overview(): + conn = db() + removed = 0 + if current_user.has("trash.delete"): + with transaction(): + removed = _purge_expired(conn) + if removed: + audit.log( + conn, + actor="system", + action="trash.auto_purged", + detail=f"{removed} abgelaufene Eintraege", + severity="notice", + ) + if removed: + flash(f"{removed} abgelaufene Eintraege wurden endgueltig entfernt.", "info") + + sections = [] + for provider in providers().values(): + entries = provider.list_fn(conn) + sections.append( + { + "key": provider.key, + "label": provider.label, + "icon": provider.icon, + "hint": provider.hint, + "entries": entries, + } + ) + return render_template( + "tesm_core/trash.html", + sections=sections, + retention_days=_retention_days(), + total=sum(len(section["entries"]) for section in sections), + ) + + +def _provider_or_404(key: str) -> TrashProvider: + provider = providers().get(key) + if provider is None: + abort(404) + return provider + + +@bp.post("///wiederherstellen") +@permission_required("trash.edit") +def restore(kind: str, ident: str): + extension = core() + if extension.license is not None and not extension.license.feature_allowed("trash_restore"): + flash( + "Das Wiederherstellen aus dem Papierkorb erfordert eine gueltige, " + "aktivierte Lizenz. Ansehen bleibt jederzeit moeglich.", + "danger", + ) + return redirect(url_for("trash.overview")) + + provider = _provider_or_404(kind) + try: + with transaction() as conn: + provider.restore_fn(conn, ident) + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="trash.restored", + target=f"{kind}:{ident}", + ip=client_ip(), + severity="notice", + ) + flash("Eintrag wiederhergestellt.", "success") + except (rbac.ValidationError, rbac.PermissionDenied) as exc: + flash(str(exc), "danger") + return redirect(url_for("trash.overview")) + + +@bp.post("///endgueltig-loeschen") +@permission_required("trash.delete") +def purge(kind: str, ident: str): + extension = core() + if extension.license is not None and not extension.license.feature_allowed("trash_restore"): + flash( + "Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.", + "danger", + ) + return redirect(url_for("trash.overview")) + + provider = _provider_or_404(kind) + try: + with transaction() as conn: + provider.purge_fn(conn, ident) + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="trash.purged", + target=f"{kind}:{ident}", + ip=client_ip(), + severity="warning", + ) + flash("Eintrag endgueltig entfernt.", "success") + except (rbac.ValidationError, rbac.PermissionDenied) as exc: + flash(str(exc), "danger") + return redirect(url_for("trash.overview")) + + +@bp.post("/leeren") +@permission_required("trash.delete") +def purge_all(): + extension = core() + if extension.license is not None and not extension.license.feature_allowed("trash_restore"): + flash("Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.", "danger") + return redirect(url_for("trash.overview")) + + kind = request.form.get("kind") or "" + removed = 0 + with transaction() as conn: + targets = [providers()[kind]] if kind in providers() else list(providers().values()) + for provider in targets: + for entry in provider.list_fn(conn): + provider.purge_fn(conn, str(entry["id"])) + removed += 1 + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="trash.emptied", + target=kind or "alle", + detail=f"{removed} Eintraege", + ip=client_ip(), + severity="warning", + ) + flash(f"{removed} Eintraege endgueltig entfernt.", "success") + return redirect(url_for("trash.overview")) diff --git a/packages/tesm-licensing/pyproject.toml b/packages/tesm-licensing/pyproject.toml new file mode 100644 index 0000000..de3276f --- /dev/null +++ b/packages/tesm-licensing/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "tesm-licensing" +version = "2.0.0" +description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" +requires-python = ">=3.11" +dependencies = ["cryptography>=42"] + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/packages/tesm-licensing/src/tesm_licensing/__init__.py b/packages/tesm-licensing/src/tesm_licensing/__init__.py new file mode 100644 index 0000000..20c2cc6 --- /dev/null +++ b/packages/tesm-licensing/src/tesm_licensing/__init__.py @@ -0,0 +1,125 @@ +"""TESM-Lizenzprotokoll -- gemeinsames Paket von TESM und TESM-Lizenzserver. + +Im Vorgaengerprojekt lag dieses Modul als handkopierte, byte-identisch zu +haltende ``licensing.py`` in beiden Repos. Jede unabhaengige Aenderung an nur +einer Kopie brach die gegenseitige Signaturpruefung, ohne dass das lokal +auffiel. Hier ist es ein installierbares Paket mit eigener Version -- die +Invariante ist damit strukturell erfuellt statt durch Disziplin. +""" + +from __future__ import annotations + +from .canonical import ( + SIG_CONTEXT_LICENSE, + SIG_CONTEXT_REQUEST, + SIG_CONTEXT_RESPONSE, + canonical_bytes, + decode_code, + encode_code, + generate_keypair, + public_from_private, + sign, + verify, +) +from .errors import ( + InvalidLicenseFile, + LicensingError, + ProtocolViolation, + ReplayDetected, + SignatureInvalid, + UntrustedIssuer, +) +from .fingerprint import machine_identity, system_fingerprint +from .license import ( + ALL_MODULES, + EXPIRY_WARNING_DAYS, + FORMAT_VERSION, + GRACE_PERIOD_DAYS, + HEARTBEAT_WARNING_DAYS, + LICENSE_ROLES, + LICENSE_TYPE_LABELS, + LICENSE_TYPES, + LIFETIME_EXPIRES_AT, + MODULE_LABELS, + ROLE_LABELS, + TRIAL_DEFAULT_DAYS, + LicenseStatus, + issue_license, + iso, + key_id, + license_status, + parse_iso, + resign_license, + split_bundle, + utcnow, + verify_license, +) +from .protocol import ( + ACTIONS, + CLOCK_SKEW_SECONDS, + HEARTBEAT_INTERVAL_SECONDS, + MAX_REPORT_ROWS, + REQUEST_MAX_AGE_SECONDS, + RESPONSE_STATES, + build_client_request, + build_master_response, + new_nonce, + verify_client_request, + verify_master_response, +) + +__version__ = "2.0.0" + +__all__ = [ + "ACTIONS", + "ALL_MODULES", + "CLOCK_SKEW_SECONDS", + "EXPIRY_WARNING_DAYS", + "FORMAT_VERSION", + "GRACE_PERIOD_DAYS", + "HEARTBEAT_INTERVAL_SECONDS", + "HEARTBEAT_WARNING_DAYS", + "InvalidLicenseFile", + "LICENSE_ROLES", + "LICENSE_TYPES", + "LICENSE_TYPE_LABELS", + "LIFETIME_EXPIRES_AT", + "LicenseStatus", + "LicensingError", + "MAX_REPORT_ROWS", + "MODULE_LABELS", + "ProtocolViolation", + "REQUEST_MAX_AGE_SECONDS", + "RESPONSE_STATES", + "ROLE_LABELS", + "ReplayDetected", + "SIG_CONTEXT_LICENSE", + "SIG_CONTEXT_REQUEST", + "SIG_CONTEXT_RESPONSE", + "SignatureInvalid", + "TRIAL_DEFAULT_DAYS", + "UntrustedIssuer", + "build_client_request", + "build_master_response", + "canonical_bytes", + "decode_code", + "encode_code", + "generate_keypair", + "issue_license", + "iso", + "key_id", + "license_status", + "machine_identity", + "new_nonce", + "parse_iso", + "public_from_private", + "resign_license", + "sign", + "split_bundle", + "system_fingerprint", + "utcnow", + "verify", + "verify_client_request", + "verify_license", + "verify_master_response", +] diff --git a/packages/tesm-licensing/src/tesm_licensing/canonical.py b/packages/tesm-licensing/src/tesm_licensing/canonical.py new file mode 100644 index 0000000..19ad3aa --- /dev/null +++ b/packages/tesm-licensing/src/tesm_licensing/canonical.py @@ -0,0 +1,115 @@ +"""Kanonisierung und domaenengetrennte Ed25519-Signaturen. + +Warum eine eigene Datei: Signieren und Verifizieren muessen auf Client- und +Master-Seite bitidentisch arbeiten. Im Vorgaengerprojekt wurde das durch eine +handkopierte ``licensing.py`` in beiden Repos geloest -- hier ist es *ein* +installierbares Paket, das beide Anwendungen als Abhaengigkeit einbinden. + +Sicherheitsrelevante Abweichung vom Vorgaenger: jede Signatur ist +**domaenengetrennt**. Vor die kanonischen Bytes wird ein Kontext-Praefix +gestellt (``SIG_CONTEXT_*``). Eine Signatur ueber eine Lizenz-Payload kann +dadurch nie als Signatur ueber eine Aktivierungsantwort durchgehen, selbst wenn +ein Angreifer Feldnamen so waehlen koennte, dass zwei Strukturen identische +kanonische Bytes ergeben. +""" + +from __future__ import annotations + +import base64 +import binascii +import json +from typing import Any, Mapping + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric import ed25519 + +# Domaenen-Praefixe. Aenderungen hieran sind ein Protokollbruch. +SIG_CONTEXT_LICENSE = b"tesm/license-file/v2\x00" +SIG_CONTEXT_REQUEST = b"tesm/client-request/v2\x00" +SIG_CONTEXT_RESPONSE = b"tesm/master-response/v2\x00" + + +def canonical_bytes(payload: Mapping[str, Any]) -> bytes: + """Deterministische JSON-Serialisierung: sortierte Schluessel, keine Whitespaces. + + ``ensure_ascii=True`` (Default) ist hier gewollt: die Bytes bleiben damit + unabhaengig von der Unicode-Normalform der Eingabe reproduzierbar. + """ + return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _b64d(value: str, *, field: str) -> bytes: + try: + return base64.b64decode(value, validate=True) + except (binascii.Error, ValueError, TypeError) as exc: # pragma: no cover - defensiv + raise ValueError(f"{field} ist kein gueltiges Base64") from exc + + +def generate_keypair() -> tuple[str, str]: + """Erzeugt ein Ed25519-Schluesselpaar. Rueckgabe: ``(private_b64, public_b64)``.""" + private = ed25519.Ed25519PrivateKey.generate() + return ( + base64.b64encode(private.private_bytes_raw()).decode("ascii"), + base64.b64encode(private.public_key().public_bytes_raw()).decode("ascii"), + ) + + +def public_from_private(private_key_b64: str) -> str: + """Leitet den oeffentlichen Teil aus einem privaten Schluessel ab.""" + key = ed25519.Ed25519PrivateKey.from_private_bytes( + _b64d(private_key_b64, field="private_key") + ) + return base64.b64encode(key.public_key().public_bytes_raw()).decode("ascii") + + +def sign(payload: Mapping[str, Any], private_key_b64: str, context: bytes) -> str: + """Signiert ``payload`` domaenengetrennt. Rueckgabe: Base64-Signatur.""" + key = ed25519.Ed25519PrivateKey.from_private_bytes( + _b64d(private_key_b64, field="private_key") + ) + return base64.b64encode(key.sign(context + canonical_bytes(payload))).decode("ascii") + + +def verify( + payload: Mapping[str, Any], + signature_b64: str, + public_key_b64: str, + context: bytes, +) -> bool: + """Verifiziert eine Signatur. + + Gibt ``False`` zurueck statt zu werfen -- der Aufrufer kann "ungueltig" so + nie mit "abgestuerzt" verwechseln. Das war im Vorgaengerprojekt bereits eine + bewusste Entscheidung und bleibt es. + """ + if not signature_b64 or not public_key_b64: + return False + try: + key = ed25519.Ed25519PublicKey.from_public_bytes( + _b64d(public_key_b64, field="public_key") + ) + key.verify(_b64d(signature_b64, field="signature"), context + canonical_bytes(payload)) + return True + except (InvalidSignature, ValueError, TypeError, KeyError): + return False + + +def encode_code(data: Mapping[str, Any]) -> str: + """Kodiert ein Protokollobjekt als kopierbaren Offline-Code (Base64url, ohne Padding).""" + raw = base64.urlsafe_b64encode(canonical_bytes(data)).decode("ascii") + return raw.rstrip("=") + + +def decode_code(code: str) -> dict[str, Any]: + """Dekodiert einen Offline-Code. Toleriert fehlendes Padding und Whitespace/Umbrueche.""" + cleaned = "".join(code.split()) + if not cleaned: + raise ValueError("Leerer Code") + cleaned += "=" * (-len(cleaned) % 4) + try: + data = json.loads(base64.urlsafe_b64decode(cleaned.encode("ascii")).decode("utf-8")) + except (binascii.Error, ValueError, UnicodeDecodeError) as exc: + raise ValueError("Code ist unlesbar oder beschaedigt") from exc + if not isinstance(data, dict): + raise ValueError("Code enthaelt kein Objekt") + return data diff --git a/packages/tesm-licensing/src/tesm_licensing/errors.py b/packages/tesm-licensing/src/tesm_licensing/errors.py new file mode 100644 index 0000000..b9177c0 --- /dev/null +++ b/packages/tesm-licensing/src/tesm_licensing/errors.py @@ -0,0 +1,32 @@ +"""Fehlerklassen des Lizenzprotokolls. + +Alle Fehler sind bewusst so gebaut, dass sie *keine* Geheimnisse (Schluessel, +Signaturen, Payload-Inhalte) in ihrer Textform tragen -- sie landen in Logs und +teilweise in HTTP-Antworten. +""" + +from __future__ import annotations + + +class LicensingError(Exception): + """Basisklasse aller Protokollfehler.""" + + +class InvalidLicenseFile(LicensingError): + """Lizenzdatei ist strukturell kaputt, unvollstaendig oder falsch versioniert.""" + + +class UntrustedIssuer(LicensingError): + """Signatur ist in sich gueltig, der Aussteller aber nicht vertrauenswuerdig (Pinning).""" + + +class SignatureInvalid(LicensingError): + """Signatur passt nicht zur Payload.""" + + +class ReplayDetected(LicensingError): + """Nonce wurde schon gesehen oder Zeitstempel liegt ausserhalb des Frischefensters.""" + + +class ProtocolViolation(LicensingError): + """Anfrage/Antwort verletzt das erwartete Format oder die erwartete Aktion.""" diff --git a/packages/tesm-licensing/src/tesm_licensing/fingerprint.py b/packages/tesm-licensing/src/tesm_licensing/fingerprint.py new file mode 100644 index 0000000..aa46b2d --- /dev/null +++ b/packages/tesm-licensing/src/tesm_licensing/fingerprint.py @@ -0,0 +1,54 @@ +"""Maschinen-Fingerprint der lizenzierten Instanz. + +Jede Bindungs-/Autorisierungsentscheidung des Protokolls laeuft ueber diesen +Wert -- nie ueber ``hostname`` oder ``license_id`` allein. + +Aenderung gegenueber dem Vorgaenger (bewusst): der Hostname ist **nicht** mehr +Teil des Hashes. Im Vorgaengerprojekt war ``sha256(machine_id + ":" + hostname)`` +im Einsatz, wodurch eine reine Umbenennung des Hosts den Fingerprint aenderte +und die Lizenz "already_bound_elsewhere" lief -- ein Support-Fall ohne +Sicherheitsgewinn. Jetzt zaehlt ausschliesslich die stabile Maschinenidentitaet +(``/etc/machine-id``, ergaenzt um die DMI-Produkt-UUID, falls lesbar). +""" + +from __future__ import annotations + +import hashlib +import socket +from pathlib import Path + +_MACHINE_ID_PATHS = (Path("/etc/machine-id"), Path("/var/lib/dbus/machine-id")) +_DMI_UUID_PATH = Path("/sys/class/dmi/id/product_uuid") + +FINGERPRINT_CONTEXT = b"tesm/machine-fingerprint/v2\x00" + + +def _read_first(paths: tuple[Path, ...]) -> str: + for path in paths: + try: + value = path.read_text(encoding="utf-8", errors="strict").strip() + except OSError: + continue + if value: + return value + return "" + + +def machine_identity() -> str: + """Rohe, ungehashte Maschinenidentitaet -- nur fuer Diagnose/Tests.""" + machine_id = _read_first(_MACHINE_ID_PATHS) + dmi_uuid = "" + try: + dmi_uuid = _DMI_UUID_PATH.read_text(encoding="utf-8").strip() + except OSError: + pass + if machine_id or dmi_uuid: + return f"{machine_id}|{dmi_uuid}" + # Letzter Ausweg (Windows/Container ohne machine-id): Hostname. Produktivziel + # ist Linux, dort greift dieser Zweig nicht. + return f"hostname:{socket.gethostname()}" + + +def system_fingerprint() -> str: + """SHA-256-Hex-Digest der Maschinenidentitaet, domaenengetrennt gehasht.""" + return hashlib.sha256(FINGERPRINT_CONTEXT + machine_identity().encode("utf-8")).hexdigest() diff --git a/packages/tesm-licensing/src/tesm_licensing/license.py b/packages/tesm-licensing/src/tesm_licensing/license.py new file mode 100644 index 0000000..feec766 --- /dev/null +++ b/packages/tesm-licensing/src/tesm_licensing/license.py @@ -0,0 +1,417 @@ +"""Lizenzdatei-Format, Ausstellung, Verifikation und Statusberechnung. + +Format-Version 2. Unterschiede zur Version 1 des Vorgaengerprojekts, jeweils mit +Begruendung: + +1. **Der private Client-Schluessel steckt nicht mehr in der Lizenz-Payload.** + V1 lieferte ``license_privkey`` als Feld neben der signierten Payload in + derselben Datei aus. Wer diese Datei zu Supportzwecken weitergab, gab damit + das Aktivierungs-Credential mit. V2 liefert ein Bundle aus + ``{"license": , "client_key": }``, + das der Client beim Import aufteilt: Lizenzdokument nach ``license.json``, + Schluessel nach ``license_key.json`` mit Modus 0600. +2. **``lifetime`` ist ein signiertes Feld**, nicht mehr nur aus dem + Sentinel-Datum abgeleitet. Der Sentinel bleibt zusaetzlich erhalten, damit + jede Datumsvergleichslogik ohne Sonderfall auskommt. +3. **``not_before``** existiert und wird geprueft -- eine Lizenz, die versehentlich + mit Zukunftsdatum ausgestellt wurde, gilt nicht rueckwirkend. +4. **``master_key_id``** (Fingerprint des Master-Public-Keys) erlaubt Pinning und + Schluesselrotation auf Client-Seite. +5. **``seq``** (monoton steigende Revision) erlaubt es dem Client, ein per + Heartbeat nachgeliefertes ``license_update`` gegen Downgrade-Replay zu + schuetzen. +6. **Verifikation gegen einen gepinnten Aussteller-Schluessel** ist vorgesehen + (``trusted_master_keys``). V1 pruefte die Signatur ausschliesslich gegen den in + derselben Datei eingebetteten ``master_pubkey`` -- das beweist nur innere + Selbstkonsistenz, nicht die Herkunft. +""" + +from __future__ import annotations + +import hashlib +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import Any, Iterable, Mapping, Sequence + +from .canonical import SIG_CONTEXT_LICENSE, generate_keypair, sign, verify +from .errors import InvalidLicenseFile, SignatureInvalid, UntrustedIssuer + +FORMAT_VERSION = 2 + +LICENSE_TYPES: tuple[str, ...] = ("trial", "standard", "custom", "enterprise") +LICENSE_ROLES: tuple[str, ...] = ("master", "license_server", "customer") +ALL_MODULES: tuple[str, ...] = ("dhcp", "fileshare", "maintenance") + +LICENSE_TYPE_LABELS: dict[str, str] = { + "trial": "Testlizenz", + "standard": "Standard", + "custom": "Individuell", + "enterprise": "Enterprise", +} + +MODULE_LABELS: dict[str, str] = { + "dhcp": "DHCP-Server (Kea)", + "fileshare": "Dateifreigaben", + "maintenance": "Wartung (SSH-Bulk)", +} + +ROLE_LABELS: dict[str, str] = { + "master": "Master-Lizenzserver", + "license_server": "Lizenzserver", + "customer": "Kundeninstanz", +} + +#: Kulanzzeitraum nach Ablauf, in dem Module weiterlaufen. +GRACE_PERIOD_DAYS = 30 +#: Ab wann "laeuft bald ab" gemeldet wird. +EXPIRY_WARNING_DAYS = 30 +#: Ab wann ein ausgebliebener Heartbeat gemeldet wird (schaltet nichts ab). +HEARTBEAT_WARNING_DAYS = 14 +#: Standardlaufzeit einer Trial-Ausstellung. +TRIAL_DEFAULT_DAYS = 30 +#: Sentinel-Ablaufdatum fuer Lifetime-Lizenzen. +LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z" + +_PAYLOAD_FIELDS = ( + "fmt", + "license_id", + "seq", + "customer", + "type", + "role", + "modules", + "lifetime", + "issued_at", + "not_before", + "expires_at", + "license_pubkey", + "master_pubkey", + "master_key_id", + "master_endpoint", + "vendor", +) + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def iso(value: datetime) -> str: + """ISO-8601 in UTC mit ``Z``-Suffix, sekundengenau (keine Mikrosekunden).""" + return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def parse_iso(value: str) -> datetime: + """Parst ISO-8601. Ohne Zeitzone wird UTC angenommen.""" + text = (value or "").strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + parsed = datetime.fromisoformat(text) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def key_id(public_key_b64: str) -> str: + """Kurzer, stabiler Fingerprint eines Public Keys -- fuer Pinning und Anzeige.""" + return hashlib.sha256((public_key_b64 or "").encode("ascii")).hexdigest()[:16] + + +@dataclass(frozen=True, slots=True) +class LicenseStatus: + """Auswertung einer Lizenzdatei zu einem Zeitpunkt. Rein rechnerisch, ohne I/O.""" + + license_id: str + seq: int + type: str + role: str + customer: Mapping[str, Any] + vendor: Mapping[str, Any] + master_endpoint: str + master_key_id: str + modules: frozenset[str] + lifetime: bool + issued_at: datetime | None + expires_at: datetime | None + expired: bool + not_yet_valid: bool + days_left: int | None + days_since_expiry: int | None + expiring_soon: bool + grace_active: bool + modules_active: bool + warnings: tuple[str, ...] = field(default=()) + + def has_module(self, name: str) -> bool: + return self.modules_active and name in self.modules + + +def _validate_issue_args( + license_type: str, role: str, modules: Sequence[str], lifetime: bool +) -> None: + if license_type not in LICENSE_TYPES: + raise ValueError(f"Unbekannter Lizenztyp: {license_type!r}") + if role not in LICENSE_ROLES: + raise ValueError(f"Unbekannte Rolle: {role!r}") + unknown = sorted(set(modules) - set(ALL_MODULES)) + if unknown: + raise ValueError(f"Unbekannte Module: {', '.join(unknown)}") + if lifetime and license_type == "trial": + # Dritte und letzte Absicherung derselben Regel (UI + Service pruefen + # vorher schon): eine unbefristete Trial-Lizenz waere eine permanente + # Lizenz ohne Module -- ein sinnloser Zustand. + raise ValueError("Trial-Lizenzen koennen nicht unbefristet sein") + if role != "customer" and modules: + raise ValueError("Lizenzserver-Lizenzen tragen keine TESM-Module") + + +def issue_license( + *, + customer: Mapping[str, Any], + license_type: str, + modules: Iterable[str], + valid_days: int, + master_private_key_b64: str, + master_public_key_b64: str, + master_endpoint: str, + vendor: Mapping[str, Any], + license_id: str | None = None, + role: str = "customer", + lifetime: bool = False, + seq: int = 1, + now: datetime | None = None, +) -> tuple[dict[str, Any], str]: + """Stellt eine Lizenz aus. + + Rueckgabe: ``(bundle, license_public_key)``. ``bundle`` ist die Datei, die + an den Kunden geht; ``license_public_key`` wandert in die Master-Datenbank, + damit der Master spaeter Client-Anfragen dieser Lizenz verifizieren kann. + """ + module_list = sorted({str(m) for m in modules}) + _validate_issue_args(license_type, role, module_list, lifetime) + if not lifetime and int(valid_days) <= 0: + raise ValueError("valid_days muss positiv sein") + + moment = now or utcnow() + client_private, client_public = generate_keypair() + payload: dict[str, Any] = { + "fmt": FORMAT_VERSION, + "license_id": license_id or str(uuid.uuid4()), + "seq": int(seq), + "customer": dict(customer), + "type": license_type, + "role": role, + "modules": module_list, + "lifetime": bool(lifetime), + "issued_at": iso(moment), + "not_before": iso(moment), + "expires_at": LIFETIME_EXPIRES_AT + if lifetime + else iso(moment + timedelta(days=int(valid_days))), + "license_pubkey": client_public, + "master_pubkey": master_public_key_b64, + "master_key_id": key_id(master_public_key_b64), + "master_endpoint": (master_endpoint or "").rstrip("/"), + "vendor": dict(vendor), + } + signature = sign(payload, master_private_key_b64, SIG_CONTEXT_LICENSE) + document = {**payload, "signature": signature} + bundle = { + "kind": "tesm-license-bundle", + "fmt": FORMAT_VERSION, + "license": document, + "client_key": client_private, + } + return bundle, client_public + + +def resign_license( + document: Mapping[str, Any], + *, + master_private_key_b64: str, + changes: Mapping[str, Any], + now: datetime | None = None, +) -> dict[str, Any]: + """Erzeugt eine neu signierte Revision eines bestehenden Lizenzdokuments. + + Wird fuer ``license_update`` im Heartbeat gebraucht (z. B. nachtraeglich + erweiterte Module oder verlaengerte Laufzeit). ``seq`` steigt dabei zwingend, + damit der Client eine aeltere Revision nicht als Update akzeptiert. + """ + payload = {k: v for k, v in document.items() if k != "signature"} + payload.update(changes) + payload["seq"] = int(document.get("seq") or 1) + 1 + payload["issued_at"] = iso(now or utcnow()) + _validate_issue_args( + str(payload.get("type")), + str(payload.get("role")), + [str(m) for m in payload.get("modules") or ()], + bool(payload.get("lifetime")), + ) + payload["signature"] = sign(payload, master_private_key_b64, SIG_CONTEXT_LICENSE) + return payload + + +def split_bundle(bundle: Mapping[str, Any]) -> tuple[dict[str, Any], str]: + """Zerlegt ein Auslieferungs-Bundle in ``(lizenzdokument, client_private_key)``. + + Akzeptiert zusaetzlich ein bereits aufgeteiltes Dokument (dann ist der + zweite Rueckgabewert leer) sowie -- fuer Migrationen -- eine V1-Datei mit + eingebettetem ``license_privkey``. + """ + if not isinstance(bundle, Mapping): + raise InvalidLicenseFile("Lizenzdatei enthaelt kein Objekt") + + inner = bundle.get("license") + if isinstance(inner, Mapping): + return dict(inner), str(bundle.get("client_key") or "") + + if "license_privkey" in bundle: # V1-Kompatibilitaet + document = {k: v for k, v in bundle.items() if k != "license_privkey"} + return document, str(bundle.get("license_privkey") or "") + + if "signature" in bundle and "license_id" in bundle: + return dict(bundle), "" + + raise InvalidLicenseFile("Unbekanntes Lizenzdatei-Format") + + +def verify_license( + document: Mapping[str, Any], + *, + trusted_master_keys: Sequence[str] | None = None, +) -> None: + """Verifiziert ein Lizenzdokument. Wirft bei Problemen, gibt sonst ``None``. + + ``trusted_master_keys`` sind Base64-Public-Keys, denen der Client vertraut + (im Build mitgeliefert oder beim ersten Import per Trust-on-First-Use + gepinnt). Ist die Liste leer/``None``, wird nur die innere Konsistenz der + Datei geprueft -- dann traegt der Status eine entsprechende Warnung. + """ + if not isinstance(document, Mapping): + raise InvalidLicenseFile("Lizenzdokument ist kein Objekt") + + missing = [name for name in _PAYLOAD_FIELDS if name not in document] + if missing: + raise InvalidLicenseFile(f"Lizenzdokument unvollstaendig: {', '.join(missing)}") + if int(document.get("fmt") or 0) != FORMAT_VERSION: + raise InvalidLicenseFile( + f"Nicht unterstuetzte Formatversion: {document.get('fmt')!r}" + f" (erwartet {FORMAT_VERSION})" + ) + if document.get("type") not in LICENSE_TYPES: + raise InvalidLicenseFile("Unbekannter Lizenztyp") + if document.get("role") not in LICENSE_ROLES: + raise InvalidLicenseFile("Unbekannte Rolle") + + master_pubkey = str(document.get("master_pubkey") or "") + if key_id(master_pubkey) != str(document.get("master_key_id") or ""): + raise InvalidLicenseFile("master_key_id passt nicht zum eingebetteten Schluessel") + + payload = {k: v for k, v in document.items() if k != "signature"} + if not verify( + payload, str(document.get("signature") or ""), master_pubkey, SIG_CONTEXT_LICENSE + ): + raise SignatureInvalid("Signatur der Lizenzdatei ist ungueltig") + + if trusted_master_keys and master_pubkey not in set(trusted_master_keys): + raise UntrustedIssuer( + "Die Lizenz wurde nicht von einem vertrauenswuerdigen Lizenzserver ausgestellt" + ) + + +def license_status( + document: Mapping[str, Any], + *, + now: datetime | None = None, + issuer_pinned: bool = False, +) -> LicenseStatus: + """Berechnet den Status. Verifiziert nicht selbst -- das macht ``verify_license``. + + Bewusst getrennt gehalten, damit hier niemand versehentlich den Status einer + unverifizierten Datei berechnet und weiterverwendet. + """ + moment = now or utcnow() + lifetime = bool(document.get("lifetime")) + warnings: list[str] = [] + if not issuer_pinned: + warnings.append("Aussteller nicht gepinnt -- nur innere Konsistenz geprueft") + + issued_at: datetime | None = None + expires_at: datetime | None = None + not_before: datetime | None = None + for name in ("issued_at", "expires_at", "not_before"): + raw = document.get(name) + if not raw: + continue + try: + parsed = parse_iso(str(raw)) + except ValueError: + warnings.append(f"Feld {name} ist kein gueltiges Datum") + continue + if name == "issued_at": + issued_at = parsed + elif name == "expires_at": + expires_at = parsed + else: + not_before = parsed + + not_yet_valid = bool(not_before and moment < not_before) + + days_left: int | None + days_since_expiry: int | None + if lifetime: + days_left = None + days_since_expiry = None + expired = False + elif expires_at is None: + # Fail-safe: unlesbares Ablaufdatum gilt als abgelaufen, nicht als gueltig. + days_left = None + days_since_expiry = None + expired = True + warnings.append("Ablaufdatum fehlt oder ist unlesbar") + else: + delta = expires_at - moment + expired = delta.total_seconds() <= 0 + if expired: + days_left = 0 + days_since_expiry = int((moment - expires_at).total_seconds() // 86400) + else: + days_left = -(-int(delta.total_seconds()) // 86400) + days_since_expiry = None + + grace_active = bool( + expired and days_since_expiry is not None and days_since_expiry < GRACE_PERIOD_DAYS + ) + modules_active = not not_yet_valid and (lifetime or not expired or grace_active) + expiring_soon = bool( + not lifetime + and not expired + and days_left is not None + and days_left <= EXPIRY_WARNING_DAYS + ) + + return LicenseStatus( + license_id=str(document.get("license_id") or ""), + seq=int(document.get("seq") or 1), + type=str(document.get("type") or ""), + # Fehlt die Rolle, gilt "customer" -- niemals ein privilegierterer Wert. + role=str(document.get("role") or "customer"), + customer=dict(document.get("customer") or {}), + vendor=dict(document.get("vendor") or {}), + master_endpoint=str(document.get("master_endpoint") or ""), + master_key_id=str(document.get("master_key_id") or ""), + modules=frozenset(str(m) for m in (document.get("modules") or ())), + lifetime=lifetime, + issued_at=issued_at, + expires_at=expires_at, + expired=expired, + not_yet_valid=not_yet_valid, + days_left=days_left, + days_since_expiry=days_since_expiry, + expiring_soon=expiring_soon, + grace_active=grace_active, + modules_active=modules_active, + warnings=tuple(warnings), + ) diff --git a/packages/tesm-licensing/src/tesm_licensing/protocol.py b/packages/tesm-licensing/src/tesm_licensing/protocol.py new file mode 100644 index 0000000..f053e45 --- /dev/null +++ b/packages/tesm-licensing/src/tesm_licensing/protocol.py @@ -0,0 +1,240 @@ +"""Aktivierungs-, Deaktivierungs- und Heartbeat-Protokoll. + +Beide Transportwege -- HTTPS-JSON und kopierbarer Offline-Code -- tragen exakt +dieselben signierten Objekte. Es gibt deshalb nur *einen* Satz +Verifikationsfunktionen; der Transport ist reine Verpackung. + +Haertungen gegenueber dem Vorgaengerprojekt: + +* **Domaenengetrennte Signaturen** (siehe ``canonical``): eine Lizenz-Signatur + kann nie als Antwort-Signatur durchgehen und umgekehrt. +* **Frischefenster**: Anfragen und Antworten tragen einen Zeitstempel, der + gegen ein Fenster geprueft wird (``REQUEST_MAX_AGE_SECONDS``, plus + ``CLOCK_SKEW_SECONDS`` Toleranz in die Zukunft). V1 pruefte den Zeitstempel + gar nicht -- eine abgefangene Aktivierungsanfrage war unbegrenzt + wiederverwendbar. +* **Nonce-Replay-Schutz**: der Master kann ueber ``seen_nonce`` eine + Persistenzschicht einhaengen; ein zweites Mal gesehene Nonce wird abgewiesen. +* **Antwort-an-Anfrage-Bindung**: die Master-Antwort echot die Nonce der Anfrage + in ihrer signierten Payload. Eine alte "ok"-Antwort kann dadurch nicht gegen + eine neue Anfrage eingespielt werden (relevant vor allem im Offline-Code-Weg, + wo Codes per Copy-Paste durch die Gegend wandern). +* **Downgrade-Schutz fuer ``license_update``**: das nachgelieferte Dokument muss + eine hoehere ``seq`` tragen als das aktuell installierte. +""" + +from __future__ import annotations + +import re +import secrets +from datetime import datetime, timedelta +from typing import Any, Callable, Mapping + +from .canonical import SIG_CONTEXT_REQUEST, SIG_CONTEXT_RESPONSE, sign, verify +from .errors import ProtocolViolation, ReplayDetected, SignatureInvalid +from .license import FORMAT_VERSION, iso, parse_iso, utcnow +from .fingerprint import system_fingerprint + +#: Erlaubte Aktionen des Protokolls. +ACTIONS: tuple[str, ...] = ("activate", "deactivate", "heartbeat") + +#: Antwortstatus, die der Master senden kann. +RESPONSE_STATES: tuple[str, ...] = ("ok", "revoked", "expired", "rejected") + +#: Wie alt eine Anfrage/Antwort maximal sein darf (Offline-Codes brauchen Luft). +REQUEST_MAX_AGE_SECONDS = 7 * 24 * 3600 +#: Toleranz fuer Uhren, die leicht vorlaufen. +CLOCK_SKEW_SECONDS = 300 +#: Regulaerer Heartbeat-Abstand des Clients. +HEARTBEAT_INTERVAL_SECONDS = 6 * 3600 +#: Obergrenze fuer Zeilen in einem Sub-Lizenzserver-Bericht. +MAX_REPORT_ROWS = 5000 + +_NONCE_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$") +_FINGERPRINT_RE = re.compile(r"^[0-9a-f]{64}$") + + +def new_nonce() -> str: + return secrets.token_urlsafe(18) + + +def build_client_request( + action: str, + document: Mapping[str, Any], + client_private_key: str, + *, + hostname: str = "", + nonce: str | None = None, + report: Mapping[str, Any] | None = None, + now: datetime | None = None, +) -> dict[str, Any]: + """Baut eine signierte Client-Anfrage. + + ``report`` wird ausschliesslich von Heartbeats einer Instanz mit + ``role="license_server"`` gesetzt (Sub-Lizenzserver-Reporting). Alle anderen + Aufrufer lassen das Feld weg. + """ + if action not in ACTIONS: + raise ProtocolViolation(f"Unbekannte Aktion: {action!r}") + if not client_private_key: + raise ProtocolViolation("Kein Client-Schluessel vorhanden -- Lizenz unvollstaendig importiert") + + payload: dict[str, Any] = { + "fmt": FORMAT_VERSION, + "license_id": str(document.get("license_id") or ""), + "seq": int(document.get("seq") or 1), + "fingerprint": system_fingerprint(), + "hostname": hostname, + "action": action, + "nonce": nonce or new_nonce(), + "timestamp": iso(now or utcnow()), + } + if report is not None: + payload["report"] = dict(report) + payload["signature"] = sign(payload, client_private_key, SIG_CONTEXT_REQUEST) + return payload + + +def _check_freshness(timestamp: str, now: datetime, what: str) -> None: + try: + stamp = parse_iso(timestamp) + except (ValueError, TypeError) as exc: + raise ProtocolViolation(f"{what}: Zeitstempel unlesbar") from exc + if stamp > now + timedelta(seconds=CLOCK_SKEW_SECONDS): + raise ReplayDetected(f"{what}: Zeitstempel liegt in der Zukunft") + if stamp < now - timedelta(seconds=REQUEST_MAX_AGE_SECONDS): + raise ReplayDetected(f"{what}: Zeitstempel ist zu alt") + + +def verify_client_request( + request: Mapping[str, Any], + license_public_key: str, + *, + expected_action: str | None = None, + now: datetime | None = None, + seen_nonce: Callable[[str], bool] | None = None, +) -> None: + """Verifiziert eine Client-Anfrage serverseitig. Wirft bei Problemen. + + ``seen_nonce`` ist ein Callback, der ``True`` zurueckgibt, wenn die Nonce + bereits verbraucht ist. Der Aufrufer entscheidet ueber die Persistenz + (in dieser Codebasis: Tabelle ``license_nonces``). + """ + if not isinstance(request, Mapping): + raise ProtocolViolation("Anfrage ist kein Objekt") + if int(request.get("fmt") or 0) != FORMAT_VERSION: + raise ProtocolViolation("Nicht unterstuetzte Protokollversion") + + action = str(request.get("action") or "") + if action not in ACTIONS: + raise ProtocolViolation("Unbekannte Aktion") + if expected_action and action != expected_action: + raise ProtocolViolation("Aktion passt nicht zum Endpunkt") + + nonce = str(request.get("nonce") or "") + if not _NONCE_RE.match(nonce): + raise ProtocolViolation("Nonce hat ein unerwartetes Format") + fingerprint = str(request.get("fingerprint") or "") + if not _FINGERPRINT_RE.match(fingerprint): + raise ProtocolViolation("Fingerprint hat ein unerwartetes Format") + + payload = {k: v for k, v in request.items() if k != "signature"} + if not verify( + payload, str(request.get("signature") or ""), license_public_key, SIG_CONTEXT_REQUEST + ): + raise SignatureInvalid("Signatur der Anfrage ist ungueltig") + + # Frische und Replay erst *nach* der Signaturpruefung: unsignierte Muellanfragen + # sollen keine Nonce-Eintraege in der Datenbank erzeugen koennen. + moment = now or utcnow() + _check_freshness(str(request.get("timestamp") or ""), moment, "Anfrage") + if seen_nonce is not None and seen_nonce(nonce): + raise ReplayDetected("Diese Anfrage wurde bereits verarbeitet") + + report = request.get("report") + if report is not None: + if not isinstance(report, Mapping): + raise ProtocolViolation("Bericht ist kein Objekt") + total = sum(len(report.get(key) or ()) for key in ("customers", "tickets", "licenses")) + if total > MAX_REPORT_ROWS: + raise ProtocolViolation("Bericht ueberschreitet die Groessengrenze") + + +def build_master_response( + action: str, + *, + license_id: str, + fingerprint: str, + request_nonce: str, + master_private_key: str, + status: str = "ok", + detail: str = "", + license_update: Mapping[str, Any] | None = None, + now: datetime | None = None, +) -> dict[str, Any]: + """Baut eine signierte Master-Antwort, gebunden an die Nonce der Anfrage.""" + if status not in RESPONSE_STATES: + raise ProtocolViolation(f"Unbekannter Antwortstatus: {status!r}") + payload: dict[str, Any] = { + "fmt": FORMAT_VERSION, + "license_id": license_id, + "fingerprint": fingerprint, + "action": action, + "status": status, + "detail": detail, + "nonce": request_nonce, + "timestamp": iso(now or utcnow()), + } + if license_update is not None: + payload["license_update"] = dict(license_update) + payload["signature"] = sign(payload, master_private_key, SIG_CONTEXT_RESPONSE) + return payload + + +def verify_master_response( + response: Mapping[str, Any], + master_public_key: str, + *, + expected_license_id: str, + expected_fingerprint: str, + expected_action: str, + expected_nonce: str, + current_seq: int = 0, + now: datetime | None = None, +) -> None: + """Verifiziert eine Master-Antwort clientseitig, komplett offline. + + Prueft neben der Signatur, dass die Antwort tatsaechlich zu *dieser* Anfrage + gehoert (Lizenz-ID, Fingerprint, Aktion, Nonce) und dass ein mitgeliefertes + ``license_update`` keine aeltere Revision ist. + """ + if not isinstance(response, Mapping): + raise ProtocolViolation("Antwort ist kein Objekt") + if int(response.get("fmt") or 0) != FORMAT_VERSION: + raise ProtocolViolation("Nicht unterstuetzte Protokollversion") + + payload = {k: v for k, v in response.items() if k != "signature"} + if not verify( + payload, str(response.get("signature") or ""), master_public_key, SIG_CONTEXT_RESPONSE + ): + raise SignatureInvalid("Signatur der Antwort ist ungueltig") + + if str(response.get("license_id") or "") != expected_license_id: + raise ProtocolViolation("Antwort gehoert zu einer anderen Lizenz") + if str(response.get("fingerprint") or "") != expected_fingerprint: + raise ProtocolViolation("Antwort gehoert zu einer anderen Maschine") + if str(response.get("action") or "") != expected_action: + raise ProtocolViolation("Antwort gehoert zu einer anderen Aktion") + if str(response.get("nonce") or "") != expected_nonce: + raise ReplayDetected("Antwort gehoert nicht zu dieser Anfrage") + if str(response.get("status") or "") not in RESPONSE_STATES: + raise ProtocolViolation("Unbekannter Antwortstatus") + + _check_freshness(str(response.get("timestamp") or ""), now or utcnow(), "Antwort") + + update = response.get("license_update") + if update is not None: + if not isinstance(update, Mapping): + raise ProtocolViolation("license_update ist kein Objekt") + if int(update.get("seq") or 0) < int(current_seq or 0): + raise ReplayDetected("license_update ist aelter als die installierte Lizenz") diff --git a/skripts/accept_all_hostkeys.py b/skripts/accept_all_hostkeys.py deleted file mode 100644 index c09fcf1..0000000 --- a/skripts/accept_all_hostkeys.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -"""Akzeptiert die SSH-Host-Keys aller aktiven Geraete (Clients) in einem -Rutsch -- macht technisch dasselbe wie "Verbindung testen" im Browser- -Terminal (Verbindungsaufbau -> Host-Key abfragen -> "yes" -> trennen), -nur automatisiert fuer alle Geraete statt eines einzelnen. - -Nutzt bewusst dieselbe known_hosts-Datei/dasselbe Format wie die App -(SSH_KNOWN_HOSTS_PATH in app.py) -- Geraete, deren Key hier akzeptiert -wird, muessen danach in der Weboberflaeche NICHT mehr einzeln bestaetigt -werden (SSH-Verbindungstest und Wartungs-Aktionen teilen sich dieselbe -Datei). - -Aufruf auf dem Server: sudo /srv/tesm/venv/bin/python3 accept_all_hostkeys.py -""" -import socket -import sqlite3 -from concurrent.futures import ThreadPoolExecutor, as_completed - -import paramiko - -DB_PATH = "/srv/tesm/sqlite.db" -KNOWN_HOSTS_PATH = "/srv/tesm/known_hosts" -DEFAULT_SSH_PORT = 22 -CONNECT_TIMEOUT = 4 -MAX_WORKERS = 32 - -conn = sqlite3.connect(DB_PATH) -conn.row_factory = sqlite3.Row -devices = conn.execute( - "SELECT name, ip, ssh_port FROM devices WHERE deleted_at IS NULL ORDER BY name" -).fetchall() -conn.close() - - -def fetch_key(dev): - """Nur der Handshake (Verbindungsaufbau + Schluesseltausch), KEIN Login - -- exakt der Teil, der im Browser-Terminal vor der Passwortabfrage - passiert. Parallel je Gerät, sonst dauert eine Liste mit vielen - offline Geraeten (je bis zu CONNECT_TIMEOUT Sekunden) unnoetig lange.""" - host = dev["ip"] - port = dev["ssh_port"] or DEFAULT_SSH_PORT - try: - sock = socket.create_connection((host, port), timeout=CONNECT_TIMEOUT) - transport = paramiko.Transport(sock) - transport.start_client(timeout=CONNECT_TIMEOUT) - server_key = transport.get_remote_server_key() - transport.close() - return dev, server_key, None - except (OSError, paramiko.SSHException, EOFError) as e: - return dev, None, str(e) - - -hostkeys = paramiko.HostKeys() -try: - hostkeys.load(KNOWN_HOSTS_PATH) -except (OSError, paramiko.SSHException): - pass - -accepted, already_known, changed, unreachable = [], [], [], [] - -with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: - futures = [pool.submit(fetch_key, dev) for dev in devices] - for future in as_completed(futures): - dev, server_key, err = future.result() - host = dev["ip"] - port = dev["ssh_port"] or DEFAULT_SSH_PORT - lookup_host = host if port == DEFAULT_SSH_PORT else f"[{host}]:{port}" - - if err is not None: - unreachable.append((dev["name"], host, err)) - continue - - existing = hostkeys.lookup(lookup_host) - if existing and existing.get(server_key.get_name()): - if existing[server_key.get_name()] == server_key: - already_known.append((dev["name"], host)) - continue - else: - # Nicht blind ueberschreiben -- das waere genau das MITM- - # Szenario, vor dem die App im Terminal auch schuetzt (harte - # Ablehnung statt automatischer Uebernahme eines GEAENDERTEN - # Keys). - changed.append((dev["name"], host)) - continue - - hostkeys.add(lookup_host, server_key.get_name(), server_key) - accepted.append((dev["name"], host)) - -hostkeys.save(KNOWN_HOSTS_PATH) - -print(f"Neu akzeptiert: {len(accepted)}") -for name, host in accepted: - print(f" + {name} ({host})") - -print(f"Bereits bekannt: {len(already_known)}") - -if changed: - print(f"ACHTUNG -- Host-Key geaendert, NICHT automatisch uebernommen ({len(changed)}):") - for name, host in changed: - print(f" ! {name} ({host}) -- manuell in der App pruefen (moeglicher MITM oder Geraetetausch)") - -print(f"Nicht erreichbar: {len(unreachable)}") -for name, host, err in unreachable: - print(f" - {name} ({host}): {err}") diff --git a/skripts/assign_credential_to_all_devices.py b/skripts/assign_credential_to_all_devices.py deleted file mode 100644 index fcd995c..0000000 --- a/skripts/assign_credential_to_all_devices.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -"""Weist allen aktiven Geraeten (Clients) auf einen Schlag dieselben -Zugangsdaten zu -- spart das manuelle Durchklicken jedes einzelnen -Geraets in der Weboberflaeche. - -Aufruf auf dem Server: - sudo /srv/tesm/venv/bin/python3 assign_credential_to_all_devices.py [NAME] - -NAME ist der Name des Zugangsdaten-Eintrags (Standard: "HAPS").""" -import sqlite3 -import sys -from datetime import datetime - -DB_PATH = "/srv/tesm/sqlite.db" -CRED_NAME = sys.argv[1] if len(sys.argv) > 1 else "HAPS" - -conn = sqlite3.connect(DB_PATH) - -cred = conn.execute( - "SELECT id FROM credentials WHERE name = ? AND deleted_at IS NULL", (CRED_NAME,) -).fetchone() -if not cred: - print(f"Fehler: Zugangsdaten '{CRED_NAME}' nicht gefunden (oder im Papierkorb).") - raise SystemExit(1) -credential_id = cred[0] - -now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") -cur = conn.execute( - "UPDATE devices SET credential_id=?, last_modified_by=?, last_modified_at=? WHERE deleted_at IS NULL", - (credential_id, "Admin (Bulk)", now), -) -count = cur.rowcount - -conn.execute( - "INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)", - (now, "system", "devices.bulk_credential", CRED_NAME, f"{count} Geräte auf Zugangsdaten '{CRED_NAME}' gesetzt"), -) - -conn.commit() -conn.close() -print(f"{count} Geräte auf Zugangsdaten '{CRED_NAME}' (id={credential_id}) gesetzt.") diff --git a/srv/tesm/SCHEMA_VERSION b/srv/tesm/SCHEMA_VERSION deleted file mode 100644 index 0cfbf08..0000000 --- a/srv/tesm/SCHEMA_VERSION +++ /dev/null @@ -1 +0,0 @@ -2 diff --git a/srv/tesm/VERSION b/srv/tesm/VERSION deleted file mode 100644 index db6fb4a..0000000 --- a/srv/tesm/VERSION +++ /dev/null @@ -1 +0,0 @@ -1.2.8 diff --git a/srv/tesm/app.py b/srv/tesm/app.py deleted file mode 100644 index b6544c8..0000000 --- a/srv/tesm/app.py +++ /dev/null @@ -1,9051 +0,0 @@ -#!/usr/bin/env python3 -""" -TESM (TimEShepManager) — Web-App zur Überwachung und Steuerung von -PoE-Devices über Aruba-Switche. - -Funktional identisch zur Ursprungsversion (Login, Dashboard, Device-/ -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, 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 -from simple_websocket.errors import ConnectionClosed -from werkzeug.utils import secure_filename -from cryptography.fernet import Fernet -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, io, ipaddress, logging, math, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback, zipfile -import paramiko -import yaml -import ssl -from ldap3 import Server, Connection, Tls, ALL, SIMPLE, BASE -from ldap3.core.exceptions import ( - LDAPException, - LDAPSocketOpenError, - LDAPSocketReceiveError, - LDAPSocketSendError, - LDAPResponseTimeoutError, -) -from ldap3.utils.conv import escape_filter_chars -import urllib.request -import urllib.error -import licensing - -LDAP_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941" - - -class _SendAllSocketProxy: - """Leitet alles an das echte Socket weiter, außer .send() -> .sendall().""" - - def __init__(self, sock): - object.__setattr__(self, "_sock", sock) - - def send(self, data): - return self._sock.sendall(data) - - def __getattr__(self, name): - return getattr(self._sock, name) - - -def _patch_simple_websocket_partial_writes(): - """ - simple-websocket (genutzt von Flask-Sock) schreibt WebSocket-Frames per - `socket.send()`, das laut Doku aber nur *irgendeine* Anzahl Bytes - schreiben muss — nicht zwingend alle. Folgen zwei Frames sehr schnell - aufeinander (z.B. eine Nachricht direkt gefolgt vom Schließen der - Verbindung, wie beim SSH-Terminal), kann ein unvollständig geschriebenes - Frame den Frame-Stream für den Client zerstören ("Invalid frame header"). - Patch: das Socket-Objekt jeder Verbindung durch einen Proxy ersetzen, - dessen .send() intern .sendall() aufruft. Rein additiv/defensiv — falls - die Bibliothek ihre interne Struktur ändert, wird der Patch übersprungen - statt den Start zu blockieren. - """ - try: - from simple_websocket.ws import Base - - original_init = Base.__init__ - - def patched_init(self, *args, **kwargs): - original_init(self, *args, **kwargs) - if self.sock is not None and not isinstance(self.sock, _SendAllSocketProxy): - self.sock = _SendAllSocketProxy(self.sock) - - Base.__init__ = patched_init - except Exception: - pass - - -_patch_simple_websocket_partial_writes() - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) - -DB_PATH = os.environ.get("TESM_DB_PATH", os.path.join(BASE_DIR, "sqlite.db")) -POE_SCRIPT = os.environ.get("POE_SCRIPT", "/usr/local/bin/custom/poe.sh") -FERNET_KEY_PATH = os.environ.get("TESM_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key")) -SSH_KNOWN_HOSTS_PATH = os.environ.get("TESM_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts")) -SECRET_KEY_PATH = os.environ.get("TESM_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key")) -LICENSE_PATH = os.environ.get("TESM_LICENSE_PATH", os.path.join(BASE_DIR, "license.json")) -AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars") - -TESM_LOG_DIR = os.environ.get("TESM_LOG_DIR", "/var/log/tesm") -TESM_LIVE_LOG_PATH = os.path.join(TESM_LOG_DIR, "live.log") -TESM_CHANGES_LOG_PATH = os.path.join(TESM_LOG_DIR, "changes.log") -TESM_APP_LOG_PATH = os.path.join(TESM_LOG_DIR, "app.log") -TESM_KEA_LOG_PATH = os.environ.get("TESM_KEA_LOG_PATH", "/var/log/kea/kea-dhcp4.log") -LOGROTATE_CONFIG_PATH = os.environ.get("TESM_LOGROTATE_CONFIG", "/etc/logrotate.d/tesm") - -# Auditlog-Archivierung: eigenständig und bewusst UNABHÄNGIG von der -# logrotate-Rotation von changes.log (siehe _archive_old_audit_log_rows). -# Schwellenwerte mit dem Nutzer abgestimmt (20.000 Zeilen Trigger, Ziel -# 15.000) -- Aufbewahrung der Archivdateien selbst ist bewusst unbegrenzt, -# stattdessen nur eine Warnung bei knappem Speicherplatz plus manueller -# Export-und-Löschen-Funktion unter Verlauf. -AUDIT_ARCHIVE_DIR = os.path.join(TESM_LOG_DIR, "audit-archive") -AUDIT_LOG_ARCHIVE_THRESHOLD = 20000 -AUDIT_LOG_ARCHIVE_TARGET = 15000 -AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS = 24 * 60 * 60 -LOG_DISK_SPACE_WARNING_BYTES = 1 * 1024 * 1024 * 1024 # 1 GiB frei -try: - os.makedirs(TESM_LOG_DIR, exist_ok=True) - # War früher 0o777, als noch nicht jeder schreibende Prozess (tesm.service, - # tesm-check.service) als root lief. Ein world-writable Verzeichnis lässt - # logrotate die Rotation aber aus Sicherheitsgründen komplett verweigern - # ("insecure permissions") -- live reproduziert: ohne Rotation blieb - # live.log unbegrenzt wachsen, siehe auch "su root root" in - # _write_logrotate_config als zusätzliche Absicherung. - os.chmod(TESM_LOG_DIR, 0o755) -except OSError: - pass -SWITCH_DEFAULT_SSH_PORT = 22 -ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"} - -NGINX_SITE_CONFIG_PATH = os.environ.get("TESM_NGINX_CONFIG", "/etc/nginx/sites-available/tesm") -TESM_SSL_DIR = os.environ.get("TESM_SSL_DIR", "/etc/tesm/ssl") -TESM_SSL_CERT_PATH = os.path.join(TESM_SSL_DIR, "cert.pem") -TESM_SSL_KEY_PATH = os.path.join(TESM_SSL_DIR, "key.pem") -TESM_ACME_WEBROOT = os.environ.get("TESM_ACME_WEBROOT", "/var/www/tesm-acme") -CERTBOT_DEPLOY_HOOK_PATH = "/etc/letsencrypt/renewal-hooks/deploy/tesm-reload-nginx.sh" -try: - # Bewusst AUSSERHALB von /srv/tesm -- das rsync --delete beim Deployen - # (siehe install.sh) würde ein dort abgelegtes Zertifikat sonst bei - # jedem Update wieder löschen. Analog zu /etc/nginx/ selbst: liegt - # außerhalb des App-Verzeichnisses, übersteht also jedes Update. - os.makedirs(TESM_SSL_DIR, exist_ok=True) - os.chmod(TESM_SSL_DIR, 0o700) - os.makedirs(TESM_ACME_WEBROOT, exist_ok=True) -except OSError: - pass - - -def _le_cert_path(domain): - return f"/etc/letsencrypt/live/{domain}/fullchain.pem" - - -def _le_key_path(domain): - return f"/etc/letsencrypt/live/{domain}/privkey.pem" - - -def _write_certbot_deploy_hook(): - """certbot führt nach JEDER erfolgreichen Erneuerung (egal für welche - Domain) automatisch alle ausführbaren Skripte in diesem Verzeichnis aus - -- ohne diesen Hook würde nginx nach einer automatischen Verlängerung - weiter das alte (bald ablaufende) Zertifikat verwenden, da es die - Datei nicht von selbst neu einliest. Läuft wie _write_logrotate_config - bei jedem App-Start neu, damit ein frisch installiertes certbot den - Hook auch ohne manuellen Aufruf der NGINX-Seite bekommt.""" - try: - os.makedirs(os.path.dirname(CERTBOT_DEPLOY_HOOK_PATH), exist_ok=True) - with open(CERTBOT_DEPLOY_HOOK_PATH, "w", encoding="utf-8") as f: - f.write("#!/bin/bash\nsystemctl reload nginx\n") - os.chmod(CERTBOT_DEPLOY_HOOK_PATH, 0o755) - except OSError: - pass - - -_write_certbot_deploy_hook() - -CREDENTIAL_CATEGORIES = [ - ("switch", "Switch"), - ("linux", "Linux-Client"), - ("windows", "Windows-Client"), - ("router", "Router/Sonstiges"), -] -CREDENTIAL_CATEGORY_KEYS = {c[0] for c in CREDENTIAL_CATEGORIES} -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") - _app_log_handler.setLevel(logging.WARNING) - _app_log_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s", "%Y-%m-%d %H:%M:%S")) - app.logger.addHandler(_app_log_handler) - app.logger.setLevel(logging.WARNING) -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: - return env_secret - if os.path.exists(SECRET_KEY_PATH): - with open(SECRET_KEY_PATH, "r") as f: - return f.read().strip() - new_secret = secrets.token_hex(32) - with open(SECRET_KEY_PATH, "w") as f: - f.write(new_secret) - return new_secret - - -def _load_or_create_fernet() -> Fernet: - if os.path.exists(FERNET_KEY_PATH): - with open(FERNET_KEY_PATH, "rb") as f: - key = f.read() - else: - key = Fernet.generate_key() - with open(FERNET_KEY_PATH, "wb") as f: - f.write(key) - return Fernet(key) - - -app.secret_key = _load_or_create_secret() -bcrypt = Bcrypt(app) -fernet = _load_or_create_fernet() -sock = Sock(app) - -login_manager = LoginManager() -login_manager.login_view = "login" -login_manager.init_app(app) - - -PERMISSION_ROW_TYPES = [ - ("view", "R", "Lesen (Read)"), - ("create", "W", "Schreiben/Anlegen (Write)"), - ("edit", "E", "Ändern inkl. Löschen (Edit)"), - ("extra", "D", "Dashboard-Neustart (nur bei Clients — kein Löschen-Recht)"), - ("export", "X", "Export (nur bei Im-/Export — Export-Datei enthält Passwörter im Klartext)"), -] - -PERMISSIONS = { - "devices_group": { - "label": "Geräte", - "view_key": "devices_group.view", - "children": { - "devices": { - "label": "Clients", - "rows": { - "view": "devices.view", - "create": "devices.create", - "edit": "devices.edit", - "extra": "devices.restart", - }, - }, - "switches": { - "label": "Switche", - "rows": { - "view": "switches.view", - "create": "switches.create", - "edit": "switches.edit", - }, - }, - "credentials": { - "label": "Zugangsdaten", - "rows": { - "view": "credentials.view", - "create": "credentials.create", - "edit": "credentials.edit", - }, - }, - "maintenance": { - "label": "Wartung", - "rows": { - "view": "maintenance.view", - "edit": "maintenance.edit", - }, - }, - }, - }, - "logs_group": { - "label": "Logs", - "view_key": "logs_group.view", - "children": { - "logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}}, - "logs_history": {"label": "Verlauf", "rows": {"view": "logs_history.view", "edit": "logs_history.edit"}}, - "logs_activity": {"label": "Auditlog", "rows": {"view": "logs_activity.view"}}, - "logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}}, - }, - }, - "settings_group": { - "label": "Einstellungen", - "view_key": "settings_group.view", - "children": { - "users": { - "label": "Benutzer", - "rows": { - "view": "users.view", - "create": "users.create", - "edit": "users.edit", - }, - }, - "groups": { - "label": "Gruppen", - "rows": { - "view": "groups.view", - "create": "groups.create", - "edit": "groups.edit", - }, - }, - "settings_system": { - "label": "Systemeinstellungen", - "rows": {"view": "settings_system.view", "edit": "settings_system.edit"}, - }, - "settings_license": { - "label": "Lizenz", - "rows": {"view": "settings_license.view", "edit": "settings_license.edit"}, - }, - "settings_importexport": { - "label": "Im-/Export", - "rows": { - "view": "settings_importexport.view", - "edit": "settings_importexport.edit", - "export": "settings_importexport.export", - }, - }, - "settings_dhcp": { - "label": "DHCP", - "rows": {"view": "settings_dhcp.view", "edit": "settings_dhcp.edit"}, - }, - "settings_ldap": { - "label": "LDAP", - "rows": {"view": "settings_ldap.view", "edit": "settings_ldap.edit"}, - }, - "settings_nginx": { - "label": "NGINX", - "rows": {"view": "settings_nginx.view", "edit": "settings_nginx.edit"}, - }, - "papierkorb": { - "label": "Papierkorb", - "rows": {"view": "papierkorb.view", "edit": "papierkorb.edit"}, - }, - }, - }, - "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 = { - "devices_group.view": "Geräte-Bereich anzeigen", - "logs_group.view": "Logs-Bereich anzeigen", - "settings_group.view": "Einstellungen-Bereich anzeigen", - "devices.view": "Clients lesen", "devices.create": "Clients anlegen", - "devices.edit": "Clients ändern (inkl. Aktivieren/Deaktivieren, Löschen)", - "devices.restart": "PoE-Neustart über das Dashboard ausführen (automatische Neustarts bei Ausfall bleiben unberührt)", - "switches.view": "Switche lesen", "switches.create": "Switche anlegen", - "switches.edit": "Switche ändern (inkl. Löschen)", - "credentials.view": "Zugangsdaten lesen", "credentials.create": "Zugangsdaten anlegen", - "credentials.edit": "Zugangsdaten ändern (inkl. Löschen)", - "maintenance.view": "Wartungsseite ansehen (Geräte-Status, Job-Verlauf)", - "maintenance.edit": "Wartungsaktionen ausführen (SSH-Update, SSH-Neustart)", - "papierkorb.view": "Papierkorb ansehen (alle gelöschten Geräte/Switche/Zugangsdaten/Benutzer/Gruppen, unabhängig von deren eigenen Leserechten)", - "papierkorb.edit": "Papierkorb bearbeiten (beliebige Einträge wiederherstellen oder endgültig löschen, unabhängig vom sonstigen Bearbeiten-Recht der jeweiligen Ressource)", - "logs_live.view": "Live-Log lesen", "logs_activity.view": "Auditlog lesen", - "logs_kea.view": "Kea-DHCP-Log lesen", - "logs_history.view": "Log-Verlauf lesen (ältere, durch logrotate rotierte Kopien des Live-Logs, sowie archivierte Auditlog-Tage)", - "logs_history.edit": "Archivierte Auditlog-Tage exportieren (ZIP-Download) und danach vom Server löschen", - "users.view": "Benutzer lesen", "users.create": "Benutzer anlegen", - "users.edit": "Benutzer ändern (inkl. Löschen)", - "groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen", - "groups.edit": "Gruppen ändern (inkl. Löschen)", - "settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern", - "settings_license.view": "Lizenzstatus ansehen", - "settings_license.edit": "Lizenzdatei hochladen sowie Lizenz aktivieren/deaktivieren (online oder per Code)", - "settings_importexport.view": "Im-/Export-Seite ansehen", - "settings_importexport.edit": "Daten importieren", - "settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)", - "settings_dhcp.view": "DHCP-Status und -Konfiguration lesen", - "settings_dhcp.edit": "DHCP-Konfiguration speichern und Reservierungsdatei schreiben", - "settings_ldap.view": "LDAP/AD-Konfiguration lesen", - "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 = [] -PERMISSION_PARENT_GROUP = {} -for _group in PERMISSIONS.values(): - ALL_PERMISSION_KEYS.append(_group["view_key"]) - for _child in _group["children"].values(): - for _key in _child["rows"].values(): - ALL_PERMISSION_KEYS.append(_key) - PERMISSION_PARENT_GROUP[_key] = _group["view_key"] - -GROUP_ROW_TYPES = {} -for _group_key, _group in PERMISSIONS.items(): - _used_row_keys = {"view"} - for _child in _group["children"].values(): - _used_row_keys.update(_child["rows"].keys()) - GROUP_ROW_TYPES[_group_key] = [rt for rt in PERMISSION_ROW_TYPES if rt[0] in _used_row_keys] - -DEFAULT_GROUP_NAME = "Benutzer" -DEFAULT_GROUP_PERMISSIONS = [ - "devices_group.view", "devices.view", "switches.view", - "logs_group.view", "logs_live.view", -] - -NAV_ITEMS = [ - {"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"}, - {"key": "devices_group", "label": "Geräte", "icon": "cpu", "children": [ - {"key": "devices", "label": "Clients", "icon": "cpu", "endpoint": "devices"}, - {"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"}, - {"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"}, - {"key": "maintenance", "label": "Wartung", "icon": "wrench", "endpoint": "maintenance"}, - ]}, - {"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_license", "label": "Lizenz", "icon": "key", "endpoint": "settings_license"}, - {"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"}, - {"key": "settings_dhcp", "label": "DHCP", "icon": "network", "endpoint": "settings_dhcp"}, - {"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"}, - {"key": "settings_nginx", "label": "NGINX", "icon": "server", "endpoint": "settings_nginx"}, - {"key": "papierkorb", "label": "Papierkorb", "icon": "trash", "endpoint": "papierkorb"}, - ]}, - {"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [ - {"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"}, - {"key": "logs_history", "label": "Verlauf", "icon": "clock", "endpoint": "logs_history"}, - {"key": "logs_activity", "label": "Auditlog", "icon": "history", "endpoint": "activity_log"}, - {"key": "logs_kea", "label": "Kea-DHCP", "icon": "network", "endpoint": "kea_log"}, - ]}, -] -DEFAULT_NAV_ORDER = [item["key"] for item in NAV_ITEMS] -NAV_ITEMS_BY_KEY = {item["key"]: item for item in NAV_ITEMS} - - -def _nav_key_visible(key, user): - if key == "index": - return True - if key == "devices": - return user.can_view_devices - if key == "switches": - return user.can_manage_switches - if key == "credentials": - return user.can_manage_credentials - if key == "maintenance": - return user.can_view_maintenance and module_licensed("maintenance") - if key == "papierkorb": - return user.can_view_papierkorb - if key == "users": - 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()) - and module_licensed("fileshare") - ) - if key == "settings_system": - return user.can_view_settings_system - if key == "settings_license": - # Eigenes Recht (settings_license.view), unabhängig von - # settings_system -- muss auch OHNE eine aktive Lizenz sichtbar - # bleiben (das ist eine andere Prüfung, siehe license_active()), - # sonst könnte niemand jemals eine erste Lizenz hochladen/aktivieren. - return user.can_view_settings_license - if key == "settings_importexport": - return user.can_view_settings_importexport - if key == "settings_dhcp": - return user.can_view_settings_dhcp and module_licensed("dhcp") - if key == "settings_ldap": - return user.can_view_settings_ldap - if key == "settings_nginx": - return user.has_permission("settings_nginx.view") - if key == "logs_live": - return user.can_view_live_log - if key == "logs_history": - return user.can_view_log_history - if key == "logs_activity": - return user.can_view_activity_log - if key == "logs_kea": - return user.can_view_kea_log and module_licensed("dhcp") - return False - - -def _ordered_nav_items(): - """Komplette Navbar-Struktur (Top-Level + Unterpunkte je Gruppe) in der - gespeicherten Reihenfolge, OHNE Sichtbarkeitsfilter — Grundlage sowohl - für die Sidebar (dort wird danach noch nach Berechtigung gefiltert) als - auch für den Reihenfolge-Editor auf der Account-Seite (der die - komplette Navbar zeigen soll, unabhängig davon, was der bearbeitende - Admin selbst sehen dürfte — als Admin ohnehin alles).""" - try: - stored_order = json.loads(get_setting("nav_order", "") or "[]") - except (ValueError, TypeError): - stored_order = [] - ordered_keys = [k for k in stored_order if k in NAV_ITEMS_BY_KEY] - ordered_keys += [k for k in DEFAULT_NAV_ORDER if k not in ordered_keys] - - try: - stored_child_order = json.loads(get_setting("nav_child_order", "") or "{}") - except (ValueError, TypeError): - stored_child_order = {} - - items = [] - for k in ordered_keys: - item = NAV_ITEMS_BY_KEY[k] - if item.get("children"): - children_by_key = {c["key"]: c for c in item["children"]} - stored = stored_child_order.get(k, []) - child_keys = [ck for ck in stored if ck in children_by_key] - child_keys += [c["key"] for c in item["children"] if c["key"] not in child_keys] - items.append({**item, "children": [children_by_key[ck] for ck in child_keys]}) - else: - items.append(item) - return items - - -@app.context_processor -def inject_asset_version(): - """Cache-Busting für statische Dateien (CSS/JS): hängt die letzte - Änderungszeit der Datei als ?v=... an, damit Browser nach einem Update - (z.B. app.js) nicht stillschweigend eine veraltete, gecachte Version - weiterverwenden — sonst können neue Funktionen scheinbar "nicht - funktionieren", obwohl der Code auf dem Server längst aktuell ist.""" - def asset_url(filename): - try: - mtime = int(os.path.getmtime(os.path.join(app.static_folder, filename))) - except OSError: - mtime = 0 - return f"{url_for('static', filename=filename)}?v={mtime}" - return {"asset_url": asset_url} - - -@app.context_processor -def inject_current_year(): - """Für die Copyright-Zeile im Sidebar-Footer (siehe base.html).""" - return {"current_year": datetime.now().year} - - -def _read_tesm_version(): - """Liest die Versionsnummer aus VERSION (im Repo neben app.py, wird von - install.sh 1:1 nach /srv/tesm mitkopiert). Fehlt die Datei (z.B. lokale - Entwicklung ohne vollständigen Checkout), lieber "unbekannt" anzeigen - als die App deswegen abstürzen zu lassen.""" - try: - with open(os.path.join(BASE_DIR, "VERSION"), "r", encoding="utf-8") as f: - return f.read().strip() or "unbekannt" - except OSError: - return "unbekannt" - - -TESM_VERSION = _read_tesm_version() - - -@app.context_processor -def inject_tesm_version(): - """Für die Versionsanzeige im Sidebar-Footer (siehe base.html) — beim - Modulimport einmalig gelesen (Neustart des Dienstes nach einem Update - erforderlich, damit eine neue Versionsnummer sichtbar wird — geschieht - ohnehin bei jedem install.sh/update.sh-Lauf).""" - return {"tesm_version": TESM_VERSION} - - -@app.context_processor -def inject_nav(): - if not current_user.is_authenticated: - return {} - nav_items_ordered = [] - for item in _ordered_nav_items(): - if item.get("children"): - visible_children = [c for c in item["children"] if _nav_key_visible(c["key"], current_user)] - if visible_children: - nav_items_ordered.append({**item, "children": visible_children}) - elif _nav_key_visible(item["key"], current_user): - nav_items_ordered.append(item) - return {"nav_items_ordered": nav_items_ordered} - - -class User(UserMixin): - def __init__(self, id_, username, is_admin, permissions=None, group_names=None, - first_name=None, last_name=None, avatar_filename=None, auth_source="local"): - self.id = id_ - self.username = username - self.is_admin = bool(is_admin) - self.permissions = permissions or set() - self.group_names = group_names or "" - self.first_name = first_name - self.last_name = last_name - self.avatar_filename = avatar_filename - self.auth_source = auth_source - - @property - def is_ldap_user(self): - return self.auth_source == "ldap" - - def has_permission(self, key): - """Admins dürfen immer alles. Sonst muss das Recht selbst gesetzt - sein UND — falls es ein übergeordnetes Bereichs-Recht gibt (siehe - PERMISSION_PARENT_GROUP) — dieses ebenfalls: das übergeordnete Recht - wirkt als Kill-Switch für den ganzen Bereich, selbst wenn ein - einzelnes Kind-Recht noch gesetzt ist.""" - if self.is_admin: - return True - if key not in self.permissions: - return False - parent = PERMISSION_PARENT_GROUP.get(key) - return parent is None or parent in self.permissions - - def has_any_permission(self, keys): - return any(self.has_permission(k) for k in keys) - - @property - def can_view_devices(self): - return self.has_permission("devices.view") - - @property - def can_manage_switches(self): - return self.has_any_permission( - ["switches.view", "switches.create", "switches.edit"] - ) - - @property - def can_manage_credentials(self): - return self.has_any_permission( - ["credentials.view", "credentials.create", "credentials.edit"] - ) - - @property - def can_view_maintenance(self): - return self.has_any_permission(["maintenance.view", "maintenance.edit"]) - - @property - def can_run_maintenance(self): - return self.has_permission("maintenance.edit") - - @property - def can_view_papierkorb(self): - """Papierkorb: eigener, globaler Bereich unter Einstellungen, komplett - unabhängig von den Leserechten der einzelnen Ressourcen (Geräte/ - Switche/Zugangsdaten/Benutzer/Gruppen) -- vorher implizit sichtbar - für jeden mit irgendeiner der fünf Ressourcen-Leserechte, wodurch - praktisch jeder Benutzer den Papierkorb sehen konnte (Standardgruppe - hat bereits devices.view). papierkorb.view zeigt jetzt ALLE fünf - Kategorien auf einmal, unabhängig davon, ob der Benutzer die - jeweilige aktive Ressource überhaupt sehen darf.""" - return self.has_permission("papierkorb.view") - - @property - def can_edit_papierkorb(self): - """Wiederherstellen/endgültig Löschen im Papierkorb: eigenes, - globales Recht (papierkorb.edit) -- vorher an das jeweilige - Ressourcen-Recht gekoppelt (z.B. switches.edit für Switche im - Papierkorb). Wer den Papierkorb bearbeiten darf, darf jetzt JEDEN - Eintrag darin wiederherstellen oder endgültig löschen, unabhängig - vom sonstigen Bearbeiten-Recht der jeweiligen Ressource.""" - return self.has_permission("papierkorb.edit") - - @property - def can_view_live_log(self): - return self.has_permission("logs_live.view") - - @property - def can_view_log_history(self): - """Ältere, durch logrotate rotierte Kopien des Live-Logs (Seite - Verlauf) -- bewusst eigenes Recht statt an logs_live.view gekoppelt, - damit z.B. ein Nutzer den aktuellen Live-Status sehen darf, ohne - automatisch auch durch ältere Logstände blättern zu können.""" - return self.has_permission("logs_history.view") - - @property - def can_manage_log_history(self): - """Zusätzlich zum reinen Lesen (can_view_log_history): archivierte - Auditlog-Tage als ZIP exportieren und danach vom Server löschen -- - bewusst eigenes Edit-Recht, weil das eine destruktive Aktion ist, - die über reines Einsehen des Verlaufs hinausgeht.""" - return self.has_permission("logs_history.edit") - - @property - def can_view_activity_log(self): - return self.has_permission("logs_activity.view") - - @property - def can_view_kea_log(self): - return self.has_permission("logs_kea.view") - - @property - def can_manage_users(self): - return self.has_any_permission( - ["users.view", "users.create", "users.edit"] - ) - - @property - def can_manage_groups(self): - return self.has_any_permission( - ["groups.view", "groups.create", "groups.edit"] - ) - - @property - def can_view_settings_system(self): - return self.has_permission("settings_system.view") - - @property - def can_view_settings_license(self): - return self.has_permission("settings_license.view") - - @property - def can_view_settings_importexport(self): - return self.has_any_permission([ - "settings_importexport.view", "settings_importexport.edit", "settings_importexport.export", - ]) - - @property - def can_view_settings_dhcp(self): - return self.has_permission("settings_dhcp.view") - - @property - def can_view_settings_ldap(self): - return self.has_any_permission(["settings_ldap.view", "settings_ldap.edit"]) - - @property - def full_name(self): - parts = [p for p in (self.first_name, self.last_name) if p] - return " ".join(parts) - - @property - def display_name(self): - return self.full_name or self.username - - @property - def avatar_url(self): - if self.avatar_filename: - return url_for("static", filename=f"uploads/avatars/{self.avatar_filename}") - return None - - -def get_db_connection(): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn - - -DHCP_STANDARD_OPTIONS = [ - (4, "time-servers", "ipv4-address", "Zeitserver (RFC 868)"), - (7, "log-servers", "ipv4-address", "Log-Server (MIT-LCS UDP)"), - (8, "cookie-servers", "ipv4-address", "Cookie-Server"), - (9, "lpr-servers", "ipv4-address", "LPR-Druckserver"), - (10, "impress-servers", "ipv4-address", "Impress-Server"), - (11, "resource-location-servers", "ipv4-address", "Resource Location Server"), - (13, "boot-size", "uint16", "Boot-Image-Größe (Blöcke)"), - (14, "merit-dump", "string", "Crash-Dump-Datei"), - (16, "swap-server", "ipv4-address", "Swap-Server"), - (17, "root-path", "string", "Root-Pfad (NFS)"), - (18, "extensions-path", "string", "Extensions-Pfad (BOOTP)"), - (19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"), - (20, "non-local-source-routing", "boolean", "Non-Local Source Routing"), - (21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"), - (22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgröße"), - (23, "default-ip-ttl", "uint8", "Standard-TTL für IP"), - (24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"), - (25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"), - (26, "interface-mtu", "uint16", "MTU des Interfaces"), - (27, "all-subnets-local", "boolean", "Alle Subnetze lokal erreichbar"), - (28, "broadcast-address", "ipv4-address", "Broadcast-Adresse"), - (29, "perform-mask-discovery", "boolean", "Subnetzmasken-Discovery"), - (30, "mask-supplier", "boolean", "Subnetzmasken-Anbieter"), - (31, "router-discovery", "boolean", "Router Discovery (RFC 1256)"), - (32, "router-solicitation-address", "ipv4-address", "Router-Solicitation-Adresse"), - (33, "static-routes", "string", "Statische Routen (Ziel,Gateway,...)"), - (34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"), - (35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"), - (36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"), - (37, "default-tcp-ttl", "uint8", "Standard-TTL für TCP"), - (38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"), - (40, "nis-domain", "string", "NIS-Domain"), - (41, "nis-servers", "ipv4-address", "NIS-Server"), - (42, "ntp-servers", "ipv4-address", "NTP-Server"), - (44, "netbios-name-servers", "ipv4-address", "WINS/NBNS-Server"), - (45, "netbios-dd-server", "ipv4-address", "NetBIOS Datagram Distribution Server"), - (46, "netbios-node-type", "uint8", "WINS/NBT-Knotentyp"), - (47, "netbios-scope", "string", "NetBIOS-Scope-ID"), - (48, "font-servers", "ipv4-address", "X-Window-Font-Server"), - (49, "x-display-manager", "ipv4-address", "X-Window Display Manager"), - (60, "vendor-class-identifier", "string", "Vendor Class Identifier"), - (64, "nisplus-domain-name", "string", "NIS+-Domain"), - (65, "nisplus-servers", "ipv4-address", "NIS+-Server"), - (66, "tftp-server-name", "string", "Boot-Server (TFTP-Hostname)"), - (67, "boot-file-name", "string", "Bootdatei-Name"), - (68, "mobile-ip-home-agent", "ipv4-address", "Mobile-IP-Home-Agent"), - (69, "smtp-server", "ipv4-address", "SMTP-Server"), - (70, "pop-server", "ipv4-address", "POP3-Server"), - (71, "nntp-server", "ipv4-address", "NNTP-Server"), - (72, "www-server", "ipv4-address", "WWW-Server"), - (73, "finger-server", "ipv4-address", "Finger-Server"), - (74, "irc-server", "ipv4-address", "IRC-Server"), - (75, "streettalk-server", "ipv4-address", "StreetTalk-Server"), - (76, "streettalk-directory-assistance-server", "ipv4-address", "StreetTalk Directory Assistance Server"), - (118, "subnet-selection", "ipv4-address", "Subnet Selection (RFC 3011)"), - (119, "domain-search", "string", "DNS-Suchliste (Domain Search)"), -] - - -def _ensure_schema(): - """Legt neue Tabellen (Gruppen/Rechte) an, falls die DB noch aus einer - älteren Version stammt — idempotent, sicher bei jedem Start aufzurufen. - Sorgt außerdem dafür, dass die Standardgruppe 'Benutzer' existiert und - jeder Benutzer ohne Gruppe ihr zugeordnet ist.""" - conn = get_db_connection() - conn.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)") - conn.execute("CREATE TABLE IF NOT EXISTS groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL)") - conn.execute(""" - CREATE TABLE IF NOT EXISTS group_permissions ( - group_id INTEGER NOT NULL, - permission TEXT NOT NULL, - PRIMARY KEY (group_id, permission), - FOREIGN KEY (group_id) REFERENCES groups(id) - ) - """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS user_groups ( - user_id INTEGER NOT NULL, - group_id INTEGER NOT NULL, - PRIMARY KEY (user_id, group_id), - FOREIGN KEY (user_id) REFERENCES users(id), - FOREIGN KEY (group_id) REFERENCES groups(id) - ) - """) - - existing_cols = {row["name"] for row in conn.execute("PRAGMA table_info(groups)").fetchall()} - if "is_default" not in existing_cols: - conn.execute("ALTER TABLE groups ADD COLUMN is_default INTEGER DEFAULT 0") - if "is_system" not in existing_cols: - conn.execute("ALTER TABLE groups ADD COLUMN is_system INTEGER DEFAULT 0") - - user_cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)").fetchall()} - if "first_name" not in user_cols: - conn.execute("ALTER TABLE users ADD COLUMN first_name TEXT") - if "last_name" not in user_cols: - conn.execute("ALTER TABLE users ADD COLUMN last_name TEXT") - if "avatar_filename" not in user_cols: - conn.execute("ALTER TABLE users ADD COLUMN avatar_filename TEXT") - if "auth_source" not in user_cols: - conn.execute("ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'local'") - if "email" not in user_cols: - conn.execute("ALTER TABLE users ADD COLUMN email TEXT") - if "is_locked" not in user_cols: - conn.execute("ALTER TABLE users ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0") - - conn.execute(""" - CREATE TABLE IF NOT EXISTS service_accounts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - purpose TEXT UNIQUE NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL - ) - """) - - conn.execute(""" - CREATE TABLE IF NOT EXISTS ldap_group_mappings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ad_group_dn TEXT UNIQUE NOT NULL, - ad_group_name TEXT NOT NULL, - app_group_id INTEGER NOT NULL, - FOREIGN KEY (app_group_id) REFERENCES groups(id) - ) - """) - - 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, - ts TEXT NOT NULL, - username TEXT NOT NULL, - action TEXT NOT NULL, - target TEXT, - details TEXT - ) - """) - - device_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()} - if "rpi_ip" in device_cols and "ip" not in device_cols: - conn.execute("ALTER TABLE devices RENAME COLUMN rpi_ip TO ip") - device_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()} - - if "last_modified_by" not in device_cols: - conn.execute("ALTER TABLE devices ADD COLUMN last_modified_by TEXT") - if "last_modified_at" not in device_cols: - conn.execute("ALTER TABLE devices ADD COLUMN last_modified_at TEXT") - - conn.execute(""" - CREATE TABLE IF NOT EXISTS credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL, - category TEXT NOT NULL DEFAULT 'switch' - ) - """) - credential_cols = {row["name"] for row in conn.execute("PRAGMA table_info(credentials)").fetchall()} - if "category" not in credential_cols: - conn.execute("ALTER TABLE credentials ADD COLUMN category TEXT NOT NULL DEFAULT 'switch'") - switch_cols = {row["name"] for row in conn.execute("PRAGMA table_info(switches)").fetchall()} - if "credential_id" not in switch_cols: - conn.execute("ALTER TABLE switches ADD COLUMN credential_id INTEGER") - if "last_modified_by" not in switch_cols: - conn.execute("ALTER TABLE switches ADD COLUMN last_modified_by TEXT") - if "last_modified_at" not in switch_cols: - conn.execute("ALTER TABLE switches ADD COLUMN last_modified_at TEXT") - if "ssh_port" not in switch_cols: - conn.execute("ALTER TABLE switches ADD COLUMN ssh_port INTEGER") - - device_ssh_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()} - if "credential_id" not in device_ssh_cols: - conn.execute("ALTER TABLE devices ADD COLUMN credential_id INTEGER") - if "ssh_port" not in device_ssh_cols: - conn.execute("ALTER TABLE devices ADD COLUMN ssh_port INTEGER") - - for _table in ("devices", "switches", "credentials", "users", "groups"): - _cols = {row["name"] for row in conn.execute(f"PRAGMA table_info({_table})").fetchall()} - if "deleted_at" not in _cols: - conn.execute(f"ALTER TABLE {_table} ADD COLUMN deleted_at TEXT") - - conn.execute(""" - CREATE TABLE IF NOT EXISTS dhcp_option_defs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code INTEGER NOT NULL UNIQUE, - name TEXT NOT NULL UNIQUE, - type TEXT NOT NULL, - description TEXT, - is_standard INTEGER NOT NULL DEFAULT 0 - ) - """) - option_def_cols = {row["name"] for row in conn.execute("PRAGMA table_info(dhcp_option_defs)").fetchall()} - if "is_standard" not in option_def_cols: - conn.execute("ALTER TABLE dhcp_option_defs ADD COLUMN is_standard INTEGER NOT NULL DEFAULT 0") - conn.execute(""" - CREATE TABLE IF NOT EXISTS dhcp_option_values ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - option_def_id INTEGER NOT NULL, - device_mac TEXT NOT NULL DEFAULT '', - value TEXT NOT NULL, - FOREIGN KEY (option_def_id) REFERENCES dhcp_option_defs(id), - UNIQUE (option_def_id, device_mac) - ) - """) - _migration_key_std_opts = "_seeded_dhcp_standard_options_v1" - if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts,)).fetchone(): - for code, name, opt_type, description in DHCP_STANDARD_OPTIONS: - conn.execute( - "INSERT OR IGNORE INTO dhcp_option_defs (code, name, type, description, is_standard) " - "VALUES (?, ?, ?, ?, 1)", - (code, name, opt_type, description), - ) - conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts,)) - _migration_key_std_opts_v2 = "_seeded_dhcp_standard_options_v2" - if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts_v2,)).fetchone(): - for code, name, opt_type, description in DHCP_STANDARD_OPTIONS: - conn.execute( - "INSERT OR IGNORE INTO dhcp_option_defs (code, name, type, description, is_standard) " - "VALUES (?, ?, ?, ?, 1)", - (code, name, opt_type, description), - ) - conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts_v2,)) - _migration_key_std_opts_v3 = "_removed_dhcp_standard_option_hostname_v3" - if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts_v3,)).fetchone(): - stale = conn.execute("SELECT id FROM dhcp_option_defs WHERE name='host-name' AND is_standard=1").fetchone() - if stale: - conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=?", (stale["id"],)) - conn.execute("DELETE FROM dhcp_option_defs WHERE id=?", (stale["id"],)) - conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts_v3,)) - - conn.execute(""" - CREATE TABLE IF NOT EXISTS dhcp_manual_reservations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - mac TEXT NOT NULL UNIQUE, - ip TEXT NOT NULL, - name TEXT NOT NULL - ) - """) - - conn.execute(""" - CREATE TABLE IF NOT EXISTS dhcp_subnets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - interface TEXT NOT NULL, - range_start TEXT NOT NULL, - range_end TEXT NOT NULL, - gateway TEXT, - dns TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, - enabled INTEGER NOT NULL DEFAULT 1 - ) - """) - dhcp_subnet_cols = {row["name"] for row in conn.execute("PRAGMA table_info(dhcp_subnets)").fetchall()} - if "enabled" not in dhcp_subnet_cols: - conn.execute("ALTER TABLE dhcp_subnets ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1") - _migration_key_dhcp = "_migrated_dhcp_single_range_to_subnets_v1" - if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_dhcp,)).fetchone(): - legacy_start = conn.execute("SELECT value FROM settings WHERE key='dhcp_range_start'").fetchone() - legacy_end = conn.execute("SELECT value FROM settings WHERE key='dhcp_range_end'").fetchone() - if legacy_start and legacy_end and legacy_start["value"] and legacy_end["value"]: - legacy_interface = conn.execute("SELECT value FROM settings WHERE key='dhcp_interface'").fetchone() - legacy_gateway = conn.execute("SELECT value FROM settings WHERE key='dhcp_gateway'").fetchone() - legacy_dns = conn.execute("SELECT value FROM settings WHERE key='dhcp_dns'").fetchone() - conn.execute( - "INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, sort_order) " - "VALUES (?, ?, ?, ?, ?, 0)", - ( - (legacy_interface["value"] if legacy_interface else None) or "eth0", - legacy_start["value"], legacy_end["value"], - legacy_gateway["value"] if legacy_gateway else None, - legacy_dns["value"] if legacy_dns else None, - ), - ) - conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_dhcp,)) - - if "username" in switch_cols and "password" in switch_cols: - legacy_switches = conn.execute( - "SELECT hostname, username, password FROM switches WHERE credential_id IS NULL" - ).fetchall() - for sw in legacy_switches: - base_name = f"{sw['hostname']} (migriert)" - final_name, suffix = base_name, 1 - while conn.execute("SELECT 1 FROM credentials WHERE name=?", (final_name,)).fetchone(): - suffix += 1 - final_name = f"{base_name} {suffix}" - cur = conn.execute( - "INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)", - (final_name, sw["username"], sw["password"]), - ) - conn.execute( - "UPDATE switches SET credential_id=? WHERE hostname=?", - (cur.lastrowid, sw["hostname"]), - ) - - _migration_key = "_migrated_permission_hierarchy_v1" - if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key,)).fetchone(): - toggle_groups = conn.execute( - "SELECT DISTINCT group_id FROM group_permissions WHERE permission='devices.toggle'" - ).fetchall() - for row in toggle_groups: - conn.execute( - "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, 'devices.edit')", - (row["group_id"],), - ) - conn.execute("DELETE FROM group_permissions WHERE permission='devices.toggle'") - - _switch_to_cred = { - "switches.view": "credentials.view", - "switches.create": "credentials.create", - "switches.edit": "credentials.edit", - "switches.delete": "credentials.delete", - } - for switch_perm, cred_perm in _switch_to_cred.items(): - rows = conn.execute( - "SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (switch_perm,) - ).fetchall() - for row in rows: - conn.execute( - "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)", - (row["group_id"], cred_perm), - ) - - _parent_backfill = [ - ("devices_group.view", [ - "devices.view", "devices.create", "devices.edit", "devices.delete", "devices.restart", - "switches.view", "switches.create", "switches.edit", "switches.delete", - "credentials.view", "credentials.create", "credentials.edit", "credentials.delete", - ]), - ("logs_group.view", ["logs_live.view", "logs_activity.view"]), - ("settings_group.view", [ - "users.view", "users.create", "users.edit", "users.delete", - "groups.view", "groups.create", "groups.edit", "groups.delete", - "settings_system.view", "settings_system.edit", - "settings_importexport.view", "settings_importexport.edit", - ]), - ] - for parent_key, child_keys in _parent_backfill: - placeholders = ",".join("?" * len(child_keys)) - rows = conn.execute( - f"SELECT DISTINCT group_id FROM group_permissions WHERE permission IN ({placeholders})", - child_keys, - ).fetchall() - for row in rows: - conn.execute( - "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)", - (row["group_id"], parent_key), - ) - - conn.execute( - "INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key,) - ) - - _migration_key_v2 = "_migrated_permission_delete_fold_v2" - if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_v2,)).fetchone(): - _delete_to_edit = { - "devices.delete": "devices.edit", - "switches.delete": "switches.edit", - "credentials.delete": "credentials.edit", - "users.delete": "users.edit", - "groups.delete": "groups.edit", - } - for delete_perm, edit_perm in _delete_to_edit.items(): - rows = conn.execute( - "SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (delete_perm,) - ).fetchall() - for row in rows: - conn.execute( - "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)", - (row["group_id"], edit_perm), - ) - conn.execute("DELETE FROM group_permissions WHERE permission=?", (delete_perm,)) - - conn.execute( - "INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_v2,) - ) - - _migration_key_license_perm = "_migrated_settings_license_permission_v1" - if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_license_perm,)).fetchone(): - # "Lizenz" (settings_license) hatte bislang KEIN eigenes Recht -- - # die Seite lief komplett über settings_system.view/.edit mit. - # Backfill: jede Gruppe, die settings_system.view/.edit bereits - # hatte, bekommt automatisch das neue, gespiegelte - # settings_license.view/.edit dazu -- ohne diesen Schritt würden - # bestehende Gruppen beim Update stillschweigend den Zugriff auf - # die Lizenzseite verlieren, den sie vorher implizit hatten. - _system_to_license = { - "settings_system.view": "settings_license.view", - "settings_system.edit": "settings_license.edit", - } - for system_perm, license_perm in _system_to_license.items(): - rows = conn.execute( - "SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (system_perm,) - ).fetchall() - for row in rows: - conn.execute( - "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)", - (row["group_id"], license_perm), - ) - conn.execute( - "INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_license_perm,) - ) - - _migration_key_old_logs = "_cleaned_up_legacy_rpi_log_files_v1" - if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_old_logs,)).fetchone(): - for stale_log in glob.glob("/var/log/rpi-*.log"): - try: - os.remove(stale_log) - except OSError: - pass - conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_old_logs,)) - - cur = conn.execute( - "INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", - (DEFAULT_GROUP_NAME,), - ) - default_group_id = cur.lastrowid if cur.rowcount > 0 else conn.execute( - "SELECT id FROM groups WHERE name=?", (DEFAULT_GROUP_NAME,) - ).fetchone()["id"] - conn.execute("UPDATE groups SET is_default=1, is_system=1 WHERE id=?", (default_group_id,)) - conn.execute("DELETE FROM group_permissions WHERE group_id=?", (default_group_id,)) - conn.executemany( - "INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)", - [(default_group_id, p) for p in DEFAULT_GROUP_PERMISSIONS], - ) - - orphan_users = conn.execute(""" - SELECT users.id FROM users - LEFT JOIN user_groups ON user_groups.user_id = users.id - WHERE users.is_admin = 0 AND user_groups.user_id IS NULL AND users.deleted_at IS NULL - """).fetchall() - conn.executemany( - "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", - [(u["id"], default_group_id) for u in orphan_users], - ) - - conn.commit() - conn.close() - - -_ensure_schema() - - -def _build_user(row): - """Baut ein vollständiges User-Objekt inkl. effektiver Rechte aus allen - Gruppen, denen der Benutzer angehört (Vereinigung, nicht Schnittmenge).""" - conn = get_db_connection() - group_rows = conn.execute(""" - SELECT g.id, g.name FROM groups g - JOIN user_groups ug ON ug.group_id = g.id - WHERE ug.user_id = ? AND g.deleted_at IS NULL - ORDER BY g.name ASC - """, (row["id"],)).fetchall() - - permissions = set() - if group_rows: - placeholders = ",".join("?" * len(group_rows)) - perm_rows = conn.execute( - f"SELECT DISTINCT permission FROM group_permissions WHERE group_id IN ({placeholders})", - [g["id"] for g in group_rows], - ).fetchall() - permissions = {p["permission"] for p in perm_rows} - conn.close() - - group_names = ", ".join(g["name"] for g in group_rows) - return User( - row["id"], row["username"], row["is_admin"], permissions, group_names, - first_name=row["first_name"] if "first_name" in row.keys() else None, - last_name=row["last_name"] if "last_name" in row.keys() else None, - avatar_filename=row["avatar_filename"] if "avatar_filename" in row.keys() else None, - auth_source=row["auth_source"] if "auth_source" in row.keys() else "local", - ) - - -def _append_changes_log(ts, who, action, target, details): - """Spiegelt jeden Änderungslog-Eintrag zusätzlich in eine eigene, per - logrotate rotierte Datei (TESM_CHANGES_LOG_PATH) — die audit_log-Tabelle - bleibt die Quelle für die durchsuchbare/sortierbare Seite unter - Logs → Auditlog, wächst aber unbegrenzt weiter; die Datei bekommt - dieselbe wöchentliche Rotation/Aufbewahrung wie die anderen drei Logs. - Ein Schreibfehler hier darf den eigentlichen, DB-basierten Audit-Trail - nicht gefährden — daher bewusst best-effort mit breitem except.""" - try: - line = f"{ts} [{who}] {action}" - if target: - line += f" — {target}" - if details: - line += f": {details}" - with open(TESM_CHANGES_LOG_PATH, "a", encoding="utf-8") as f: - f.write(line + "\n") - except OSError: - pass - - -def log_action(action, target=None, details=None): - """Schreibt einen Eintrag ins Änderungsverlauf-Log. Absichtlich NICHT für - PoE-Neustarts genutzt (die stehen bereits im Live-Log von poe.sh).""" - who = current_user.username if current_user.is_authenticated else "system" - ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn = get_db_connection() - conn.execute( - "INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)", - (ts, who, action, target, details), - ) - conn.commit() - conn.close() - _append_changes_log(ts, who, action, target, details) - - -def log_action_system(action, target=None, details=None): - """Wie log_action(), aber ohne Abhängigkeit von current_user — für - Code, der außerhalb eines Request-Kontexts läuft (z.B. der - Netzwerk-Auto-Revert-Timer in einem eigenen Thread, wo current_user - nicht auflösbar ist und einen AttributeError werfen würde).""" - ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn = get_db_connection() - conn.execute( - "INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)", - (ts, "system", action, target, details), - ) - conn.commit() - conn.close() - _append_changes_log(ts, "system", action, target, details) - - -def _format_audit_line(ts, who, action, target, details): - """Gleiches Zeilenformat wie _append_changes_log(), damit ein - archivierter Auditlog-Tag optisch identisch zu changes.log aussieht.""" - line = f"{ts} [{who}] {action}" - if target: - line += f" — {target}" - if details: - line += f": {details}" - return line - - -def _audit_archive_file_path(day_str): - """day_str: 'YYYY-MM-DD', ausschließlich aus intern erzeugten - DB-Zeitstempeln abgeleitet, nie aus Nutzereingabe -- anders als bei - _resolve_log_history_file() ist hier kein Path-Traversal-Schutz nötig.""" - return os.path.join(AUDIT_ARCHIVE_DIR, f"audit-{day_str}.log") - - -def _audit_archive_files(): - """Für die Verlauf-Seite: alle bereits archivierten Auditlog-Tage, - neueste zuerst, mit Größe für die Dateiliste.""" - if not os.path.isdir(AUDIT_ARCHIVE_DIR): - return [] - out = [] - for name in os.listdir(AUDIT_ARCHIVE_DIR): - m = re.fullmatch(r"audit-(\d{4}-\d{2}-\d{2})\.log", name) - if not m: - continue - full_path = os.path.join(AUDIT_ARCHIVE_DIR, name) - try: - size = os.path.getsize(full_path) - except OSError: - continue - out.append({"filename": name, "day": m.group(1), "size": size}) - out.sort(key=lambda f: f["day"], reverse=True) - return out - - -def _resolve_audit_archive_file(filename): - """Path-Traversal-Schutz analog _resolve_log_history_file(): nur exakt - ein Dateiname im erwarteten audit-YYYY-MM-DD.log-Muster, innerhalb von - AUDIT_ARCHIVE_DIR.""" - if not filename or not re.fullmatch(r"audit-\d{4}-\d{2}-\d{2}\.log", filename): - return None - full_path = os.path.join(AUDIT_ARCHIVE_DIR, filename) - if os.path.dirname(os.path.abspath(full_path)) != os.path.abspath(AUDIT_ARCHIVE_DIR): - return None - if not os.path.isfile(full_path): - return None - return full_path - - -def _log_disk_space_warning(): - """None wenn genug Platz frei ist, sonst ein Warnhinweis-Text fürs - Verlauf-Template. Bewusst nur eine Warnung (kein automatisches - Löschen) -- Aufräumen bleibt eine bewusste, manuelle Entscheidung - über den Export-und-Löschen-Button.""" - try: - free = shutil.disk_usage(TESM_LOG_DIR).free - except OSError: - return None - if free >= LOG_DISK_SPACE_WARNING_BYTES: - return None - free_mb = free // (1024 * 1024) - return ( - f"Wenig Speicherplatz im Log-Verzeichnis (nur noch {free_mb} MB frei). " - f"Archivierte Auditlog-Tage können unten exportiert (ZIP-Download) und " - f"dabei vom Server gelöscht werden, um Platz zu schaffen." - ) - - -def _archive_old_audit_log_rows(): - """Hält die audit_log-Tabelle auf eine vernünftige historische - Zeilenanzahl begrenzt: sobald AUDIT_LOG_ARCHIVE_THRESHOLD überschritten - ist, werden die ältesten VOLLSTÄNDIGEN Kalendertage (alles vor dem - heutigen Tag -- der laufende Tag wird nie mitten am Tag angefasst) in je - eine eigene Datei audit-YYYY-MM-DD.log unter AUDIT_ARCHIVE_DIR - geschrieben und anschließend aus der DB gelöscht, so lange bis - AUDIT_LOG_ARCHIVE_TARGET wieder unterschritten ist oder kein - archivierbarer (nicht-heutiger) Tag mehr übrig ist. - - Bewusst unabhängig von der wöchentlichen logrotate-Rotation von - changes.log (siehe _write_logrotate_config): die Dateien hier sind - tagesgenau benannt, damit sie unter Verlauf direkt nach Kalendertag - auswählbar sind -- eine logrotate-Kopie (changes.log.1.gz usw.) lässt - sich nicht so eindeutig einem Tag zuordnen. - - Schreiben je Tagesdatei erfolgt im Overwrite-Modus ('w', nicht 'a'): - die DB ist bis zum COMMIT des DELETE die alleinige Quelle der Wahrheit - für einen noch nicht archivierten Tag -- ein erneuter Lauf (z.B. nach - einem Absturz zwischen Schreiben und Löschen) erzeugt exakt denselben - Dateiinhalt erneut, statt Zeilen zu duplizieren.""" - conn = get_db_connection() - archived_days = 0 - archived_rows = 0 - try: - total = conn.execute("SELECT COUNT(*) AS n FROM audit_log").fetchone()["n"] - if total <= AUDIT_LOG_ARCHIVE_THRESHOLD: - return - - today_start = datetime.now().strftime("%Y-%m-%d 00:00:00") - os.makedirs(AUDIT_ARCHIVE_DIR, exist_ok=True) - - while total > AUDIT_LOG_ARCHIVE_TARGET: - oldest = conn.execute( - "SELECT MIN(ts) AS ts FROM audit_log WHERE ts < ?", (today_start,) - ).fetchone()["ts"] - if not oldest: - break # nur noch der heutige Tag übrig -- nicht anfassen - day_str = oldest[:10] - day_start, day_end = f"{day_str} 00:00:00", f"{day_str} 23:59:59" - rows = conn.execute( - "SELECT ts, username, action, target, details FROM audit_log " - "WHERE ts >= ? AND ts <= ? ORDER BY ts, id", - (day_start, day_end), - ).fetchall() - if not rows: - break - - lines = [ - _format_audit_line(r["ts"], r["username"], r["action"], r["target"], r["details"]) - for r in rows - ] - with open(_audit_archive_file_path(day_str), "w", encoding="utf-8") as f: - f.write("\n".join(lines) + "\n") - - conn.execute("DELETE FROM audit_log WHERE ts >= ? AND ts <= ?", (day_start, day_end)) - conn.commit() - - archived_days += 1 - archived_rows += len(rows) - total -= len(rows) - finally: - conn.close() - - if archived_rows: - log_action_system( - "auditlog.archive", - "Auditlog", - f"{archived_rows} Einträge über {archived_days} Tag(e) archiviert " - f"(Schwellenwert {AUDIT_LOG_ARCHIVE_THRESHOLD}, Ziel {AUDIT_LOG_ARCHIVE_TARGET}).", - ) - - -def touch_record(conn, table, key_col, key_val): - """Trägt 'zuletzt geändert von/am' direkt am Datensatz ein (Devices/Switches).""" - who = current_user.username if current_user.is_authenticated else "system" - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn.execute( - f"UPDATE {table} SET last_modified_by=?, last_modified_at=? WHERE {key_col}=?", - (who, now, key_val), - ) - - -def _derive_export_fernet(passphrase: str, salt: bytes) -> Fernet: - """Leitet aus einer Passphrase + Salt einen Fernet-Schlüssel ab (PBKDF2) — - für portable, umgebungsunabhängige Ver-/Entschlüsselung beim Import/Export - (im Gegensatz zum eigentlichen fernet.key, der pro Installation neu ist).""" - kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=390000) - key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8"))) - return Fernet(key) - - -def encrypt_password(password: str) -> str: - return fernet.encrypt(password.encode()).decode() - - -def decrypt_password(token: str) -> str: - return fernet.decrypt(token.encode()).decode() - - -def get_setting(key, default=None): - conn = get_db_connection() - row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() - conn.close() - return row["value"] if row else default - - -def set_setting(key, value): - conn = get_db_connection() - conn.execute( - "INSERT INTO settings (key, value) VALUES (?, ?) " - "ON CONFLICT(key) DO UPDATE SET value=excluded.value", - (key, value), - ) - conn.commit() - conn.close() - - -LOG_ROTATION_INTERVALS = {"daily", "weekly", "monthly"} -LOG_ROTATION_DEFAULT_INTERVAL = "weekly" -LOG_ROTATION_DEFAULT_KEEP = 4 - - -def _write_logrotate_config(): - """Schreibt/aktualisiert die logrotate-Konfiguration für alle vier - App-Logs (Live, Auditlog, App unter TESM_LOG_DIR; Kea-DHCP separat - unter TESM_KEA_LOG_PATH — Keas AppArmor-Profil erlaubt nur genau diesen - einen Pfad, siehe Kommentar bei TESM_KEA_LOG_PATH) anhand der aktuellen - Einstellungen (Systemeinstellungen → Logs). Läuft sowohl beim App-Start - (Rotation ist dadurch auch ohne einen expliziten Besuch der - Settings-Seite von Anfang an aktiv) als auch nach jedem Speichern dort. - "copytruncate" statt Signal-/Neustart-basierter Rotation, da keiner der - drei schreibenden Prozesse (poe.sh, Flask, kea-dhcp4-server) ein - Log-Reopen-Signal (SIGHUP o.ä.) implementiert — copytruncate - funktioniert ohne jede Kooperation des schreibenden Prozesses (Kea - eingeschlossen: logrotate läuft als root, unterliegt also nicht Keas - eigenem AppArmor-Profil). "su root root" ist eine explizite Absicherung - dagegen, dass logrotate die Rotation wegen "insecure permissions" - verweigert, sobald TESM_LOG_DIR aus irgendeinem Grund wieder - gruppen-/world-writable wird (live reproduziert: bei 0o777 rotierte - logrotate live.log gar nicht mehr, siehe auch os.chmod(TESM_LOG_DIR) - weiter oben, das den eigentlichen Regelfall -- 0o755 -- durchsetzt).""" - interval = get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL) - if interval not in LOG_ROTATION_INTERVALS: - interval = LOG_ROTATION_DEFAULT_INTERVAL - try: - keep = int(get_setting("log_rotation_keep", LOG_ROTATION_DEFAULT_KEEP)) - if keep < 1: - keep = LOG_ROTATION_DEFAULT_KEEP - except (TypeError, ValueError): - keep = LOG_ROTATION_DEFAULT_KEEP - - content = ( - f"{TESM_LOG_DIR}/*.log {TESM_KEA_LOG_PATH} {{\n" - f" {interval}\n" - f" rotate {keep}\n" - f" missingok\n" - f" notifempty\n" - f" copytruncate\n" - f" su root root\n" - f"}}\n" - ) - try: - with open(LOGROTATE_CONFIG_PATH, "w", encoding="utf-8") as f: - f.write(content) - return True - except OSError: - return False - - -_write_logrotate_config() - - -@login_manager.user_loader -def load_user(user_id): - conn = get_db_connection() - user = conn.execute("SELECT * FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,)).fetchone() - conn.close() - if user: - return _build_user(user) - return None - - - -LDAP_DEFAULT_PORT = 389 -LDAP_DEFAULT_FILTER_ATTR = "sAMAccountName" - - -def _ldap_groups_for_dropdown(): - conn = get_db_connection() - rows = conn.execute( - "SELECT id, name, is_default FROM groups WHERE deleted_at IS NULL ORDER BY is_default DESC, name ASC" - ).fetchall() - conn.close() - return rows - - -LDAP_BIND_SERVICE_ACCOUNT_PURPOSE = "ldap_bind" - - -def _ldap_bind_account(): - """Liest das LDAP-Bind-Konto aus der service_accounts-Tabelle (nicht - aus settings — siehe Kommentar bei der Tabellen-Definition).""" - conn = get_db_connection() - row = conn.execute( - "SELECT username, password FROM service_accounts WHERE purpose=?", - (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,), - ).fetchone() - conn.close() - return (row["username"], row["password"]) if row else ("", "") - - -def _ldap_settings(): - bind_dn, bind_password_enc = _ldap_bind_account() - return { - "enabled": get_setting("ldap_enabled", "0") == "1", - "server": get_setting("ldap_server", "") or "", - "port": get_setting("ldap_port", str(LDAP_DEFAULT_PORT)) or str(LDAP_DEFAULT_PORT), - "use_ssl": get_setting("ldap_use_ssl", "0") == "1", - "tls_skip_verify": get_setting("ldap_tls_skip_verify", "0") == "1", - "bind_dn": bind_dn, - "bind_password_enc": bind_password_enc, - "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 "", - } - - -_LDAP_TRANSIENT_EXCEPTIONS = ( - LDAPSocketOpenError, - LDAPSocketReceiveError, - LDAPSocketSendError, - LDAPResponseTimeoutError, -) - - -def _ldap_connect_with_retry(factory): - """Baut eine LDAP-Verbindung auf, mit genau einem automatischen - Wiederholversuch bei einem rein netzwerk-/timeout-bedingten Fehler - (z.B. ein Domain-Controller in einem anderen, langsameren Netzsegment - -- die allererste Verbindung dorthin ist oft spürbar langsamer als - jede folgende, live reproduziert: erster Login schlug mit "Benutzer/ - Passwort ungültig" fehl, ein sofortiger zweiter Versuch klappte). Der - Retry greift NICHT bei einer tatsächlich abgelehnten Anmeldung (z.B. - falsches Passwort, eigene Exception-Klasse in ldap3) -- sonst würde - ein einzelner Tippfehler beim Passwort einen AD-Kontosperren-Zähler - unnötig doppelt statt einmal hochzählen. factory ist ein parameterloser - Callable, das die Verbindung aufbaut (auto_bind=True) und zurückgibt.""" - try: - return factory() - except _LDAP_TRANSIENT_EXCEPTIONS: - return factory() - - -def _ldap_connect_service(cfg): - """Baut Server-Objekt + Service-Konto-Verbindung auf. Wirft nie — - liefert (server, connection) oder (None, None) samt Fehlermeldung im - zweiten Rückgabewert bei Bedarf über die aufrufende Funktion.""" - try: - port = int(cfg["port"]) - except (TypeError, ValueError): - port = LDAP_DEFAULT_PORT - try: - bind_password = decrypt_password(cfg["bind_password_enc"]) if cfg["bind_password_enc"] else "" - except Exception: - return None, "Bind-Passwort konnte nicht entschlüsselt werden." - try: - tls = None - if cfg["use_ssl"] and cfg.get("tls_skip_verify"): - tls = Tls(validate=ssl.CERT_NONE) - server = Server(cfg["server"], port=port, use_ssl=cfg["use_ssl"], tls=tls, get_info=ALL, connect_timeout=10) - conn = _ldap_connect_with_retry(lambda: Connection( - server, user=cfg["bind_dn"], password=bind_password, - authentication=SIMPLE, auto_bind=True, receive_timeout=10, - )) - return (server, conn), None - except LDAPException as e: - return None, str(e) - - -def _ldap_base_dn(server, cfg): - if cfg["base_dn"].strip(): - return cfg["base_dn"].strip() - try: - return str(server.info.other["defaultNamingContext"][0]) - except (KeyError, IndexError, TypeError, AttributeError): - return None - - -def _ldap_test_connection(cfg=None): - """Für den 'Verbindung testen'-Button in den Systemeinstellungen — - bindet mit dem Service-Konto und ermittelt die (automatische oder - konfigurierte) Base-DN, ohne einen echten Benutzer zu prüfen. Testet - standardmäßig die zuletzt gespeicherten Einstellungen, akzeptiert aber - ein cfg-Override, damit "Verbindung testen" wie beim SSH-Terminal von - Switchen/Geräten die aktuell im Formular eingetragenen (noch nicht - gespeicherten) Werte prüfen kann. Gibt (True, meldung) oder - (False, fehler) zurück.""" - cfg = cfg or _ldap_settings() - if not cfg["server"] or not cfg["bind_dn"]: - return False, "Server und Bind-Konto müssen ausgefüllt sein." - result, error = _ldap_connect_service(cfg) - if error: - return False, f"Verbindung/Bind fehlgeschlagen: {error}" - server, conn = result - base_dn = _ldap_base_dn(server, cfg) - conn.unbind() - if not base_dn: - return False, "Verbunden, aber Base-DN konnte nicht automatisch ermittelt werden — bitte manuell angeben." - return True, f"Verbindung erfolgreich. Base-DN: {base_dn}" - - -_LDAP_ENTRY_ATTRS = ["distinguishedName", "givenName", "sn", "displayName", "userPrincipalName", "userAccountControl"] - - -def _ldap_find_entry(service_conn, base_dn, cfg, value): - """Sucht GENAU einen Verzeichniseintrag, dessen konfiguriertes - Login-Attribut (Standard sAMAccountName) ODER UPN (userPrincipalName, - dient hier zugleich als "E-Mail" -- siehe _ldap_entry_info) mit dem - übergebenen Wert übereinstimmt — deckt damit Login per bloßem - Benutzernamen UND per user@domain in einer einzigen Suche ab. Fällt bei - einem LDAP-Fehler (z.B. ein Server ohne userPrincipalName-Attribut im - Schema) auf eine einfache Suche nur nach dem Login-Attribut zurück. - Gibt das ldap3-Entry-Objekt oder None zurück (None auch bei mehr als - einem Treffer — mehrdeutig soll wie "nicht gefunden" behandelt werden).""" - safe_value = escape_filter_chars(value) - combined_filter = f"(|({cfg['filter_attr']}={safe_value})(userPrincipalName={safe_value}))" - try: - service_conn.search(search_base=base_dn, search_filter=combined_filter, attributes=_LDAP_ENTRY_ATTRS) - except LDAPException: - try: - service_conn.search( - search_base=base_dn, search_filter=f"({cfg['filter_attr']}={safe_value})", - attributes=_LDAP_ENTRY_ATTRS, - ) - except LDAPException: - return None - if len(service_conn.entries) != 1: - return None - return service_conn.entries[0] - - -def _ldap_entry_info(entry, cfg): - """Extrahiert Benutzername/Name/E-Mail/Sperrstatus aus einem - ldap3-Entry. Nutzt givenName/sn (echte AD-Attribute) statt eine - Vermutung anhand von displayName — fällt auf ein Aufsplitten von - displayName nur zurück, wenn givenName/sn nicht gesetzt sind (z.B. - minimalistisches generisches LDAP ohne diese Attribute).""" - def _attr(name): - val = getattr(entry, name, None) - return str(val) if val is not None and val.value else None - - canonical_username = _attr(cfg["filter_attr"]) - first_name = _attr("givenName") - last_name = _attr("sn") - if not first_name and not last_name: - display_name = _attr("displayName") or "" - first_name, _, last_name = display_name.partition(" ") - first_name = first_name or None - last_name = last_name or None - - disabled = False - uac = _attr("userAccountControl") - if uac is not None: - try: - disabled = bool(int(uac) & 2) - except ValueError: - pass - - return { - "username": canonical_username, - "first_name": first_name, - "last_name": last_name, - "email": _attr("userPrincipalName"), - "disabled": disabled, - } - - -def _ldap_lookup(username): - """Wie _ldap_authenticate(), aber OHNE Passwort-Prüfung — für die - Vorab-Zuweisung eines AD-Benutzers zu einer Gruppe, bevor er sich das - erste Mal angemeldet hat (siehe /users/ldap_add). Gibt (True, info) - oder (False, None) zurück.""" - cfg = _ldap_settings() - if not cfg["enabled"] or not username: - return False, None - if not cfg["server"] or not cfg["bind_dn"]: - return False, None - result, error = _ldap_connect_service(cfg) - if error: - return False, None - server, service_conn = result - try: - base_dn = _ldap_base_dn(server, cfg) - if not base_dn: - return False, None - entry = _ldap_find_entry(service_conn, base_dn, cfg, username) - if not entry: - return False, None - return True, _ldap_entry_info(entry, cfg) - except LDAPException: - return False, None - finally: - try: - service_conn.unbind() - except Exception: - pass - - -def _ldap_search_users(query, limit=25): - """Durchsucht das Verzeichnis per Substring-Suche über Login-Attribut, - Vor-/Nachname und E-Mail — für "Aus Active Directory hinzufügen" in - der Benutzerverwaltung (Vorab-Zuweisung, siehe _ldap_lookup). Gibt eine - Liste von Dicts zurück (siehe _ldap_entry_info), leer bei Fehler/ - deaktiviertem LDAP.""" - cfg = _ldap_settings() - if not cfg["enabled"] or not query.strip(): - return [] - if not cfg["server"] or not cfg["bind_dn"]: - return [] - result, error = _ldap_connect_service(cfg) - if error: - return [] - server, service_conn = result - try: - base_dn = _ldap_base_dn(server, cfg) - if not base_dn: - return [] - safe_q = escape_filter_chars(query.strip()) - filt = ( - f"(&(objectClass=person)" - f"(|({cfg['filter_attr']}=*{safe_q}*)(displayName=*{safe_q}*)(givenName=*{safe_q}*)(sn=*{safe_q}*)(userPrincipalName=*{safe_q}*)))" - ) - try: - service_conn.search(search_base=base_dn, search_filter=filt, attributes=_LDAP_ENTRY_ATTRS, size_limit=limit) - except LDAPException: - return [] - results = [] - for entry in service_conn.entries: - info = _ldap_entry_info(entry, cfg) - if info["username"]: - results.append(info) - return results - except LDAPException: - return [] - finally: - try: - service_conn.unbind() - except Exception: - pass - - -def _ldap_is_member_of(service_conn, user_dn, group_dn): - """Prüft AD-Gruppenmitgliedschaft REKURSIV (auch über verschachtelte - Gruppen) über LDAP_MATCHING_RULE_IN_CHAIN, statt nur das flache, - nicht-rekursive memberOf-Attribut des Benutzers direkt zu lesen — - jemand in einer Gruppe, die wiederum Mitglied der erforderlichen - Gruppe ist, soll sich genauso anmelden dürfen wie ein direktes - Mitglied.""" - try: - service_conn.search( - search_base=user_dn, search_scope=BASE, - search_filter=f"(memberOf:{LDAP_MATCHING_RULE_IN_CHAIN}:={escape_filter_chars(group_dn)})", - attributes=[], - ) - return len(service_conn.entries) == 1 - except LDAPException: - return False - - -def _ldap_list_groups(limit=300): - """Alle AD-Gruppen fürs Auswahl-Dropdown der erforderlichen Gruppe in - den Systemeinstellungen — per Service-Konto, keine Benutzer-Zugangsdaten - nötig. Gibt eine Liste von {"dn":..., "name":...} zurück, sortiert nach - Name, leer bei Fehler.""" - cfg = _ldap_settings() - if not cfg["server"] or not cfg["bind_dn"]: - return [] - result, error = _ldap_connect_service(cfg) - if error: - return [] - server, service_conn = result - try: - base_dn = _ldap_base_dn(server, cfg) - if not base_dn: - return [] - try: - service_conn.search( - search_base=base_dn, search_filter="(objectClass=group)", - attributes=["distinguishedName", "cn", "sAMAccountName"], size_limit=limit, - ) - except LDAPException: - return [] - groups = [] - for entry in service_conn.entries: - dn = str(entry.distinguishedName) if "distinguishedName" in entry and entry.distinguishedName.value else None - if not dn: - continue - name = None - if "cn" in entry and entry.cn.value: - name = str(entry.cn) - elif "sAMAccountName" in entry and entry.sAMAccountName.value: - name = str(entry.sAMAccountName) - groups.append({"dn": dn, "name": name or dn}) - groups.sort(key=lambda g: g["name"].lower()) - return groups - except LDAPException: - return [] - finally: - try: - service_conn.unbind() - except Exception: - pass - - -def _ldap_group_mappings(): - conn = get_db_connection() - rows = conn.execute(""" - SELECT m.id, m.ad_group_dn, m.ad_group_name, m.app_group_id, - CASE - WHEN m.app_group_id = 'admin' THEN 'Admin' - WHEN g.name IS NULL THEN '(gelöscht)' - ELSE g.name - END AS app_group_name - FROM ldap_group_mappings m - LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL - ORDER BY m.ad_group_name ASC - """).fetchall() - conn.close() - return rows - - -def _ldap_resolve_app_groups(service_conn, user_dn): - """Prüft für jede konfigurierte AD-Gruppen-Zuordnung, ob der Benutzer - Mitglied ist (auch über verschachtelte Gruppen, siehe - _ldap_is_member_of), und gibt die Menge der zugeordneten App-Gruppen - zurück -- normale Gruppen-IDs (int) sowie ggf. das Sentinel "admin". - Additiv gedacht: mehrere passende AD-Gruppen ergeben die Vereinigung - ihrer App-Gruppen, passend zum bestehenden Rechtesystem (mehrere - Gruppen je Benutzer, Rechte addieren sich).""" - matched = set() - for mapping in _ldap_group_mappings(): - if _ldap_is_member_of(service_conn, user_dn, mapping["ad_group_dn"]): - matched.add(mapping["app_group_id"]) - 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 - wie bei _ldap_entry_info), sonst (False, None) — nie eine Exception - nach außen, ein LDAP-Fehler soll wie eine falsche Anmeldung behandelt - werden, nicht die Login-Seite mit einem 500er abbrechen.""" - cfg = _ldap_settings() - if not cfg["enabled"] or not username or not password: - return False, None - if not cfg["server"] or not cfg["bind_dn"]: - return False, None - - result, error = _ldap_connect_service(cfg) - if error: - return False, None - server, service_conn = result - - try: - base_dn = _ldap_base_dn(server, cfg) - if not base_dn: - return False, None - entry = _ldap_find_entry(service_conn, base_dn, cfg, username) - if not entry: - return False, None - user_dn = str(entry.distinguishedName) - 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: - try: - service_conn.unbind() - except Exception: - pass - - try: - user_conn = _ldap_connect_with_retry(lambda: Connection( - server, user=user_dn, password=password, - authentication=SIMPLE, auto_bind=True, receive_timeout=10, - )) - user_conn.unbind() - except LDAPException: - return False, None - - return True, info - - - -def _assign_ldap_groups(conn, user_id, app_groups): - """Weist die per AD-Gruppenzuordnung ermittelten App-Gruppen zu — rein - additiv (nimmt nie eine bereits vorhandene, z.B. manuell zugewiesene - Gruppe wieder weg), damit ein Admin-Eingriff nie durch den nächsten - Login überschrieben wird. Das Sentinel "admin" setzt is_admin direkt.""" - if "admin" in app_groups: - conn.execute("UPDATE users SET is_admin=1 WHERE id=?", (user_id,)) - for group_id in app_groups: - if group_id == "admin": - continue - if not conn.execute("SELECT 1 FROM groups WHERE id=? AND deleted_at IS NULL", (group_id,)).fetchone(): - continue - conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (user_id, group_id)) - - -def _ldap_default_group_id(conn): - default_group = get_setting("ldap_default_group", "") - if default_group: - if conn.execute( - "SELECT 1 FROM groups WHERE id=? AND deleted_at IS NULL", (default_group,) - ).fetchone(): - return default_group - row = conn.execute( - "SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (DEFAULT_GROUP_NAME,) - ).fetchone() - 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() - - -# ============================================================ Lizenzsystem == -# Kryptographie/Protokoll: siehe licensing.py. Hier nur der TESM-seitige -# Zustand: In-Memory-Cache der aktuell installierten Lizenzdatei (nach dem -# gleichen Prinzip wie _active_fileshare_mounts oben -- einmal geladen, -# NICHT pro Request neu von der Platte gelesen) plus die zwei zentralen -# Gate-Funktionen license_active()/module_licensed(), die von -# _nav_key_visible(), den Routen-Guards und den Templates aus aufgerufen -# werden. Kleine operative Zusatzangaben (letzter Heartbeat, letzter -# Fehler, Aktivierungsstatus) leben als gewöhnliche settings-Zeilen -# (get_setting/set_setting) -- die Lizenzdatei selbst bleibt eine flache -# Datei neben app.py (LICENSE_PATH), genau wie fernet.key/known_hosts. -LICENSE_HEARTBEAT_INTERVAL_SECONDS = 6 * 3600 - -_license_state = {"file": None, "valid": False, "status": None, "error": None} - - -def _load_license(): - """Liest LICENSE_PATH neu ein und prüft die Master-Signatur. Wird beim - Modulimport einmal sowie nach jedem Upload/jeder Aktivierung/ - Deaktivierung/erfolgreichem Heartbeat erneut aufgerufen -- niemals - implizit pro Request (siehe TESM_VERSION weiter oben für dasselbe - Prinzip: Neuladen ist ein bewusster, seltener Vorgang, kein Read-Pfad).""" - global _license_state - try: - with open(LICENSE_PATH, "r", encoding="utf-8") as f: - license_file = json.load(f) - except (OSError, ValueError): - _license_state = {"file": None, "valid": False, "status": None, "error": None} - return - if not licensing.verify_license_file(license_file): - _license_state = { - "file": license_file, "valid": False, "status": None, - "error": "Signatur ungültig -- Lizenzdatei beschädigt oder manipuliert.", - } - return - _license_state = { - "file": license_file, "valid": True, - "status": licensing.license_status(license_file), "error": None, - } - - -_load_license() - - -def _write_license_file(license_file): - """Schreibt eine neue, signaturgeprüfte Lizenzdatei nach LICENSE_PATH - und lädt den In-Memory-Zustand sofort neu (kein Dienst-Neustart - nötig -- anders als z.B. bei der Versionsanzeige, siehe TESM_VERSION).""" - with open(LICENSE_PATH, "w", encoding="utf-8") as f: - json.dump(license_file, f, indent=2) - try: - os.chmod(LICENSE_PATH, 0o600) - except OSError: - pass - _load_license() - - -def _delete_license_file(): - """Löscht die lokale Lizenzdatei unwiderruflich -- Schritt 5 der - Deaktivierung (siehe licensing.py-Moduldocstring): erst NACH - erfolgreich verifizierter Deaktivierungs-Bestätigung des Masters - aufrufen, nie vorher.""" - try: - os.remove(LICENSE_PATH) - except OSError: - pass - set_setting("license_activation_status", "") - _load_license() - - -def license_activated(): - """True erst NACH einer beim Master erfolgreich abgeschlossenen - Aktivierung (online oder per Offline-Code) -- reines Hochladen einer - gültig signierten Lizenzdatei allein reicht laut Protokoll bewusst - NICHT aus, siehe licensing.py-Moduldocstring Schritt 5.""" - return get_setting("license_activation_status", "") == "active" - - -def license_active(): - """Zentrales Gate für alles, was 'sichtbar, aber ohne Lizenz inaktiv' - ist (LDAP, NGINX, Papierkorb-Bearbeiten, Export, Prüfintervall, - automatischer PoE-Neustart). True nur bei: signaturgültiger, - aktivierter Lizenz, deren Ablaufdatum entweder noch nicht erreicht ist - oder deren 30-tägige Gnadenfrist danach noch läuft - (licensing.GRACE_PERIOD_DAYS).""" - return bool( - _license_state["valid"] - and _license_state["status"] - and _license_state["status"]["modules_active"] - and license_activated() - ) - - -def module_licensed(module_key): - """Gate für die komplett ausgeblendeten Vollmodule (dhcp/fileshare/ - maintenance) -- zusätzlich zu license_active() muss das Modul auch - tatsächlich in der Lizenz enthalten sein.""" - return license_active() and module_key in _license_state["status"]["modules"] - - -def _require_module(module_key): - """Serverseitiger Guard für die drei komplett ausgeblendeten Vollmodule - (dhcp/fileshare/maintenance) -- ein direkter URL-Aufruf ohne Lizenz - muss ebenso abgewiesen werden wie das reine Fehlen des Nav-Eintrags - (_nav_key_visible). Rückgabe: eine fertige Flash+Redirect-Response, - falls das Modul nicht lizenziert ist, sonst None -- Aufrufer prüft - `if resp := _require_module(...): return resp` nach dem eigentlichen - Berechtigungs-Check, exakt im gleichen Stil wie die übrigen - Permission-Guards in diesem File.""" - if module_licensed(module_key): - return None - flash("Für dieses Modul ist keine gültige Lizenz vorhanden.", "danger") - return redirect(url_for("index")) - - -def license_export_allowed(): - """Export ist die EINE bewusste Ausnahme: bleibt auch im lizenzlosen - Zustand nutzbar, wenn dieser Zustand durch eine vom Kunden selbst - ausgelöste Deaktivierung (Systemwechsel-Fall) entstanden ist, damit - die eigenen Daten vor dem Umzug noch exportierbar sind. Sobald wieder - eine Lizenz aktiviert wird, greift ohnehin license_active() normal.""" - return license_active() or get_setting("license_export_grace", "") == "1" - - -def license_info(): - """Für Topbar/Settings-Seite: kompletter aktueller Rohzustand.""" - return _license_state - - -app.jinja_env.globals["license_active"] = license_active -app.jinja_env.globals["module_licensed"] = module_licensed -app.jinja_env.globals["license_export_allowed"] = license_export_allowed - - -def _license_api_post(endpoint, path, payload, timeout=10): - """Einfacher JSON-POST gegen den Lizenzserver über die Python- - Standardbibliothek (urllib) -- bewusst ohne zusätzliche Abhängigkeit - wie 'requests', da genau drei schmale Aufrufe (activate/deactivate/ - heartbeat) das nicht rechtfertigen. Wirft bei jedem Fehler (Netzwerk, - Timeout, HTTP-Fehlerstatus, kaputtes JSON) eine Exception -- der - Aufrufer entscheidet, ob das den Online- oder den Offline-Code-Pfad - bedeutet.""" - url = endpoint.rstrip("/") + path - data = json.dumps(payload).encode("utf-8") - req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") - with urllib.request.urlopen(req, timeout=timeout) as resp: - return json.loads(resp.read().decode("utf-8")) - - -def _send_heartbeat(): - """Ein einzelner Heartbeat-Versuch gegen den in der Lizenz hinterlegten - master_endpoint. Bei Erfolg: license_last_heartbeat_at aktualisieren - und eine ggf. mitgeschickte aktualisierte Lizenz NUR nach eigener - Signaturprüfung übernehmen. Bei Fehlschlag lediglich den Fehler - vermerken -- ein ausbleibender Heartbeat schaltet für sich genommen - nie ein Modul ab (siehe licensing.heartbeat_stale_warning, rein - informativ in der Topbar).""" - license_file = _license_state.get("file") - if not license_file or not _license_state.get("valid") or not license_activated(): - return - endpoint = license_file.get("master_endpoint", "") - if not endpoint: - return - request_payload = licensing.build_client_request("heartbeat", license_file) - try: - response_payload = _license_api_post(endpoint, "/api/heartbeat", request_payload) - except Exception as exc: - set_setting("license_last_error", f"Heartbeat fehlgeschlagen: {exc}") - return - - if not licensing.verify_master_response(response_payload, license_file.get("master_pubkey", "")): - set_setting("license_last_error", "Heartbeat-Antwort mit ungültiger Signatur erhalten -- ignoriert.") - return - - set_setting("license_last_heartbeat_at", datetime.now().strftime("%Y-%m-%d %H:%M:%S")) - set_setting("license_last_error", "") - - if response_payload.get("status") == "revoked": - log_action_system("license.revoked_remote", license_file.get("license_id"), - "Lizenz vom Lizenzserver per Heartbeat widerrufen.") - _delete_license_file() - return - - license_update = response_payload.get("license_update") - if license_update and licensing.verify_license_file(license_update): - _write_license_file(license_update) - log_action_system("license.updated_remote", license_update.get("license_id"), - "Aktualisierte Lizenz per Heartbeat übernommen.") - - -def _license_heartbeat_loop(): - """Läuft alle LICENSE_HEARTBEAT_INTERVAL_SECONDS (6h, siehe Plan) und - direkt einmal beim Start, analog zu _audit_archive_loop weiter oben.""" - while True: - try: - _send_heartbeat() - except Exception: - app.logger.error("Lizenz-Heartbeat fehlgeschlagen:\n%s", traceback.format_exc()) - time.sleep(LICENSE_HEARTBEAT_INTERVAL_SECONDS) - - -if _IS_WEB_PROCESS: - threading.Thread(target=_license_heartbeat_loop, daemon=True).start() - - -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 _audit_archive_loop(): - """Prüft einmal täglich (siehe AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS), ob - die audit_log-Tabelle den Schwellenwert überschritten hat, und - archiviert dann bei Bedarf über _archive_old_audit_log_rows(). Läuft - direkt beim Start einmal sofort (statt erst nach 24h zu prüfen), damit - ein frisch aktualisierter/neu gestarteter Dienst eine bereits - übervolle Tabelle nicht einen ganzen Tag lang unangetastet lässt.""" - while True: - try: - _archive_old_audit_log_rows() - except Exception: - app.logger.error("Auditlog-Archivierung fehlgeschlagen:\n%s", traceback.format_exc()) - time.sleep(AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS) - - -if _IS_WEB_PROCESS: - threading.Thread(target=_audit_archive_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")) - if resp := _require_module("fileshare"): - return resp - 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": - username = request.form["username"].strip() - password = request.form["password"] - conn = get_db_connection() - - user = conn.execute( - "SELECT * FROM users WHERE username = ? AND deleted_at IS NULL", (username,) - ).fetchone() - - if not user: - user = conn.execute( - "SELECT * FROM users WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != '' " - "AND deleted_at IS NULL", - (username,), - ).fetchone() - - if not user: - user = conn.execute( - "SELECT * FROM users WHERE auth_source='ldap' AND username = ? COLLATE NOCASE AND deleted_at IS NULL", - (username,), - ).fetchone() - - if user and user["is_locked"]: - conn.close() - flash("Dieses Konto ist gesperrt.", "danger") - return render_template("login.html") - - if user and user["auth_source"] == "ldap": - ok, info = _ldap_authenticate(user["username"], password) - if ok: - conn.execute( - "UPDATE users SET first_name=?, last_name=?, email=? WHERE id=?", - (info.get("first_name"), info.get("last_name"), info.get("email"), user["id"]), - ) - _assign_ldap_groups(conn, user["id"], info.get("app_groups") or set()) - conn.commit() - refreshed = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone() - conn.close() - 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") - - elif user: - conn.close() - if bcrypt.check_password_hash(user["password"], password): - login_user(_build_user(user)) - return redirect(url_for("index")) - flash("Ungültiger Benutzername oder Passwort", "danger") - - else: - ok, info = _ldap_authenticate(username, password) - if ok: - canonical_username = info.get("username") or username - placeholder_hash = bcrypt.generate_password_hash(secrets.token_hex(32)).decode("utf-8") - app_groups = info.get("app_groups") or set() - is_admin_new = 1 if "admin" in app_groups else 0 - try: - cur = conn.execute( - "INSERT INTO users (username, password, is_admin, first_name, last_name, email, auth_source) " - "VALUES (?, ?, ?, ?, ?, ?, 'ldap')", - (canonical_username, placeholder_hash, is_admin_new, - info.get("first_name"), info.get("last_name"), info.get("email")), - ) - if app_groups: - _assign_ldap_groups(conn, cur.lastrowid, app_groups) - else: - default_group = _ldap_default_group_id(conn) - if default_group: - conn.execute( - "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", - (cur.lastrowid, default_group), - ) - conn.commit() - 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") - 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() - flash("Ungültiger Benutzername oder Passwort", "danger") - else: - conn.close() - flash("Ungültiger Benutzername oder Passwort", "danger") - return render_template("login.html") - - -@app.route("/logout") -@login_required -def logout(): - _unmount_fileshares_for_current_session() - logout_user() - return redirect(url_for("index")) - - - -@app.route("/account") -@login_required -def account(): - return render_template("account.html") - - -@app.route("/profile", methods=["POST"]) -@login_required -def profile(): - conn = get_db_connection() - - if "update_profile" in request.form: - first_name = request.form.get("first_name", "").strip() or None - last_name = request.form.get("last_name", "").strip() or None - conn.execute( - "UPDATE users SET first_name=?, last_name=? WHERE id=?", - (first_name, last_name, current_user.id), - ) - conn.commit() - log_action("profile.update", current_user.username) - flash("Profil aktualisiert.", "success") - - elif "change_password" in request.form: - if current_user.is_ldap_user: - flash("Dieses Konto meldet sich über Active Directory an — das Passwort wird dort verwaltet.", "danger") - conn.close() - return redirect(url_for("account")) - current_password = request.form.get("current_password", "") - new_password = request.form.get("new_password", "") - confirm_password = request.form.get("confirm_password", "") - row = conn.execute("SELECT password FROM users WHERE id=?", (current_user.id,)).fetchone() - if not row or not bcrypt.check_password_hash(row["password"], current_password): - flash("Aktuelles Passwort ist falsch.", "danger") - elif not new_password or new_password != confirm_password: - flash("Neues Passwort und Bestätigung stimmen nicht überein.", "danger") - else: - pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8") - conn.execute("UPDATE users SET password=? WHERE id=?", (pw_hash, current_user.id)) - conn.commit() - log_action("profile.password", current_user.username) - flash("Passwort geändert.", "success") - - elif "upload_avatar" in request.form: - file = request.files.get("avatar") - if not file or not file.filename: - flash("Bitte ein Bild auswählen.", "danger") - else: - ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "" - if ext not in ALLOWED_AVATAR_EXT: - flash("Nur PNG, JPG, GIF oder WEBP erlaubt.", "danger") - else: - old_row = conn.execute( - "SELECT avatar_filename FROM users WHERE id=?", (current_user.id,) - ).fetchone() - filename = secure_filename(f"user_{current_user.id}_{secrets.token_hex(4)}.{ext}") - file.save(os.path.join(AVATAR_DIR, filename)) - conn.execute("UPDATE users SET avatar_filename=? WHERE id=?", (filename, current_user.id)) - conn.commit() - if old_row and old_row["avatar_filename"]: - old_path = os.path.join(AVATAR_DIR, old_row["avatar_filename"]) - if os.path.isfile(old_path): - os.remove(old_path) - flash("Profilbild aktualisiert.", "success") - - conn.close() - return redirect(request.referrer or url_for("index")) - - - -def _latest_log_file(): - """Aktuelles Live-Log — seit der Umstellung auf eine einzige, per - logrotate rotierte Datei (statt vorher bei jedem Dienst-Neustart ein - neues rpi-.log) gibt es nur noch GENAU eine "aktuelle" - Datei, kein Ermitteln der "neuesten von vielen" mehr nötig.""" - return TESM_LIVE_LOG_PATH if os.path.exists(TESM_LIVE_LOG_PATH) else None - - -LIVE_LOG_TAIL_LINES = 300 - - -def _tail_lines(path, n=LIVE_LOG_TAIL_LINES, chunk_size=65536): - """Liest effizient nur die letzten n Zeilen einer Datei, ohne sie - komplett einzulesen -- sucht dazu vom Dateiende rückwärts in Blöcken, - bis n Zeilenumbrüche gefunden wurden oder der Dateianfang erreicht ist. - Auf größeren Umgebungen (viele Geräte, kurzes Prüfintervall) kann - live.log zwischen zwei logrotate-Läufen mehrere MB groß werden; ein - komplettes Einlesen bei jedem Seitenaufruf/Live-Poll hat dort spürbare - Ladezeiten verursacht -- das war der eigentliche Auslöser dieser - Funktion.""" - with open(path, "rb") as f: - f.seek(0, os.SEEK_END) - remaining = f.tell() - data = b"" - while remaining > 0 and data.count(b"\n") <= n: - read_size = min(chunk_size, remaining) - remaining -= read_size - f.seek(remaining) - data = f.read(read_size) + data - text = data.decode("utf-8", errors="replace") - lines = text.split("\n") - return "\n".join(lines[-n:]) if len(lines) > n else text - - -def _count_lines(path): - """Schnelle Zeilenzählung über rohe Byte-Chunks (nur Zählen von \\n, - kein Aufbau einer Python-Zeilenliste) -- für die "letzte n von insgesamt - X Zeilen"-Anzeige, ohne den Performance-Vorteil von _tail_lines wieder - durch ein volles Einlesen zunichte zu machen.""" - try: - count = 0 - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(1024 * 1024), b""): - count += chunk.count(b"\n") - return count - except OSError: - return None - - -def _format_log_size(num_bytes): - size = float(num_bytes) - for unit in ("B", "KB", "MB", "GB"): - if size < 1024 or unit == "GB": - return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}" - size /= 1024 - return f"{size:.1f} GB" - - -_LOG_HISTORY_FILE_RE = re.compile(r"^live\.log\.([1-9][0-9]*)$") -_LOG_HISTORY_DATE_FMT = "%d.%m.%Y %H:%M" - - -def _live_log_history_files(): - """Alle über die Verlauf-Seite auswählbaren Stände des Live-Logs -- - generation 0 ist die aktuelle, noch laufende live.log selbst, 1 die - zuletzt rotierte Kopie, 2 der Lauf davor usw. (copytruncate ohne - compress, siehe _write_logrotate_config, daher reine Textdateien ohne - .gz), sortiert von neu (0) nach alt. - - Jede Kopie deckt den Zeitraum zwischen ZWEI Rotationen ab: ihr eigenes - mtime ist der Zeitpunkt, an dem ihr Inhalt eingefroren wurde (Ende des - Zeitraums), das mtime der NÄCHSTälteren Generation der Beginn -- ein - reines rename() bei jeder weiteren Rotation ändert mtime nicht, jede - Generation behält also dauerhaft den Zeitpunkt ihrer eigenen - Entstehung. Für die aktuelle live.log gibt es kein eigenes "Ende", - dort wird stattdessen "laufend" angezeigt.""" - results = [] - try: - if os.path.isfile(TESM_LIVE_LOG_PATH): - mtime = os.path.getmtime(TESM_LIVE_LOG_PATH) - size = os.path.getsize(TESM_LIVE_LOG_PATH) - results.append({ - "filename": "live.log", "generation": 0, - "mtime": mtime, "size_str": _format_log_size(size), - }) - except OSError: - pass - - try: - entries = os.listdir(TESM_LOG_DIR) - except OSError: - entries = [] - for entry in entries: - m = _LOG_HISTORY_FILE_RE.match(entry) - if not m: - continue - full_path = os.path.join(TESM_LOG_DIR, entry) - if not os.path.isfile(full_path): - continue - try: - mtime = os.path.getmtime(full_path) - size = os.path.getsize(full_path) - except OSError: - continue - results.append({ - "filename": entry, "generation": int(m.group(1)), - "mtime": mtime, "size_str": _format_log_size(size), - }) - results.sort(key=lambda r: r["generation"]) - - for i, entry in enumerate(results): - older = results[i + 1] if i + 1 < len(results) else None - start_str = datetime.fromtimestamp(older["mtime"]).strftime(_LOG_HISTORY_DATE_FMT) if older else None - if entry["generation"] == 0: - end_str = "laufend" - else: - end_str = datetime.fromtimestamp(entry["mtime"]).strftime(_LOG_HISTORY_DATE_FMT) - entry["range_label"] = f"{start_str or '?'} – {end_str}" - return results - - -def _resolve_log_history_file(filename): - """Validiert einen vom Client übergebenen Dateinamen (?file=...) gegen - die tatsächlich vorhandene aktuelle live.log bzw. rotierte Kopien davon - in TESM_LOG_DIR -- verhindert Path-Traversal bzw. beliebiges - Datei-Lesen über den Query-Parameter. Gibt den vollen Pfad oder None - zurück.""" - if filename == "live.log": - return TESM_LIVE_LOG_PATH if os.path.isfile(TESM_LIVE_LOG_PATH) else None - if not filename or not _LOG_HISTORY_FILE_RE.match(filename): - return None - full_path = os.path.join(TESM_LOG_DIR, filename) - if os.path.dirname(os.path.abspath(full_path)) != os.path.abspath(TESM_LOG_DIR): - return None - if not os.path.isfile(full_path): - return None - return full_path - - -def get_last_seen(dev_name: str): - """Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war. Sucht - bewusst auch in den von logrotate rotierten Kopien (live.log.1, .2, …), - nicht nur in der aktuellen Datei — sonst würde nach einer Rotation der - "zuletzt online"-Zeitpunkt eines länger schon offline befindlichen - Geräts verloren gehen, obwohl er in einer älteren Rotation noch - vorhanden wäre.""" - log_files = glob.glob(TESM_LIVE_LOG_PATH + "*") - if not log_files: - return None - - latest_time = None - for logfile in sorted(log_files): - try: - with open(logfile, "r") as f: - for line in f: - line = line.strip() - if f"{dev_name} ist erreichbar!" in line: - try: - ts_str = line.split(" ")[0] + " " + line.split(" ")[1] - ts = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S") - if latest_time is None or ts > latest_time: - latest_time = ts - except Exception: - continue - except OSError: - continue - - if latest_time: - return latest_time.strftime("Zuletzt Online am %d.%m.%Y um %H:%M Uhr") - return None - - -_LOG_LINE_RE = re.compile( - r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (.+?) ist (erreichbar|nicht erreichbar)!$" -) - - -def _format_ts(ts_str: str) -> str: - try: - return datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").strftime("%d.%m.%Y %H:%M") - except ValueError: - return ts_str - - -def get_last_run_at(): - """Zeitpunkt des letzten Prüf-Durchlaufs (jüngste Zeile im aktuellsten - Logfile) — Basis für den globalen 'Nächste Prüfung'-Countdown in der - Topbar, unabhängig von einer bestimmten Geräteliste.""" - latest_log = _latest_log_file() - if not latest_log: - return None - latest_ts_str = None - with open(latest_log, "r") as f: - for line in f: - m = _LOG_LINE_RE.match(line.strip()) - if m and (latest_ts_str is None or m.group(1) > latest_ts_str): - latest_ts_str = m.group(1) - if not latest_ts_str: - return None - try: - return datetime.strptime(latest_ts_str, "%Y-%m-%d %H:%M:%S") - except ValueError: - return None - - -@app.context_processor -def inject_check_timer(): - """Stellt den 'Nächste Prüfung'-Countdown global für die Topbar bereit - (auf jeder Seite sichtbar, siehe base.html) — unabhängig davon, ob die - jeweilige Route selbst etwas mit Geräten/Logs zu tun hat.""" - interval = int(get_setting("interval", 5)) - last_run_at = get_last_run_at() - return { - "global_check_interval": interval, - "global_last_run_epoch_ms": int(last_run_at.timestamp() * 1000) if last_run_at else None, - } - - -@app.context_processor -def inject_dhcp_topbar_status(): - """Ampel-Punkt für den Kea-Dienst neben dem Prüfintervall-Timer — nur - für Benutzer mit mindestens Leserecht auf DHCP, und nur ein einzelner, - kurzer systemctl-Aufruf (nicht die volle Status-Erkennung von - _detect_dhcp_status), damit das nicht jede Seite spürbar verlangsamt.""" - if ( - not current_user.is_authenticated - or not current_user.has_permission("settings_dhcp.view") - or not module_licensed("dhcp") - ): - return {"dhcp_topbar_active": None} - try: - result = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=2) - active = result.stdout.strip() == "active" - except Exception: - active = None - return {"dhcp_topbar_active": active} - - -@app.context_processor -def inject_license_topbar(): - """Lizenz-Ampel neben dem Prüfintervall-Countdown -- für jeden - angemeldeten Benutzer sichtbar, unabhängig von Rechten (genau wie der - Countdown selbst). Prioritätsreihenfolge exakt wie im Lizenz-Plan - festgelegt: fehlt > abgelaufen > läuft in ≤30 Tagen ab > Heartbeat seit - ≥14 Tagen ausgeblieben (rein informativ) > alles in Ordnung (keine - Anzeige).""" - if not current_user.is_authenticated: - return {"license_topbar": None} - - state = _license_state - if not state["valid"] or not license_activated(): - return {"license_topbar": {"level": "danger", "blink": True, "text": "Lizenz fehlt"}} - - status = state["status"] - if status["expired"]: - try: - expiry_str = datetime.strptime(state["file"]["expires_at"], "%Y-%m-%dT%H:%M:%SZ").strftime("%d.%m.") - except ValueError: - expiry_str = "?" - return {"license_topbar": {"level": "danger", "blink": True, "text": f"Lizenz abgelaufen (seit {expiry_str})"}} - - if status["expiring_soon"]: - days_left = max(1, math.ceil(status["days_left"])) - plural = "en" if days_left != 1 else "" - return {"license_topbar": {"level": "warning", "blink": True, "text": f"Lizenz läuft in {days_left} Tag{plural} ab"}} - - last_heartbeat = get_setting("license_last_heartbeat_at") - if last_heartbeat: - try: - last_ts = datetime.strptime(last_heartbeat, "%Y-%m-%d %H:%M:%S").timestamp() - except ValueError: - last_ts = None - if last_ts and licensing.heartbeat_stale_warning(last_ts): - days = int((time.time() - last_ts) / 86400) - return {"license_topbar": { - "level": "warning", "blink": False, - "text": f"Lizenzserver seit {days} Tag{'en' if days != 1 else ''} nicht erreichbar", - }} - - return {"license_topbar": None} - - -def get_device_status(devices): - """ - Ermittelt aus dem aktuellsten Logfile in einem Durchlauf je Gerät: - - status: online / offline / unbekannt - - last_checked: Zeitpunkt der letzten Prüfung (unabhängig vom Ergebnis) - - last_seen: Zeitpunkt des letzten "online"-Status (nur für aktuell offline Geräte, - über alle Logfiles hinweg — siehe get_last_seen) - - last_run_at: Zeitpunkt (datetime) des letzten Prüf-Durchlaufs insgesamt - (jüngste Zeile im Logfile) — Basis für den Countdown "Nächste Prüfung". - """ - status_dict, last_seen_dict, last_checked_dict = {}, {}, {} - latest_log = _latest_log_file() - if not latest_log: - return status_dict, last_seen_dict, last_checked_dict, None - - name_to_mac = {d["name"]: d["mac"] for d in devices} - last_line_for_name = {} - latest_ts_str = None - - with open(latest_log, "r") as f: - for line in f: - m = _LOG_LINE_RE.match(line.strip()) - if not m: - continue - ts_str, name, state = m.groups() - if latest_ts_str is None or ts_str > latest_ts_str: - latest_ts_str = ts_str - if name in name_to_mac: - last_line_for_name[name] = (ts_str, state == "erreichbar") - - for dev in devices: - entry = last_line_for_name.get(dev["name"]) - if entry: - ts_str, is_online = entry - status_dict[dev["mac"]] = "online" if is_online else "offline" - last_checked_dict[dev["mac"]] = _format_ts(ts_str) - if not is_online: - last_seen_dict[dev["mac"]] = get_last_seen(dev["name"]) - else: - status_dict[dev["mac"]] = "unbekannt" - - last_run_at = None - if latest_ts_str: - try: - last_run_at = datetime.strptime(latest_ts_str, "%Y-%m-%d %H:%M:%S") - except ValueError: - last_run_at = None - - return status_dict, last_seen_dict, last_checked_dict, last_run_at - - - -def _dashboard_context(): - """Gemeinsam von index() und dem AJAX-Partial /dashboard/tiles genutzt, - damit das Live-Update des Dashboards (ohne vollen Seiten-Reload) exakt - dieselben Daten liefert wie der initiale Seitenaufruf.""" - conn = get_db_connection() - all_devices = conn.execute( - "SELECT mac, name, ip, switch_hostname, port, is_active FROM devices WHERE deleted_at IS NULL ORDER BY name ASC" - ).fetchall() - conn.close() - - interval = int(get_setting("interval", 5)) - status, last_seen, last_checked, last_run_at = get_device_status(all_devices) - is_authenticated = current_user.is_authenticated - - def by_name(devs): - return sorted(devs, key=lambda d: d["name"].lower()) - - offline_devices = by_name( - d for d in all_devices if d["is_active"] and status.get(d["mac"]) != "online" - ) - online_devices = by_name( - d for d in all_devices if d["is_active"] and status.get(d["mac"]) == "online" - ) - disabled_devices = by_name(d for d in all_devices if not d["is_active"]) if is_authenticated else [] - - visible_devices = offline_devices + online_devices + disabled_devices - devices = offline_devices + online_devices - - stats = {"online": len(online_devices), "offline": len(offline_devices), "total": len(devices)} - if is_authenticated: - stats["total"] = len(all_devices) - stats["disabled"] = len(disabled_devices) - - last_run_epoch_ms = int(last_run_at.timestamp() * 1000) if last_run_at else None - - return dict( - offline_devices=offline_devices, online_devices=online_devices, disabled_devices=disabled_devices, - device_count=len(visible_devices), - status=status, last_seen=last_seen, - last_checked=last_checked, interval=interval, stats=stats, - last_run_epoch_ms=last_run_epoch_ms, - ) - - -@app.route("/") -def index(): - """ - Dashboard als Kachel-Ansicht, in drei Abschnitten: Offline, Online, - Deaktiviert (jeweils alphabetisch) — jeweils nur gerendert, wenn nicht - leer. Ohne Login: keine deaktivierten Geräte, nur Online/Offline/Gesamt- - Statistik, keine Interaktion. Eingeloggt: alle Geräte, volle - Statistik-Kacheln, Klick-Details, manueller PoE-Neustart bzw. bei - deaktivierten Geräten eine Aktivieren-Option. - """ - return render_template("index.html", **_dashboard_context()) - - -@app.route("/dashboard/tiles") -def dashboard_tiles(): - """AJAX-Partial für das Live-Update der Dashboard-Kacheln (Status, - Statistik) ohne vollen Seiten-Reload — liefert dasselbe Fragment, das - index.html beim initialen Aufruf einbindet (siehe _dashboard_tiles.html).""" - return render_template("_dashboard_tiles.html", **_dashboard_context()) - - -def _restart_check_service(): - """Startet tesm-check.service (poe_wrapper.py -> poe.sh) neu, damit ein - geändertes Prüfintervall sofort greift (poe.sh liest SLEEP nur einmal - beim Start aus der DB) — läuft dabei automatisch auch ein sofortiger - Prüf-Durchlauf mit frischem Logfile an. Schlägt in Umgebungen ohne - systemd (z.B. Container) lautlos fehl, statt die Anfrage abzubrechen.""" - try: - subprocess.run(["systemctl", "restart", "tesm-check.service"], check=False) - except FileNotFoundError: - pass - - -@app.route("/check/run-now", methods=["POST"]) -@login_required -def run_check_now(): - """Manueller 'Jetzt prüfen'-Trigger (Zahnrad-Icon neben dem Prüf-Timer): - startet denselben Service neu wie eine Intervalländerung — das erzeugt - sofort ein frisches Logfile mit einem neuen Durchlauf. Liefert den neuen - Zeitpunkt direkt in der Antwort mit, damit app.js den Topbar-Countdown - auf JEDER Seite zurücksetzen kann (vorher aktualisierte nur die - Dashboard-eigene Live-Aktualisierung die Anzeige — auf allen anderen - Seiten lief der Countdown mit dem alten Zeitpunkt weiter, obwohl im - Hintergrund längst neu geprüft wurde).""" - if not current_user.is_admin: - return {"success": False, "msg": "Keine Berechtigung."}, 403 - _restart_check_service() - log_action("check.run_now", "Prüfintervall") - time.sleep(1.5) - last_run_at = get_last_run_at() - return { - "success": True, - "msg": "Prüfung gestartet.", - "last_run_epoch_ms": int(last_run_at.timestamp() * 1000) if last_run_at else None, - } - - - - -NETWORK_REVERT_SECONDS = 45 -_pending_network_revert = {} -NETPLAN_CONFIG_PATH = "/etc/netplan/90-tesm.yaml" - - -def _detect_network_backend(): - """Rein lesend: welcher der gängigen Netzwerk-Manager ist aktiv? Keine - Annahme, falls keiner läuft (z.B. in dieser WSL-Testumgebung, die ihr - Netz über den Hyper-V-Adapter bezieht) — dann bleibt die Seite bewusst - rein lesend.""" - for service, name in (("NetworkManager", "networkmanager"), ("dhcpcd", "dhcpcd")): - try: - result = subprocess.run(["systemctl", "is-active", service], capture_output=True, text=True, timeout=5) - if result.stdout.strip() == "active": - return name - except Exception: - pass - try: - result = subprocess.run(["systemctl", "is-active", "systemd-networkd"], capture_output=True, text=True, timeout=5) - if result.stdout.strip() == "active" and shutil.which("netplan"): - return "netplan" - except Exception: - pass - return "unknown" - - -_RESOLVED_STUB_ADDRESSES = {"127.0.0.53", "127.0.0.1", "::1"} - - -def _read_resolv_conf_dns(): - try: - with open("/etc/resolv.conf", encoding="utf-8") as f: - return [line.split()[1] for line in f if line.strip().startswith("nameserver")] - except OSError: - return [] - - -def _read_configured_dns(interface): - """Die tatsächlich konfigurierten (Upstream-)DNS-Server statt des - lokalen systemd-resolved-Stub-Resolvers: /etc/resolv.conf zeigt unter - systemd-resolved (Standard auf aktuellem Ubuntu) immer nur "nameserver - 127.0.0.53" — die echten Server stehen stattdessen pro Link in - "resolvectl dns ". Fällt auf resolv.conf zurück, wenn - resolvectl nicht verfügbar ist (z.B. anderes DNS-Setup ohne - systemd-resolved); 127.0.0.53/127.0.0.1/::1 werden in beiden Fällen - herausgefiltert, da sie nie ein sinnvoller "tatsächlicher" Server sind.""" - servers = [] - if shutil.which("resolvectl"): - ok, out = _dhcp_run_privileged(["resolvectl", "dns", interface], timeout=5) - if ok: - for line in out.splitlines(): - stripped = line.strip() - if not stripped: - continue - m = re.match(r"^Link \d+ \([^)]*\):\s*(.*)$", stripped) - servers.extend((m.group(1) if m else stripped).split()) - if not servers: - servers = _read_resolv_conf_dns() - servers = [s for s in servers if s not in _RESOLVED_STUB_ADDRESSES] - ipv4_servers = [] - for s in servers: - try: - ipaddress.IPv4Address(s) - except ValueError: - continue - ipv4_servers.append(s) - return ipv4_servers - - -def _read_network_state(interface, backend): - """Aktueller Ist-Zustand — IP/Prefix/Gateway aus _detect_interface_network - (bereits für die DHCP-Server-Funktion gebaut), DNS aus resolv.conf, - Modus (dhcp/static) backend-spezifisch ermittelt. Jeder Erkennungsschritt - ist defensiv (try/except) — im Zweifel lieber "unbekannt" anzeigen als - einen falschen Zustand zu behaupten.""" - net_info = _detect_interface_network(interface) - mode = "unknown" - if backend == "networkmanager": - try: - dev_out = subprocess.run( - ["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface], - capture_output=True, text=True, timeout=5, - ) - conn_name = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None - if conn_name: - method_out = subprocess.run( - ["nmcli", "-g", "ipv4.method", "connection", "show", conn_name], - capture_output=True, text=True, timeout=5, - ) - mode = "static" if method_out.stdout.strip() == "manual" else "dhcp" - except Exception: - pass - elif backend == "dhcpcd": - try: - with open("/etc/dhcpcd.conf", encoding="utf-8") as f: - content = f.read() - block_match = re.search(rf"^interface\s+{re.escape(interface)}\s*$(.*?)(?=^interface\s|\Z)", content, re.S | re.M) - block = block_match.group(1) if block_match else "" - mode = "static" if re.search(r"^\s*static\s+ip_address=", block, re.M) else "dhcp" - except OSError: - mode = "dhcp" - elif backend == "netplan": - if net_info.get("ok"): - mode = "dhcp" if net_info.get("dynamic") else "static" - return {**net_info, "dns": _read_configured_dns(interface), "mode": mode, "backend": backend} - - -def _backup_network_config(interface, backend): - """Kompletten Ist-Zustand vor einer Änderung sichern, damit - _revert_network_config() ihn exakt wiederherstellen kann.""" - state = _read_network_state(interface, backend) - backup = { - "interface": interface, "backend": backend, "mode": state["mode"], - "ip": state.get("ip"), "prefix": state.get("prefix"), "gateway": state.get("gateway"), - "dns": state.get("dns", []), - } - if backend == "dhcpcd": - try: - with open("/etc/dhcpcd.conf", encoding="utf-8") as f: - backup["dhcpcd_conf"] = f.read() - except OSError: - backup["dhcpcd_conf"] = None - elif backend == "networkmanager": - try: - dev_out = subprocess.run( - ["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface], - capture_output=True, text=True, timeout=5, - ) - backup["nm_connection"] = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None - except Exception: - backup["nm_connection"] = None - elif backend == "netplan": - try: - with open(NETPLAN_CONFIG_PATH, encoding="utf-8") as f: - backup["netplan_conf"] = f.read() - except OSError: - backup["netplan_conf"] = None - return backup - - -def _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_list): - """Wendet die neue Konfiguration über das erkannte Backend an. Gibt - (ok, message) zurück statt zu werfen, damit der Aufrufer immer eine - Flash-Meldung bekommt statt eines 500ers.""" - dns_csv = ",".join(dns_list) - if backend == "networkmanager": - try: - dev_out = subprocess.run( - ["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface], - capture_output=True, text=True, timeout=5, - ) - conn_name = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None - if not conn_name: - return False, f"Keine aktive NetworkManager-Verbindung für {interface} gefunden." - args = ["nmcli", "connection", "modify", conn_name] - if mode == "static": - args += ["ipv4.method", "manual", "ipv4.addresses", f"{ip}/{prefix}", "ipv4.gateway", gateway] - else: - args += ["ipv4.method", "auto", "ipv4.addresses", "", "ipv4.gateway", ""] - if dns_csv: - args += ["ipv4.dns", dns_csv, "ipv4.ignore-auto-dns", "yes"] - else: - args += ["ipv4.dns", "", "ipv4.ignore-auto-dns", "no"] - ok, out = _dhcp_run_privileged(args, timeout=15) - if not ok: - return False, out - return _dhcp_run_privileged(["nmcli", "connection", "up", conn_name], timeout=20) - except Exception as e: - return False, str(e) - - if backend == "dhcpcd": - try: - with open("/etc/dhcpcd.conf", encoding="utf-8") as f: - content = f.read() - content = re.sub(rf"^interface\s+{re.escape(interface)}\s*$(.*?)(?=^interface\s|\Z)", "", content, flags=re.S | re.M) - block_lines = [f"interface {interface}"] - if mode == "static": - block_lines.append(f"static ip_address={ip}/{prefix}") - if gateway: - block_lines.append(f"static routers={gateway}") - if dns_list: - block_lines.append(f"static domain_name_servers={' '.join(dns_list)}") - if len(block_lines) > 1: - content = content.rstrip() + "\n\n" + "\n".join(block_lines) + "\n" - with open("/etc/dhcpcd.conf", "w", encoding="utf-8") as f: - f.write(content) - return _dhcp_run_privileged(["systemctl", "restart", "dhcpcd"], timeout=20) - except Exception as e: - return False, str(e) - - if backend == "netplan": - try: - eth_cfg = {} - if mode == "static": - eth_cfg["dhcp4"] = False - eth_cfg["addresses"] = [f"{ip}/{prefix}"] - if gateway: - eth_cfg["routes"] = [{"to": "default", "via": gateway}] - else: - eth_cfg["dhcp4"] = True - if dns_list: - eth_cfg["dhcp4-overrides"] = {"use-dns": False} - if dns_list: - eth_cfg["nameservers"] = {"addresses": dns_list} - try: - with open(NETPLAN_CONFIG_PATH, encoding="utf-8") as f: - existing_doc = yaml.safe_load(f) or {} - except OSError: - existing_doc = {} - ethernets = ((existing_doc.get("network") or {}).get("ethernets") or {}).copy() - ethernets[interface] = eth_cfg - doc = {"network": {"version": 2, "ethernets": ethernets}} - with open(NETPLAN_CONFIG_PATH, "w", encoding="utf-8") as f: - yaml.safe_dump(doc, f, default_flow_style=False) - os.chmod(NETPLAN_CONFIG_PATH, 0o600) - return _dhcp_run_privileged(["netplan", "apply"], timeout=20) - except Exception as e: - return False, str(e) - - return False, "Kein unterstütztes Netzwerk-Backend erkannt (weder NetworkManager, dhcpcd noch netplan aktiv)." - - -def _revert_network_config(token): - """Timer-Callback: falls binnen NETWORK_REVERT_SECONDS keine Bestätigung - einging, gesicherten Zustand wiederherstellen. Läuft in einem - Hintergrund-Thread (threading.Timer) — Fehler werden geloggt statt die - App zum Absturz zu bringen.""" - entry = _pending_network_revert.get(token) - if not entry: - return - backup = entry["backup"] - try: - if backup["backend"] == "dhcpcd" and backup.get("dhcpcd_conf") is not None: - with open("/etc/dhcpcd.conf", "w", encoding="utf-8") as f: - f.write(backup["dhcpcd_conf"]) - subprocess.run(["systemctl", "restart", "dhcpcd"], timeout=20) - elif backup["backend"] == "networkmanager": - _apply_network_config( - "networkmanager", backup["interface"], - backup["mode"], backup.get("ip"), backup.get("prefix"), backup.get("gateway"), backup.get("dns", []), - ) - elif backup["backend"] == "netplan": - if backup.get("netplan_conf") is not None: - with open(NETPLAN_CONFIG_PATH, "w", encoding="utf-8") as f: - f.write(backup["netplan_conf"]) - else: - try: - os.remove(NETPLAN_CONFIG_PATH) - except OSError: - pass - subprocess.run(["netplan", "apply"], timeout=20) - if _stop_dhcp_service_if_active(): - log_action_system("settings.network_revert", backup["interface"], "DHCP-Dienst wegen Rollback gestoppt") - log_action_system("settings.network_revert", backup["interface"], "automatisch nach Timeout zurückgerollt") - except Exception as e: - app.logger.error("Network auto-revert failed: %s", e) - finally: - _pending_network_revert.pop(token, None) - - -# ============================================================================ -# NGINX (Systemeinstellungen -> NGINX) -# Verwaltet den kompletten Reverse-Proxy: Domain/Ports, SSL/HSTS, -# Zertifikat (Upload ODER Let's Encrypt). -# -# Let's Encrypt läuft bewusst über "certbot certonly --webroot", NICHT über -# das certbot-nginx-Plugin -- das Plugin würde NGINX_SITE_CONFIG_PATH -# direkt editieren (eigene server-Blöcke/Zeilen einfügen) und diese -# Änderungen bei der nächsten Änderung über diese Seite (die die Datei -# komplett neu schreibt, siehe _render_nginx_config) wieder verlieren. -# Mit der webroot-Methode bleibt _render_nginx_config die EINZIGE Quelle -# für den Datei-Inhalt; certbot legt nur Dateien unter TESM_ACME_WEBROOT -# ab und liest/schreibt sein Zertifikat unter /etc/letsencrypt/live/. -# ============================================================================ - -NGINX_REVERT_SECONDS = 45 -_pending_nginx_revert = {} - -_DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$") - - -def _valid_server_name(name): - """"_" (nginx-Catch-all, kein bestimmter Hostname -- Standard für rein - interne Instanzen ohne eigene Domain) oder eine plausibel geformte - FQDN.""" - return name == "_" or bool(_DOMAIN_RE.match(name)) - - -def _valid_port(value): - try: - n = int(value) - except (TypeError, ValueError): - return False - return 1 <= n <= 65535 and n != 5000 - - -_NGINX_PROXY_LOCATIONS = """ location /ws/ { - proxy_pass http://127.0.0.1:5000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 3600s; - proxy_send_timeout 3600s; - } - - 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; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /static/ { - alias /srv/tesm/static/; - expires 7d; - } -""" - - -def _render_nginx_config(server_name, http_port, https_port, ssl_enabled, hsts_enabled, cert_path, key_path): - """Erzeugt den kompletten Inhalt von NGINX_SITE_CONFIG_PATH. Die - Reverse-Proxy-Regeln (WebSocket-Terminal, Haupt-App, statische Dateien) - sind bei aktivem SSL identisch, nur hinter einem HTTPS-Block mit - Zertifikat/HSTS statt direkt hinter dem HTTP-Port -- der HTTP-Port - leitet dann nur noch auf https weiter, damit ein alter Bookmark/Link - nicht plötzlich ins Leere läuft. Die ACME-Challenge-Location wird - IMMER mitgeschrieben (auch ohne aktives SSL) -- eine Let's-Encrypt- - (Erst-)Anforderung braucht sie schon VOR dem ersten SSL-Aktivieren, - eine Verlängerung später ebenso. ACME HTTP-01 verbindet sich immer auf - Port 80 (protokollbedingt, nicht konfigurierbar) -- weicht der - eingestellte HTTP-Port davon ab, wird zusätzlich ein minimaler, nur - dafür zuständiger Port-80-Block ergänzt, damit Let's Encrypt trotzdem - funktioniert.""" - header = 'map $http_upgrade $connection_upgrade {\n default upgrade;\n "" close;\n}\n\n' - acme_location = ( - " location /.well-known/acme-challenge/ {\n" - f" root {TESM_ACME_WEBROOT};\n" - " try_files $uri =404;\n" - " }\n\n" - ) - - extra_acme_block = "" - if str(http_port) != "80": - extra_acme_block = ( - "server {\n listen 80;\n server_name _;\n\n" - + acme_location - + " location / { return 404; }\n}\n\n" - ) - - if not ssl_enabled: - return ( - header + extra_acme_block - + f"server {{\n listen {http_port};\n server_name {server_name};\n\n" - + acme_location - + _NGINX_PROXY_LOCATIONS - + "}\n" - ) - - hsts_line = "" - if hsts_enabled: - hsts_line = ' add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;\n\n' - redirect_target = "https://$host$request_uri" if str(https_port) == "443" else f"https://$host:{https_port}$request_uri" - return ( - header + extra_acme_block - + f"server {{\n listen {http_port};\n server_name {server_name};\n\n" - + acme_location - + f" location / {{\n return 301 {redirect_target};\n }}\n" - + "}\n\n" - + f"server {{\n listen {https_port} ssl;\n server_name {server_name};\n\n" - + f" ssl_certificate {cert_path};\n" - + f" ssl_certificate_key {key_path};\n\n" - + hsts_line - + _NGINX_PROXY_LOCATIONS - + "}\n" - ) - - -def _current_cert_paths(): - """(cert_path, key_path) je nach aktiver Zertifikatsquelle (Setting - ssl_source: "upload" oder "letsencrypt"), oder (None, None) wenn - keins vorhanden ist.""" - if get_setting("ssl_source", "upload") == "letsencrypt": - domain = get_setting("nginx_server_name", "_") - cert, key = _le_cert_path(domain), _le_key_path(domain) - if os.path.isfile(cert) and os.path.isfile(key): - return cert, key - return None, None - if os.path.isfile(TESM_SSL_CERT_PATH) and os.path.isfile(TESM_SSL_KEY_PATH): - return TESM_SSL_CERT_PATH, TESM_SSL_KEY_PATH - return None, None - - -def _apply_nginx_config(server_name, http_port, https_port, ssl_enabled, hsts_enabled): - """Schreibt die neue nginx-Konfiguration, validiert sie (nginx -t) und - lädt nginx erst danach neu -- schlägt Validierung ODER Reload fehl, - wird der vorherige Inhalt sofort wiederhergestellt (Selbstschutz gegen - einen Tippfehler/ein defektes Zertifikat, das sonst den kompletten - Reverse-Proxy lahmlegen würde). Gibt (ok, fehlermeldung, alter_inhalt) - zurück -- den alten Inhalt braucht der Aufrufer zusätzlich für das - zeitgesteuerte Rückroll-Sicherheitsnetz, siehe NGINX_REVERT_SECONDS.""" - try: - with open(NGINX_SITE_CONFIG_PATH, encoding="utf-8") as f: - previous = f.read() - except OSError: - previous = None - - cert_path = key_path = None - if ssl_enabled: - cert_path, key_path = _current_cert_paths() - if not cert_path: - return False, "Kein Zertifikat verfügbar.", previous - - new_content = _render_nginx_config(server_name, http_port, https_port, ssl_enabled, hsts_enabled, cert_path, key_path) - try: - with open(NGINX_SITE_CONFIG_PATH, "w", encoding="utf-8") as f: - f.write(new_content) - except OSError as e: - return False, f"Konfiguration konnte nicht geschrieben werden: {e}", previous - - ok, out = _dhcp_run_privileged(["nginx", "-t"], timeout=10) - if ok: - ok, out = _dhcp_run_privileged(["systemctl", "reload", "nginx"], timeout=10) - - if not ok: - if previous is not None: - try: - with open(NGINX_SITE_CONFIG_PATH, "w", encoding="utf-8") as f: - f.write(previous) - subprocess.run(["systemctl", "reload", "nginx"], capture_output=True, timeout=10) - except OSError: - pass - return False, out, previous - - return True, "", previous - - -def _revert_nginx_config(token): - """Timer-Callback analog zu _revert_network_config: keine Bestätigung - binnen NGINX_REVERT_SECONDS -> vorherige nginx-Konfiguration UND alle - zugehörigen Einstellungen (Domain, Ports, SSL, HSTS) wiederherstellen.""" - entry = _pending_nginx_revert.get(token) - if not entry: - return - try: - if entry["previous"] is not None: - with open(NGINX_SITE_CONFIG_PATH, "w", encoding="utf-8") as f: - f.write(entry["previous"]) - subprocess.run(["nginx", "-t"], capture_output=True, timeout=10) - subprocess.run(["systemctl", "reload", "nginx"], capture_output=True, timeout=10) - for key, value in entry["prev_settings"].items(): - set_setting(key, value) - log_action_system("settings.nginx_revert", "nginx", "automatisch nach Timeout zurückgerollt") - except Exception as e: - app.logger.error("nginx auto-revert failed: %s", e) - finally: - _pending_nginx_revert.pop(token, None) - - -def _parse_cert(cert_bytes): - """Lädt ein PEM-Zertifikat und liefert sowohl das cryptography-Objekt - (für den Schlüssel-Abgleich) als auch die für die UI relevanten Felder - (Subject, Aussteller, Ablaufdatum).""" - cert = x509.load_pem_x509_certificate(cert_bytes) - try: - not_after = cert.not_valid_after_utc.replace(tzinfo=None) - except AttributeError: - not_after = cert.not_valid_after - return cert, { - "subject": cert.subject.rfc4514_string(), - "issuer": cert.issuer.rfc4514_string(), - "not_after": not_after, - } - - -def _validate_cert_key_pair(cert_bytes, key_bytes): - """Prüft ein hochgeladenes Zertifikat+Key-Paar VOR dem Speichern -- - verhindert, dass ein kaputtes oder nicht zusammenpassendes Paar erst - beim tatsächlichen SSL-Aktivieren auffällt (mit dem entsprechenden - Ausfallrisiko für den Reverse-Proxy). Der Schlüssel-Abgleich - funktioniert algorithmus-unabhängig (RSA/EC/...), da nur die - öffentlichen Schlüssel als DER-Bytes verglichen werden, nicht - algorithmus-spezifische Felder. Gibt (ok, fehlermeldung, info-dict) - zurück.""" - try: - cert, info = _parse_cert(cert_bytes) - except ValueError as e: - return False, f"Datei ist kein gültiges PEM-Zertifikat ({e}).", {} - - if info["not_after"] < datetime.utcnow(): - return False, f"Zertifikat ist bereits abgelaufen (gültig bis {info['not_after']:%d.%m.%Y}).", info - - try: - private_key = serialization.load_pem_private_key(key_bytes, password=None) - except (ValueError, TypeError) as e: - return False, f"Datei ist kein gültiger, unverschlüsselter privater Schlüssel ({e}).", info - - cert_pub = cert.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo) - key_pub = private_key.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo) - if cert_pub != key_pub: - return False, "Zertifikat und privater Schlüssel passen nicht zusammen.", info - - return True, "", info - - -def _current_cert_info(): - """Für die NGINX-Seite: Status des aktuell aktiven Zertifikats (Upload - oder Let's Encrypt, je nach ssl_source) inkl. verbleibender - Gültigkeitsdauer.""" - cert_path, _key_path = _current_cert_paths() - if not cert_path: - return None - try: - with open(cert_path, "rb") as f: - _, info = _parse_cert(f.read()) - info["days_left"] = (info["not_after"] - datetime.utcnow()).days - info["source"] = get_setting("ssl_source", "upload") - return info - except (OSError, ValueError): - return None - - -def _request_letsencrypt_cert(domain, email=None): - """Fordert per certbot (webroot-Methode, siehe TESM_ACME_WEBROOT) ein - Zertifikat für die angegebene Domain an. Setzt voraus, dass die Domain - öffentlich auf diesen Host auflöst UND Port 80 aus dem Internet - erreichbar ist (ACME HTTP-01) -- beides kann diese App nicht prüfen, - ein Fehlschlag hier liegt i.d.R. an der Netzwerk-/DNS-Situation, nicht - an TESM selbst. Automatische Verlängerung übernimmt certbots eigener - systemd-Timer/Cron (Standard-Paketverhalten unter Debian/Ubuntu), der - Deploy-Hook (_write_certbot_deploy_hook) lädt nginx danach neu.""" - args = [ - "certbot", "certonly", "--webroot", "-w", TESM_ACME_WEBROOT, - "-d", domain, "--non-interactive", "--agree-tos", "--cert-name", domain, - ] - args += ["--email", email] if email else ["--register-unsafely-without-email"] - return _dhcp_run_privileged(args, timeout=120) - - -def _certbot_timer_status(): - """Status von certbot.timer -- dem systemd-Timer, der die automatische - Verlängerung tatsächlich antreibt (Standard-Paketverhalten unter - Debian/Ubuntu, zweimal täglich). Rein informativ für die NGINX-Seite: - "automatische Verlängerung" soll für den Admin sichtbar/nachprüfbar - sein, nicht nur eine Behauptung im Quelltext bleiben. None, wenn - certbot (noch) nicht installiert ist.""" - active_ok, active_out = _dhcp_run_privileged(["systemctl", "is-active", "certbot.timer"], timeout=5) - if not active_ok and "could not be found" in active_out.lower(): - return None - next_run = None - ok, out = _dhcp_run_privileged(["systemctl", "list-timers", "certbot.timer", "--no-legend"], timeout=5) - if ok and out.strip(): - parts = out.strip().split(None, 4) - if len(parts) >= 4: - next_run = " ".join(parts[0:4]) - return {"active": active_out.strip() == "active", "next_run": next_run} - - -@app.route("/settings/nginx/raw") -@login_required -def settings_nginx_raw(): - """Komplette, unbearbeitete nginx-Konfiguration für das RAW-Popup -- - gleiches Muster wie beim Live-Log (window.openRawLogModal).""" - if not current_user.has_permission("settings_nginx.view"): - return "Keine Berechtigung.", 403 - try: - with open(NGINX_SITE_CONFIG_PATH, "r", encoding="utf-8", errors="replace") as f: - content = f.read() - except OSError as e: - content = f"Fehler beim Lesen der Konfiguration: {e}" - response = app.response_class(content, mimetype="text/plain") - response.headers["X-Log-Name"] = os.path.basename(NGINX_SITE_CONFIG_PATH) - return response - - -@app.route("/settings/nginx", methods=["GET", "POST"]) -@login_required -def settings_nginx(): - if request.method == "GET" and not current_user.has_permission("settings_nginx.view"): - flash("Keine Berechtigung, die NGINX-Einstellungen anzusehen.", "danger") - return redirect(url_for("index")) - - if request.method == "POST": - if not current_user.has_permission("settings_nginx.edit"): - flash("Keine Berechtigung, die NGINX-Einstellungen zu ändern.", "danger") - return redirect(url_for("settings_nginx")) - - if "upload_cert" in request.form: - cert_file = request.files.get("cert_file") - key_file = request.files.get("key_file") - if not cert_file or not cert_file.filename or not key_file or not key_file.filename: - flash("Bitte sowohl Zertifikat (PEM) als auch privaten Schlüssel (PEM) auswählen.", "danger") - else: - cert_bytes = cert_file.read() - key_bytes = key_file.read() - ok, msg, info = _validate_cert_key_pair(cert_bytes, key_bytes) - if not ok: - flash(f"Zertifikat/Schlüssel nicht gespeichert: {msg}", "danger") - else: - try: - os.makedirs(TESM_SSL_DIR, exist_ok=True) - with open(TESM_SSL_CERT_PATH, "wb") as f: - f.write(cert_bytes) - with open(TESM_SSL_KEY_PATH, "wb") as f: - f.write(key_bytes) - os.chmod(TESM_SSL_KEY_PATH, 0o600) - os.chmod(TESM_SSL_CERT_PATH, 0o644) - set_setting("ssl_source", "upload") - log_action("settings.nginx_cert_upload", info.get("subject", ""), f"gültig bis {info['not_after']:%d.%m.%Y}") - flash( - f"Zertifikat gespeichert (Subject: {info.get('subject')}, gültig bis " - f"{info['not_after']:%d.%m.%Y}). SSL ist damit noch nicht aktiviert -- unten aktivieren.", - "success", - ) - except OSError as e: - flash(f"Zertifikat konnte nicht gespeichert werden: {e}", "danger") - - elif "request_letsencrypt" in request.form: - domain = request.form.get("le_domain", "").strip() - email = request.form.get("le_email", "").strip() or None - if not domain or not _DOMAIN_RE.match(domain): - flash("Bitte eine gültige, öffentlich auflösbare Domain angeben.", "danger") - else: - ok, out = _request_letsencrypt_cert(domain, email) - if not ok: - flash(f"Let's-Encrypt-Anforderung fehlgeschlagen: {out.strip()[-800:]}", "danger") - else: - set_setting("ssl_source", "letsencrypt") - set_setting("nginx_server_name", domain) - log_action("settings.nginx_letsencrypt_request", domain, "Zertifikat erfolgreich ausgestellt") - flash( - f"Let's-Encrypt-Zertifikat für {domain} ausgestellt (automatische Verlängerung läuft über " - f"certbots eigenen Timer). SSL ist damit noch nicht aktiviert -- unten aktivieren.", - "success", - ) - - elif "test_renewal" in request.form: - if get_setting("ssl_source", "upload") != "letsencrypt": - flash("Verlängerungs-Test setzt ein per Let's Encrypt ausgestelltes Zertifikat voraus.", "danger") - else: - domain = get_setting("nginx_server_name", "_") - ok, out = _dhcp_run_privileged(["certbot", "renew", "--dry-run", "--cert-name", domain], timeout=120) - if ok: - log_action("settings.nginx_letsencrypt_test_renewal", domain) - flash("Verlängerungs-Test erfolgreich (Dry-Run — es wurde nichts tatsächlich verändert).", "success") - else: - flash(f"Verlängerungs-Test fehlgeschlagen: {out.strip()[-800:]}", "danger") - - elif "force_renew" in request.form: - if get_setting("ssl_source", "upload") != "letsencrypt": - flash("Manuelle Verlängerung setzt ein per Let's Encrypt ausgestelltes Zertifikat voraus.", "danger") - else: - domain = get_setting("nginx_server_name", "_") - ok, out = _dhcp_run_privileged(["certbot", "renew", "--force-renewal", "--cert-name", domain], timeout=120) - if ok: - log_action("settings.nginx_letsencrypt_force_renew", domain) - flash("Zertifikat manuell erneuert — nginx wurde über den Deploy-Hook automatisch neu geladen.", "success") - else: - flash(f"Manuelle Verlängerung fehlgeschlagen: {out.strip()[-800:]}", "danger") - - elif "apply_nginx" in request.form: - new_server_name = request.form.get("server_name", "_").strip() or "_" - new_http_port = request.form.get("http_port", "80").strip() - new_https_port = request.form.get("https_port", "443").strip() - new_ssl = "ssl_enabled" in request.form - new_hsts = "hsts_enabled" in request.form - - if not _valid_server_name(new_server_name): - flash("Ungültiger Domain-/Servername.", "danger") - elif not _valid_port(new_http_port) or not _valid_port(new_https_port): - flash("Port muss eine Zahl zwischen 1 und 65535 sein (5000 ist für die App selbst reserviert).", "danger") - elif new_http_port == new_https_port: - flash("HTTP- und HTTPS-Port müssen unterschiedlich sein.", "danger") - elif new_ssl and not _current_cert_paths()[0]: - flash("Kein Zertifikat verfügbar -- bitte zuerst hochladen oder per Let's Encrypt anfordern.", "danger") - elif new_hsts and not new_ssl: - flash("HSTS setzt aktives SSL voraus.", "danger") - else: - prev_settings = { - "nginx_server_name": get_setting("nginx_server_name", "_"), - "nginx_http_port": get_setting("nginx_http_port", "80"), - "nginx_https_port": get_setting("nginx_https_port", "443"), - "ssl_enabled": get_setting("ssl_enabled", "0"), - "hsts_enabled": get_setting("hsts_enabled", "0"), - } - ok, err, previous = _apply_nginx_config(new_server_name, new_http_port, new_https_port, new_ssl, new_hsts) - if not ok: - flash(f"nginx-Konfiguration konnte nicht angewendet werden, unverändert gelassen: {err}", "danger") - else: - set_setting("nginx_server_name", new_server_name) - set_setting("nginx_http_port", new_http_port) - set_setting("nginx_https_port", new_https_port) - set_setting("ssl_enabled", "1" if new_ssl else "0") - set_setting("hsts_enabled", "1" if new_hsts else "0") - log_action( - "settings.nginx_apply", new_server_name, - f"http={new_http_port} https={new_https_port} ssl={new_ssl} hsts={new_hsts}", - ) - token = secrets.token_hex(8) - timer = threading.Timer(NGINX_REVERT_SECONDS, _revert_nginx_config, args=(token,)) - timer.daemon = True - _pending_nginx_revert.clear() - _pending_nginx_revert[token] = {"timer": timer, "previous": previous, "prev_settings": prev_settings} - timer.start() - scheme = "https" if new_ssl else "http" - port = new_https_port if new_ssl else new_http_port - port_suffix = "" if port in ("80", "443") else f":{port}" - flash( - f"nginx-Konfiguration angewendet. Falls diese Seite jetzt noch über {scheme}{port_suffix} " - f"erreichbar ist, bitte unten bestätigen — sonst wird nach {NGINX_REVERT_SECONDS}s " - f"automatisch zurückgerollt.", - "success", - ) - - elif "confirm_nginx" in request.form: - token = request.form.get("confirm_nginx") - entry = _pending_nginx_revert.pop(token, None) - if entry: - entry["timer"].cancel() - log_action("settings.nginx_confirm", "nginx") - flash("nginx-Konfiguration bestätigt — kein automatisches Rollback mehr.", "success") - else: - flash("Keine ausstehende Bestätigung gefunden (evtl. bereits abgelaufen).", "danger") - - elif "remove_cert" in request.form: - if get_setting("ssl_enabled", "0") == "1": - flash("Zertifikat kann nicht entfernt werden, solange SSL aktiv ist -- bitte zuerst deaktivieren.", "danger") - elif get_setting("ssl_source", "upload") == "letsencrypt": - domain = get_setting("nginx_server_name", "_") - ok, out = _dhcp_run_privileged(["certbot", "delete", "--cert-name", domain, "--non-interactive"], timeout=30) - if not ok: - flash(f"Let's-Encrypt-Zertifikat konnte nicht entfernt werden: {out}", "danger") - else: - log_action("settings.nginx_cert_remove", domain, "Let's Encrypt") - flash("Let's-Encrypt-Zertifikat entfernt.", "success") - else: - for path in (TESM_SSL_CERT_PATH, TESM_SSL_KEY_PATH): - try: - os.remove(path) - except OSError: - pass - log_action("settings.nginx_cert_remove", "upload") - flash("Hochgeladenes Zertifikat entfernt.", "success") - - return redirect(url_for("settings_nginx")) - - pending_token = next(iter(_pending_nginx_revert), None) - return render_template( - "settings_nginx.html", - cert_info=_current_cert_info(), - ssl_enabled=get_setting("ssl_enabled", "0") == "1", - hsts_enabled=get_setting("hsts_enabled", "0") == "1", - server_name=get_setting("nginx_server_name", "_"), - http_port=get_setting("nginx_http_port", "80"), - https_port=get_setting("nginx_https_port", "443"), - pending_nginx_token=pending_token, - nginx_revert_seconds=NGINX_REVERT_SECONDS, - certbot_timer=_certbot_timer_status(), - ) - - -@app.route("/settings", methods=["GET", "POST"]) -@login_required -def settings(): - if request.method == "GET" and not current_user.can_view_settings_system: - flash("Keine Berechtigung, die Systemeinstellungen anzusehen.", "danger") - return redirect(url_for("index")) - - interval = int(get_setting("interval", 5)) - - if request.method == "POST": - if not current_user.has_permission("settings_system.edit"): - flash("Keine Berechtigung, die Systemeinstellungen zu ändern.", "danger") - return redirect(url_for("settings")) - - if "interval" in request.form: - if not license_active(): - flash("Das Prüfintervall kann nur mit gültiger Lizenz geändert werden.", "danger") - return redirect(url_for("settings")) - new_interval = int(request.form["interval"]) - set_setting("interval", new_interval) - set_setting("check_interval", new_interval * 60) - log_action("settings.update", "Prüfintervall", f"{new_interval} Minuten") - _restart_check_service() - flash(f"Intervall auf {new_interval} Minuten gesetzt und Service neu gestartet!", "success") - - elif "apply_network" in request.form: - interface = request.form.get("net_interface", "").strip() - mode = request.form.get("net_mode", "dhcp") - ip = request.form.get("net_ip", "").strip() - prefix = request.form.get("net_prefix", "").strip() - gateway = request.form.get("net_gateway", "").strip() - dns_list = [d.strip() for d in request.form.get("net_dns", "").split(",") if d.strip()] - backend = _detect_network_backend() - - if interface not in _list_network_interfaces(): - flash(f"Interface „{interface}“ existiert nicht auf diesem Host.", "danger") - elif backend == "unknown": - flash("Kein unterstütztes Netzwerk-Backend erkannt — Änderungen über die App sind deaktiviert.", "danger") - elif mode == "static" and not (ip and prefix and gateway): - flash("Für eine statische Konfiguration werden IP-Adresse, Prefix und Gateway benötigt.", "danger") - else: - backup = _backup_network_config(interface, backend) - ok, out = _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_list) - if ok: - token = secrets.token_hex(8) - timer = threading.Timer(NETWORK_REVERT_SECONDS, _revert_network_config, args=(token,)) - timer.daemon = True - _pending_network_revert.clear() - _pending_network_revert[token] = {"timer": timer, "backup": backup} - timer.start() - set_setting("net_interface", interface) - log_action("settings.network_apply", interface, f"Modus {mode}") - msg = ( - f"Netzwerkkonfiguration angewendet. Falls diese Seite jetzt noch erreichbar ist, bitte " - f"unten bestätigen — sonst wird nach {NETWORK_REVERT_SECONDS}s automatisch zurückgerollt." - ) - if _stop_dhcp_service_if_active(): - log_action("settings.network_apply", interface, "DHCP-Dienst wegen Netzwerkänderung gestoppt") - msg += ( - " Der DHCP-Server wurde dabei gestoppt und deaktiviert, da die konfigurierte Range zum " - "neuen Netz nicht mehr passen könnte — bitte auf der DHCP-Seite prüfen und bei Bedarf " - "neu starten (bleibt sonst auch nach einem Host-Neustart aus)." - ) - flash(msg, "success") - else: - flash(f"Anwenden fehlgeschlagen: {out}", "danger") - - elif "hostname" in request.form: - new_hostname = request.form.get("hostname", "").strip() - if not re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$", new_hostname): - flash("Ungültiger Hostname (nur Buchstaben, Ziffern und Bindestrich, max. 63 Zeichen).", "danger") - else: - ok, out = _dhcp_run_privileged(["hostnamectl", "set-hostname", new_hostname], timeout=10) - if ok: - log_action("settings.update", "Hostname", new_hostname) - flash(f"Hostname auf „{new_hostname}“ gesetzt.", "success") - else: - flash(f"Hostname konnte nicht gesetzt werden: {out}", "danger") - - elif "timezone" in request.form: - new_tz = request.form.get("timezone", "").strip() - if new_tz not in _list_timezones(): - flash("Ungültige Zeitzone.", "danger") - else: - ok, out = _dhcp_run_privileged(["timedatectl", "set-timezone", new_tz], timeout=10) - if ok: - time.tzset() - log_action("settings.update", "Zeitzone", new_tz) - flash(f"Zeitzone auf „{new_tz}“ gesetzt.", "success") - else: - flash(f"Zeitzone konnte nicht gesetzt werden: {out}", "danger") - - elif "confirm_network" in request.form: - token = request.form.get("confirm_network") - entry = _pending_network_revert.pop(token, None) - if entry: - entry["timer"].cancel() - log_action("settings.network_confirm", entry["backup"]["interface"]) - flash("Netzwerkkonfiguration bestätigt — kein automatisches Rollback mehr.", "success") - else: - flash("Keine ausstehende Bestätigung gefunden (evtl. bereits abgelaufen).", "danger") - - elif "save_log_rotation" in request.form: - new_log_interval = request.form.get("log_rotation_interval", "") - new_log_keep_raw = request.form.get("log_rotation_keep", "") - if new_log_interval not in LOG_ROTATION_INTERVALS: - flash("Ungültiges Rotations-Intervall.", "danger") - elif not new_log_keep_raw.isdigit() or int(new_log_keep_raw) < 1: - flash("Aufbewahrung muss eine Zahl ≥ 1 sein.", "danger") - else: - set_setting("log_rotation_interval", new_log_interval) - set_setting("log_rotation_keep", int(new_log_keep_raw)) - if _write_logrotate_config(): - log_action("settings.update", "Log-Rotation", f"{new_log_interval}, {new_log_keep_raw} Rotationen") - flash(f"Log-Rotation gespeichert: {new_log_interval}, {new_log_keep_raw} Rotationen aufbewahrt.", "success") - else: - flash(f"Einstellung gespeichert, aber {LOGROTATE_CONFIG_PATH} konnte nicht geschrieben werden.", "danger") - - elif "save_trash_retention" in request.form: - new_days_raw = request.form.get("trash_retention_days", "").strip() - if not new_days_raw.isdigit() or int(new_days_raw) < 1: - flash("Aufbewahrungsdauer muss eine Zahl ≥ 1 sein.", "danger") - else: - set_setting("trash_retention_days", new_days_raw) - log_action("settings.update", "Papierkorb-Aufbewahrung", f"{new_days_raw} Tage") - flash(f"Papierkorb-Aufbewahrung auf {new_days_raw} Tage gesetzt.", "success") - - elif "purge_trash_now" in request.form: - _purge_expired_trash() - log_action("settings.update", "Papierkorb sofort bereinigt") - flash("Abgelaufene Papierkorb-Einträge wurden endgültig gelöscht.", "success") - - - return redirect(url_for("settings")) - - net_backend = _detect_network_backend() - all_interfaces = _list_network_interfaces() - net_interface = get_setting("net_interface") or (all_interfaces or [None])[0] - net_state = _read_network_state(net_interface, net_backend) if net_interface else None - active_net_states = [ - {"interface": iface, **_read_network_state(iface, net_backend)} - for iface in all_interfaces - ] - active_net_states = [s for s in active_net_states if s.get("ok")] - pending_token = next(iter(_pending_network_revert), None) - try: - current_hostname = socket.gethostname() - except Exception: - current_hostname = None - return render_template( - "settings.html", - interval=interval, - current_hostname=current_hostname, - net_backend=net_backend, - net_interfaces=all_interfaces, - net_interface=net_interface, - net_state=net_state, - active_net_states=active_net_states, - net_revert_seconds=NETWORK_REVERT_SECONDS, - pending_network_token=pending_token, - log_rotation_interval=get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL), - log_rotation_keep=get_setting("log_rotation_keep", LOG_ROTATION_DEFAULT_KEEP), - log_paths={ - "live": TESM_LIVE_LOG_PATH, - "changes": TESM_CHANGES_LOG_PATH, - "app": TESM_APP_LOG_PATH, - "kea": TESM_KEA_LOG_PATH, - }, - current_timezone=_current_timezone(), - timezones=_list_timezones(), - full_nav_items=_ordered_nav_items() if current_user.is_admin else [], - trash_retention_days=_trash_retention_days(), - ) - - -@app.route("/settings/license") -@login_required -def settings_license(): - if not current_user.can_view_settings_license: - flash("Keine Berechtigung, die Lizenz anzusehen.", "danger") - return redirect(url_for("index")) - pending_raw = get_setting("license_pending_request", "") - pending = json.loads(pending_raw) if pending_raw else None - return render_template( - "settings_license.html", - license_file=_license_state["file"], - license_valid=_license_state["valid"], - license_error=_license_state["error"], - license_status=_license_state["status"], - license_is_activated=license_activated(), - license_last_heartbeat_at=get_setting("license_last_heartbeat_at", ""), - license_last_error=get_setting("license_last_error", ""), - license_fingerprint=licensing.system_fingerprint(), - pending_action=pending, - pending_code=licensing.encode_code(pending) if pending else None, - ) - - -@app.route("/settings/license/upload", methods=["POST"]) -@login_required -def license_upload(): - if not current_user.has_permission("settings_license.edit"): - flash("Keine Berechtigung, die Lizenz zu ändern.", "danger") - return redirect(url_for("settings_license")) - uploaded = request.files.get("license_file") - if not uploaded or not uploaded.filename: - flash("Bitte eine Lizenzdatei auswählen.", "danger") - return redirect(url_for("settings_license")) - try: - license_file = json.loads(uploaded.read().decode("utf-8")) - except (ValueError, UnicodeDecodeError): - flash("Datei ist keine gültige Lizenzdatei (kein lesbares JSON).", "danger") - return redirect(url_for("settings_license")) - if not licensing.verify_license_file(license_file): - flash("Lizenzdatei-Signatur ungültig -- Datei beschädigt, manipuliert oder von einem anderen Lizenzserver.", "danger") - return redirect(url_for("settings_license")) - _write_license_file(license_file) - set_setting("license_activation_status", "") - set_setting("license_pending_request", "") - customer_name = license_file.get("customer", {}).get("name", "?") - log_action("license.upload", license_file.get("license_id", ""), f"Lizenz für {customer_name} hochgeladen -- muss noch aktiviert werden.") - flash("Lizenzdatei eingespielt. Bitte jetzt aktivieren.", "success") - return redirect(url_for("settings_license")) - - -def _license_handshake(action, endpoint_path, success_setting_fn, success_log_action, success_flash): - """Gemeinsame Logik für Aktivierung/Deaktivierung: EIN Protokoll, zwei - Transportwege (siehe licensing.py-Moduldocstring). Versucht zuerst - automatisch online; schlägt das fehl (keine Netzwerkverbindung zum - Lizenzserver o.ä.), wird die Anfrage als Code hinterlegt, den der - Admin manuell beim Lizenzserver eingeben kann -- die Bestätigung - kommt dann über *_confirm() unten zurück.""" - license_file = _license_state.get("file") - request_payload = licensing.build_client_request(action, license_file) - endpoint = license_file.get("master_endpoint", "") - try: - if not endpoint: - raise RuntimeError("Kein Lizenzserver in der Lizenzdatei hinterlegt.") - response_payload = _license_api_post(endpoint, endpoint_path, request_payload) - if not licensing.verify_master_response(response_payload, license_file.get("master_pubkey", "")): - raise RuntimeError("Antwort mit ungültiger Signatur erhalten.") - if response_payload.get("status") != "ok": - raise RuntimeError(response_payload.get("status", "abgelehnt")) - success_setting_fn() - set_setting("license_pending_request", "") - log_action(success_log_action, license_file.get("license_id", ""), "Online beim Lizenzserver bestätigt.") - flash(success_flash, "success") - except Exception as exc: - set_setting("license_pending_request", json.dumps(request_payload)) - set_setting("license_last_error", f"Online-{action} fehlgeschlagen: {exc}") - flash( - "Online-Verbindung zum Lizenzserver nicht möglich. Bitte den unten angezeigten Code beim " - "Lizenzserver-Administrator eingeben und den dort erhaltenen Bestätigungscode unten eintragen.", - "warning", - ) - return redirect(url_for("settings_license")) - - -def _license_handshake_confirm(action, success_setting_fn, success_log_action, success_flash): - """Gegenstück zu _license_handshake() für den Offline-Zweig: der Admin - hat den vom Master erzeugten Bestätigungscode erhalten und trägt ihn - hier ein.""" - license_file = _license_state.get("file") - pending_raw = get_setting("license_pending_request", "") - if not license_file or not pending_raw: - flash("Keine offene Anfrage vorhanden.", "danger") - return redirect(url_for("settings_license")) - code = request.form.get("confirmation_code", "").strip() - try: - response_payload = licensing.decode_code(code) - except Exception: - flash("Code konnte nicht gelesen werden -- bitte vollständig kopieren.", "danger") - return redirect(url_for("settings_license")) - if not licensing.verify_master_response(response_payload, license_file.get("master_pubkey", "")): - flash("Code hat eine ungültige Signatur -- bitte erneut vom Lizenzserver kopieren.", "danger") - return redirect(url_for("settings_license")) - pending = json.loads(pending_raw) - if (response_payload.get("license_id") != pending.get("license_id") - or response_payload.get("fingerprint") != pending.get("fingerprint")): - flash("Code passt nicht zur offenen Anfrage.", "danger") - return redirect(url_for("settings_license")) - if response_payload.get("status") != "ok": - flash(f"Lizenzserver hat die Anfrage abgelehnt ({response_payload.get('status')}).", "danger") - return redirect(url_for("settings_license")) - success_setting_fn() - set_setting("license_pending_request", "") - log_action(success_log_action, license_file.get("license_id", ""), "Offline per Code bestätigt.") - flash(success_flash, "success") - return redirect(url_for("settings_license")) - - -@app.route("/settings/license/activate", methods=["POST"]) -@login_required -def license_activate(): - if not current_user.has_permission("settings_license.edit"): - flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger") - return redirect(url_for("settings_license")) - if not _license_state.get("file") or not _license_state.get("valid"): - flash("Keine gültige Lizenzdatei vorhanden.", "danger") - return redirect(url_for("settings_license")) - - def on_success(): - set_setting("license_activation_status", "active") - set_setting("license_export_grace", "") - # Sofort einen ersten Heartbeat auslösen, statt bis zu 6h auf den - # nächsten planmäßigen Durchlauf zu warten (siehe - # _license_heartbeat_loop) -- ohne das würde "Letzter Heartbeat" - # direkt nach der Aktivierung fälschlich wie ein Verbindungsproblem - # aussehen ("noch nie"), obwohl schlicht noch keine Zeit vergangen - # ist. Ein Fehlschlag hier (z.B. Kunde offline) wird wie jeder - # andere Heartbeat-Fehlschlag behandelt (nur vermerkt, nichts wird - # deswegen abgeschaltet). - try: - _send_heartbeat() - except Exception: - app.logger.error("Erster Heartbeat nach Aktivierung fehlgeschlagen:\n%s", traceback.format_exc()) - - return _license_handshake("activate", "/api/activate", on_success, "license.activated", "Lizenz erfolgreich aktiviert.") - - -@app.route("/settings/license/activate/confirm", methods=["POST"]) -@login_required -def license_activate_confirm(): - if not current_user.has_permission("settings_license.edit"): - flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger") - return redirect(url_for("settings_license")) - - def on_success(): - set_setting("license_activation_status", "active") - set_setting("license_export_grace", "") - # Sofort einen ersten Heartbeat auslösen, statt bis zu 6h auf den - # nächsten planmäßigen Durchlauf zu warten (siehe - # _license_heartbeat_loop) -- ohne das würde "Letzter Heartbeat" - # direkt nach der Aktivierung fälschlich wie ein Verbindungsproblem - # aussehen ("noch nie"), obwohl schlicht noch keine Zeit vergangen - # ist. Ein Fehlschlag hier (z.B. Kunde offline) wird wie jeder - # andere Heartbeat-Fehlschlag behandelt (nur vermerkt, nichts wird - # deswegen abgeschaltet). - try: - _send_heartbeat() - except Exception: - app.logger.error("Erster Heartbeat nach Aktivierung fehlgeschlagen:\n%s", traceback.format_exc()) - - return _license_handshake_confirm("activate", on_success, "license.activated", "Lizenz erfolgreich aktiviert.") - - -@app.route("/settings/license/deactivate", methods=["POST"]) -@login_required -def license_deactivate(): - if not current_user.has_permission("settings_license.edit"): - flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger") - return redirect(url_for("settings_license")) - if not _license_state.get("file") or not license_activated(): - flash("Keine aktive Lizenz zum Deaktivieren vorhanden.", "danger") - return redirect(url_for("settings_license")) - - def on_success(): - set_setting("license_export_grace", "1") - _delete_license_file() - - return _license_handshake( - "deactivate", "/api/deactivate", on_success, "license.deactivated", - "Lizenz deaktiviert. Das System ist jetzt lizenzlos -- Export bleibt bis zur nächsten Aktivierung möglich.", - ) - - -@app.route("/settings/license/deactivate/confirm", methods=["POST"]) -@login_required -def license_deactivate_confirm(): - if not current_user.has_permission("settings_license.edit"): - flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger") - return redirect(url_for("settings_license")) - - def on_success(): - set_setting("license_export_grace", "1") - _delete_license_file() - - return _license_handshake_confirm( - "deactivate", on_success, "license.deactivated", - "Lizenz deaktiviert. Das System ist jetzt lizenzlos -- Export bleibt bis zur nächsten Aktivierung möglich.", - ) - - -@app.route("/settings/network_state") -@login_required -def settings_network_state(): - """JSON-Endpunkt für die Interface-Auswahl auf der Netzwerkeinstellungen- - Karte -- liefert den tatsächlichen Ist-Zustand DES ausgewählten - Interfaces, damit die Eingabemaske beim Umschalten live aktualisiert - wird statt weiter die Werte des zuvor angezeigten Interfaces zu zeigen - (bei mehreren Interfaces sonst leicht mit dessen Konfiguration zu - verwechseln -- live reproduziert).""" - if not current_user.can_view_settings_system: - return jsonify({"error": "Keine Berechtigung."}), 403 - interface = request.args.get("interface", "").strip() - if interface not in _list_network_interfaces(): - return jsonify({"error": "Unbekanntes Interface."}), 404 - backend = _detect_network_backend() - state = _read_network_state(interface, backend) - state.pop("network", None) - return jsonify(state) - - -@app.route("/settings/import-export") -@login_required -def settings_import_export(): - if not current_user.can_view_settings_importexport: - flash("Keine Berechtigung für Im-/Export.", "danger") - return redirect(url_for("index")) - return render_template( - "settings_import_export.html", - export_sections=EXPORT_SECTIONS, - admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS, - export_section_hints=EXPORT_SECTION_HINTS, - ) - - - -DHCP_PACKAGE = "kea-dhcp4-server" -DHCP_SERVICE = "kea-dhcp4-server" -KEA_LEASE_FILE = "/var/lib/kea/kea-leases4.csv" - -DHCP_DEFAULTS = { - "dhcp_domain": "local", - "dhcp_lease_default": "600", - "dhcp_lease_max": "7200", - "dhcp_output_path": "/etc/kea/kea-dhcp4.conf", -} - - -def _dhcp_subnets(conn): - return conn.execute("SELECT * FROM dhcp_subnets ORDER BY sort_order ASC, id ASC").fetchall() - - -def _dhcp_subnet_matches(subnet_row): - """Validiert eine einzelne Subnetz-Zeile gegen das tatsächliche Netz - ihres Interfaces (siehe _dhcp_matching_network) und gibt bei Erfolg - (net_info, None) zurück, sonst (None, Fehlertext). Eine Subnetz-Zeile - ohne passendes reales Netz kann Kea nicht zugeordnet werden — es muss - eine physische IP im gewünschten Bereich vorhanden sein, sonst fehlt - die CIDR/Netzmaske, die Kea für den subnet4-Block braucht.""" - return _dhcp_matching_network(subnet_row["interface"], subnet_row["range_start"], subnet_row["range_end"]) - -DHCP_OPTION_TYPES = [ - ("string", "Text (string)"), - ("ipv4-address", "IP-Adresse (ipv4-address)"), - ("uint8", "Zahl, 1 Byte (uint8)"), - ("uint16", "Zahl, 2 Byte (uint16)"), - ("uint32", "Zahl, 4 Byte (uint32)"), - ("boolean", "Wahrheitswert (boolean)"), - ("binary", "Hexadezimal/Rohdaten (binary)"), -] -DHCP_OPTION_TYPE_KEYS = {t[0] for t in DHCP_OPTION_TYPES} - - -def _load_dhcp_settings(): - return {key: get_setting(key, default) for key, default in DHCP_DEFAULTS.items()} - - -def _detect_dhcp_status(): - """Rein lesende Erkennung, ob Kea auf diesem Host installiert ist und - läuft — löst selbst nichts aus. Installation/Start laufen ausschließlich - über die separaten, bestätigten Aktionen weiter unten.""" - kea_path = shutil.which("kea-dhcp4") - installed = kea_path is not None - active = None - enabled = None - version = None - if installed: - try: - version_out = subprocess.run( - ["kea-dhcp4", "-v"], capture_output=True, text=True, timeout=5 - ) - out = (version_out.stdout or version_out.stderr or "").strip() - version = out.splitlines()[0] if out else None - except Exception: - version = None - try: - status = subprocess.run( - ["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5, - ) - active = status.stdout.strip() == "active" - except Exception: - active = None - try: - enabled_out = subprocess.run( - ["systemctl", "is-enabled", DHCP_SERVICE], capture_output=True, text=True, timeout=5, - ) - enabled = enabled_out.stdout.strip() == "enabled" - except Exception: - enabled = None - return {"installed": installed, "path": kea_path, "active": active, "enabled": enabled, "version": version} - - -def _stop_dhcp_service_if_active(): - """Stoppt UND deaktiviert den Kea-DHCP-Dienst, falls er gerade läuft — - aufgerufen, wenn sich die Host-Netzwerkkonfiguration ändert - (Systemeinstellungen > Netzwerkeinstellungen). Eine vorher passende - Range/Interface-Bindung kann nach einer IP-/Subnetzänderung plötzlich - zum falschen Netz gehören. Bewusst nur stoppen, nicht automatisch neu - starten — der Admin muss die Range aktiv auf der DHCP-Seite - prüfen/bestätigen, statt dass die App mit einer möglicherweise falschen - Konfiguration weiterläuft oder blind rät. Zusätzlich deaktivieren (nicht - nur stoppen), damit ein Host-Neustart vor dieser Prüfung den Dienst - nicht trotzdem automatisch mit der ggf. falschen Range wieder hochfährt.""" - try: - status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5) - if status.stdout.strip() != "active": - return False - ok = subprocess.run(["systemctl", "stop", DHCP_SERVICE], timeout=15).returncode == 0 - subprocess.run(["systemctl", "disable", DHCP_SERVICE], timeout=15) - return ok - except Exception: - return False - - -def _list_network_interfaces(): - """Echte Netzwerkschnittstellen dieses Hosts (ohne loopback) — Quelle - für die Interface-Auswahl, damit nur tatsächlich vorhandene Interfaces - wählbar sind statt eines frei eintippbaren Textfelds.""" - try: - return sorted(name for name in os.listdir("/sys/class/net") if name != "lo") - except OSError: - return [] - - -def _detect_interface_networks(interface): - """Wie _detect_interface_network() (siehe unten), aber für ALLE IPv4- - Adressen des Interfaces statt nur der ersten — ein Interface kann - mehrere IPs/Subnetze gleichzeitig tragen (z.B. Alias-IPs oder mehrere - Ranges auf derselben Karte). Gibt eine Liste von net_info-Dicts - zurück (leer, falls keine IPv4-Adresse gefunden wurde/das Interface - nicht existiert).""" - if not interface: - return [] - try: - addr_out = subprocess.run( - ["ip", "-4", "-o", "addr", "show", "dev", interface], - capture_output=True, text=True, timeout=5, - ) - route_out = subprocess.run( - ["ip", "-4", "route", "show", "default", "dev", interface], - capture_output=True, text=True, timeout=5, - ) - gw_match = re.search(r"via (\d+\.\d+\.\d+\.\d+)", route_out.stdout) - if not gw_match: - route_out = subprocess.run( - ["ip", "-4", "route", "show", "default"], capture_output=True, text=True, timeout=5, - ) - gw_match = re.search(r"via (\d+\.\d+\.\d+\.\d+)", route_out.stdout) - gateway = gw_match.group(1) if gw_match else None - - results = [] - for line in addr_out.stdout.splitlines(): - match = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/(\d+)", line) - if not match: - continue - ip_str, prefix = match.group(1), int(match.group(2)) - network = ipaddress.IPv4Network(f"{ip_str}/{prefix}", strict=False) - dynamic = "dynamic" in line - results.append({ - "ok": True, "ip": ip_str, "prefix": prefix, "network": network, - "netmask": str(network.netmask), "gateway": gateway, "dynamic": dynamic, "error": None, - }) - return results - except Exception: - return [] - - -def _detect_interface_network(interface): - """Liest Subnet, Netzmaske und Gateway der PRIMÄREN (ersten) IPv4- - Adresse eines Interfaces direkt aus der laufenden System- - Netzwerkkonfiguration aus (statt sie manuell pflegen zu lassen) — - dadurch koppelt sich die generierte DHCP-Konfiguration immer an das - Netz, in dem der Host tatsächlich hängt, auch wenn sich dessen - IP/Subnet mal ändert. Trägt das Interface mehrere IPv4-Adressen, - siehe _detect_interface_networks() für alle.""" - if not interface: - return {"ok": False, "ip": None, "prefix": None, "netmask": None, "network": None, "gateway": None, - "dynamic": None, "error": "Kein Interface ausgewählt."} - networks = _detect_interface_networks(interface) - if not networks: - return {"ok": False, "ip": None, "prefix": None, "netmask": None, "network": None, "gateway": None, - "dynamic": None, "error": f"Keine IPv4-Adresse auf „{interface}“ gefunden."} - return networks[0] - - -def _dhcp_matching_network(interface, range_start, range_end): - """Prüft eine DHCP-Range gegen ALLE auf dem Interface aktuell - konfigurierten IPv4-Netze (ein Interface kann mehrere IPs/Subnetze - tragen, z.B. Alias-IPs) und gibt bei Erfolg (net_info, None) zurück, - sonst (None, Fehlertext). Verhindert, dass eine Range gespeichert, - geschrieben oder ein Dienststart erlaubt wird, die zu keinem - tatsächlich vorhandenen Subnetz passt — z.B. wenn sich das Netz des - Hosts seit dem letzten Speichern geändert hat.""" - try: - start_ip = ipaddress.IPv4Address(range_start) - end_ip = ipaddress.IPv4Address(range_end) - except ValueError: - return None, "Range Start/Ende müssen gültige IPv4-Adressen sein." - if int(start_ip) > int(end_ip): - return None, "Range Start muss kleiner oder gleich Range Ende sein." - networks = _detect_interface_networks(interface) - if not networks: - return None, f"Keine IPv4-Adresse auf „{interface}“ gefunden." - for net_info in networks: - if start_ip in net_info["network"] and end_ip in net_info["network"]: - return net_info, None - nets_str = ", ".join(str(n["network"]) for n in networks) - return None, f"Range {range_start}–{range_end} liegt in keinem der auf „{interface}“ erkannten Netze ({nets_str})." - - -def _dhcp_safe_hostname(name, mac): - """Wandelt einen Client-Namen in einen für Kea-Reservierungen/hostname - gültigen Bezeichner um (nur a-z0-9-, keine führenden/abschließenden/ - doppelten Bindestriche). Fällt bei leerem Ergebnis auf die letzten 4 - Byte der MAC zurück, damit nie ein leerer oder doppelter Bezeichner - entsteht.""" - slug = re.sub(r"[^a-z0-9-]+", "-", (name or "").strip().lower()).strip("-") - slug = re.sub(r"-{2,}", "-", slug) - if slug: - return slug - return "geraet-" + re.sub(r"[^0-9a-f]", "", mac.lower())[-8:] - - -def _dhcp_valid_subnets(subnets): - """Nimmt die rohen dhcp_subnets-Zeilen und validiert JEDE einzeln gegen - ihr tatsächliches Netz (siehe _dhcp_subnet_matches — es muss eine - physische IP im gewünschten Bereich vorhanden sein, sonst kann Kea - damit nichts anfangen). Deaktivierte Subnetze (enabled=0) werden dabei - komplett übersprungen — bewusst nicht als "invalid" gewertet, da das - Fehlen eines echten Netzes hier kein Fehler ist, sondern Absicht. - Gibt (valid, invalid) zurück: valid ist um "net_info" angereichert (für - Reservierungs-Zuordnung und Kea-Config), invalid enthält (Zeile, - Fehlertext) für die Anzeige/Warnungen.""" - valid, invalid = [], [] - for s in subnets: - if not s["enabled"]: - continue - net_info, error = _dhcp_subnet_matches(s) - if error: - invalid.append((s, error)) - else: - valid.append({ - "id": s["id"], "interface": s["interface"], - "range_start": s["range_start"], "range_end": s["range_end"], - "gateway": s["gateway"], "dns": s["dns"], "net_info": net_info, - }) - return valid, invalid - - -def _dhcp_reservation_candidates(conn, valid_subnets): - """Baut die Liste der Kea-Reservierungen aus ZWEI Quellen: automatisch - aus aktiven Geräten mit gültiger MAC + IP (Clients dieser App) sowie - zusätzlich aus manuell angelegten Reservierungen (dhcp_manual_reservations - — für Geräte, die nicht als Client gepflegt werden, z.B. ein externes - Gerät, dem bewusst eine feste IP gegeben werden soll). Bei einer MAC in - beiden Quellen gewinnt die automatische (echte Client-Daten vor einer - ggf. veralteten manuellen Zeile). Geräte ohne ip/mac werden - übersprungen statt eine kaputte Reservierung zu erzeugen. Ebenso - übersprungen: Einträge, deren IP zu KEINEM der übergebenen (bereits - validierten) Subnetze gehört — Kea lehnt eine Reservierung außerhalb - ihres subnet4-Blocks als Konfigurationsfehler ab (mit einem Live-Test - gegen echtes Kea verifiziert). Gibt (reservations_by_subnet_id, - out_of_subnet_count) zurück — jeder Eintrag landet bei genau einem - Subnetz, dem seine IP tatsächlich zugehört (ein Host kann mehrere - Subnetze gleichzeitig anbieten).""" - auto_rows = conn.execute( - "SELECT mac, ip, name FROM devices " - "WHERE is_active=1 AND deleted_at IS NULL AND mac IS NOT NULL AND ip IS NOT NULL ORDER BY name ASC" - ).fetchall() - manual_rows = conn.execute("SELECT id, mac, ip, name FROM dhcp_manual_reservations ORDER BY name ASC").fetchall() - - candidates = {} - for row in manual_rows: - candidates[row["mac"].upper()] = { - "mac": row["mac"], "ip": row["ip"], "name": row["name"], "source": "manual", "manual_id": row["id"], - } - for row in auto_rows: - candidates[row["mac"].upper()] = { - "mac": row["mac"], "ip": row["ip"], "name": row["name"], "source": "auto", "manual_id": None, - } - - seen_hostnames_by_subnet = {s["id"]: set() for s in valid_subnets} - reservations_by_subnet = {s["id"]: [] for s in valid_subnets} - out_of_subnet = 0 - for c in sorted(candidates.values(), key=lambda c: c["name"]): - try: - ip = ipaddress.IPv4Address(c["ip"]) - except ValueError: - out_of_subnet += 1 - continue - matched = next((s for s in valid_subnets if ip in s["net_info"]["network"]), None) - if matched is None: - out_of_subnet += 1 - continue - hostname = _dhcp_safe_hostname(c["name"], c["mac"]) - seen = seen_hostnames_by_subnet[matched["id"]] - base_hostname, suffix = hostname, 2 - while hostname in seen: - hostname = f"{base_hostname}-{suffix}" - suffix += 1 - seen.add(hostname) - reservations_by_subnet[matched["id"]].append({ - "mac": c["mac"], "ip": c["ip"], "hostname": hostname, "name": c["name"], "source": c["source"], - "manual_id": c["manual_id"], "subnet_id": matched["id"], - "subnet_label": f"{matched['interface']} ({matched['net_info']['network']})", - }) - return reservations_by_subnet, out_of_subnet - - -def _dhcp_option_defs(conn): - return conn.execute( - "SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs ORDER BY code ASC" - ).fetchall() - - -def _dhcp_option_values(conn): - """{option_def_id: {"": , "": , ...}} - — device_mac='' ist der globale Wert (siehe create_db.py).""" - values = {} - for row in conn.execute("SELECT option_def_id, device_mac, value FROM dhcp_option_values"): - values.setdefault(row["option_def_id"], {})[row["device_mac"]] = row["value"] - return values - - -def _dhcp_read_leases(): - """Liest die aktuell aktiven Kea-Leases direkt aus der memfile-CSV - (siehe lease-file in _render_kea_config) — kein Kea-Control-Agent - nötig, nur Lesezugriff auf die Datei. Wichtig, um Clients OHNE eigene - Reservierung sichtbar zu machen, die sich einfach eine freie IP aus dem - Pool genommen haben (siehe reserved-Flag, wird vom Aufrufer ergänzt). - Gibt None zurück, falls die Datei (noch) nicht existiert — z.B. weil - der Dienst noch nie gestartet wurde oder noch keine Adresse vergeben - hat — statt fälschlich "keine Leases" zu behaupten.""" - try: - with open(KEA_LEASE_FILE, newline="", encoding="utf-8") as f: - rows = list(csv.DictReader(f)) - except OSError: - return None - now = int(time.time()) - by_ip = {} - for row in rows: - if (row.get("state") or "0") != "0": - continue - try: - expire = int(row.get("expire") or 0) - except ValueError: - expire = 0 - if expire and expire < now: - continue - ip = row.get("address") or "" - existing = by_ip.get(ip) - if existing is not None and (existing.get("expire") or 0) >= expire: - continue - by_ip[ip] = {"expire": expire, "row": row} - leases = [] - for ip, entry in by_ip.items(): - row, expire = entry["row"], entry["expire"] - leases.append({ - "ip": ip, - "mac": (row.get("hwaddr") or "").upper(), - "hostname": row.get("hostname") or "", - "expires_at": datetime.utcfromtimestamp(expire).strftime("%Y-%m-%d %H:%M:%S") if expire else None, - "expire_epoch_ms": expire * 1000 if expire else None, - }) - try: - leases.sort(key=lambda l: ipaddress.IPv4Address(l["ip"])) - except ValueError: - pass - return leases - - -def _dhcp_delete_lease(ip): - """Entfernt ALLE Zeilen einer Adresse direkt aus der Kea-Lease-CSV - (memfile-Backend, siehe _dhcp_read_leases für dieselbe Datei) und - startet den laufenden Dienst neu, damit Kea die Datei beim Hochfahren - frisch neu einliest -- ein bloßes Editieren bei laufendem Dienst würde - sonst von Keas eigenem, periodischem Lease-File-Cleanup irgendwann - wieder mit dem alten Stand aus dem Speicher überschrieben. Startet den - Dienst nur neu, wenn er gerade aktiv ist (kein ungefragtes Hochfahren - eines bewusst gestoppten Dienstes). Gibt (ok, message) zurück.""" - try: - original_stat = os.stat(KEA_LEASE_FILE) - with open(KEA_LEASE_FILE, newline="", encoding="utf-8") as f: - reader = csv.DictReader(f) - fieldnames = reader.fieldnames - rows = list(reader) - except OSError: - return False, "Keine Lease-Datei gefunden." - remaining = [r for r in rows if (r.get("address") or "") != ip] - if len(remaining) == len(rows): - return False, f"Keine Lease für {ip} in der Datei gefunden." - try: - with open(KEA_LEASE_FILE, "w", newline="", encoding="utf-8") as f: - writer = csv.DictWriter(f, fieldnames=fieldnames, lineterminator="\n") - writer.writeheader() - writer.writerows(remaining) - os.chown(KEA_LEASE_FILE, original_stat.st_uid, original_stat.st_gid) - os.chmod(KEA_LEASE_FILE, stat.S_IMODE(original_stat.st_mode)) - except OSError as e: - return False, f"Lease-Datei konnte nicht geschrieben werden: {e}" - try: - status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5) - active = status.stdout.strip() == "active" - except Exception: - active = False - if active: - ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30) - if not ok_restart: - return False, f"Lease für {ip} aus der Datei entfernt, Neustart des Dienstes fehlgeschlagen: {restart_out}" - return True, f"Lease für {ip} gelöscht." - - -def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values): - """Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON- - serialisierbar) — ein subnet4-Block je konfiguriertem Subnetz, damit ein - Host mehrere IPs/Interfaces mit jeweils eigenem Netz gleichzeitig - bedienen kann. Subnet/Netzmaske kommen bewusst NICHT aus manueller - Eingabe, sondern aus dem net_info jedes Subnetzes (siehe - _dhcp_valid_subnets) — nur Subnetze mit einer zu einem echten, - tatsächlich vorhandenen Netz passenden Range fließen hier ein - (`valid_subnets`, bereits gefiltert). Gateway/DNS sind je Subnetz - eigenes option-data (unterschiedliche Netze haben i.d.R. - unterschiedliche Router); Domain und selbstdefinierte Options bleiben - global. Client-Overrides landen im option-data der jeweiligen - Reservierung — Kea überschreibt damit automatisch gleichnamige - globale/Subnet-Options für genau diesen Client, ohne dass wir das - selbst zusammenrechnen müssen.""" - option_def_list = [ - {"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"} - for d in option_defs if not d["is_standard"] - ] - - global_option_data = [] - if cfg["dhcp_domain"]: - global_option_data.append({"name": "domain-name", "data": cfg["dhcp_domain"]}) - for d in option_defs: - global_value = option_values.get(d["id"], {}).get("") - if global_value: - global_option_data.append({"name": d["name"], "data": global_value}) - - subnet4 = [] - for idx, s in enumerate(valid_subnets, start=1): - router = (s["gateway"] or "").strip() or s["net_info"].get("gateway") or "" - dns_servers = ", ".join(x.strip() for x in (s["dns"] or "").split(",") if x.strip()) - sub_option_data = [] - if router: - sub_option_data.append({"name": "routers", "data": router}) - if dns_servers: - sub_option_data.append({"name": "domain-name-servers", "data": dns_servers}) - - kea_reservations = [] - for r in reservations_by_subnet.get(s["id"], []): - res_option_data = [] - for d in option_defs: - override = option_values.get(d["id"], {}).get(r["mac"]) - if override: - res_option_data.append({"name": d["name"], "data": override}) - entry = { - "hw-address": r["mac"].lower(), - "ip-address": r["ip"], - "hostname": r["hostname"], - } - if res_option_data: - entry["option-data"] = res_option_data - kea_reservations.append(entry) - - subnet4.append({ - "id": idx, - "subnet": f"{s['net_info']['network'].network_address}/{s['net_info']['network'].prefixlen}", - "pools": [{"pool": f"{s['range_start']} - {s['range_end']}"}], - **({"option-data": sub_option_data} if sub_option_data else {}), - "reservations": kea_reservations, - }) - - return { - "Dhcp4": { - "interfaces-config": {"interfaces": sorted({s["interface"] for s in valid_subnets})}, - "lease-database": {"type": "memfile", "lfc-interval": 3600, "name": KEA_LEASE_FILE}, - "valid-lifetime": int(cfg["dhcp_lease_default"] or 600), - "max-valid-lifetime": int(cfg["dhcp_lease_max"] or 7200), - "option-def": option_def_list, - "option-data": global_option_data, - "subnet4": subnet4, - "loggers": [ - { - "name": "kea-dhcp4", - "output_options": [{"output": TESM_KEA_LOG_PATH}], - "severity": "INFO", - } - ], - } - } - - -def _dhcp_run_privileged(args, timeout): - """Führt einen Installations-/Dienst-Befehl aus und fasst das Ergebnis - einheitlich zusammen — wirft nie, damit der aufrufende Code immer eine - Flash-Meldung statt eines 500ers bekommt.""" - try: - result = subprocess.run(args, capture_output=True, text=True, timeout=timeout) - return result.returncode == 0, (result.stdout or "") + (result.stderr or "") - except Exception as e: - return False, str(e) - - -def _list_timezones(): - """Alle von diesem System unterstützten IANA-Zeitzonen fürs - Auswahl-Dropdown (Systemeinstellungen → Zeitzone) — timedatectl selbst - validiert beim tatsächlichen Setzen ohnehin noch einmal serverseitig.""" - ok, out = _dhcp_run_privileged(["timedatectl", "list-timezones"], timeout=10) - if not ok: - return [] - return [line.strip() for line in out.splitlines() if line.strip()] - - -def _current_timezone(): - ok, out = _dhcp_run_privileged(["timedatectl", "show", "--property=Timezone", "--value"], timeout=5) - return out.strip() if ok else None - - -def _dhcp_write_and_restart(conn): - """Schreibt die Kea-Konfiguration aus dem aktuellen DB-Stand neu und - startet den Dienst neu, FALLS er gerade aktiv ist -- aufgerufen direkt - nach jeder Änderung, die sich auf die geschriebene Konfiguration - auswirkt (Reservierung, Option, Subnetz, globale Einstellung), damit - eine Änderung sofort wirksam wird und ein manuelles "Datei schreiben" + - "Neu starten" nicht mehr vergessen werden kann. Schreibt nur, wenn - mindestens ein gültiges Subnetz konfiguriert ist (sonst gäbe es nichts - Sinnvolles zu schreiben); startet nur neu, wenn der Dienst bereits - läuft -- ein bewusst gestoppter Dienst wird dadurch nicht ungefragt - wieder hochgefahren. Flasht nur bei einem tatsächlichen Fehler etwas - Zusätzliches, ein stiller Erfolg reiht sich in die ohnehin schon - gezeigte Erfolgsmeldung der jeweiligen Aktion ein.""" - cfg = _load_dhcp_settings() - valid_subnets, _ = _dhcp_valid_subnets(_dhcp_subnets(conn)) - if not valid_subnets: - return - reservations_by_subnet, _ = _dhcp_reservation_candidates(conn, valid_subnets) - option_defs = _dhcp_option_defs(conn) - option_values = _dhcp_option_values(conn) - content = json.dumps( - _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values), - indent=2, ensure_ascii=False, - ) - output_path = cfg["dhcp_output_path"] - try: - os.makedirs(os.path.dirname(output_path), exist_ok=True) - with open(output_path, "w", encoding="utf-8") as f: - f.write(content + "\n") - except OSError as e: - flash(f"Konfiguration konnte nicht automatisch geschrieben werden: {e}", "danger") - return - try: - status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5) - active = status.stdout.strip() == "active" - except Exception: - active = False - if active: - ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30) - if not ok_restart: - flash(f"Konfiguration geschrieben, Neustart des DHCP-Dienstes fehlgeschlagen: {restart_out}", "danger") - - -@app.route("/settings/dhcp", methods=["GET", "POST"]) -@login_required -def settings_dhcp(): - if request.method == "GET" and not current_user.can_view_settings_dhcp: - flash("Keine Berechtigung, die DHCP-Einstellungen anzusehen.", "danger") - return redirect(url_for("index")) - if resp := _require_module("dhcp"): - return resp - - conn = get_db_connection() - - if request.method == "POST": - if not current_user.has_permission("settings_dhcp.edit"): - flash("Keine Berechtigung, die DHCP-Konfiguration zu ändern.", "danger") - conn.close() - return redirect(url_for("settings_dhcp")) - - if "save_dhcp_config" in request.form: - for key in DHCP_DEFAULTS: - value = request.form.get(key, "").strip() - if value: - set_setting(key, value) - log_action("settings.update", "DHCP-Konfiguration") - flash("DHCP-Konfiguration gespeichert.", "success") - _dhcp_write_and_restart(conn) - - elif "add_dhcp_subnet" in request.form: - interface = request.form.get("interface", "").strip() - range_start = request.form.get("range_start", "").strip() - range_end = request.form.get("range_end", "").strip() - gateway = request.form.get("gateway", "").strip() - dns = request.form.get("dns", "").strip() - if interface not in _list_network_interfaces(): - flash(f"Interface „{interface}“ existiert nicht auf diesem Host.", "danger") - else: - _, range_error = _dhcp_matching_network(interface, range_start, range_end) - if range_error: - flash(range_error, "danger") - else: - max_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1) AS m FROM dhcp_subnets").fetchone()["m"] - conn.execute( - "INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, sort_order) " - "VALUES (?, ?, ?, ?, ?, ?)", - (interface, range_start, range_end, gateway or None, dns or None, max_order + 1), - ) - conn.commit() - log_action("settings.dhcp_subnet_add", interface, f"{range_start}–{range_end}") - flash(f"Subnetz {range_start}–{range_end} auf „{interface}“ hinzugefügt.", "success") - _dhcp_write_and_restart(conn) - - elif "edit_dhcp_subnet" in request.form: - subnet_id = request.form.get("edit_dhcp_subnet") - interface = request.form.get("interface", "").strip() - range_start = request.form.get("range_start", "").strip() - range_end = request.form.get("range_end", "").strip() - gateway = request.form.get("gateway", "").strip() - dns = request.form.get("dns", "").strip() - if interface not in _list_network_interfaces(): - flash(f"Interface „{interface}“ existiert nicht auf diesem Host.", "danger") - else: - _, range_error = _dhcp_matching_network(interface, range_start, range_end) - if range_error: - flash(range_error, "danger") - else: - conn.execute( - "UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=? WHERE id=?", - (interface, range_start, range_end, gateway or None, dns or None, subnet_id), - ) - conn.commit() - log_action("settings.dhcp_subnet_edit", interface, f"{range_start}–{range_end}") - flash("Subnetz aktualisiert.", "success") - _dhcp_write_and_restart(conn) - - elif "delete_dhcp_subnet" in request.form: - subnet_id = request.form.get("delete_dhcp_subnet") - conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,)) - conn.commit() - log_action("settings.dhcp_subnet_delete", subnet_id) - flash("Subnetz gelöscht.", "success") - _dhcp_write_and_restart(conn) - - elif "toggle_dhcp_subnet" in request.form: - subnet_id = request.form.get("toggle_dhcp_subnet") - row = conn.execute("SELECT enabled, interface FROM dhcp_subnets WHERE id=?", (subnet_id,)).fetchone() - if row: - new_state = 0 if row["enabled"] else 1 - conn.execute("UPDATE dhcp_subnets SET enabled=? WHERE id=?", (new_state, subnet_id)) - conn.commit() - log_action("settings.dhcp_subnet_toggle", row["interface"], "aktiviert" if new_state else "deaktiviert") - flash( - "Subnetz aktiviert." if new_state else - "Subnetz deaktiviert — wird beim nächsten Schreiben/Starten nicht mehr berücksichtigt.", - "success", - ) - _dhcp_write_and_restart(conn) - - elif "add_dhcp_option" in request.form: - code_raw = request.form.get("code", "").strip() - name = request.form.get("name", "").strip() - opt_type = request.form.get("type", "").strip() - description = request.form.get("description", "").strip() - initial_value = request.form.get("initial_value", "").strip() - if not code_raw.isdigit() or not (1 <= int(code_raw) <= 254): - flash("Code muss eine Zahl zwischen 1 und 254 sein.", "danger") - elif not re.match(r"^[a-z][a-z0-9-]*$", name): - flash("Name muss mit einem Kleinbuchstaben beginnen und darf nur a-z, 0-9 und - enthalten.", "danger") - elif opt_type not in DHCP_OPTION_TYPE_KEYS: - flash("Ungültiger Datentyp.", "danger") - else: - try: - cur = conn.execute( - "INSERT INTO dhcp_option_defs (code, name, type, description) VALUES (?, ?, ?, ?)", - (int(code_raw), name, opt_type, description or None), - ) - if initial_value: - conn.execute( - "INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, '', ?)", - (cur.lastrowid, initial_value), - ) - conn.commit() - log_action("settings.dhcp_option_add", name, f"Code {code_raw}, Typ {opt_type}") - flash(f"Option „{name}“ (Code {code_raw}) angelegt.", "success") - _dhcp_write_and_restart(conn) - except sqlite3.IntegrityError: - flash("Code oder Name wird bereits verwendet.", "danger") - - elif "delete_dhcp_option" in request.form: - option_def_id = request.form.get("delete_dhcp_option") - option_def = conn.execute("SELECT name, is_standard FROM dhcp_option_defs WHERE id=?", (option_def_id,)).fetchone() - if option_def and option_def["is_standard"]: - flash("Standard-Optionen können nicht gelöscht werden — leeren Wert speichern, um sie auszublenden.", "danger") - else: - conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=?", (option_def_id,)) - conn.execute("DELETE FROM dhcp_option_defs WHERE id=?", (option_def_id,)) - conn.commit() - log_action("settings.dhcp_option_delete", option_def["name"] if option_def else option_def_id) - flash("Option gelöscht.", "success") - _dhcp_write_and_restart(conn) - - elif "save_dhcp_option_global" in request.form: - option_def_id = request.form.get("option_def_id") - value = request.form.get("value", "").strip() - if value: - conn.execute( - "INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, '', ?) " - "ON CONFLICT(option_def_id, device_mac) DO UPDATE SET value=excluded.value", - (option_def_id, value), - ) - else: - conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=? AND device_mac=''", (option_def_id,)) - conn.commit() - log_action("settings.dhcp_option_value", option_def_id, "global") - flash("Globaler Wert gespeichert.", "success") - _dhcp_write_and_restart(conn) - - elif "save_dhcp_device_options" in request.form: - mac = request.form.get("mac", "").strip() - for d in _dhcp_option_defs(conn): - value = request.form.get(f"opt_{d['id']}", "").strip() - if value: - conn.execute( - "INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, ?, ?) " - "ON CONFLICT(option_def_id, device_mac) DO UPDATE SET value=excluded.value", - (d["id"], mac, value), - ) - else: - conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=? AND device_mac=?", (d["id"], mac)) - conn.commit() - log_action("settings.dhcp_option_value", mac, "Client-Override") - flash(f"DHCP-Optionen für {mac} gespeichert.", "success") - _dhcp_write_and_restart(conn) - - elif "add_dhcp_reservation" in request.form: - mac = request.form.get("mac", "").strip().upper() - ip = request.form.get("ip", "").strip() - name = request.form.get("name", "").strip() - if not re.match(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$", mac): - flash("Ungültige MAC-Adresse (Format xx:xx:xx:xx:xx:xx).", "danger") - elif not name: - flash("Name darf nicht leer sein.", "danger") - else: - try: - ipaddress.IPv4Address(ip) - except ValueError: - flash("Ungültige IPv4-Adresse.", "danger") - else: - existing_device = conn.execute( - "SELECT 1 FROM devices WHERE is_active=1 AND deleted_at IS NULL AND UPPER(mac)=?", (mac,) - ).fetchone() - if existing_device: - flash(f"MAC {mac} gehört bereits zu einem Client — dafür entsteht die Reservierung automatisch.", "danger") - else: - try: - conn.execute( - "INSERT INTO dhcp_manual_reservations (mac, ip, name) VALUES (?, ?, ?)", (mac, ip, name) - ) - conn.commit() - log_action("settings.dhcp_reservation_add", mac, f"{ip} ({name})") - flash(f"Manuelle Reservierung für {mac} hinzugefügt.", "success") - _dhcp_write_and_restart(conn) - except sqlite3.IntegrityError: - flash(f"Für MAC {mac} existiert bereits eine manuelle Reservierung.", "danger") - - elif "delete_dhcp_reservation" in request.form: - reservation_id = request.form.get("delete_dhcp_reservation") - row = conn.execute("SELECT mac FROM dhcp_manual_reservations WHERE id=?", (reservation_id,)).fetchone() - conn.execute("DELETE FROM dhcp_manual_reservations WHERE id=?", (reservation_id,)) - conn.commit() - log_action("settings.dhcp_reservation_delete", row["mac"] if row else reservation_id) - flash("Manuelle Reservierung gelöscht.", "success") - _dhcp_write_and_restart(conn) - - elif "write_dhcp_file" in request.form: - cfg = _load_dhcp_settings() - valid_subnets, invalid_subnets = _dhcp_valid_subnets(_dhcp_subnets(conn)) - if not valid_subnets: - flash( - "Kein gültiges Subnetz konfiguriert — mindestens ein Subnetz mit einer zu einem echten Netz " - "passenden Range wird benötigt, bevor eine Konfiguration geschrieben wird.", - "danger", - ) - conn.close() - return redirect(url_for("settings_dhcp")) - reservations_by_subnet, _ = _dhcp_reservation_candidates(conn, valid_subnets) - option_defs = _dhcp_option_defs(conn) - option_values = _dhcp_option_values(conn) - content = json.dumps( - _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values), - indent=2, ensure_ascii=False, - ) - output_path = cfg["dhcp_output_path"] - total_reservations = sum(len(v) for v in reservations_by_subnet.values()) - try: - os.makedirs(os.path.dirname(output_path), exist_ok=True) - with open(output_path, "w", encoding="utf-8") as f: - f.write(content + "\n") - log_action( - "settings.dhcp_write", output_path, - f"{len(valid_subnets)} Subnetz(e), {total_reservations} Reservierungen", - ) - try: - status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5) - active = status.stdout.strip() == "active" - except Exception: - active = False - restart_ok, restart_out = (True, "") - if active: - restart_ok, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30) - msg = ( - f"{len(valid_subnets)} Subnetz(e) mit insgesamt {total_reservations} Reservierungen nach " - f"{output_path} geschrieben" - + (" und Dienst neu gestartet." if active and restart_ok else "." if not active else "") - ) - if active and not restart_ok: - msg += f" Neustart des Dienstes fehlgeschlagen: {restart_out}" - if invalid_subnets: - skipped = ", ".join(f"{s['interface']} ({s['range_start']}–{s['range_end']})" for s, _ in invalid_subnets) - msg += f" Übersprungen, da zu keinem echten Netz passend: {skipped}." - flash(msg, "success" if (not active or restart_ok) else "danger") - except OSError as e: - flash(f"Konnte Datei nicht schreiben: {e}", "danger") - - elif "install_dhcp_package" in request.form: - ok_update, _ = _dhcp_run_privileged(["apt-get", "update"], timeout=60) - env_prefix = {"DEBIAN_FRONTEND": "noninteractive"} - try: - result = subprocess.run( - ["apt-get", "install", "-y", DHCP_PACKAGE], - capture_output=True, text=True, timeout=180, - env={**os.environ, **env_prefix}, - ) - ok = result.returncode == 0 - output_tail = "\n".join(((result.stdout or "") + (result.stderr or "")).strip().splitlines()[-10:]) - except Exception as e: - ok, output_tail = False, str(e) - log_action("settings.dhcp_install", DHCP_PACKAGE, "erfolgreich" if ok else "fehlgeschlagen") - if ok: - flash(f"{DHCP_PACKAGE} installiert.", "success") - else: - flash(f"Installation fehlgeschlagen: {output_tail or 'unbekannter Fehler'}", "danger") - - elif "dhcp_service_action" in request.form: - action = request.form.get("dhcp_service_action") - valid_subnets_now, _ = _dhcp_valid_subnets(_dhcp_subnets(conn)) - if action == "enable_restart" and not valid_subnets_now: - flash( - "Mindestens ein Subnetz mit einer zu einem echten Netz passenden Range muss konfiguriert und " - "die Konfiguration geschrieben sein, bevor der Dienst gestartet wird.", - "danger", - ) - elif action == "enable_restart": - ok_enable, _ = _dhcp_run_privileged(["systemctl", "enable", DHCP_SERVICE], timeout=15) - ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30) - log_action("settings.dhcp_service", DHCP_SERVICE, "enable+restart " + ("ok" if (ok_enable and ok_restart) else "fehlgeschlagen")) - if ok_enable and ok_restart: - flash(f"{DHCP_SERVICE} aktiviert und neu gestartet.", "success") - else: - flash(f"Dienst-Aktion fehlgeschlagen: {restart_out or 'unbekannter Fehler'}", "danger") - elif action == "stop": - ok_stop, stop_out = _dhcp_run_privileged(["systemctl", "stop", DHCP_SERVICE], timeout=15) - _dhcp_run_privileged(["systemctl", "disable", DHCP_SERVICE], timeout=15) - log_action("settings.dhcp_service", DHCP_SERVICE, "stop " + ("ok" if ok_stop else "fehlgeschlagen")) - flash(f"{DHCP_SERVICE} gestoppt und deaktiviert (startet nach einem Neustart nicht automatisch wieder)." if ok_stop else f"Stoppen fehlgeschlagen: {stop_out}", "success" if ok_stop else "danger") - - conn.close() - return redirect(url_for("settings_dhcp")) - - cfg = _load_dhcp_settings() - subnets = _dhcp_subnets(conn) - valid_subnets, invalid_subnets = _dhcp_valid_subnets(subnets) - reservations_by_subnet, out_of_subnet_count = _dhcp_reservation_candidates(conn, valid_subnets) - reservations = [r for rs in reservations_by_subnet.values() for r in rs] - option_defs = _dhcp_option_defs(conn) - option_values = _dhcp_option_values(conn) - skipped_count = conn.execute( - "SELECT COUNT(*) AS n FROM devices WHERE is_active=1 AND deleted_at IS NULL AND (mac IS NULL OR ip IS NULL)" - ).fetchone()["n"] - conn.close() - status = _detect_dhcp_status() - preview = json.dumps( - _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values), - indent=2, ensure_ascii=False, - ) - reserved_macs = {r["mac"].upper() for r in reservations} - leases = _dhcp_read_leases() - if leases is not None: - for lease in leases: - lease["reserved"] = lease["mac"] in reserved_macs - subnet_status = {} - for vs in valid_subnets: - subnet_status[vs["id"]] = {"ok": True, "network": str(vs["net_info"]["network"])} - for s, error in invalid_subnets: - subnet_status[s["id"]] = {"ok": False, "error": error} - return render_template( - "settings_dhcp.html", - cfg=cfg, - status=status, - subnets=subnets, - subnet_status=subnet_status, - interfaces=_list_network_interfaces(), - reservations=reservations, - skipped_count=skipped_count, - out_of_subnet_count=out_of_subnet_count, - preview=preview, - option_defs=option_defs, - option_values=option_values, - option_types=DHCP_OPTION_TYPES, - dhcp_package=DHCP_PACKAGE, - dhcp_service=DHCP_SERVICE, - leases=leases, - ) - - -@app.route("/settings/dhcp/lease/delete", methods=["POST"]) -@login_required -def delete_dhcp_lease(): - if not current_user.has_permission("settings_dhcp.edit"): - flash("Keine Berechtigung, Leases zu löschen.", "danger") - return redirect(url_for("settings_dhcp")) - ip = request.form.get("ip", "").strip() - ok, message = _dhcp_delete_lease(ip) - log_action("settings.dhcp_lease_delete", ip, message) - flash(message, "success" if ok else "danger") - return redirect(url_for("settings_dhcp")) - - -@app.route("/settings/dhcp/autostart/toggle", methods=["POST"]) -@login_required -def toggle_dhcp_autostart(): - """Eigenständiger Autostart-Schalter, entkoppelt vom aktuellen - Start/Stopp-Zustand -- steuert ausschließlich, ob systemd den Dienst - bei einem Host-Neustart automatisch mitstartet (systemctl enable/ - disable), OHNE ihn hier auch tatsächlich zu (de)starten.""" - if not current_user.has_permission("settings_dhcp.edit"): - return {"success": False, "msg": "Keine Berechtigung."}, 403 - try: - result = subprocess.run(["systemctl", "is-enabled", DHCP_SERVICE], capture_output=True, text=True, timeout=5) - currently_enabled = result.stdout.strip() == "enabled" - except Exception: - currently_enabled = False - action = "disable" if currently_enabled else "enable" - ok, out = _dhcp_run_privileged(["systemctl", action, DHCP_SERVICE], timeout=15) - if not ok: - verb = "deaktivieren" if currently_enabled else "aktivieren" - return {"success": False, "msg": f"Autostart konnte nicht {verb} werden: {out}"} - new_enabled = not currently_enabled - log_action("settings.dhcp_autostart", DHCP_SERVICE, "aktiviert" if new_enabled else "deaktiviert") - return { - "success": True, - "msg": f"Autostart {'aktiviert' if new_enabled else 'deaktiviert'}.", - "new_status": 1 if new_enabled else 0, - } - - -@app.route("/settings/ldap", methods=["GET", "POST"]) -@login_required -def settings_ldap(): - if request.method == "GET" and not current_user.can_view_settings_ldap: - flash("Keine Berechtigung, die LDAP/AD-Einstellungen anzusehen.", "danger") - return redirect(url_for("index")) - - if request.method == "POST": - if not current_user.has_permission("settings_ldap.edit"): - flash("Keine Berechtigung, die LDAP/AD-Konfiguration zu ändern.", "danger") - return redirect(url_for("settings_ldap")) - - if "save_ldap" in request.form: - new_port_raw = request.form.get("ldap_port", "").strip() - new_bind_dn = request.form.get("ldap_bind_dn", "").strip() - if not new_port_raw.isdigit() or not (1 <= int(new_port_raw) <= 65535): - flash("LDAP-Port muss eine Zahl zwischen 1 und 65535 sein.", "danger") - else: - set_setting("ldap_enabled", "1" if request.form.get("ldap_enabled") else "0") - set_setting("ldap_server", request.form.get("ldap_server", "").strip()) - set_setting("ldap_port", new_port_raw) - set_setting("ldap_use_ssl", "1" if request.form.get("ldap_use_ssl") else "0") - set_setting("ldap_tls_skip_verify", "1" if request.form.get("ldap_tls_skip_verify") else "0") - 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() - if new_bind_password: - conn.execute( - "INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) " - "ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, password=excluded.password", - (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE, new_bind_dn, encrypt_password(new_bind_password)), - ) - else: - existing = conn.execute( - "SELECT password FROM service_accounts WHERE purpose=?", - (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,), - ).fetchone() - conn.execute( - "INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) " - "ON CONFLICT(purpose) DO UPDATE SET username=excluded.username", - (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE, new_bind_dn, existing["password"] if existing else ""), - ) - conn.commit() - conn.close() - log_action("settings.update", "LDAP/Active Directory") - flash("LDAP-Einstellungen gespeichert.", "success") - - elif "test_ldap" in request.form: - new_bind_password = request.form.get("ldap_bind_password", "") - _, saved_bind_password_enc = _ldap_bind_account() - test_cfg = { - "server": request.form.get("ldap_server", "").strip(), - "port": request.form.get("ldap_port", "").strip() or str(LDAP_DEFAULT_PORT), - "use_ssl": bool(request.form.get("ldap_use_ssl")), - "tls_skip_verify": bool(request.form.get("ldap_tls_skip_verify")), - "bind_dn": request.form.get("ldap_bind_dn", "").strip(), - "base_dn": request.form.get("ldap_base_dn", "").strip(), - "bind_password_enc": encrypt_password(new_bind_password) if new_bind_password else saved_bind_password_enc, - } - ok, message = _ldap_test_connection(test_cfg) - flash(message, "success" if ok else "danger") - - elif "clear_ldap_bind" in request.form: - conn = get_db_connection() - conn.execute("DELETE FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,)) - conn.commit() - conn.close() - set_setting("ldap_enabled", "0") - log_action("settings.update", "LDAP-Bind-Konto gelöscht") - flash("LDAP-Bind-Konto gelöscht. LDAP-Anmeldung wurde deaktiviert.", "success") - - elif "add_ldap_group_mapping" in request.form: - 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() - conn.execute( - "INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id) VALUES (?, ?, ?) " - "ON CONFLICT(ad_group_dn) DO UPDATE SET ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id", - (ad_group_dn, ad_group_name or ad_group_dn, app_group_id), - ) - conn.commit() - conn.close() - 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() - row = conn.execute("SELECT ad_group_name FROM ldap_group_mappings WHERE id=?", (mapping_id,)).fetchone() - conn.execute("DELETE FROM ldap_group_mappings WHERE id=?", (mapping_id,)) - conn.commit() - conn.close() - 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( - "settings_ldap.html", - ldap=_ldap_settings(), - ldap_groups=_ldap_groups_for_dropdown(), - mappings=_ldap_group_mappings(), - fileshare_mappings=_ldap_fileshare_mappings(), - ) - - -@app.route("/settings/ldap/ad-groups") -@login_required -def settings_ldap_ad_groups(): - """JSON-Endpunkt für den 'AD-Gruppen laden'-Button in settings_ldap.html - -- fragt live per Bind-Konto alle Gruppen aus AD ab, damit die - Zuordnungstabelle nicht von Hand mit DNs gefüttert werden muss.""" - if not current_user.has_permission("settings_ldap.edit"): - return jsonify({"error": "Keine Berechtigung."}), 403 - return jsonify(_ldap_list_groups()) - - -@app.route("/settings/nav-order", methods=["POST"]) -@login_required -def save_nav_order(): - if not current_user.is_admin: - flash("Nur Admins dürfen die Navbar-Reihenfolge ändern!", "danger") - return redirect(url_for("index")) - - order = [k for k in request.form.getlist("nav_order") if k in NAV_ITEMS_BY_KEY] - order += [k for k in DEFAULT_NAV_ORDER if k not in order] - set_setting("nav_order", json.dumps(order)) - - child_order = {} - for group_key, item in NAV_ITEMS_BY_KEY.items(): - if not item.get("children"): - continue - valid_child_keys = {c["key"] for c in item["children"]} - submitted = [k for k in request.form.getlist(f"nav_child_order_{group_key}") if k in valid_child_keys] - submitted += [c["key"] for c in item["children"] if c["key"] not in submitted] - child_order[group_key] = submitted - set_setting("nav_child_order", json.dumps(child_order)) - - log_action("settings.update", "Navbar-Reihenfolge") - flash("Navbar-Reihenfolge gespeichert.", "success") - return redirect(url_for("account")) - - - -def _read_known_hosts_content(): - """Für den Export: der Inhalt von known_hosts gehört inhaltlich zu den - Zugangsdaten (jeder per SSH bereits bestätigte Host-Key eines Geräts/ - Switches, das über gespeicherte Zugangsdaten erreicht wurde) -- landet - daher NICHT als eigene EXPORT_BUILDERS-Kategorie, sondern als - zusätzlicher Payload-Schlüssel "known_hosts", sobald "credentials" - mitexportiert wird (siehe export_data()).""" - try: - with open(SSH_KNOWN_HOSTS_PATH, "r", encoding="utf-8") as f: - content = f.read() - return content if content.strip() else None - except OSError: - return None - - -def _merge_known_hosts_content(new_content): - """Für den Import: ergänzt die eigene known_hosts-Datei um die im - Export enthaltenen Zeilen -- bewusst ergänzend statt überschreibend - (die Zielumgebung kann bereits eigene, unabhängig bestätigte Host-Keys - haben) und mit Deduplizierung nach vollständiger Zeile, damit ein - wiederholter Import derselben Exportdatei nicht bei jedem Mal weitere - Duplikate anhäuft. Gibt die Anzahl tatsächlich neu hinzugefügter - Zeilen zurück.""" - try: - try: - with open(SSH_KNOWN_HOSTS_PATH, "r", encoding="utf-8") as f: - existing_lines = set(f.read().splitlines()) - except OSError: - existing_lines = set() - new_lines = [ln for ln in new_content.splitlines() if ln.strip() and ln not in existing_lines] - if not new_lines: - return 0 - os.makedirs(os.path.dirname(SSH_KNOWN_HOSTS_PATH) or ".", exist_ok=True) - with open(SSH_KNOWN_HOSTS_PATH, "a", encoding="utf-8") as f: - f.write("\n".join(new_lines) + "\n") - return len(new_lines) - except OSError: - return 0 - - -EXPORT_SECTIONS = [ - ("devices", "Clients"), - ("switches", "Switche"), - ("credentials", "Zugangsdaten"), - ("users", "Benutzer"), - ("groups", "Gruppen"), - ("ldap", "LDAP/AD"), - ("dhcp", "DHCP"), - ("nginx", "NGINX"), - ("logs", "Auditlog"), -] -EXPORT_SECTION_LABELS = dict(EXPORT_SECTIONS) - -# Zusätzlicher Hinweistext je Kategorie fürs "i"-Icon neben dem Label -# (siehe _hint_icon.html) -- nur für Kategorien mit erklärungsbedürftigem -# Zusatzverhalten, das über den reinen Namen hinausgeht. -EXPORT_SECTION_HINTS = { - "users": "Nur lokale Konten. AD/LDAP-Benutzer werden NICHT gesichert — sie werden über Active Directory verwaltet, nicht über diese App, und legen sich beim nächsten Login automatisch wieder an.", - "ldap": "Gesichert werden Server-/Bind-Einstellungen, die automatischen AD-Gruppen- und Fileshare-Zuordnungen sowie für bereits bekannte AD-Benutzer deren Sperrstatus und individuelle Gruppenzuweisungen — damit ein gesperrtes AD-Konto auf dem Zielsystem gesperrt bleibt und manuell vergebene Rechte erhalten bleiben, statt beim nächsten Login verloren zu gehen.", - "credentials": "Enthält zusätzlich den Inhalt von known_hosts (bereits bestätigte SSH-Host-Keys) — wird beim Import ergänzend (nicht überschreibend) eingespielt.", - "nginx": "Enthält Domain, Ports und SSL/HSTS-Schalter sowie — falls vorhanden — das aktuell hinterlegte TLS-Zertifikat und den privaten Schlüssel selbst.", - "logs": "Alle aktuell in der Datenbank vorhandenen Einträge (bereits archivierte Auditlog-Tage liegen als eigene Dateien unter Verlauf und sind kein Teil dieses Exports).", -} - -IMPORT_EXPORT_ADMIN_ONLY_SECTIONS = {"users", "groups", "ldap"} - -LDAP_SETTING_KEYS = [ - "ldap_enabled", "ldap_server", "ldap_port", "ldap_use_ssl", "ldap_tls_skip_verify", - "ldap_base_dn", "ldap_user_filter_attr", "ldap_default_group", "ldap_required_login_group", -] - - -def _export_devices(conn): - return [dict(r) for r in conn.execute(""" - SELECT devices.mac, devices.ip, devices.port, devices.name, devices.switch_hostname, - devices.is_active, devices.ssh_port, credentials.name AS credential_name - FROM devices LEFT JOIN credentials ON credentials.id = devices.credential_id - WHERE devices.deleted_at IS NULL - """).fetchall()] - - -def _export_switches(conn): - return [dict(r) for r in conn.execute(""" - SELECT switches.hostname, switches.ip, switches.ssh_port, credentials.name AS credential_name - FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id - WHERE switches.deleted_at IS NULL - """).fetchall()] - - -def _export_credentials(conn): - rows = conn.execute("SELECT name, username, password, category FROM credentials WHERE deleted_at IS NULL").fetchall() - return [ - {"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"]), "category": r["category"]} - for r in rows - ] - - -def _export_users(conn): - """avatar_filename wird bewusst NICHT exportiert -- das ist nur ein - Dateiname, die eigentliche Bilddatei liegt unter static/uploads/avatars - auf DIESEM Host und würde auf dem Zielsystem nicht mitkommen; ein - exportierter Verweis auf eine dort nicht existierende Datei wäre - schlimmer als gar keiner (kaputtes , siehe auch die analoge - Begründung bei _export_nginx() für TLS-Zertifikat/Schlüssel).""" - rows = conn.execute( - "SELECT id, username, password, first_name, last_name, email, is_admin, is_locked " - "FROM users WHERE auth_source='local' AND deleted_at IS NULL" - ).fetchall() - result = [] - for r in rows: - groups = [g["name"] for g in conn.execute( - "SELECT g.name FROM user_groups ug JOIN groups g ON g.id=ug.group_id " - "WHERE ug.user_id=? AND g.deleted_at IS NULL", - (r["id"],), - ).fetchall()] - result.append({ - "username": r["username"], "password_hash": r["password"], "first_name": r["first_name"], - "last_name": r["last_name"], "email": r["email"], "is_admin": r["is_admin"], - "is_locked": r["is_locked"], "groups": groups, - }) - return result - - -def _export_groups(conn): - rows = conn.execute("SELECT id, name, is_default FROM groups WHERE is_system=0 AND deleted_at IS NULL").fetchall() - result = [] - for g in rows: - perms = [p["permission"] for p in conn.execute( - "SELECT permission FROM group_permissions WHERE group_id=?", (g["id"],) - ).fetchall()] - members = [m["username"] for m in conn.execute( - "SELECT u.username FROM user_groups ug JOIN users u ON u.id=ug.user_id " - "WHERE ug.group_id=? AND u.deleted_at IS NULL", - (g["id"],), - ).fetchall()] - result.append({"name": g["name"], "is_default": g["is_default"], "permissions": perms, "members": members}) - return result - - -def _export_ldap(conn): - """app_group_id ist eine rohe, lokale groups.id (oder der Sonderwert - "admin") -- über eine Umgebungsgrenze hinweg garantiert NICHT stabil - (frische Installation vergibt Gruppen-IDs neu). Exportiert wird daher - der Gruppenname (analog _export_switches()' credential_name), _import_ldap - löst ihn auf dem Zielsystem wieder zur dortigen ID auf -- gleiches - "admin"/g.name-Mapping wie in _ldap_group_mappings() weiter oben. - - ad_user_states: Sperrstatus + aktuelle Gruppenzugehörigkeit (inkl. - Admin-Sentinel) je bereits bekanntem AD-Benutzer -- bewusst hier bei - LDAP statt bei "users" (das AD-Konten komplett ausschließt, siehe - _export_users()), da es explizit um AD-Konten geht: ein gesperrtes - AD-Konto soll auf dem Zielsystem gesperrt bleiben (nicht durch die - Migration wieder hereinkommen), und individuell/manuell vergebene - Rechte sollen erhalten bleiben statt beim nächsten Login verloren zu - gehen.""" - settings_data = {k: get_setting(k) for k in LDAP_SETTING_KEYS if get_setting(k) not in (None, "")} - data = {"settings": settings_data, "group_mappings": [], "fileshare_mappings": [], "ad_user_states": []} - bind = conn.execute( - "SELECT username, password FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,) - ).fetchone() - if bind: - data["bind_username"] = bind["username"] - data["bind_password"] = decrypt_password(bind["password"]) - data["group_mappings"] = [dict(m) for m in conn.execute(""" - SELECT m.ad_group_dn, m.ad_group_name, - CASE WHEN m.app_group_id = 'admin' THEN 'Admin' ELSE g.name END AS app_group_name - FROM ldap_group_mappings m LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL - """).fetchall()] - data["fileshare_mappings"] = [dict(m) for m in conn.execute( - "SELECT ad_group_dn, ad_group_name, share_label, share_unc FROM ldap_fileshare_mappings" - ).fetchall()] - ad_users = conn.execute( - "SELECT id, username, is_admin, is_locked FROM users WHERE auth_source='ldap' AND deleted_at IS NULL" - ).fetchall() - for u in ad_users: - groups = [g["name"] for g in conn.execute( - "SELECT g.name FROM user_groups ug JOIN groups g ON g.id=ug.group_id " - "WHERE ug.user_id=? AND g.deleted_at IS NULL", (u["id"],) - ).fetchall()] - if u["is_admin"]: - groups.append("Admin") - data["ad_user_states"].append({"username": u["username"], "is_locked": u["is_locked"], "groups": groups}) - if not (settings_data or "bind_username" in data or data["group_mappings"] or data["fileshare_mappings"] - or data["ad_user_states"]): - return None - return data - - -def _export_dhcp(conn): - settings_data = {k: get_setting(k) for k in DHCP_DEFAULTS if get_setting(k) not in (None, "")} - subnets = [dict(r) for r in conn.execute( - "SELECT interface, range_start, range_end, gateway, dns, sort_order, enabled FROM dhcp_subnets" - ).fetchall()] - reservations = [dict(r) for r in conn.execute( - "SELECT mac, ip, name FROM dhcp_manual_reservations" - ).fetchall()] - option_defs = conn.execute( - "SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs" - ).fetchall() - options = [] - for d in option_defs: - values = conn.execute( - "SELECT device_mac, value FROM dhcp_option_values WHERE option_def_id=?", (d["id"],) - ).fetchall() - if not values and d["is_standard"]: - continue - options.append({ - "code": d["code"], "name": d["name"], "type": d["type"], "description": d["description"], - "is_standard": d["is_standard"], - "values": [{"device_mac": v["device_mac"], "value": v["value"]} for v in values], - }) - if not (settings_data or subnets or reservations or options): - return None - return {"settings": settings_data, "subnets": subnets, "reservations": reservations, "options": options} - - -NGINX_SETTING_KEYS = ["nginx_server_name", "nginx_http_port", "nginx_https_port", "ssl_enabled", "hsts_enabled"] - - -def _export_nginx(conn): - """Exportiert Domain/Ports/SSL-HSTS-Schalter sowie, falls vorhanden, - das aktuell hinterlegte TLS-Zertifikat+Schlüssel selbst (Base64, da - JSON keine Binärdaten kann) -- anders als bei avatar_filename ist das - hier sinnvoll: Zertifikat/Schlüssel liegen bereits vollständig auf - diesem Host vor und lassen sich 1:1 auf ein Zielsystem übertragen, - genau wie andere Geheimnisse (Zugangsdaten-Passwörter, LDAP-Bind- - Passwort), die ebenfalls im Klartext in den -- als Ganzes - passphrasenverschlüsselten -- Export wandern. Bewusst AUSGESCHLOSSEN - bleibt trotzdem jeglicher Let's-Encrypt-/certbot-Zustand (ssl_source, - ACME-Konto, Renewal-Timer): das ist an genau diesen Host und dessen - Domain-Registrierung gebunden und kann nicht mitziehen -- ein Import - setzt ssl_source deshalb immer auf "upload", siehe _import_nginx().""" - settings_data = {k: get_setting(k) for k in NGINX_SETTING_KEYS if get_setting(k) not in (None, "")} - data = {"settings": settings_data} - try: - if os.path.isfile(TESM_SSL_CERT_PATH) and os.path.isfile(TESM_SSL_KEY_PATH): - with open(TESM_SSL_CERT_PATH, "rb") as f: - data["cert_pem"] = base64.b64encode(f.read()).decode("ascii") - with open(TESM_SSL_KEY_PATH, "rb") as f: - data["key_pem"] = base64.b64encode(f.read()).decode("ascii") - except OSError: - pass - if not (settings_data or "cert_pem" in data): - return None - return data - - -def _export_logs(conn): - """Exportiert das komplette, aktuell in der DB stehende Auditlog -- - bereits archivierte Tage (siehe _archive_old_audit_log_rows) liegen - als eigene Dateien unter AUDIT_ARCHIVE_DIR und sind bewusst NICHT Teil - dieses Exports, das Auditlog-Export-Formular exportiert nur die - Datenbank-Kategorien.""" - rows = conn.execute( - "SELECT ts, username, action, target, details FROM audit_log ORDER BY id DESC" - ).fetchall() - return [dict(r) for r in rows] if rows else None - - -EXPORT_BUILDERS = { - "devices": _export_devices, "switches": _export_switches, "credentials": _export_credentials, - "users": _export_users, "groups": _export_groups, "ldap": _export_ldap, "dhcp": _export_dhcp, - "nginx": _export_nginx, "logs": _export_logs, -} - - -def _import_devices(conn, items): - n = 0 - for d in items: - cred_id = None - if d.get("credential_name"): - row = conn.execute( - "SELECT id FROM credentials WHERE name=? AND deleted_at IS NULL", (d["credential_name"],) - ).fetchone() - cred_id = row["id"] if row else None - conn.execute( - """INSERT INTO devices (mac, ip, port, name, switch_hostname, is_active, ssh_port, credential_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, port=excluded.port, name=excluded.name, - switch_hostname=excluded.switch_hostname, is_active=excluded.is_active, - ssh_port=excluded.ssh_port, credential_id=excluded.credential_id - WHERE devices.deleted_at IS NULL""", - (d["mac"], d["ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1), - d.get("ssh_port"), cred_id), - ) - n += 1 - return n - - -def _import_switches(conn, items): - n = 0 - for s in items: - cred_id = None - if s.get("credential_name"): - row = conn.execute( - "SELECT id FROM credentials WHERE name=? AND deleted_at IS NULL", (s["credential_name"],) - ).fetchone() - cred_id = row["id"] if row else None - conn.execute( - """INSERT INTO switches (hostname, ip, ssh_port, credential_id) VALUES (?, ?, ?, ?) - ON CONFLICT(hostname) DO UPDATE SET ip=excluded.ip, ssh_port=excluded.ssh_port, - credential_id=excluded.credential_id - WHERE switches.deleted_at IS NULL""", - (s["hostname"], s["ip"], s.get("ssh_port"), cred_id), - ) - n += 1 - return n - - -def _import_credentials(conn, items): - n = 0 - for c in items: - conn.execute( - """INSERT INTO credentials (name, username, password, category) VALUES (?, ?, ?, ?) - ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password, - category=excluded.category - WHERE credentials.deleted_at IS NULL""", - (c["name"], c["username"], encrypt_password(c["password"]), c.get("category", "switch")), - ) - n += 1 - return n - - -def _import_users(conn, items): - n = 0 - for u in items: - existing = conn.execute( - "SELECT id, auth_source, deleted_at FROM users WHERE username=?", (u["username"],) - ).fetchone() - if existing and existing["auth_source"] != "local": - continue - if existing and existing["deleted_at"] is not None: - continue - if existing: - conn.execute( - "UPDATE users SET password=?, is_admin=?, first_name=?, last_name=?, email=?, is_locked=? WHERE id=?", - (u["password_hash"], u.get("is_admin", 0), u.get("first_name"), u.get("last_name"), - u.get("email"), u.get("is_locked", 0), existing["id"]), - ) - user_id = existing["id"] - else: - cur = conn.execute( - "INSERT INTO users (username, password, is_admin, first_name, last_name, email, is_locked, auth_source) " - "VALUES (?, ?, ?, ?, ?, ?, ?, 'local')", - (u["username"], u["password_hash"], u.get("is_admin", 0), u.get("first_name"), u.get("last_name"), - u.get("email"), u.get("is_locked", 0)), - ) - user_id = cur.lastrowid - conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) - for gname in u.get("groups", []): - grow = conn.execute( - "SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (gname,) - ).fetchone() - if grow: - conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (user_id, grow["id"])) - n += 1 - return n - - -def _import_groups(conn, items): - n = 0 - for g in items: - existing = conn.execute( - "SELECT id, is_system, deleted_at FROM groups WHERE name=?", (g["name"],) - ).fetchone() - if existing and (existing["is_system"] or existing["deleted_at"] is not None): - continue - if existing: - group_id = existing["id"] - else: - cur = conn.execute( - "INSERT INTO groups (name, is_default, is_system) VALUES (?, ?, 0)", - (g["name"], g.get("is_default", 0)), - ) - group_id = cur.lastrowid - conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) - valid_perms = [p for p in g.get("permissions", []) if p in ALL_PERMISSION_KEYS] - conn.executemany( - "INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)", [(group_id, p) for p in valid_perms] - ) - for username in g.get("members", []): - urow = conn.execute( - "SELECT id FROM users WHERE username=? AND deleted_at IS NULL", (username,) - ).fetchone() - if urow: - conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (urow["id"], group_id)) - n += 1 - return n - - -def _import_ldap(conn, data): - for key, value in data.get("settings", {}).items(): - if key in LDAP_SETTING_KEYS: - conn.execute( - "INSERT INTO settings (key, value) VALUES (?, ?) " - "ON CONFLICT(key) DO UPDATE SET value=excluded.value", - (key, value), - ) - if data.get("bind_username"): - conn.execute( - "INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) " - "ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, password=excluded.password", - (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE, data["bind_username"], encrypt_password(data.get("bind_password", ""))), - ) - n = 0 - for m in data.get("group_mappings", []): - app_group_name = m.get("app_group_name") - if app_group_name == "Admin": - app_group_id = "admin" - else: - grow = conn.execute( - "SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (app_group_name,) - ).fetchone() - if not grow: - continue # Rechtegruppe existiert auf dem Zielsystem (noch) nicht -- Zuordnung überspringen statt kaputt anzulegen - app_group_id = grow["id"] - conn.execute( - "INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id) VALUES (?, ?, ?) " - "ON CONFLICT(ad_group_dn) DO UPDATE SET ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id", - (m["ad_group_dn"], m["ad_group_name"], app_group_id), - ) - n += 1 - for m in data.get("fileshare_mappings", []): - 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", - (m["ad_group_dn"], m["ad_group_name"], m["share_label"], m["share_unc"]), - ) - n += 1 - for u in data.get("ad_user_states", []): - groups = u.get("groups", []) - is_admin = 1 if "Admin" in groups else 0 - existing = conn.execute( - "SELECT id FROM users WHERE username=? AND auth_source='ldap'", (u["username"],) - ).fetchone() - if existing: - user_id = existing["id"] - conn.execute( - "UPDATE users SET is_locked=?, is_admin=? WHERE id=?", - (u.get("is_locked", 0), is_admin, user_id), - ) - else: - # Konto hat sich auf dem Zielsystem noch nie eingeloggt -- ein - # Platzhalter-Passwort-Hash wird nie geprüft (der Login-Zweig - # für auth_source='ldap' authentifiziert immer gegen AD, siehe - # login()), er füllt nur das NOT-NULL-Feld. Wichtig ist einzig, - # dass is_locked schon VOR dem ersten Login greift. - placeholder_hash = bcrypt.generate_password_hash(secrets.token_hex(32)).decode("utf-8") - cur = conn.execute( - "INSERT INTO users (username, password, is_admin, auth_source, is_locked) VALUES (?, ?, ?, 'ldap', ?)", - (u["username"], placeholder_hash, is_admin, u.get("is_locked", 0)), - ) - user_id = cur.lastrowid - for gname in groups: - if gname == "Admin": - continue - grow = conn.execute( - "SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (gname,) - ).fetchone() - if grow: - conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (user_id, grow["id"])) - n += 1 - return n - - -def _import_dhcp(conn, data): - for key, value in data.get("settings", {}).items(): - if key in DHCP_DEFAULTS: - conn.execute( - "INSERT INTO settings (key, value) VALUES (?, ?) " - "ON CONFLICT(key) DO UPDATE SET value=excluded.value", - (key, value), - ) - n = 0 - for s in data.get("subnets", []): - exists = conn.execute( - "SELECT 1 FROM dhcp_subnets WHERE interface=? AND range_start=? AND range_end=?", - (s["interface"], s["range_start"], s["range_end"]), - ).fetchone() - if not exists: - max_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1) AS m FROM dhcp_subnets").fetchone()["m"] - conn.execute( - "INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, sort_order, enabled) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - (s["interface"], s["range_start"], s["range_end"], s.get("gateway"), s.get("dns"), - max_order + 1, s.get("enabled", 1)), - ) - n += 1 - for r in data.get("reservations", []): - conn.execute( - "INSERT INTO dhcp_manual_reservations (mac, ip, name) VALUES (?, ?, ?) " - "ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, name=excluded.name", - (r["mac"], r["ip"], r["name"]), - ) - n += 1 - for opt in data.get("options", []): - conn.execute( - "INSERT INTO dhcp_option_defs (code, name, type, description, is_standard) VALUES (?, ?, ?, ?, ?) " - "ON CONFLICT(name) DO UPDATE SET code=excluded.code, type=excluded.type, description=excluded.description", - (opt["code"], opt["name"], opt["type"], opt.get("description"), opt.get("is_standard", 0)), - ) - def_id = conn.execute("SELECT id FROM dhcp_option_defs WHERE name=?", (opt["name"],)).fetchone()["id"] - for v in opt.get("values", []): - conn.execute( - "INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, ?, ?) " - "ON CONFLICT(option_def_id, device_mac) DO UPDATE SET value=excluded.value", - (def_id, v.get("device_mac", ""), v["value"]), - ) - n += 1 - return n - - -def _import_nginx(conn, data): - n = 0 - for key, value in data.get("settings", {}).items(): - if key in NGINX_SETTING_KEYS: - conn.execute( - "INSERT INTO settings (key, value) VALUES (?, ?) " - "ON CONFLICT(key) DO UPDATE SET value=excluded.value", - (key, value), - ) - n += 1 - if data.get("cert_pem") and data.get("key_pem"): - try: - cert_bytes = base64.b64decode(data["cert_pem"]) - key_bytes = base64.b64decode(data["key_pem"]) - ok, _msg, _info = _validate_cert_key_pair(cert_bytes, key_bytes) - if ok: - os.makedirs(TESM_SSL_DIR, exist_ok=True) - with open(TESM_SSL_CERT_PATH, "wb") as f: - f.write(cert_bytes) - with open(TESM_SSL_KEY_PATH, "wb") as f: - f.write(key_bytes) - os.chmod(TESM_SSL_KEY_PATH, 0o600) - os.chmod(TESM_SSL_CERT_PATH, 0o644) - # ACME-/certbot-Zustand kommt bewusst nie mit (siehe - # _export_nginx) -- nach dem Import ist es technisch nur - # noch ein hochgeladenes Zertifikat, keine automatische - # Verlängerung mehr, auch wenn es ursprünglich per Let's - # Encrypt ausgestellt wurde. - conn.execute( - "INSERT INTO settings (key, value) VALUES ('ssl_source', 'upload') " - "ON CONFLICT(key) DO UPDATE SET value=excluded.value" - ) - n += 1 - except (OSError, ValueError, TypeError): - pass - return n - - -def _import_logs(conn, items): - conn.executemany( - "INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)", - [(l["ts"], l["username"], l["action"], l.get("target"), l.get("details")) for l in items], - ) - return len(items) - - -IMPORT_APPLIERS = { - "devices": _import_devices, "switches": _import_switches, "credentials": _import_credentials, - "users": _import_users, "groups": _import_groups, "ldap": _import_ldap, "dhcp": _import_dhcp, - "nginx": _import_nginx, "logs": _import_logs, -} - -_pending_imports = {} -PENDING_IMPORT_TTL_SECONDS = 900 - - -def _purge_expired_imports(): - now = datetime.now() - expired = [t for t, e in _pending_imports.items() if (now - e["created_at"]).total_seconds() > PENDING_IMPORT_TTL_SECONDS] - for t in expired: - _pending_imports.pop(t, None) - - -@app.route("/settings/export", methods=["POST"]) -@login_required -def export_data(): - if not current_user.has_permission("settings_importexport.export"): - flash("Keine Berechtigung, Daten zu exportieren.", "danger") - return redirect(url_for("index")) - if not license_export_allowed(): - flash("Für den Export ist keine gültige Lizenz vorhanden.", "danger") - return redirect(url_for("settings_import_export")) - - passphrase = request.form.get("export_passphrase", "") - sections = [s for s in request.form.getlist("export_sections") if s in EXPORT_BUILDERS] - if not passphrase: - flash("Bitte eine Passphrase für den Export angeben.", "danger") - return redirect(url_for("settings_import_export")) - if not sections: - flash("Bitte mindestens eine Kategorie zum Exportieren auswählen.", "danger") - return redirect(url_for("settings_import_export")) - if not current_user.is_admin and (set(sections) & IMPORT_EXPORT_ADMIN_ONLY_SECTIONS): - flash("Benutzer, Custom-Gruppen und LDAP-Einstellungen dürfen nur Admins exportieren.", "danger") - return redirect(url_for("settings_import_export")) - - conn = get_db_connection() - payload, counts = {}, {} - for key in sections: - value = EXPORT_BUILDERS[key](conn) - if value: - payload[key] = value - counts[key] = len(value) if isinstance(value, list) else 1 - conn.close() - - if not payload: - flash("Keine Daten in den ausgewählten Kategorien vorhanden — nichts exportiert.", "danger") - return redirect(url_for("settings_import_export")) - - if "credentials" in payload: - known_hosts_content = _read_known_hosts_content() - if known_hosts_content: - payload["known_hosts"] = known_hosts_content - - salt = secrets.token_bytes(16) - export_fernet = _derive_export_fernet(passphrase, salt) - encrypted_payload = export_fernet.encrypt(json.dumps(payload).encode("utf-8")) - - envelope = { - "app": "tesm", - "version": 2, - "exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "salt": base64.urlsafe_b64encode(salt).decode("utf-8"), - "payload": encrypted_payload.decode("utf-8"), - } - - summary = ", ".join(f"{EXPORT_SECTION_LABELS[k]}: {counts[k]}" for k in payload if k in EXPORT_SECTION_LABELS) - if "known_hosts" in payload: - summary += " (inkl. known_hosts)" - log_action("data.export", ", ".join(EXPORT_SECTION_LABELS[k] for k in payload if k in EXPORT_SECTION_LABELS), summary) - - filename = f"tesm_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" - response = jsonify(envelope) - response.headers["Content-Disposition"] = f"attachment; filename={filename}" - return response - - -@app.route("/settings/import", methods=["POST"]) -@login_required -def import_data(): - if not current_user.has_permission("settings_importexport.edit"): - flash("Keine Berechtigung, Daten zu importieren.", "danger") - return redirect(url_for("index")) - - passphrase = request.form.get("import_passphrase", "") - upload = request.files.get("import_file") - if not passphrase or not upload or not upload.filename: - flash("Bitte Passphrase und Export-Datei angeben.", "danger") - return redirect(url_for("settings_import_export")) - - try: - envelope = json.loads(upload.read().decode("utf-8")) - salt = base64.urlsafe_b64decode(envelope["salt"]) - export_fernet = _derive_export_fernet(passphrase, salt) - payload = json.loads(export_fernet.decrypt(envelope["payload"].encode("utf-8")).decode("utf-8")) - except Exception: - flash("Import fehlgeschlagen: Datei ungültig oder Passphrase falsch.", "danger") - return redirect(url_for("settings_import_export")) - - present_sections = [key for key in EXPORT_BUILDERS if payload.get(key)] - if not present_sections: - flash("Die Datei enthält keine bekannten/unterstützten Daten.", "danger") - return redirect(url_for("settings_import_export")) - - _purge_expired_imports() - token = secrets.token_urlsafe(24) - _pending_imports[token] = {"payload": payload, "created_at": datetime.now(), "by": current_user.username} - return redirect(url_for("settings_import_preview", token=token)) - - -@app.route("/settings/import/preview/") -@login_required -def settings_import_preview(token): - if not current_user.has_permission("settings_importexport.edit"): - flash("Keine Berechtigung, Daten zu importieren.", "danger") - return redirect(url_for("index")) - - entry = _pending_imports.get(token) - if not entry: - flash("Die Import-Vorschau ist abgelaufen oder ungültig — bitte die Datei erneut hochladen.", "danger") - return redirect(url_for("settings_import_export")) - - payload = entry["payload"] - present_sections = [key for key in EXPORT_BUILDERS if payload.get(key)] - preview_sections = [ - { - "key": key, "label": EXPORT_SECTION_LABELS.get(key, key), - "count": len(payload[key]) if isinstance(payload[key], list) else 1, - "admin_only": key in IMPORT_EXPORT_ADMIN_ONLY_SECTIONS, - } - for key in present_sections - ] - return render_template( - "settings_import_export.html", - import_preview={"token": token, "sections": preview_sections}, - export_sections=EXPORT_SECTIONS, - admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS, - export_section_hints=EXPORT_SECTION_HINTS, - ) - - -@app.route("/settings/import/apply", methods=["POST"]) -@login_required -def import_apply(): - if not current_user.has_permission("settings_importexport.edit"): - flash("Keine Berechtigung, Daten zu importieren.", "danger") - return redirect(url_for("index")) - - token = request.form.get("import_token", "") - entry = _pending_imports.pop(token, None) - if not entry: - flash("Die Import-Vorschau ist abgelaufen oder ungültig — bitte die Datei erneut hochladen.", "danger") - return redirect(url_for("settings_import_export")) - - payload = entry["payload"] - selected = [s for s in request.form.getlist("import_sections") if s in IMPORT_APPLIERS and payload.get(s)] - if not selected: - flash("Bitte mindestens eine Kategorie zum Importieren auswählen.", "danger") - return redirect(url_for("settings_import_export")) - if not current_user.is_admin and (set(selected) & IMPORT_EXPORT_ADMIN_ONLY_SECTIONS): - flash("Benutzer, Custom-Gruppen und LDAP-Einstellungen dürfen nur Admins importieren.", "danger") - return redirect(url_for("settings_import_export")) - - conn = get_db_connection() - results = {key: IMPORT_APPLIERS[key](conn, payload[key]) for key in selected} - conn.commit() - conn.close() - - summary = ", ".join(f"{EXPORT_SECTION_LABELS.get(k, k)}: {results[k]}" for k in selected) - if "credentials" in selected and payload.get("known_hosts"): - added = _merge_known_hosts_content(payload["known_hosts"]) - summary += f", known_hosts: {added} neue Zeile(n)" - log_action("data.import", ", ".join(EXPORT_SECTION_LABELS.get(k, k) for k in selected), summary) - flash(f"Import abgeschlossen: {summary}.", "success") - return redirect(url_for("settings_import_export")) - - - -@app.route("/devices", methods=["GET", "POST"]) -@login_required -def devices(): - if request.method == "GET" and not current_user.can_view_devices: - flash("Keine Berechtigung, die Devices-Seite anzusehen.", "danger") - return redirect(url_for("index")) - - conn = get_db_connection() - switches = conn.execute( - "SELECT hostname FROM switches WHERE deleted_at IS NULL ORDER BY hostname ASC" - ).fetchall() - - if request.method == "POST": - if "add_device" in request.form: - if not current_user.has_permission("devices.create"): - flash("Keine Berechtigung, Geräte anzulegen.", "danger") - return redirect(url_for("devices")) - - mac = request.form.get("mac") - ip = request.form.get("ip") - port = request.form.get("port") - name = request.form.get("name") - switch_hostname = request.form.get("switch_hostname") or None - is_active = 1 if "is_active" in request.form else 0 - - if not all([mac, ip, name]): - flash("Alle Felder müssen ausgefüllt sein!", "danger") - return redirect(url_for("devices")) - - if conn.execute("SELECT name FROM devices WHERE ip=? AND deleted_at IS NULL", (ip,)).fetchone(): - flash("IP-Adresse existiert bereits für ein anderes Gerät!", "danger") - return redirect(url_for("devices")) - - if conn.execute("SELECT name FROM devices WHERE mac=? AND deleted_at IS NULL", (mac,)).fetchone(): - flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger") - return redirect(url_for("devices")) - - ssh_port, port_error = _parse_ssh_port(request.form) - credential_id, cred_error = (None, None) - if request.form.get("credential_choice"): - credential_id, cred_error = _resolve_credential_choice(conn, default_category="linux") - if port_error: - flash(port_error, "danger") - return redirect(url_for("devices")) - if cred_error: - flash(cred_error, "danger") - return redirect(url_for("devices")) - - try: - conn.execute( - "INSERT INTO devices (mac, ip, port, name, switch_hostname, is_active, credential_id, ssh_port) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - (mac, ip, port, name, switch_hostname, is_active, credential_id, ssh_port), - ) - touch_record(conn, "devices", "mac", mac) - conn.commit() - log_action("device.create", name, f"MAC {mac}, IP {ip}") - flash(f"Gerät {name} hinzugefügt.", "success") - except sqlite3.IntegrityError: - flash("Fehler beim Hinzufügen des Geräts — MAC oder IP bereits vergeben (ggf. im Papierkorb unter Wartung, dort zuerst wiederherstellen oder endgültig löschen).", "danger") - - elif "edit_device" in request.form: - if not current_user.has_permission("devices.edit"): - flash("Keine Berechtigung, Geräte zu bearbeiten.", "danger") - return redirect(url_for("devices")) - - old_mac = request.form.get("old_mac") - mac = request.form.get("mac") - ip = request.form.get("ip") - port = request.form.get("port") or None - name = request.form.get("name") - switch_hostname_field = "switch_hostname" in request.form - - if switch_hostname_field and not any([mac, ip, name]): - device = conn.execute( - "SELECT name, switch_hostname FROM devices WHERE mac=? AND deleted_at IS NULL", (old_mac,) - ).fetchone() - if not device: - flash("Gerät nicht gefunden!", "danger") - return redirect(url_for("devices")) - - old_switch = device["switch_hostname"] or "kein Switch" - new_switch = request.form.get("switch_hostname") or None - conn.execute( - "UPDATE devices SET switch_hostname=? WHERE mac=?", (new_switch, old_mac) - ) - touch_record(conn, "devices", "mac", old_mac) - conn.commit() - log_action("device.edit", device["name"], f"Switch: {old_switch} → {new_switch or 'Kein Switch'}") - flash( - f"Switch von {device['name']} geändert: {old_switch} → {new_switch or 'Kein Switch'}", - "success", - ) - return redirect(url_for("devices")) - - if not all([old_mac, mac, ip, name]): - flash("Felder 'MAC', 'IP' und 'Name' müssen ausgefüllt sein!", "danger") - return redirect(url_for("devices")) - - if conn.execute( - "SELECT name FROM devices WHERE ip=? AND mac<>? AND deleted_at IS NULL", (ip, old_mac) - ).fetchone(): - flash("IP-Adresse existiert bereits für ein anderes Gerät!", "danger") - return redirect(url_for("devices")) - - if conn.execute( - "SELECT name FROM devices WHERE mac=? AND mac<>? AND deleted_at IS NULL", (mac, old_mac) - ).fetchone(): - flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger") - return redirect(url_for("devices")) - - ssh_port, port_error = _parse_ssh_port(request.form) - credential_id, cred_error = (None, None) - if request.form.get("credential_choice"): - credential_id, cred_error = _resolve_credential_choice(conn, default_category="linux") - if port_error: - flash(port_error, "danger") - return redirect(url_for("devices")) - if cred_error: - flash(cred_error, "danger") - return redirect(url_for("devices")) - - try: - conn.execute( - "UPDATE devices SET mac=?, ip=?, port=?, name=?, credential_id=?, ssh_port=? " - "WHERE mac=? AND deleted_at IS NULL", - (mac, ip, port, name, credential_id, ssh_port, old_mac), - ) - touch_record(conn, "devices", "mac", mac) - conn.commit() - log_action("device.edit", name, f"MAC {old_mac} → {mac}, IP {ip}") - flash(f"Gerät {name} aktualisiert.", "success") - except sqlite3.IntegrityError: - flash("Fehler beim Aktualisieren des Geräts — MAC oder IP bereits vergeben (ggf. im Papierkorb unter Wartung).", "danger") - - elif "delete_device" in request.form: - if not current_user.has_permission("devices.edit"): - flash("Keine Berechtigung, Geräte zu löschen.", "danger") - return redirect(url_for("devices")) - - del_mac = request.form.get("delete_device") - if del_mac: - device = conn.execute( - "SELECT name FROM devices WHERE mac=? AND deleted_at IS NULL", (del_mac,) - ).fetchone() - name = device["name"] if device else del_mac - conn.execute( - "UPDATE devices SET deleted_at=? WHERE mac=? AND deleted_at IS NULL", - (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), del_mac), - ) - conn.commit() - log_action("device.delete", name, f"MAC {del_mac} — in den Papierkorb verschoben") - flash(f"Gerät {name} in den Papierkorb verschoben.", "success") - else: - flash("Keine MAC-Adresse übermittelt!", "danger") - - conn.close() - return redirect(url_for("devices")) - - device_rows = conn.execute(""" - SELECT devices.mac, devices.ip, devices.port, devices.name, devices.is_active, - devices.credential_id, devices.ssh_port, - switches.hostname AS switch_hostname, - credentials.name AS credential_name, credentials.username AS credential_username - FROM devices - LEFT JOIN switches ON devices.switch_hostname = switches.hostname - LEFT JOIN credentials ON credentials.id = devices.credential_id - WHERE devices.deleted_at IS NULL - ORDER BY switches.hostname ASC, devices.name ASC - """).fetchall() - all_credentials = conn.execute( - "SELECT id, name, username, category FROM credentials WHERE deleted_at IS NULL ORDER BY name ASC" - ).fetchall() - conn.close() - return render_template("devices.html", devices=device_rows, switches=switches, all_credentials=all_credentials) - - -@app.route("/devices/toggle/", methods=["POST"]) -@login_required -def toggle_device(mac): - if not current_user.has_permission("devices.edit"): - return {"success": False, "msg": "Keine Berechtigung."}, 403 - - conn = get_db_connection() - device = conn.execute( - "SELECT is_active, name FROM devices WHERE mac=? AND deleted_at IS NULL", (mac,) - ).fetchone() - if not device: - conn.close() - return {"success": False, "msg": "Gerät nicht gefunden!"}, 404 - - new_status = 0 if device["is_active"] else 1 - conn.execute("UPDATE devices SET is_active=? WHERE mac=? AND deleted_at IS NULL", (new_status, mac)) - touch_record(conn, "devices", "mac", mac) - conn.commit() - conn.close() - - status_text = "deaktiviert" if new_status == 0 else "aktiviert" - log_action("device.deactivate" if new_status == 0 else "device.activate", device["name"]) - return {"success": True, "msg": f"Gerät {device['name']} wurde {status_text}.", "new_status": new_status} - - - -def _resolve_credential_choice(conn, default_category="switch"): - """ - Liest die Zugangsdaten-Auswahl aus einem Switch-/Client-Formular: - entweder eine bestehende credential_id, oder (choice == "new") legt - direkt aus dem Formular heraus neue Zugangsdaten an — damit man beim - Anlegen nicht zuerst zu "Zugangsdaten" wechseln muss. default_category - wird nur für neu angelegte Zugangsdaten verwendet (z.B. "linux", wenn - aus dem Client-Formular heraus angelegt), bestehende behalten ihre - eigene Kategorie. Gibt (credential_id, error_message) zurück; - error_message ist None bei Erfolg. - """ - choice = request.form.get("credential_choice", "") - if choice == "new": - if not current_user.has_permission("credentials.create"): - return None, "Keine Berechtigung, neue Zugangsdaten anzulegen." - name = request.form.get("new_credential_name", "").strip() - username = request.form.get("new_credential_username", "").strip() - password = request.form.get("new_credential_password", "") - if not (name and username and password): - return None, "Für neue Zugangsdaten müssen Name, Username und Passwort ausgefüllt sein!" - try: - cur = conn.execute( - "INSERT INTO credentials (name, username, password, category) VALUES (?, ?, ?, ?)", - (name, username, encrypt_password(password), default_category), - ) - return cur.lastrowid, None - except sqlite3.IntegrityError: - return None, "Es existieren bereits Zugangsdaten mit diesem Namen (ggf. im Papierkorb unter Wartung)!" - elif choice.isdigit(): - cred_id = int(choice) - if not conn.execute( - "SELECT 1 FROM credentials WHERE id=? AND deleted_at IS NULL", (cred_id,) - ).fetchone(): - return None, "Ausgewählte Zugangsdaten wurden zwischenzeitlich gelöscht." - return cred_id, None - return None, "Bitte Zugangsdaten auswählen oder neue anlegen." - - -def _parse_ssh_port(form): - """Liest das optionale SSH-Port-Feld aus dem Formular. Leer -> None (= - Fallback auf SWITCH_DEFAULT_SSH_PORT überall, wo der Port gebraucht - wird). Gibt (port_or_none, error_message) zurück.""" - raw = (form.get("ssh_port") or "").strip() - if not raw: - return None, None - if not raw.isdigit() or not (1 <= int(raw) <= 65535): - return None, "SSH-Port muss eine Zahl zwischen 1 und 65535 sein (oder leer für Standard 22)!" - return int(raw), None - - -@app.route("/switches", methods=["GET", "POST"]) -@login_required -def switches(): - if not current_user.can_manage_switches: - flash("Keine Berechtigung für die Switch-Verwaltung.", "danger") - return redirect(url_for("index")) - - conn = get_db_connection() - - if request.method == "POST" and "add_switch" in request.form: - if not current_user.has_permission("switches.create"): - flash("Keine Berechtigung, Switche anzulegen.", "danger") - return redirect(url_for("switches")) - hostname = request.form["hostname"] - ip = request.form["ip"] - ssh_port, port_error = _parse_ssh_port(request.form) - credential_id, cred_error = _resolve_credential_choice(conn) - if port_error: - flash(port_error, "danger") - elif cred_error: - flash(cred_error, "danger") - else: - try: - conn.execute( - "INSERT INTO switches (hostname, ip, ssh_port, credential_id) VALUES (?, ?, ?, ?)", - (hostname, ip, ssh_port, credential_id), - ) - touch_record(conn, "switches", "hostname", hostname) - conn.commit() - log_action("switch.create", hostname, f"IP {ip}") - flash(f"Switch {hostname} hinzugefügt.", "success") - except sqlite3.IntegrityError: - flash("Hostname existiert bereits (ggf. im Papierkorb unter Wartung) oder Eingabefehler!", "danger") - - if request.method == "POST" and "edit_switch" in request.form: - if not current_user.has_permission("switches.edit"): - flash("Keine Berechtigung, Switche zu bearbeiten.", "danger") - return redirect(url_for("switches")) - old_hostname = request.form["old_hostname"] - hostname = request.form["hostname"] - ip = request.form["ip"] - ssh_port, port_error = _parse_ssh_port(request.form) - credential_id, cred_error = _resolve_credential_choice(conn) - if port_error: - flash(port_error, "danger") - elif cred_error: - flash(cred_error, "danger") - else: - try: - conn.execute( - "UPDATE switches SET hostname=?, ip=?, ssh_port=?, credential_id=? " - "WHERE hostname=? AND deleted_at IS NULL", - (hostname, ip, ssh_port, credential_id, old_hostname), - ) - if hostname != old_hostname: - conn.execute( - "UPDATE devices SET switch_hostname=? WHERE switch_hostname=?", - (hostname, old_hostname), - ) - touch_record(conn, "switches", "hostname", hostname) - conn.commit() - log_action("switch.edit", hostname, f"IP {ip}") - flash(f"Switch {hostname} aktualisiert.", "success") - except sqlite3.IntegrityError: - flash("Hostname existiert bereits (ggf. im Papierkorb unter Wartung) oder Eingabefehler!", "danger") - - switch_rows = conn.execute(""" - SELECT switches.hostname, switches.ip, switches.ssh_port, switches.credential_id, - credentials.name AS credential_name, credentials.username AS credential_username - FROM switches - LEFT JOIN credentials ON credentials.id = switches.credential_id - WHERE switches.deleted_at IS NULL - ORDER BY switches.hostname ASC - """).fetchall() - all_credentials = conn.execute( - "SELECT id, name, username FROM credentials WHERE deleted_at IS NULL ORDER BY name ASC" - ).fetchall() - conn.close() - return render_template("switches.html", switches=switch_rows, all_credentials=all_credentials) - - -@app.route("/switches/delete/", methods=["POST"]) -@login_required -def delete_switch(hostname): - if not current_user.has_permission("switches.edit"): - flash("Keine Berechtigung, Switche zu löschen.", "danger") - return redirect(url_for("switches")) - - conn = get_db_connection() - assigned = conn.execute( - "SELECT name FROM devices WHERE switch_hostname=? AND deleted_at IS NULL", (hostname,) - ).fetchall() - if assigned: - names = ", ".join(d["name"] for d in assigned) - flash(f"Folgende Geräte sind noch dem Switch {hostname} zugewiesen: {names}", "danger") - conn.close() - return redirect(url_for("switches")) - - conn.execute( - "UPDATE switches SET deleted_at=? WHERE hostname=? AND deleted_at IS NULL", - (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), hostname), - ) - conn.commit() - conn.close() - log_action("switch.delete", hostname, "in den Papierkorb verschoben") - flash(f"Switch '{hostname}' in den Papierkorb verschoben.", "success") - return redirect(url_for("switches")) - - - -@app.route("/credentials", methods=["GET", "POST"]) -@login_required -def credentials(): - if not current_user.can_manage_credentials: - flash("Keine Berechtigung für die Zugangsdatenverwaltung.", "danger") - return redirect(url_for("index")) - - conn = get_db_connection() - - if request.method == "POST" and "add_credential" in request.form: - if not current_user.has_permission("credentials.create"): - flash("Keine Berechtigung, Zugangsdaten anzulegen.", "danger") - return redirect(url_for("credentials")) - name = request.form.get("name", "").strip() - username = request.form.get("username", "").strip() - password = request.form.get("password", "") - category = request.form.get("category", "").strip() - if category not in CREDENTIAL_CATEGORY_KEYS: - category = "switch" - if name and username and password: - try: - conn.execute( - "INSERT INTO credentials (name, username, password, category) VALUES (?, ?, ?, ?)", - (name, username, encrypt_password(password), category), - ) - conn.commit() - log_action("credential.create", name) - flash(f"Zugangsdaten '{name}' angelegt.", "success") - except sqlite3.IntegrityError: - flash("Es existieren bereits Zugangsdaten mit diesem Namen (ggf. im Papierkorb unter Wartung)!", "danger") - else: - flash("Name, Username und Passwort müssen ausgefüllt sein!", "danger") - - elif request.method == "POST" and "edit_credential" in request.form: - if not current_user.has_permission("credentials.edit"): - flash("Keine Berechtigung, Zugangsdaten zu bearbeiten.", "danger") - return redirect(url_for("credentials")) - cred_id = request.form.get("credential_id") - name = request.form.get("name", "").strip() - username = request.form.get("username", "").strip() - new_password = request.form.get("password", "") - category = request.form.get("category", "").strip() - if category not in CREDENTIAL_CATEGORY_KEYS: - category = "switch" - if name and username: - try: - if new_password: - conn.execute( - "UPDATE credentials SET name=?, username=?, password=?, category=? " - "WHERE id=? AND deleted_at IS NULL", - (name, username, encrypt_password(new_password), category, cred_id), - ) - else: - conn.execute( - "UPDATE credentials SET name=?, username=?, category=? WHERE id=? AND deleted_at IS NULL", - (name, username, category, cred_id), - ) - conn.commit() - log_action("credential.edit", name) - flash(f"Zugangsdaten '{name}' aktualisiert.", "success") - except sqlite3.IntegrityError: - flash("Es existieren bereits Zugangsdaten mit diesem Namen (ggf. im Papierkorb unter Wartung)!", "danger") - else: - flash("Name und Username müssen ausgefüllt sein!", "danger") - - elif request.method == "POST" and "delete_credential" in request.form: - if not current_user.has_permission("credentials.edit"): - flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger") - return redirect(url_for("credentials")) - cred_id = request.form.get("delete_credential") - used_by_switches = conn.execute( - "SELECT hostname FROM switches WHERE credential_id=? AND deleted_at IS NULL", (cred_id,) - ).fetchall() - used_by_devices = conn.execute( - "SELECT name FROM devices WHERE credential_id=? AND deleted_at IS NULL", (cred_id,) - ).fetchall() - if used_by_switches or used_by_devices: - names = ", ".join([s["hostname"] for s in used_by_switches] + [d["name"] for d in used_by_devices]) - flash(f"Diese Zugangsdaten werden noch verwendet von: {names}", "danger") - else: - cred_row = conn.execute( - "SELECT name FROM credentials WHERE id=? AND deleted_at IS NULL", (cred_id,) - ).fetchone() - conn.execute( - "UPDATE credentials SET deleted_at=? WHERE id=? AND deleted_at IS NULL", - (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), cred_id), - ) - conn.commit() - log_action("credential.delete", cred_row["name"] if cred_row else cred_id, "in den Papierkorb verschoben") - flash("Zugangsdaten in den Papierkorb verschoben.", "success") - - credential_rows = conn.execute(""" - SELECT credentials.id, credentials.name, credentials.username, credentials.category, - COUNT(DISTINCT switches.hostname) AS switch_count, - COUNT(DISTINCT devices.mac) AS device_count - FROM credentials - LEFT JOIN switches ON switches.credential_id = credentials.id AND switches.deleted_at IS NULL - LEFT JOIN devices ON devices.credential_id = credentials.id AND devices.deleted_at IS NULL - WHERE credentials.deleted_at IS NULL - GROUP BY credentials.id - ORDER BY credentials.name ASC - """).fetchall() - conn.close() - return render_template("credentials.html", credentials=credential_rows, categories=CREDENTIAL_CATEGORIES) - - - -_maintenance_jobs_lock = threading.Lock() -_maintenance_jobs = {} - - -def _maintenance_job_set(mac, **fields): - with _maintenance_jobs_lock: - job = _maintenance_jobs.setdefault(mac, {}) - job.update(fields) - - -def _maintenance_job_immediate_error(mac, action, message): - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - _maintenance_job_set(mac, action=action, status="error", message=message, output="", started=now, finished=now) - - -def _maintenance_devices(conn): - """Alle Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie - DEVICE_MAINTENANCE_CATEGORY (aktuell nur 'linux') -- nur für diese - bietet 'Wartung' überhaupt eine Aktion an (Windows/PowerShell folgt - als eigener, separater Schritt, siehe CREDENTIAL_CATEGORIES-Kommentar).""" - return conn.execute( - """ - SELECT devices.mac, devices.name, devices.ip, devices.ssh_port, - devices.credential_id, devices.is_active, - credentials.username AS cred_username - FROM devices - JOIN credentials ON credentials.id = devices.credential_id - WHERE credentials.category = ? AND devices.deleted_at IS NULL AND credentials.deleted_at IS NULL - ORDER BY devices.name ASC - """, - (DEVICE_MAINTENANCE_CATEGORY,), - ).fetchall() - - - -TRASH_RETENTION_DEFAULT_DAYS = 30 - - -def _trash_retention_days(): - try: - return max(1, int(get_setting("trash_retention_days", str(TRASH_RETENTION_DEFAULT_DAYS)))) - except (TypeError, ValueError): - return TRASH_RETENTION_DEFAULT_DAYS - - -def _trashed_devices(conn): - return conn.execute( - "SELECT mac, name, ip, deleted_at FROM devices WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC" - ).fetchall() - - -def _trashed_switches(conn): - return conn.execute( - "SELECT hostname, ip, deleted_at FROM switches WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC" - ).fetchall() - - -def _trashed_credentials(conn): - return conn.execute( - "SELECT id, name, username, category, deleted_at FROM credentials " - "WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC" - ).fetchall() - - -def _trashed_users(conn): - return conn.execute( - "SELECT id, username, first_name, last_name, deleted_at FROM users " - "WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC" - ).fetchall() - - -def _trashed_groups(conn): - return conn.execute( - "SELECT id, name, deleted_at FROM groups WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC" - ).fetchall() - - -def _purge_device(conn, mac): - conn.execute("DELETE FROM dhcp_option_values WHERE device_mac=?", (mac,)) - conn.execute("DELETE FROM devices WHERE mac=? AND deleted_at IS NOT NULL", (mac,)) - - -def _purge_switch(conn, hostname): - conn.execute("DELETE FROM switches WHERE hostname=? AND deleted_at IS NOT NULL", (hostname,)) - - -def _purge_credential(conn, cred_id): - conn.execute("DELETE FROM credentials WHERE id=? AND deleted_at IS NOT NULL", (cred_id,)) - - -def _purge_user(conn, user_id): - conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) - conn.execute("DELETE FROM users WHERE id=? AND deleted_at IS NOT NULL", (user_id,)) - - -def _purge_group(conn, group_id): - conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) - conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,)) - conn.execute("DELETE FROM ldap_group_mappings WHERE app_group_id=?", (group_id,)) - conn.execute("DELETE FROM groups WHERE id=? AND deleted_at IS NOT NULL", (group_id,)) - - -def _purge_expired_trash(): - """Wird bei jedem Aufruf der Wartung-Seite ausgeführt (kein eigener - Scheduler nötig, analog _purge_expired_imports()) -- löscht alle - Papierkorb-Einträge endgültig, deren Aufbewahrungsfrist abgelaufen ist.""" - cutoff = (datetime.now() - timedelta(days=_trash_retention_days())).strftime("%Y-%m-%d %H:%M:%S") - conn = get_db_connection() - for row in conn.execute( - "SELECT mac FROM devices WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,) - ).fetchall(): - _purge_device(conn, row["mac"]) - for row in conn.execute( - "SELECT hostname FROM switches WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,) - ).fetchall(): - _purge_switch(conn, row["hostname"]) - for row in conn.execute( - "SELECT id FROM credentials WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,) - ).fetchall(): - _purge_credential(conn, row["id"]) - for row in conn.execute( - "SELECT id FROM users WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,) - ).fetchall(): - _purge_user(conn, row["id"]) - for row in conn.execute( - "SELECT id FROM groups WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,) - ).fetchall(): - _purge_group(conn, row["id"]) - conn.commit() - conn.close() - - -@app.route("/devices/restore/", methods=["POST"]) -@login_required -def restore_device(mac): - if not current_user.has_permission("papierkorb.edit") or not license_active(): - flash("Keine Berechtigung, Geräte wiederherzustellen.", "danger") - return redirect(url_for("papierkorb")) - conn = get_db_connection() - device = conn.execute("SELECT name FROM devices WHERE mac=? AND deleted_at IS NOT NULL", (mac,)).fetchone() - conn.execute("UPDATE devices SET deleted_at=NULL WHERE mac=? AND deleted_at IS NOT NULL", (mac,)) - conn.commit() - conn.close() - log_action("device.restore", device["name"] if device else mac) - flash(f"Gerät {device['name'] if device else mac} wiederhergestellt.", "success") - return redirect(url_for("papierkorb")) - - -@app.route("/devices/purge/", methods=["POST"]) -@login_required -def purge_device(mac): - if not current_user.has_permission("papierkorb.edit") or not license_active(): - flash("Keine Berechtigung, Geräte endgültig zu löschen.", "danger") - return redirect(url_for("papierkorb")) - conn = get_db_connection() - device = conn.execute("SELECT name FROM devices WHERE mac=? AND deleted_at IS NOT NULL", (mac,)).fetchone() - _purge_device(conn, mac) - conn.commit() - conn.close() - log_action("device.purge", device["name"] if device else mac) - flash(f"Gerät {device['name'] if device else mac} endgültig gelöscht.", "success") - return redirect(url_for("papierkorb")) - - -@app.route("/switches/restore/", methods=["POST"]) -@login_required -def restore_switch(hostname): - if not current_user.has_permission("papierkorb.edit") or not license_active(): - flash("Keine Berechtigung, Switche wiederherzustellen.", "danger") - return redirect(url_for("papierkorb")) - conn = get_db_connection() - conn.execute("UPDATE switches SET deleted_at=NULL WHERE hostname=? AND deleted_at IS NOT NULL", (hostname,)) - conn.commit() - conn.close() - log_action("switch.restore", hostname) - flash(f"Switch {hostname} wiederhergestellt.", "success") - return redirect(url_for("papierkorb")) - - -@app.route("/switches/purge/", methods=["POST"]) -@login_required -def purge_switch(hostname): - if not current_user.has_permission("papierkorb.edit") or not license_active(): - flash("Keine Berechtigung, Switche endgültig zu löschen.", "danger") - return redirect(url_for("papierkorb")) - conn = get_db_connection() - _purge_switch(conn, hostname) - conn.commit() - conn.close() - log_action("switch.purge", hostname) - flash(f"Switch {hostname} endgültig gelöscht.", "success") - return redirect(url_for("papierkorb")) - - -@app.route("/credentials/restore/", methods=["POST"]) -@login_required -def restore_credential(cred_id): - if not current_user.has_permission("papierkorb.edit") or not license_active(): - flash("Keine Berechtigung, Zugangsdaten wiederherzustellen.", "danger") - return redirect(url_for("papierkorb")) - conn = get_db_connection() - cred = conn.execute("SELECT name FROM credentials WHERE id=? AND deleted_at IS NOT NULL", (cred_id,)).fetchone() - conn.execute("UPDATE credentials SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (cred_id,)) - conn.commit() - conn.close() - log_action("credential.restore", cred["name"] if cred else cred_id) - flash(f"Zugangsdaten '{cred['name'] if cred else cred_id}' wiederhergestellt.", "success") - return redirect(url_for("papierkorb")) - - -@app.route("/credentials/purge/", methods=["POST"]) -@login_required -def purge_credential(cred_id): - if not current_user.has_permission("papierkorb.edit") or not license_active(): - flash("Keine Berechtigung, Zugangsdaten endgültig zu löschen.", "danger") - return redirect(url_for("papierkorb")) - conn = get_db_connection() - cred = conn.execute("SELECT name FROM credentials WHERE id=? AND deleted_at IS NOT NULL", (cred_id,)).fetchone() - _purge_credential(conn, cred_id) - conn.commit() - conn.close() - log_action("credential.purge", cred["name"] if cred else cred_id) - flash(f"Zugangsdaten '{cred['name'] if cred else cred_id}' endgültig gelöscht.", "success") - return redirect(url_for("papierkorb")) - - -@app.route("/users/restore/", methods=["POST"]) -@login_required -def restore_user(user_id): - if not current_user.has_permission("papierkorb.edit") or not license_active(): - flash("Keine Berechtigung, Benutzer wiederherzustellen.", "danger") - return redirect(url_for("papierkorb")) - conn = get_db_connection() - target = conn.execute( - "SELECT username, is_admin FROM users WHERE id=? AND deleted_at IS NOT NULL", (user_id,) - ).fetchone() - if target and target["is_admin"] and not current_user.is_admin: - flash("Nur Admins dürfen Admin-Konten wiederherstellen.", "danger") - conn.close() - return redirect(url_for("papierkorb")) - conn.execute("UPDATE users SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (user_id,)) - conn.commit() - conn.close() - log_action("user.restore", target["username"] if target else user_id) - flash(f"Benutzer „{target['username'] if target else user_id}“ wiederhergestellt.", "success") - return redirect(url_for("papierkorb")) - - -@app.route("/users/purge/", methods=["POST"]) -@login_required -def purge_user(user_id): - if not current_user.has_permission("papierkorb.edit") or not license_active(): - flash("Keine Berechtigung, Benutzer endgültig zu löschen.", "danger") - return redirect(url_for("papierkorb")) - conn = get_db_connection() - target = conn.execute( - "SELECT username, is_admin FROM users WHERE id=? AND deleted_at IS NOT NULL", (user_id,) - ).fetchone() - if target and target["is_admin"] and not current_user.is_admin: - flash("Nur Admins dürfen Admin-Konten endgültig löschen.", "danger") - conn.close() - return redirect(url_for("papierkorb")) - _purge_user(conn, user_id) - conn.commit() - conn.close() - log_action("user.purge", target["username"] if target else user_id) - flash(f"Benutzer „{target['username'] if target else user_id}“ endgültig gelöscht.", "success") - return redirect(url_for("papierkorb")) - - -@app.route("/groups/restore/", methods=["POST"]) -@login_required -def restore_group(group_id): - if not current_user.has_permission("papierkorb.edit") or not license_active(): - flash("Keine Berechtigung, Gruppen wiederherzustellen.", "danger") - return redirect(url_for("papierkorb")) - conn = get_db_connection() - group = conn.execute("SELECT name FROM groups WHERE id=? AND deleted_at IS NOT NULL", (group_id,)).fetchone() - conn.execute("UPDATE groups SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (group_id,)) - conn.commit() - conn.close() - log_action("group.restore", group["name"] if group else group_id) - flash(f"Gruppe '{group['name'] if group else group_id}' wiederhergestellt.", "success") - return redirect(url_for("papierkorb")) - - -@app.route("/groups/purge/", methods=["POST"]) -@login_required -def purge_group(group_id): - if not current_user.has_permission("papierkorb.edit") or not license_active(): - flash("Keine Berechtigung, Gruppen endgültig zu löschen.", "danger") - return redirect(url_for("papierkorb")) - conn = get_db_connection() - group = conn.execute("SELECT name FROM groups WHERE id=? AND deleted_at IS NOT NULL", (group_id,)).fetchone() - _purge_group(conn, group_id) - conn.commit() - conn.close() - log_action("group.purge", group["name"] if group else group_id) - flash(f"Gruppe '{group['name'] if group else group_id}' endgültig gelöscht.", "success") - return redirect(url_for("papierkorb")) - - -@app.route("/maintenance") -@login_required -def maintenance(): - if not current_user.can_view_maintenance: - flash("Keine Berechtigung für die Wartungsseite.", "danger") - return redirect(url_for("index")) - if resp := _require_module("maintenance"): - return resp - conn = get_db_connection() - devices_rows = _maintenance_devices(conn) - conn.close() - with _maintenance_jobs_lock: - for mac in [m for m, job in _maintenance_jobs.items() if job.get("status") == "success"]: - del _maintenance_jobs[mac] - jobs_snapshot = {mac: dict(job) for mac, job in _maintenance_jobs.items()} - return render_template( - "maintenance.html", - devices=devices_rows, - jobs=jobs_snapshot, - can_run=current_user.can_run_maintenance, - ) - - -@app.route("/papierkorb") -@login_required -def papierkorb(): - if not current_user.can_view_papierkorb: - flash("Keine Berechtigung für den Papierkorb.", "danger") - return redirect(url_for("index")) - _purge_expired_trash() - conn = get_db_connection() - # Global: papierkorb.view zeigt alle fünf Kategorien, unabhängig vom - # jeweiligen Ressourcen-Leserecht (siehe can_view_papierkorb). - trash = { - "devices": _trashed_devices(conn), - "switches": _trashed_switches(conn), - "credentials": _trashed_credentials(conn), - "users": _trashed_users(conn), - "groups": _trashed_groups(conn), - } - conn.close() - return render_template( - "papierkorb.html", - trash=trash, - trash_retention_days=_trash_retention_days(), - ) - - -@app.route("/maintenance/status") -@login_required -def maintenance_status(): - if not current_user.can_view_maintenance: - return jsonify({"error": "Keine Berechtigung."}), 403 - with _maintenance_jobs_lock: - return jsonify({mac: dict(job) for mac, job in _maintenance_jobs.items()}) - - -_MAINTENANCE_OUTPUT_MAX_LINES = 400 -TESM_CLIENT_UPDATE_LOG_DIR = os.path.join(TESM_LOG_DIR, "clientupdate") - - -def _write_client_update_error_log(name, exit_code, lines): - """Schreibt bei einem mit Exit-Code fehlgeschlagenen Wartungs-Update - eine eigene Fehler-Logdatei pro Client (überschreibt eine vorherige aus - einem früheren Fehlschlag desselben Clients) -- zusätzlich zur ohnehin - im Job-Status/Live-Fenster sichtbaren Ausgabe, damit ein Fehlschlag - auch ohne offenes Browser-Fenster im Nachhinein auffindbar bleibt.""" - try: - os.makedirs(TESM_CLIENT_UPDATE_LOG_DIR, exist_ok=True) - os.chmod(TESM_CLIENT_UPDATE_LOG_DIR, 0o755) - safe_name = secure_filename(name) or "unbenannt" - path = os.path.join(TESM_CLIENT_UPDATE_LOG_DIR, f"{safe_name}.error") - with open(path, "w", encoding="utf-8") as f: - f.write(f"Update fehlgeschlagen: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") - f.write(f"Exit-Code: {exit_code}\n\n") - f.write("\n".join(lines)) - f.write("\n") - except OSError: - app.logger.error("_write_client_update_error_log failed:\n%s", traceback.format_exc()) - - -def _maintenance_run_update(mac, name, host, port, username, password): - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - _maintenance_job_set( - mac, action="update", status="running", message="Update läuft …", - output="", started=now, finished=None, - ) - - lines = [] - - def on_line(line, is_err): - lines.append(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {line}") - if len(lines) > _MAINTENANCE_OUTPUT_MAX_LINES: - del lines[: len(lines) - _MAINTENANCE_OUTPUT_MAX_LINES] - _maintenance_job_set(mac, output="\n".join(lines)) - - result = _run_ssh_command_streaming( - host, port, username, password, _apt_upgrade_command(), on_line, - sudo_password=password, overall_timeout=600, - ) - finished = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - if result["error"]: - lines.append(f"{finished} ✖ Update abgebrochen: {result['error']}") - _maintenance_job_set(mac, status="error", message=result["error"], output="\n".join(lines), finished=finished) - log_action_system("maintenance.update", name, f"fehlgeschlagen: {result['error']}") - elif result["success"]: - lines.append(f"{finished} ✔ All Updates Done") - _maintenance_job_set(mac, status="success", message="Update erfolgreich abgeschlossen.", output="\n".join(lines), finished=finished) - log_action_system("maintenance.update", name, "erfolgreich") - else: - lines.append(f"{finished} ✖ Update fehlgeschlagen (Exit-Code {result['exit_code']})") - _maintenance_job_set( - mac, status="error", - message=f"Update fehlgeschlagen (Exit-Code {result['exit_code']}).", - output="\n".join(lines), finished=finished, - ) - _write_client_update_error_log(name, result["exit_code"], lines) - log_action_system("maintenance.update", name, f"fehlgeschlagen: Exit-Code {result['exit_code']}") - - -_MAINTENANCE_REBOOT_GRACE_SECONDS = 15 -_MAINTENANCE_REBOOT_TIMEOUT_SECONDS = 300 -_MAINTENANCE_REBOOT_POLL_INTERVAL = 5 - - -def _ping_once(host, timeout=2): - try: - result = subprocess.run( - ["ping", "-c", "1", "-W", str(timeout), host], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout + 2, - ) - return result.returncode == 0 - except (OSError, subprocess.SubprocessError): - return False - - -def _maintenance_wait_online_after_reboot(mac, name, host): - """Wartet nach einem erfolgreich ausgelösten Neustart eine kurze - Schonfrist (die Maschine braucht einen Moment zum Herunterfahren, ein - zu früher Ping könnte noch die absterbende alte Instanz erreichen), - pingt danach regelmäßig, bis sie wieder antwortet oder ein Timeout - erreicht ist. Läuft im selben Hintergrund-Thread wie - _maintenance_run_reboot weiter, blockiert also niemanden.""" - _maintenance_job_set(mac, status="running", message="Neustart ausgelöst, warte auf Online …") - time.sleep(_MAINTENANCE_REBOOT_GRACE_SECONDS) - - deadline = time.time() + _MAINTENANCE_REBOOT_TIMEOUT_SECONDS - while time.time() < deadline: - if _ping_once(host): - _maintenance_job_set( - mac, status="success", message="Neustart erfolgreich — Gerät wieder online.", - finished=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - ) - log_action_system("maintenance.reboot", name, "wieder online") - return - time.sleep(_MAINTENANCE_REBOOT_POLL_INTERVAL) - - _maintenance_job_set( - mac, status="error", - message=f"Nach dem Neustart nicht innerhalb von {_MAINTENANCE_REBOOT_TIMEOUT_SECONDS // 60} Minuten wieder online (Ping ohne Antwort).", - finished=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - ) - log_action_system("maintenance.reboot", name, "nach Neustart nicht wieder online") - - -def _maintenance_run_reboot(mac, name, host, port, username, password): - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - _maintenance_job_set(mac, action="reboot", status="running", message="Neustart wird ausgelöst …", output="", started=now, finished=None) - result = _run_ssh_reboot(host, port, username, password) - if result["error"]: - _maintenance_job_set( - mac, status="error", message=result["error"], - finished=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - ) - log_action_system("maintenance.reboot", name, f"fehlgeschlagen: {result['error']}") - return - - log_action_system("maintenance.reboot", name, "ausgelöst") - _maintenance_wait_online_after_reboot(mac, name, host) - - -def _maintenance_dispatch(action_name, thread_target): - """Gemeinsame Logik für /maintenance/update und /maintenance/reboot: - ausgewählte, berechtigte MACs auflösen, bereits laufende überspringen, - je Gerät einen Hintergrund-Thread starten.""" - macs = set(request.form.getlist("macs")) - if not macs: - flash("Bitte mindestens ein Gerät auswählen.", "danger") - return redirect(url_for("maintenance")) - - conn = get_db_connection() - eligible = {d["mac"]: d for d in _maintenance_devices(conn) if d["mac"] in macs} - - started = [] - for mac, device in eligible.items(): - with _maintenance_jobs_lock: - existing = _maintenance_jobs.get(mac) - if existing and existing.get("status") == "running": - continue - host, port, username, password, error = _resolve_device_ssh_target(conn, device) - if error: - _maintenance_job_immediate_error(mac, action_name, error) - continue - thread = threading.Thread( - target=thread_target, - args=(mac, device["name"], host, port, username, password), - daemon=True, - ) - thread.start() - started.append(device["name"]) - conn.close() - - if started: - flash(f"{action_name.capitalize()} gestartet für: {', '.join(started)}", "success") - else: - flash("Keine Aktion gestartet (bereits laufend oder Zugangsdaten fehlerhaft).", "danger") - return redirect(url_for("maintenance")) - - -@app.route("/maintenance/update", methods=["POST"]) -@login_required -def maintenance_update(): - if not current_user.can_run_maintenance: - flash("Keine Berechtigung, Wartungsaktionen auszuführen.", "danger") - return redirect(url_for("maintenance")) - return _maintenance_dispatch("update", _maintenance_run_update) - - -@app.route("/maintenance/reboot", methods=["POST"]) -@login_required -def maintenance_reboot(): - if not current_user.can_run_maintenance: - flash("Keine Berechtigung, Wartungsaktionen auszuführen.", "danger") - return redirect(url_for("maintenance")) - return _maintenance_dispatch("reboot", _maintenance_run_reboot) - - - -_SSH_HOST_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9\.\-:]{0,253}$") -_SSH_USER_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,64}$") - - -def _send_and_close(ws, message): - """ - Nachricht senden und Verbindung schließen. Die kurze Pause dazwischen - zwingt beide WebSocket-Frames auf getrennte TCP-Schreibvorgänge — ohne - sie kommt es bei manchen Browsern vor, dass eine Nachricht direkt gefolgt - von der Schließung der Verbindung als Frame-Fehler gewertet und die - Nachricht verworfen wird, bevor sie angezeigt werden kann. - """ - try: - ws.send(message) - time.sleep(0.15) - except ConnectionClosed: - pass - try: - ws.close() - except Exception: - pass - - -def _terminal_read_line(ws, echo=True): - """Liest zeichenweise vom Terminal (wie normale Tastatureingaben), bis - Enter kommt — für Prompts, bevor eine Remote-Shell existiert, die das - selbst übernehmen könnte (Host-Key-Bestätigung, Passwortabfrage). - echo=False unterdrückt das lokale Echo (Passwort-Eingabe), genau wie - ein normales ssh-CLI beim Tippen des Passworts nichts anzeigt.""" - line = "" - while True: - try: - raw = ws.receive(timeout=120) - except ConnectionClosed: - return None - if raw is None: - return None - try: - payload = json.loads(raw) - except (TypeError, ValueError): - continue - if payload.get("type") != "input": - continue - for ch in str(payload.get("data", "")): - if ch in ("\r", "\n"): - ws.send("\r\n") - return line - elif ch in ("\x7f", "\b"): - if line: - line = line[:-1] - if echo: - ws.send("\b \b") - elif ch == "\x03": - ws.send("^C\r\n") - return None - else: - line += ch - if echo: - ws.send(ch) - - -def _verify_host_key_interactive(ws, host, port, server_key): - """Emuliert das bekannte SSH-CLI-Verhalten bei Host-Keys — unbekannt: - interaktiv über das Browser-Terminal nachfragen (wie "Are you sure you - want to continue connecting?"); GEÄNDERT (Eintrag vorhanden, aber - anderer Key): hart ablehnen ohne Rückfrage, wie ein normales ssh-CLI - bei einer möglichen MITM-Situation. Gibt True zurück, wenn die - Verbindung fortgesetzt werden darf.""" - lookup_host = host if port == 22 else f"[{host}]:{port}" - hostkeys = paramiko.HostKeys() - try: - hostkeys.load(SSH_KNOWN_HOSTS_PATH) - except (OSError, paramiko.SSHException): - pass - - existing = hostkeys.lookup(lookup_host) - if existing and existing.get(server_key.get_name()) and existing[server_key.get_name()] != server_key: - ws.send( - f"\r\n\x1b[31mWARNUNG: Host-Key für '{lookup_host}' hat sich geändert " - f"— Verbindung abgelehnt (möglicher Man-in-the-Middle).\x1b[0m\r\n" - ) - return False - if hostkeys.check(lookup_host, server_key): - return True - - fingerprint = ":".join(f"{b:02x}" for b in server_key.get_fingerprint()) - ws.send( - f"\r\nDie Authentizität von Host '{lookup_host}' kann nicht festgestellt werden.\r\n" - f"{server_key.get_name()}-Key-Fingerabdruck: {fingerprint}\r\n" - f"Trotzdem verbinden (yes/no)? " - ) - answer = _terminal_read_line(ws, echo=True) - if not answer or answer.strip().lower() not in ("yes", "y"): - return False - - hostkeys.add(lookup_host, server_key.get_name(), server_key) - try: - if not os.path.exists(SSH_KNOWN_HOSTS_PATH): - open(SSH_KNOWN_HOSTS_PATH, "a").close() - hostkeys.save(SSH_KNOWN_HOSTS_PATH) - except OSError: - app.logger.error("save_host_keys failed:\n%s", traceback.format_exc()) - return True - - -def _authenticate_interactive(ws, transport, username): - """Fragt Zugangsdaten genauso ab, wie es ein echtes ssh-CLI täte — - direkt über die WebSocket-Verbindung, da an dieser Stelle noch keine - Remote-Shell existiert, in die man tippen könnte. Vorher wurde hier nie - tatsächlich ein Passwort abgefragt (das Init-Payload vom Browser enthält - gar keins) — SSHClient.connect() ohne password/pkey/agent scheitert - sofort mit "No authentication methods available", bevor überhaupt eine - Passwortabfrage möglich wäre. Fragt daher aktiv nach, welche - Auth-Methoden der Server anbietet, und fordert bei Bedarf interaktiv - zur Eingabe auf (Passwort oder keyboard-interactive).""" - allowed = [] - try: - transport.auth_none(username) - except paramiko.BadAuthenticationType as e: - allowed = e.allowed_types - except paramiko.SSHException: - allowed = [] - - if transport.is_authenticated(): - return True - - if "password" in allowed: - ws.send("\r\nPasswort: ") - pw = _terminal_read_line(ws, echo=False) - try: - transport.auth_password(username, pw or "") - except paramiko.AuthenticationException: - pass - elif "keyboard-interactive" in allowed: - def handler(title, instructions, prompt_list): - for text in (title, instructions): - if text: - ws.send(f"\r\n{text}") - responses = [] - for prompt_text, echo in prompt_list: - ws.send(f"\r\n{prompt_text or 'Passwort: '}") - responses.append(_terminal_read_line(ws, echo=bool(echo)) or "") - return responses - try: - transport.auth_interactive(username, handler) - except paramiko.AuthenticationException: - pass - else: - ws.send(f"\r\n\x1b[31mServer bietet keine unterstützte Auth-Methode an ({', '.join(allowed) or 'keine'}).\x1b[0m\r\n") - - return transport.is_authenticated() - - -@sock.route("/ws/ssh_terminal") -def ssh_terminal(ws): - try: - return _ssh_terminal_inner(ws) - except Exception: - app.logger.error("ssh_terminal crashed:\n%s", traceback.format_exc()) - raise - - -def _ssh_terminal_inner(ws): - if not current_user.is_authenticated or not current_user.is_admin: - _send_and_close(ws, "\r\n\x1b[31mZugriff verweigert.\x1b[0m\r\n") - return - - try: - init = json.loads(ws.receive(timeout=10) or "{}") - except Exception: - ws.close() - return - - host = str(init.get("host") or "").strip() - username = str(init.get("username") or "").strip() - port_str = str(init.get("port") or "22").strip() - - if not (_SSH_HOST_RE.match(host) and _SSH_USER_RE.match(username) and port_str.isdigit()): - _send_and_close(ws, "\r\n\x1b[31mUngültiger Host oder Benutzername.\x1b[0m\r\n") - return - port = int(port_str) - - ws.send(f"\x1b[90m→ Verbinde zu {username}@{host}:{port} …\x1b[0m\r\n") - - try: - sock_conn = socket.create_connection((host, port), timeout=10) - except OSError as e: - _send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n") - return - - transport = paramiko.Transport(sock_conn) - try: - transport.start_client(timeout=10) - except (paramiko.SSHException, EOFError, OSError) as e: - _send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n") - return - - if not _verify_host_key_interactive(ws, host, port, transport.get_remote_server_key()): - transport.close() - _send_and_close(ws, "\r\n\x1b[33mHost-Key abgelehnt oder Verbindung abgebrochen.\x1b[0m\r\n") - return - - try: - authenticated = _authenticate_interactive(ws, transport, username) - except (paramiko.SSHException, OSError, EOFError) as e: - transport.close() - _send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n") - return - - if not authenticated: - transport.close() - _send_and_close(ws, "\r\n\x1b[31m✗ Zugangsdaten falsch (Authentifizierung fehlgeschlagen).\x1b[0m\r\n") - return - - ws.send("\r\n\x1b[32m✓ Login erfolgreich — Sitzung wird geöffnet …\x1b[0m\r\n") - - try: - channel = transport.open_session(timeout=10) - channel.get_pty(term="xterm-256color", width=80, height=24) - channel.invoke_shell() - except paramiko.SSHException as e: - _send_and_close(ws, f"\r\n\x1b[31m✗ Konnte keine Shell öffnen: {e}\x1b[0m\r\n") - transport.close() - return - - channel.settimeout(0.0) - closed = threading.Event() - - def pump_output(): - while not closed.is_set(): - try: - if channel.recv_ready(): - data = channel.recv(4096) - if not data: - break - ws.send(data.decode("utf-8", errors="replace")) - else: - time.sleep(0.03) - if channel.closed or channel.exit_status_ready(): - if not channel.recv_ready(): - break - except (OSError, EOFError): - break - except Exception: - break - closed.set() - - reader = threading.Thread(target=pump_output, daemon=True) - reader.start() - - try: - while not closed.is_set(): - message = ws.receive() - if message is None: - break - try: - payload = json.loads(message) - except (TypeError, ValueError): - continue - if payload.get("type") == "input": - try: - channel.send(str(payload.get("data", ""))) - except OSError: - break - elif payload.get("type") == "resize": - try: - channel.resize_pty(width=int(payload.get("cols", 80)), height=int(payload.get("rows", 24))) - except paramiko.SSHException: - pass - except Exception: - pass - finally: - closed.set() - try: - channel.close() - except Exception: - pass - try: - transport.close() - except Exception: - pass - - - -_LINUX_UPDATE_STEPS = [ - ("apt update", "apt-get update"), - ("initramfs MODULES=most setzen", "sed -i 's/^MODULES=.*/MODULES=most/' /etc/initramfs-tools/initramfs.conf"), - ( - "apt upgrade", - 'apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade', - ), - ( - "apt full-upgrade", - 'apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" full-upgrade', - ), - ("apt clean", "apt-get clean"), - ("apt autoremove", "apt-get -y autoremove"), -] - - -def _apt_upgrade_command(): - """Baut die Wartungs-Update-Kette aus _LINUX_UPDATE_STEPS zusammen -- - jeder Schritt einzeln mit eigenem "=== Schritt N/M: ... ===" und - danach "Schritt N (...) -> erfolgreich"/"-> fehlgeschlagen (Exit-Code - ...)" im Live-Log, statt nur einer einzigen Erfolgs-/Fehlermeldung für - die ganze Kette. Bricht beim ersten fehlgeschlagenen Schritt sofort ab - (dessen Exit-Code wird zum Exit-Code des Gesamtkommandos), statt - blind mit dem nächsten Schritt weiterzumachen. - - DEBIAN_FRONTEND=noninteractive plus --force-confdef/--force-confold - verhindern, dass ein Paket-Postinst-Skript auf eine Konfig-Datei- - Rückfrage wartet, die nie kommt (das Kommando würde sonst ohne PTY - unbeaufsichtigt hängen bleiben, bis der Timeout zuschlägt). - - MODULES=most wird -- anders als zuvor -- dauerhaft in - /etc/initramfs-tools/initramfs.conf gesetzt (nicht nur für diesen einen - Aufruf per Umgebungsvariable): falls apt upgrade/full-upgrade einen - neuen Kernel installiert und dabei ein initramfs neu gebaut wird, - landen so ALLE verfügbaren Treiber darin (statt nur die per "dep" - erkannten), damit das Gerät nach einem Kernel-Wechsel zuverlässig - bootet -- und das bleibt auch für jeden künftigen, unabhängig von - dieser App ausgelösten Kernel-Update/initramfs-Rebuild so bestehen.""" - script_lines = ["export DEBIAN_FRONTEND=noninteractive"] - total = len(_LINUX_UPDATE_STEPS) - for i, (label, cmd) in enumerate(_LINUX_UPDATE_STEPS, start=1): - script_lines.append(f"echo '=== Schritt {i}/{total}: {label} ==='") - script_lines.append(cmd) - script_lines.append("_rc=$?") - script_lines.append( - f'if [ "$_rc" -eq 0 ]; then echo "Schritt {i} ({label}) -> erfolgreich"; ' - f'else echo "Schritt {i} ({label}) -> fehlgeschlagen (Exit-Code $_rc)"; exit "$_rc"; fi' - ) - script = "\n".join(script_lines) - return f"sudo -S -p '' bash -c {shlex.quote(script)}" - - -def _resolve_device_ssh_target(conn, device): - """Löst Host/Port/Zugangsdaten für eine SSH-Wartungsaktion an einem - Gerät auf. Gibt (host, port, username, password, error) zurück -- bei - einem Fehler ist error gesetzt und die übrigen Werte None.""" - if not device["credential_id"]: - return None, None, None, None, f"{device['name']}: keine SSH-Zugangsdaten hinterlegt." - cred = conn.execute( - "SELECT username, password FROM credentials WHERE id=? AND deleted_at IS NULL", (device["credential_id"],) - ).fetchone() - if not cred: - return None, None, None, None, f"{device['name']}: hinterlegte Zugangsdaten existieren nicht mehr." - port = device["ssh_port"] or SWITCH_DEFAULT_SSH_PORT - try: - password = decrypt_password(cred["password"]) - except Exception: - return None, None, None, None, f"{device['name']}: Zugangsdaten konnten nicht entschlüsselt werden." - return device["ip"], port, cred["username"], password, None - - -def _run_ssh_command_streaming(host, port, username, password, command, on_line, - sudo_password=None, connect_timeout=25, overall_timeout=600): - """Wie _run_ssh_command, liest die Ausgabe aber zeilenweise WÄHREND das - Kommando noch läuft, statt sie erst nach Abschluss komplett auf einmal - zu liefern -- Grundlage für die live mitwachsende, automatisch - mitscrollende Ausgabe beim Wartungs-Update (siehe - pollMaintenanceStatus() in maintenance.html). on_line(line, is_stderr) - wird für jede vollständige Zeile aufgerufen, sobald sie eintrifft.""" - result = {"success": False, "exit_code": None, "stdout": "", "stderr": "", "error": None} - - if not os.path.exists(SSH_KNOWN_HOSTS_PATH): - result["error"] = ( - "Host-Key nicht bekannt — bitte zuerst per 'Verbindung testen' " - "einmal interaktiv verbinden und den Host-Key bestätigen." - ) - return result - - client = paramiko.SSHClient() - client.load_host_keys(SSH_KNOWN_HOSTS_PATH) - client.set_missing_host_key_policy(paramiko.RejectPolicy()) - - try: - client.connect( - host, port=port, username=username, password=password, - timeout=connect_timeout, banner_timeout=connect_timeout, auth_timeout=connect_timeout, - look_for_keys=False, allow_agent=False, - ) - except paramiko.BadHostKeyException: - result["error"] = "Host-Key hat sich geändert — Verbindung abgelehnt (möglicher Man-in-the-Middle)." - return result - except paramiko.AuthenticationException: - result["error"] = "Authentifizierung fehlgeschlagen (falsche Zugangsdaten)." - return result - except paramiko.SSHException as e: - result["error"] = f"Host-Key unbekannt — bitte zuerst per 'Verbindung testen' bestätigen ({e})." - return result - except (OSError, socket.error) as e: - result["error"] = f"Verbindung fehlgeschlagen: {e}" - return result - - out_parts, err_parts = [], [] - out_buf, err_buf = bytearray(), bytearray() - - def drain(ready, recv, buf, parts, is_err): - got_data = False - while ready(): - chunk = recv(4096) - if not chunk: - break - buf.extend(chunk) - got_data = True - while b"\n" in buf: - idx = buf.index(b"\n") - line = bytes(buf[:idx]).decode("utf-8", errors="replace").rstrip("\r") - del buf[:idx + 1] - parts.append(line) - on_line(line, is_err) - return got_data - - try: - stdin, stdout, stderr = client.exec_command(command, timeout=connect_timeout) - channel = stdout.channel - if sudo_password is not None: - try: - stdin.write(sudo_password + "\n") - stdin.flush() - except OSError: - pass - deadline = time.time() + overall_timeout - while True: - got = drain(channel.recv_ready, channel.recv, out_buf, out_parts, False) - got = drain(channel.recv_stderr_ready, channel.recv_stderr, err_buf, err_parts, True) or got - if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready(): - break - if time.time() > deadline: - result["error"] = f"Kommando nach {overall_timeout}s abgebrochen (Timeout)." - return result - if not got: - time.sleep(0.2) - if out_buf: - line = bytes(out_buf).decode("utf-8", errors="replace") - out_parts.append(line) - on_line(line, False) - if err_buf: - line = bytes(err_buf).decode("utf-8", errors="replace") - err_parts.append(line) - on_line(line, True) - result["exit_code"] = channel.recv_exit_status() - result["stdout"] = "\n".join(out_parts) - result["stderr"] = "\n".join(err_parts) - result["success"] = result["exit_code"] == 0 - except (paramiko.SSHException, OSError) as e: - result["error"] = f"Kommando fehlgeschlagen: {e}" - finally: - try: - client.close() - except Exception: - pass - - return result - - -def _run_ssh_command(host, port, username, password, command, sudo_password=None, timeout=25): - """Führt EIN nicht-interaktives Kommando per SSH aus (kein Terminal, - keine Rückfragemöglichkeit). Gibt ein dict zurück: - {"success": bool, "exit_code": int|None, "stdout": str, "stderr": str, - "error": str|None} -- "error" ist nur bei Verbindungs-/Auth-Fehlern - gesetzt, ein regulär durchgelaufenes aber fehlgeschlagenes Kommando - (exit_code != 0) zählt als success=False mit leerem error.""" - result = {"success": False, "exit_code": None, "stdout": "", "stderr": "", "error": None} - - if not os.path.exists(SSH_KNOWN_HOSTS_PATH): - result["error"] = ( - "Host-Key nicht bekannt — bitte zuerst per 'Verbindung testen' " - "einmal interaktiv verbinden und den Host-Key bestätigen." - ) - return result - - client = paramiko.SSHClient() - client.load_host_keys(SSH_KNOWN_HOSTS_PATH) - client.set_missing_host_key_policy(paramiko.RejectPolicy()) - - try: - client.connect( - host, port=port, username=username, password=password, - timeout=timeout, banner_timeout=timeout, auth_timeout=timeout, - look_for_keys=False, allow_agent=False, - ) - except paramiko.BadHostKeyException: - result["error"] = "Host-Key hat sich geändert — Verbindung abgelehnt (möglicher Man-in-the-Middle)." - return result - except paramiko.AuthenticationException: - result["error"] = "Authentifizierung fehlgeschlagen (falsche Zugangsdaten)." - return result - except paramiko.SSHException as e: - result["error"] = f"Host-Key unbekannt — bitte zuerst per 'Verbindung testen' bestätigen ({e})." - return result - except (OSError, socket.error) as e: - result["error"] = f"Verbindung fehlgeschlagen: {e}" - return result - - try: - stdin, stdout, stderr = client.exec_command(command, timeout=timeout) - if sudo_password is not None: - try: - stdin.write(sudo_password + "\n") - stdin.flush() - except OSError: - pass - result["exit_code"] = stdout.channel.recv_exit_status() - result["stdout"] = stdout.read().decode("utf-8", errors="replace") - result["stderr"] = stderr.read().decode("utf-8", errors="replace") - result["success"] = result["exit_code"] == 0 - except (paramiko.SSHException, OSError) as e: - result["error"] = f"Kommando fehlgeschlagen: {e}" - finally: - try: - client.close() - except Exception: - pass - - return result - - -def _run_ssh_reboot(host, port, username, password, timeout=15): - """Startet 'sudo reboot' per SSH. Verbindungs-/Auth-Fehler VOR dem - eigentlichen Neustart-Kommando gelten als echter Fehler; ein abrupter - Verbindungsabbruch WÄHREND/NACH dem Kommando ist dagegen erwartet (die - Maschine fährt herunter, bevor sie überhaupt noch antworten kann) und - zählt als Erfolg.""" - result = {"success": False, "error": None} - - if not os.path.exists(SSH_KNOWN_HOSTS_PATH): - result["error"] = ( - "Host-Key nicht bekannt — bitte zuerst per 'Verbindung testen' " - "einmal interaktiv verbinden und den Host-Key bestätigen." - ) - return result - - client = paramiko.SSHClient() - client.load_host_keys(SSH_KNOWN_HOSTS_PATH) - client.set_missing_host_key_policy(paramiko.RejectPolicy()) - - try: - client.connect( - host, port=port, username=username, password=password, - timeout=timeout, banner_timeout=timeout, auth_timeout=timeout, - look_for_keys=False, allow_agent=False, - ) - except paramiko.BadHostKeyException: - result["error"] = "Host-Key hat sich geändert — Verbindung abgelehnt (möglicher Man-in-the-Middle)." - return result - except paramiko.AuthenticationException: - result["error"] = "Authentifizierung fehlgeschlagen (falsche Zugangsdaten)." - return result - except paramiko.SSHException as e: - result["error"] = f"Host-Key unbekannt — bitte zuerst per 'Verbindung testen' bestätigen ({e})." - return result - except (OSError, socket.error) as e: - result["error"] = f"Verbindung fehlgeschlagen: {e}" - return result - - try: - stdin, stdout, stderr = client.exec_command("sudo -S -p '' reboot", timeout=timeout) - stdin.write(password + "\n") - stdin.flush() - stdout.channel.recv_exit_status() - except (paramiko.SSHException, OSError, EOFError): - pass - finally: - try: - client.close() - except Exception: - pass - - result["success"] = True - return result - - - -@app.route("/get_log") -@login_required -def get_log(): - """Wird vom Live-Log-Poll (Intervall-Timer + manueller Aktualisieren- - Button) aufgerufen -- liefert bewusst nur die letzten LIVE_LOG_TAIL_LINES - Zeilen statt der kompletten Datei (siehe _tail_lines), damit dieser - wiederkehrende Abruf auf größeren Umgebungen keine spürbaren Ladezeiten - mehr verursacht. Das komplette Log bleibt über get_log_raw erreichbar.""" - if not current_user.can_view_live_log: - return "Keine Berechtigung.", 403 - latest_log = _latest_log_file() - if not latest_log: - return "Keine Logfiles gefunden." - try: - content = _tail_lines(latest_log) - total_lines = _count_lines(latest_log) - except OSError as e: - content = f"Fehler beim Lesen des Logs: {e}" - total_lines = None - response = app.response_class(content, mimetype="text/plain") - response.headers["X-Log-Name"] = os.path.basename(latest_log) - if total_lines is not None: - response.headers["X-Total-Lines"] = str(total_lines) - return response - - -@app.route("/logs/live/raw") -@login_required -def get_log_raw(): - """Komplettes, unbearbeitetes Live-Log für das RAW-Popup -- bewusst ein - eigener Endpoint statt eines Query-Parameters an /get_log, damit der - normale Live-Poll (jeder Nutzer mit logs_live.view, in jedem - Prüfintervall) unter keinen Umständen versehentlich die komplette Datei - lädt. Wird nur einmalig auf explizite Nutzeraktion (Button-Klick) - nachgeladen.""" - if not current_user.can_view_live_log: - return "Keine Berechtigung.", 403 - latest_log = _latest_log_file() - if not latest_log: - return "Keine Logfiles gefunden." - try: - with open(latest_log, "r", encoding="utf-8", errors="replace") as f: - content = f.read() - except OSError as e: - content = f"Fehler beim Lesen des Logs: {e}" - response = app.response_class(content, mimetype="text/plain") - response.headers["X-Log-Name"] = os.path.basename(latest_log) - return response - - -@app.route("/logs/history") -@login_required -def logs_history(): - """Verlauf: ältere, von logrotate rotierte Kopien des Live-Logs, als - eigene Unterseite von Live erreichbar (eigenes Recht logs_history.view, - siehe User.can_view_log_history) -- bewusst getrennt vom laufenden - Live-Poll, damit das Durchblättern alter Logstände nicht denselben - Performance-Pfad wie die Live-Ansicht belastet.""" - if not current_user.can_view_log_history: - flash("Keine Berechtigung, den Log-Verlauf einzusehen.", "danger") - return redirect(url_for("index")) - - files = _live_log_history_files() - requested = request.args.get("file", "") - selected_path = _resolve_log_history_file(requested) if requested else None - selected_name = requested if selected_path else None - if not selected_path and files: - selected_name = files[0]["filename"] - selected_path = _resolve_log_history_file(selected_name) - selected_file = next((f for f in files if f["filename"] == selected_name), None) - - log_content = None - total_lines = None - if selected_path: - try: - log_content = _tail_lines(selected_path) - total_lines = _count_lines(selected_path) - except OSError as e: - log_content = f"Fehler beim Lesen des Logs: {e}" - - return render_template( - "logs_history.html", files=files, selected_name=selected_name, selected_file=selected_file, - log_content=log_content, total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES, - audit_files=_audit_archive_files(), disk_warning=_log_disk_space_warning(), - audit_threshold=AUDIT_LOG_ARCHIVE_THRESHOLD, audit_target=AUDIT_LOG_ARCHIVE_TARGET, - ) - - -@app.route("/logs/history/raw") -@login_required -def logs_history_raw(): - if not current_user.can_view_log_history: - return "Keine Berechtigung.", 403 - path = _resolve_log_history_file(request.args.get("file", "")) - if not path: - return "Unbekannte oder ungültige Log-Datei.", 404 - try: - with open(path, "r", encoding="utf-8", errors="replace") as f: - content = f.read() - except OSError as e: - content = f"Fehler beim Lesen des Logs: {e}" - response = app.response_class(content, mimetype="text/plain") - response.headers["X-Log-Name"] = os.path.basename(path) - return response - - -@app.route("/logs/history/audit-raw") -@login_required -def logs_history_audit_raw(): - """Wie logs_history_raw(), aber für einen einzelnen bereits - archivierten Auditlog-Tag (audit-YYYY-MM-DD.log) statt für eine - Live-Log-Rotation.""" - if not current_user.can_view_log_history: - return "Keine Berechtigung.", 403 - path = _resolve_audit_archive_file(request.args.get("file", "")) - if not path: - return "Unbekannte oder ungültige Archivdatei.", 404 - try: - with open(path, "r", encoding="utf-8", errors="replace") as f: - content = f.read() - except OSError as e: - content = f"Fehler beim Lesen der Datei: {e}" - response = app.response_class(content, mimetype="text/plain") - response.headers["X-Log-Name"] = os.path.basename(path) - return response - - -@app.route("/logs/history/audit-export", methods=["POST"]) -@login_required -def logs_history_audit_export(): - """Exportiert ALLE aktuell archivierten Auditlog-Tage als ein ZIP und - löscht sie danach vom Server -- bewusst eine einzige, manuell - ausgelöste Aktion (Recht logs_history.edit, siehe - User.can_manage_log_history) statt einer automatischen Löschung nach - Ablaufzeit: die Archivdateien selbst bleiben unbegrenzt liegen, bis ein - Admin hier gezielt aufräumt (z.B. wenn _log_disk_space_warning() - anschlägt).""" - if not current_user.can_manage_log_history: - return "Keine Berechtigung.", 403 - - files = _audit_archive_files() - if not files: - flash("Keine archivierten Auditlog-Tage zum Exportieren vorhanden.", "info") - return redirect(url_for("logs_history")) - - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: - for f in files: - zf.write(os.path.join(AUDIT_ARCHIVE_DIR, f["filename"]), arcname=f["filename"]) - buf.seek(0) - - deleted = [] - for f in files: - try: - os.remove(os.path.join(AUDIT_ARCHIVE_DIR, f["filename"])) - deleted.append(f["filename"]) - except OSError: - pass - - log_action( - "auditlog.export_delete", - "Auditlog", - f"{len(deleted)} Datei(en) exportiert und gelöscht: {', '.join(deleted)}", - ) - - today_str = datetime.now().strftime("%Y-%m-%d") - return send_file( - buf, mimetype="application/zip", as_attachment=True, - download_name=f"auditlog-archiv-{today_str}.zip", - ) - - -@app.route("/logs") -@login_required -def logs(): - if not current_user.can_view_live_log: - flash("Keine Berechtigung, das Live-Log einzusehen.", "danger") - return redirect(url_for("index")) - interval = int(get_setting("interval", 5)) - latest_log = _latest_log_file() - - if not latest_log: - return render_template( - "logs.html", log_content="Keine Logfiles gefunden.", interval=interval, - total_lines=None, tail_lines=LIVE_LOG_TAIL_LINES, - ) - - try: - log_content = _tail_lines(latest_log) - total_lines = _count_lines(latest_log) - except OSError as e: - log_content = f"Fehler beim Lesen des Logs: {e}" - total_lines = None - - return render_template( - "logs.html", log_content=log_content, log_name=os.path.basename(latest_log), interval=interval, - total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES, - ) - - -@app.route("/get_kea_log") -@login_required -def get_kea_log(): - if not current_user.can_view_kea_log: - return "Keine Berechtigung.", 403 - try: - with open(TESM_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f: - content = f.read() - except FileNotFoundError: - content = "Noch kein Kea-Log vorhanden — der Dienst muss dafür mindestens einmal mit der aktuellen, " \ - "über \"In Datei schreiben\" erzeugten Konfiguration gelaufen sein." - except OSError as e: - content = f"Fehler beim Lesen des Logs: {e}" - return app.response_class(content, mimetype="text/plain") - - -@app.route("/logs/kea") -@login_required -def kea_log(): - """Eigene Log-Seite für den Kea-DHCP-Dienst, konsistent mit Live-Log/ - Auditlog — die Konfiguration (siehe _render_kea_config) leitet Keas - eigene Logging-Ausgabe explizit in TESM_KEA_LOG_PATH statt in den - distributionsabhängigen Default (meist Syslog), damit sie sich hier - genauso wie die anderen Logs einsehen und per logrotate rotieren - lässt.""" - if not current_user.can_view_kea_log: - flash("Keine Berechtigung, das Kea-DHCP-Log einzusehen.", "danger") - return redirect(url_for("index")) - if resp := _require_module("dhcp"): - return resp - try: - with open(TESM_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f: - log_content = f.read() - except FileNotFoundError: - log_content = "Noch kein Kea-Log vorhanden — der Dienst muss dafür mindestens einmal mit der aktuellen, " \ - "über \"In Datei schreiben\" erzeugten Konfiguration gelaufen sein." - except OSError as e: - log_content = f"Fehler beim Lesen des Logs: {e}" - return render_template("logs_kea.html", log_content=log_content) - - -AUDIT_LOG_PAGE_SIZE = 300 - - -@app.route("/logs/aenderungen") -@login_required -def activity_log(): - """Auditlog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen, - Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts — die stehen im Live-Log. - Lädt anfangs nur die neuesten AUDIT_LOG_PAGE_SIZE Zeilen (Performance bei - einer ggf. noch nicht archivierten, großen Tabelle) -- ältere Zeilen - werden bei Bedarf über "Weitere 300 laden" bzw. "Alle laden" (siehe - activity_log_more()) nachgeladen, ohne die Seite neu zu laden.""" - if not current_user.can_view_activity_log: - flash("Keine Berechtigung, das Auditlog einzusehen.", "danger") - return redirect(url_for("index")) - - conn = get_db_connection() - entries = conn.execute( - "SELECT id, ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT ?", - (AUDIT_LOG_PAGE_SIZE,), - ).fetchall() - # Gesamtzahlen je Kategorie (nicht nur der geladenen Zeilen) für die - # "X / Y geladen"-Anzeige in den Statistik-Kacheln -- eine einzige - # aggregierte Abfrage statt die komplette Tabelle einzulesen. Die - # LIKE-Muster bilden exakt dieselbe Kategorisierung wie category_of() - # in _audit_log_macros.html ab (kind = action.split(".")[-1]). - totals_row = conn.execute( - "SELECT COUNT(*) AS total, " - "SUM(CASE WHEN action LIKE '%.create' OR action LIKE '%.upload' OR action LIKE '%.mkdir' THEN 1 ELSE 0 END) AS create_n, " - "SUM(CASE WHEN action LIKE '%.delete' THEN 1 ELSE 0 END) AS delete_n " - "FROM audit_log" - ).fetchone() - conn.close() - - total_count = totals_row["total"] or 0 - total_create = totals_row["create_n"] or 0 - total_delete = totals_row["delete_n"] or 0 - total_edit = total_count - total_create - total_delete - - oldest_loaded_id = entries[-1]["id"] if entries else None - has_more = len(entries) >= AUDIT_LOG_PAGE_SIZE and total_count > len(entries) - return render_template( - "activity_log.html", entries=entries, total_count=total_count, - total_create=total_create, total_edit=total_edit, total_delete=total_delete, - oldest_loaded_id=oldest_loaded_id, has_more=has_more, - ) - - -@app.route("/logs/aenderungen/more") -@login_required -def activity_log_more(): - """AJAX-Nachladen älterer Auditlog-Zeilen in AUDIT_LOG_PAGE_SIZE-Schritten - (Cursor-Pagination über die id-Spalte, absteigend) -- liefert reines - HTML-Zeilenfragment (dieselbe Zeilen-Darstellung wie die Erstladung, über - das gemeinsame Makro in _audit_log_macros.html) statt JSON, damit das - Anhängen im Frontend ein einfaches insertAdjacentHTML bleibt.""" - if not current_user.can_view_activity_log: - return "Keine Berechtigung.", 403 - try: - before_id = int(request.args.get("before_id", "")) - except (TypeError, ValueError): - return "Ungültiger Parameter.", 400 - - conn = get_db_connection() - entries = conn.execute( - "SELECT id, ts, username, action, target, details FROM audit_log WHERE id < ? ORDER BY id DESC LIMIT ?", - (before_id, AUDIT_LOG_PAGE_SIZE), - ).fetchall() - conn.close() - - oldest_loaded_id = entries[-1]["id"] if entries else before_id - has_more = len(entries) >= AUDIT_LOG_PAGE_SIZE - html = render_template("_audit_log_rows.html", entries=entries) - resp = app.response_class(html, mimetype="text/html") - resp.headers["X-Row-Count"] = str(len(entries)) - resp.headers["X-Has-More"] = "1" if has_more else "0" - resp.headers["X-Oldest-Id"] = str(oldest_loaded_id) - return resp - - - -@app.route("/restart/", methods=["POST"]) -@login_required -def restart_device(mac): - if not current_user.has_permission("devices.restart"): - return jsonify({"success": False, "message": "Keine Berechtigung für den PoE-Neustart."}), 403 - - conn = get_db_connection() - device = conn.execute( - "SELECT mac, name, is_active, switch_hostname, port FROM devices WHERE mac=? AND deleted_at IS NULL", (mac,) - ).fetchone() - conn.close() - - if not device: - return jsonify({"success": False, "message": "Gerät wurde nicht gefunden."}), 404 - - if device["is_active"] == 0: - return jsonify({"success": False, "message": f"{device['name']} ist deaktiviert."}), 400 - - if not device["switch_hostname"] or not device["port"]: - return jsonify({"success": False, "message": f"{device['name']} hat keinen Switch/Port zugewiesen — PoE-Neustart nicht möglich."}), 400 - - try: - subprocess.Popen( - ["/bin/bash", POE_SCRIPT, "restart", mac], - env={"PATH": os.path.join(BASE_DIR, "venv", "bin") + ":" + os.environ.get("PATH", "")}, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - return jsonify({"success": True, "device": device["name"]}) - except Exception as e: - return jsonify({"success": False, "message": str(e)}), 500 - - - -@app.route("/users", methods=["GET", "POST"]) -@login_required -def users(): - if request.method == "GET" and not current_user.can_manage_users: - flash("Keine Berechtigung, die Benutzerverwaltung anzusehen.", "danger") - return redirect(url_for("index")) - - conn = get_db_connection() - - if request.method == "POST": - if "add_user" in request.form: - if not current_user.has_permission("users.create"): - flash("Keine Berechtigung, Benutzer anzulegen.", "danger") - return redirect(url_for("users")) - - username = request.form["username"].strip() - password = request.form["password"].strip() - first_name = request.form.get("first_name", "").strip() or None - last_name = request.form.get("last_name", "").strip() or None - email = request.form.get("email", "").strip() or None - group_choice = request.form.get("group_id") or "" - if group_choice == "admin" and not current_user.is_admin: - flash("Nur Admins dürfen andere Benutzer zu Admins machen.", "danger") - return redirect(url_for("users")) - is_admin = 1 if group_choice == "admin" else 0 - - if username and password: - pw_hash = bcrypt.generate_password_hash(password).decode("utf-8") - try: - cur = conn.execute( - "INSERT INTO users (username, password, is_admin, first_name, last_name, email) " - "VALUES (?, ?, ?, ?, ?, ?)", - (username, pw_hash, is_admin, first_name, last_name, email), - ) - if not is_admin and group_choice: - conn.execute( - "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", - (cur.lastrowid, group_choice), - ) - conn.commit() - log_action("user.create", username) - flash(f"Benutzer '{username}' erfolgreich angelegt!", "success") - except sqlite3.IntegrityError: - flash("Benutzername existiert bereits (ggf. im Papierkorb unter Wartung)!", "danger") - else: - flash("Username und Passwort dürfen nicht leer sein!", "danger") - - elif "edit_user" in request.form: - if not current_user.has_permission("users.edit"): - flash("Keine Berechtigung, Benutzer zu bearbeiten.", "danger") - return redirect(url_for("users")) - - user_id = request.form["user_id"] - target_user = conn.execute( - "SELECT is_admin, auth_source FROM users WHERE id=? AND deleted_at IS NULL", (user_id,) - ).fetchone() - if target_user and target_user["is_admin"] and not current_user.is_admin: - flash("Nur Admins dürfen Admin-Konten bearbeiten.", "danger") - return redirect(url_for("users")) - - if target_user and target_user["auth_source"] == "ldap": - flash("AD/LDAP-Konten werden über Active Directory verwaltet und können hier nicht bearbeitet werden — nur sperren oder eine Gruppe zuweisen.", "danger") - return redirect(url_for("users")) - - username = request.form.get("username", "").strip() - first_name = request.form.get("first_name", "").strip() or None - last_name = request.form.get("last_name", "").strip() or None - email = request.form.get("email", "").strip() or None - new_password = request.form.get("new_password", "").strip() - if username: - if new_password: - pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8") - conn.execute( - "UPDATE users SET username=?, first_name=?, last_name=?, email=?, password=? " - "WHERE id=? AND deleted_at IS NULL", - (username, first_name, last_name, email, pw_hash, user_id), - ) - else: - conn.execute( - "UPDATE users SET username=?, first_name=?, last_name=?, email=? " - "WHERE id=? AND deleted_at IS NULL", - (username, first_name, last_name, email, user_id), - ) - conn.commit() - log_action("user.edit", username) - flash("Benutzer aktualisiert!", "success") - else: - flash("Username darf nicht leer sein!", "danger") - - elif "assign_group" in request.form: - if not current_user.has_permission("users.edit"): - flash("Keine Berechtigung, Gruppen zuzuweisen.", "danger") - return redirect(url_for("users")) - - user_id = request.form["user_id"] - choice = request.form.get("group_id") or "" - - target = conn.execute( - "SELECT username, is_admin FROM users WHERE id=? AND deleted_at IS NULL", (user_id,) - ).fetchone() - if (choice == "admin" or (target and target["is_admin"])) and not current_user.is_admin: - flash("Nur Admins dürfen Admin-Zuweisungen ändern.", "danger") - return redirect(url_for("users")) - - if target and target["is_admin"] and choice != "admin": - admin_count = conn.execute( - "SELECT COUNT(*) AS n FROM users WHERE is_admin=1 AND deleted_at IS NULL" - ).fetchone()["n"] - if admin_count <= 1: - flash("Es muss mindestens ein Admin bestehen bleiben.", "danger") - conn.close() - return redirect(url_for("users")) - - conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) - if choice == "admin": - conn.execute("UPDATE users SET is_admin=1 WHERE id=? AND deleted_at IS NULL", (user_id,)) - else: - conn.execute("UPDATE users SET is_admin=0 WHERE id=? AND deleted_at IS NULL", (user_id,)) - if choice: - conn.execute( - "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", - (user_id, choice), - ) - conn.commit() - log_action("user.assign_group", target["username"] if target else user_id, choice or "keine Gruppe") - flash("Gruppe zugewiesen!", "success") - - elif "delete_user" in request.form: - if not current_user.has_permission("users.edit"): - flash("Keine Berechtigung, Benutzer zu löschen.", "danger") - return redirect(url_for("users")) - - user_id = request.form["delete_user"] - target = conn.execute( - "SELECT username, is_admin, auth_source FROM users WHERE id=? AND deleted_at IS NULL", (user_id,) - ).fetchone() - if target and target["is_admin"] and not current_user.is_admin: - flash("Nur Admins dürfen Admin-Konten löschen.", "danger") - return redirect(url_for("users")) - - if target and target["auth_source"] == "ldap": - conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) - conn.execute("DELETE FROM users WHERE id=?", (user_id,)) - conn.commit() - log_action("user.delete", target["username"], "AD/LDAP-Konto, kein Papierkorb") - flash("AD/LDAP-Konto gelöscht (legt sich bei erneutem gültigen Login automatisch neu an).", "success") - else: - conn.execute( - "UPDATE users SET deleted_at=? WHERE id=? AND deleted_at IS NULL", - (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), user_id), - ) - conn.commit() - log_action("user.delete", target["username"] if target else user_id, "in den Papierkorb verschoben") - flash("Benutzer in den Papierkorb verschoben.", "success") - - elif "toggle_lock" in request.form: - if not current_user.has_permission("users.edit"): - flash("Keine Berechtigung, Benutzer zu sperren.", "danger") - return redirect(url_for("users")) - - user_id = request.form["toggle_lock"] - target = conn.execute( - "SELECT username, is_admin, is_locked FROM users WHERE id=? AND deleted_at IS NULL", (user_id,) - ).fetchone() - if not target: - flash("Benutzer nicht gefunden.", "danger") - return redirect(url_for("users")) - if target["is_admin"] and not current_user.is_admin: - flash("Nur Admins dürfen Admin-Konten sperren.", "danger") - return redirect(url_for("users")) - if str(user_id) == str(current_user.id): - flash("Das eigene Konto kann nicht gesperrt werden.", "danger") - return redirect(url_for("users")) - new_state = 0 if target["is_locked"] else 1 - if new_state and target["is_admin"]: - unlocked_admins = conn.execute( - "SELECT COUNT(*) AS n FROM users WHERE is_admin=1 AND is_locked=0 AND deleted_at IS NULL" - ).fetchone()["n"] - if unlocked_admins <= 1: - flash("Es muss mindestens ein entsperrtes Admin-Konto bestehen bleiben.", "danger") - conn.close() - return redirect(url_for("users")) - conn.execute("UPDATE users SET is_locked=? WHERE id=? AND deleted_at IS NULL", (new_state, user_id)) - conn.commit() - log_action("user.lock" if new_state else "user.unlock", target["username"]) - flash(f"Benutzer „{target['username']}“ {'gesperrt' if new_state else 'entsperrt'}.", "success") - - elif "ldap_add_user" in request.form: - if not current_user.has_permission("users.create"): - flash("Keine Berechtigung, Benutzer anzulegen.", "danger") - return redirect(url_for("users")) - - ldap_username = request.form.get("ldap_username", "").strip() - group_choice = request.form.get("group_id") or "" - if group_choice == "admin" and not current_user.is_admin: - flash("Nur Admins dürfen andere Benutzer zu Admins machen.", "danger") - return redirect(url_for("users")) - - found, info = _ldap_lookup(ldap_username) if ldap_username else (False, None) - if not found: - flash("Benutzer wurde im Verzeichnis nicht (mehr) gefunden.", "danger") - else: - canonical_username = info["username"] - placeholder_hash = bcrypt.generate_password_hash(secrets.token_hex(32)).decode("utf-8") - is_admin_new = 1 if group_choice == "admin" else 0 - try: - cur = conn.execute( - "INSERT INTO users (username, password, is_admin, first_name, last_name, email, auth_source) " - "VALUES (?, ?, ?, ?, ?, ?, 'ldap')", - (canonical_username, placeholder_hash, is_admin_new, - info.get("first_name"), info.get("last_name"), info.get("email")), - ) - if not is_admin_new and group_choice: - conn.execute( - "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", - (cur.lastrowid, group_choice), - ) - conn.commit() - log_action("user.ldap_add", canonical_username, "Aus Active Directory hinzugefügt") - flash(f"„{canonical_username}“ aus Active Directory hinzugefügt.", "success") - except sqlite3.IntegrityError: - flash("Dieser Benutzer existiert bereits (ggf. im Papierkorb unter Wartung).", "danger") - - users_list = conn.execute(""" - SELECT users.id, users.username, users.is_admin, users.first_name, users.last_name, - users.auth_source, users.email, users.is_locked, - GROUP_CONCAT(groups.name, ', ') AS group_names, - (SELECT group_id FROM user_groups WHERE user_groups.user_id = users.id LIMIT 1) AS group_id - FROM users - LEFT JOIN user_groups ON user_groups.user_id = users.id - LEFT JOIN groups ON groups.id = user_groups.group_id AND groups.deleted_at IS NULL - WHERE users.deleted_at IS NULL - GROUP BY users.id - ORDER BY users.username ASC - """).fetchall() - all_groups = conn.execute( - "SELECT id, name, is_default FROM groups WHERE deleted_at IS NULL ORDER BY is_default DESC, name ASC" - ).fetchall() - conn.close() - return render_template( - "users.html", users=users_list, all_groups=all_groups, - ldap_enabled=get_setting("ldap_enabled", "0") == "1", - ) - - -@app.route("/users/ldap_search") -@login_required -def users_ldap_search(): - """JSON-Endpunkt für 'Aus Active Directory hinzufügen' -- Substring-Suche - im Verzeichnis, gefiltert um bereits lokal bekannte Benutzernamen - (case-insensitiv, unabhängig von auth_source).""" - if not current_user.has_permission("users.create"): - return jsonify({"error": "Keine Berechtigung."}), 403 - query = request.args.get("q", "").strip() - if len(query) < 2: - return jsonify([]) - results = _ldap_search_users(query) - conn = get_db_connection() - existing = { - row["username"].lower() - for row in conn.execute("SELECT username FROM users WHERE deleted_at IS NULL").fetchall() - } - conn.close() - return jsonify([r for r in results if r["username"] and r["username"].lower() not in existing]) - - - -@app.route("/groups", methods=["GET", "POST"]) -@login_required -def groups(): - if request.method == "GET" and not current_user.can_manage_groups: - flash("Keine Berechtigung, die Gruppenverwaltung anzusehen.", "danger") - return redirect(url_for("index")) - - conn = get_db_connection() - - if request.method == "POST": - if "add_group" in request.form: - if not current_user.has_permission("groups.create"): - flash("Keine Berechtigung, Gruppen anzulegen.", "danger") - return redirect(url_for("groups")) - name = request.form.get("name", "").strip() - if name: - try: - cur = conn.execute("INSERT INTO groups (name) VALUES (?)", (name,)) - selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS) - if selected_permissions: - conn.executemany( - "INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)", - [(cur.lastrowid, p) for p in selected_permissions], - ) - conn.commit() - log_action("group.create", name, f"{len(selected_permissions)} Rechte" if selected_permissions else None) - flash(f"Gruppe '{name}' angelegt.", "success") - except sqlite3.IntegrityError: - flash("Eine Gruppe mit diesem Namen existiert bereits (ggf. im Papierkorb unter Wartung)!", "danger") - else: - flash("Gruppenname darf nicht leer sein!", "danger") - - elif "save_group" in request.form: - if not current_user.has_permission("groups.edit"): - flash("Keine Berechtigung, Gruppen zu bearbeiten.", "danger") - return redirect(url_for("groups")) - - group_id = request.form.get("group_id") - name = request.form.get("name", "").strip() - - if not name: - flash("Gruppenname darf nicht leer sein!", "danger") - conn.close() - return redirect(url_for("groups")) - - target_group = conn.execute( - "SELECT name, is_system FROM groups WHERE id=? AND deleted_at IS NULL", (group_id,) - ).fetchone() - is_system = bool(target_group["is_system"]) if target_group else False - system_unlocked = is_system and current_user.is_admin and request.form.get("unlock_system_group") == "1" - if is_system and "permissions_submitted" in request.form and not system_unlocked: - flash("Die Rechte der Systemgruppe können nicht geändert werden — über „Freischalten“ kann ein Admin dies gezielt erlauben.", "danger") - conn.close() - return redirect(url_for("groups")) - if is_system and target_group and name != target_group["name"]: - flash("Systemgruppen können nicht umbenannt werden.", "danger") - conn.close() - return redirect(url_for("groups")) - - try: - conn.execute("UPDATE groups SET name=? WHERE id=? AND deleted_at IS NULL", (name, group_id)) - - if "permissions_submitted" in request.form: - selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS) - conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) - conn.executemany( - "INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)", - [(group_id, p) for p in selected_permissions], - ) - - if "members_submitted" in request.form: - selected_members = {int(x) for x in request.form.getlist("members") if x.isdigit()} - conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,)) - conn.executemany( - "INSERT INTO user_groups (user_id, group_id) VALUES (?, ?)", - [(uid, group_id) for uid in selected_members], - ) - - conn.commit() - detail_bits = [] - if "permissions_submitted" in request.form: - detail_bits.append("Rechte geändert") - if "members_submitted" in request.form: - detail_bits.append("Mitglieder geändert") - log_action("group.edit", name, ", ".join(detail_bits) or None) - flash(f"Gruppe '{name}' aktualisiert.", "success") - except sqlite3.IntegrityError: - flash("Eine Gruppe mit diesem Namen existiert bereits (ggf. im Papierkorb unter Wartung)!", "danger") - - elif "assign_admins" in request.form: - if not current_user.is_admin: - flash("Nur Admins dürfen Admin-Mitgliedschaften ändern.", "danger") - return redirect(url_for("groups")) - selected_admins = {int(x) for x in request.form.getlist("members") if x.isdigit()} - if not selected_admins: - flash("Es muss mindestens ein Admin bestehen bleiben.", "danger") - else: - conn.execute("UPDATE users SET is_admin=0 WHERE deleted_at IS NULL") - conn.executemany( - "UPDATE users SET is_admin=1 WHERE id=?", - [(uid,) for uid in selected_admins], - ) - conn.executemany( - "DELETE FROM user_groups WHERE user_id=?", - [(uid,) for uid in selected_admins], - ) - conn.commit() - log_action("group.assign_admins", "Admin", f"{len(selected_admins)} Mitglieder") - flash("Admin-Zuweisung aktualisiert.", "success") - - elif "delete_group" in request.form: - if not current_user.has_permission("groups.edit"): - flash("Keine Berechtigung, Gruppen zu löschen.", "danger") - return redirect(url_for("groups")) - group_id = request.form.get("delete_group") - target = conn.execute( - "SELECT name, is_default, is_system FROM groups WHERE id=? AND deleted_at IS NULL", (group_id,) - ).fetchone() - if target and (target["is_default"] or target["is_system"]): - flash(f"Die Systemgruppe '{target['name']}' kann nicht gelöscht werden.", "danger") - else: - conn.execute( - "UPDATE groups SET deleted_at=? WHERE id=? AND deleted_at IS NULL", - (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), group_id), - ) - conn.commit() - log_action("group.delete", target["name"] if target else group_id, "in den Papierkorb verschoben") - flash("Gruppe in den Papierkorb verschoben.", "success") - - conn.close() - return redirect(url_for("groups")) - - group_rows = conn.execute( - "SELECT id, name, is_default, is_system FROM groups WHERE deleted_at IS NULL " - "ORDER BY is_default DESC, name ASC" - ).fetchall() - all_users = conn.execute( - "SELECT id, username FROM users WHERE is_admin=0 AND deleted_at IS NULL ORDER BY username ASC" - ).fetchall() - - groups_data = [] - for g in group_rows: - perm_rows = conn.execute( - "SELECT permission FROM group_permissions WHERE group_id=?", (g["id"],) - ).fetchall() - member_rows = conn.execute(""" - SELECT users.id, users.username FROM users - JOIN user_groups ON user_groups.user_id = users.id - WHERE user_groups.group_id = ? AND users.deleted_at IS NULL - ORDER BY users.username ASC - """, (g["id"],)).fetchall() - groups_data.append({ - "id": g["id"], - "name": g["name"], - "is_default": bool(g["is_default"]), - "is_system": bool(g["is_system"]), - "permissions": {p["permission"] for p in perm_rows}, - "members": {m["id"] for m in member_rows}, - "member_names": [m["username"] for m in member_rows], - }) - - admin_rows = conn.execute( - "SELECT username FROM users WHERE is_admin=1 AND deleted_at IS NULL ORDER BY username ASC" - ).fetchall() - admin_virtual_group = { - "name": "Admin", - "permissions": set(ALL_PERMISSION_KEYS), - "member_names": [u["username"] for u in admin_rows], - } - all_users_all = conn.execute( - "SELECT id, username, is_admin FROM users WHERE deleted_at IS NULL ORDER BY username ASC" - ).fetchall() - - conn.close() - return render_template( - "groups.html", - groups=groups_data, - admin_virtual_group=admin_virtual_group, - all_users=all_users, - all_users_all=all_users_all, - permission_catalog=PERMISSIONS, - permission_row_types=PERMISSION_ROW_TYPES, - group_row_types=GROUP_ROW_TYPES, - permission_labels=PERMISSION_LABELS, - ) - - -if __name__ == "__main__": - debug = os.environ.get("FLASK_DEBUG", "0") == "1" - app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=debug, threaded=True) diff --git a/srv/tesm/create_admin.py b/srv/tesm/create_admin.py deleted file mode 100644 index c1338af..0000000 --- a/srv/tesm/create_admin.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -"""Legt einen Admin-Benutzer in TESM an.""" -import os -import sqlite3 -from getpass import getpass -from flask_bcrypt import Bcrypt - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -DB_PATH = os.environ.get("TESM_DB_PATH", os.path.join(BASE_DIR, "sqlite.db")) -bcrypt = Bcrypt() - - -def main(): - username = input("Admin-Benutzername: ").strip() - password = getpass("Passwort: ") - password_confirm = getpass("Passwort bestätigen: ") - - if password != password_confirm: - print("Passwörter stimmen nicht überein!") - return - - pw_hash = bcrypt.generate_password_hash(password).decode("utf-8") - - conn = sqlite3.connect(DB_PATH) - cur = conn.cursor() - try: - cur.execute( - "INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)", - (username, pw_hash, 1), - ) - conn.commit() - print(f"Admin-Benutzer '{username}' erfolgreich angelegt.") - except sqlite3.IntegrityError: - print("Benutzername existiert bereits!") - finally: - conn.close() - - -if __name__ == "__main__": - main() diff --git a/srv/tesm/create_db.py b/srv/tesm/create_db.py deleted file mode 100644 index e24517b..0000000 --- a/srv/tesm/create_db.py +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env python3 -"""Initialisiert die SQLite-Datenbank für TESM. - -WICHTIG: Wer hier eine neue Tabelle oder Spalte hinzufügt, muss auch die -Datei SCHEMA_VERSION (daneben, im selben Verzeichnis) um 1 erhöhen. -update.sh vergleicht diese Zahl zwischen der aktuell installierten und der -neuen Version, um zu entscheiden, ob ein Update in-place laufen darf -(bestehende Datenbank bleibt erhalten) oder eine frische Installation -nötig ist (Datenbank wird beiseitegesichert). Ein vergessenes Hochzählen -würde dazu führen, dass ein Host mit altem Schema faelschlich per -In-Place-Update aktualisiert wird, statt frisch installiert zu werden -- -_ensure_schema() faengt die meisten Faelle zwar zur Laufzeit ab, ist aber -kein Ersatz fuer eine korrekte Versionsnummer hier.""" -import os -import sqlite3 - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -DB_PATH = os.environ.get("TESM_DB_PATH", os.path.join(BASE_DIR, "sqlite.db")) - -conn = sqlite3.connect(DB_PATH) -c = conn.cursor() - -c.execute(""" -CREATE TABLE IF NOT EXISTS credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL, - category TEXT NOT NULL DEFAULT 'switch', - deleted_at TEXT -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS switches ( - hostname TEXT PRIMARY KEY, - ip TEXT NOT NULL, - ssh_port INTEGER, - credential_id INTEGER, - last_modified_by TEXT, - last_modified_at TEXT, - deleted_at TEXT, - FOREIGN KEY (credential_id) REFERENCES credentials(id) -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS devices ( - mac TEXT PRIMARY KEY, - ip TEXT NOT NULL, - switch_hostname TEXT, - port TEXT, - name TEXT NOT NULL, - is_active INTEGER DEFAULT 1, - last_modified_by TEXT, - last_modified_at TEXT, - credential_id INTEGER, - ssh_port INTEGER, - deleted_at TEXT, - FOREIGN KEY (switch_hostname) REFERENCES switches(hostname) -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL, - is_admin INTEGER DEFAULT 0, - first_name TEXT, - last_name TEXT, - avatar_filename TEXT, - auth_source TEXT NOT NULL DEFAULT 'local', - email TEXT, - is_locked INTEGER NOT NULL DEFAULT 0, - deleted_at TEXT -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS groups ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL, - is_default INTEGER DEFAULT 0, - is_system INTEGER DEFAULT 0, - deleted_at TEXT -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS group_permissions ( - group_id INTEGER NOT NULL, - permission TEXT NOT NULL, - PRIMARY KEY (group_id, permission), - FOREIGN KEY (group_id) REFERENCES groups(id) -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS user_groups ( - user_id INTEGER NOT NULL, - group_id INTEGER NOT NULL, - PRIMARY KEY (user_id, group_id), - FOREIGN KEY (user_id) REFERENCES users(id), - FOREIGN KEY (group_id) REFERENCES groups(id) -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS dhcp_option_defs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code INTEGER NOT NULL UNIQUE, - name TEXT NOT NULL UNIQUE, - type TEXT NOT NULL, - description TEXT, - is_standard INTEGER NOT NULL DEFAULT 0 -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS dhcp_option_values ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - option_def_id INTEGER NOT NULL, - device_mac TEXT NOT NULL DEFAULT '', - value TEXT NOT NULL, - FOREIGN KEY (option_def_id) REFERENCES dhcp_option_defs(id), - UNIQUE (option_def_id, device_mac) -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS dhcp_subnets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - interface TEXT NOT NULL, - range_start TEXT NOT NULL, - range_end TEXT NOT NULL, - gateway TEXT, - dns TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, - enabled INTEGER NOT NULL DEFAULT 1 -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS dhcp_manual_reservations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - mac TEXT NOT NULL UNIQUE, - ip TEXT NOT NULL, - name TEXT NOT NULL -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS service_accounts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - purpose TEXT UNIQUE NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS ldap_group_mappings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ad_group_dn TEXT UNIQUE NOT NULL, - ad_group_name TEXT NOT NULL, - app_group_id INTEGER NOT NULL, - FOREIGN KEY (app_group_id) REFERENCES groups(id) -); -""") - -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, - ts TEXT NOT NULL, - username TEXT NOT NULL, - action TEXT NOT NULL, - target TEXT, - details TEXT -); -""") - -c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5")) -c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("check_interval", "300")) - -cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",)) -if cur.rowcount > 0: - default_group_id = cur.lastrowid - c.executemany( - "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)", - [ - (default_group_id, "devices_group.view"), - (default_group_id, "devices.view"), - (default_group_id, "switches.view"), - (default_group_id, "logs_group.view"), - (default_group_id, "logs_live.view"), - ], - ) - -conn.commit() -conn.close() -print(f"Datenbank '{DB_PATH}' wurde initialisiert inklusive Settings.") diff --git a/srv/tesm/generate_ips.py b/srv/tesm/generate_ips.py deleted file mode 100644 index fc428f1..0000000 --- a/srv/tesm/generate_ips.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 -""" -Gibt pipe-getrennt die Zugangsdaten aller aktiven Geräte + ihrer Switche aus. -Wird von poe.sh (Bash) konsumiert, um Erreichbarkeit zu prüfen und im -Fehlerfall den PoE-Port neu zu starten. -""" -import sqlite3 -from app import decrypt_password, DB_PATH - - -def generate_ips_list(): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - - switches = { - row["hostname"]: row - for row in conn.execute(""" - SELECT switches.hostname, switches.ip, switches.ssh_port, - credentials.username AS username, credentials.password AS password - FROM switches - LEFT JOIN credentials ON credentials.id = switches.credential_id - """) - } - - devices = conn.execute(""" - SELECT mac, ip, port, name, switch_hostname - FROM devices - WHERE is_active=1 AND deleted_at IS NULL - """).fetchall() - conn.close() - - for dev in devices: - switch = switches.get(dev["switch_hostname"]) - if switch and switch["password"]: - switch_ip = switch["ip"] - switch_ssh_port = switch["ssh_port"] or 22 - switch_user = switch["username"] - switch_pass = decrypt_password(switch["password"]) - else: - switch_ip = switch["ip"] if switch else "" - switch_ssh_port = (switch["ssh_port"] if switch else None) or 22 - switch_user = "" - switch_pass = "" - - port = dev["port"] or "" - print( - f"{dev['ip']}|" - f"{dev['name']}|" - f"{switch_ip}|" - f"{switch_ssh_port}|" - f"{dev['switch_hostname'] or 'kein Switch'}|" - f"{port}|" - f"{switch_user}|" - f"{switch_pass}|" - f"{dev['mac']}" - ) - - -if __name__ == "__main__": - generate_ips_list() diff --git a/srv/tesm/licensing.py b/srv/tesm/licensing.py deleted file mode 100644 index b42933f..0000000 --- a/srv/tesm/licensing.py +++ /dev/null @@ -1,363 +0,0 @@ -""" -TESM Lizenzsystem — gemeinsame Kryptographie- und Protokoll-Bausteine für -Kundeninstanz (Client) UND Lizenzserver (Master). Beide Seiten importieren -exakt dieselbe Datei (auf dem Master 1:1 mitkopiert) — Signieren und -Verifizieren müssen bitidentisch funktionieren, jede Abweichung würde -Lizenzen der jeweils anderen Seite unlesbar machen. - -Vollständiges Design: siehe C:\\Users\\tim\\.claude\\plans\\toasty-twirling-hickey.md -(Phase 0). Kurzfassung der Schlüsselhierarchie: - -- Master-Signaturschlüssel (Ed25519): signiert Lizenzdateien selbst UND - jede Aktivierungs-/Deaktivierungs-/Heartbeat-Antwort. Privater Teil bleibt - ausschließlich auf dem Master, öffentlicher Teil steckt in jeder - ausgestellten Lizenzdatei (ermöglicht rein OFFLINE verifizierbare - Master-Antworten beim Kunden). -- Pro-Lizenz-Schlüsselpaar (Ed25519, einmalig je Lizenz erzeugt): der - private Teil reist in der Lizenzdatei zum Kunden und signiert dessen - Aktivierungs-/Deaktivierungs-/Heartbeat-*Anfragen*; der öffentliche Teil - bleibt beim Master in dessen Datenbank (ermöglicht dem Master, jede - Anfrage einer bestimmten Lizenz zweifelsfrei zuzuordnen, ohne die - ursprüngliche Aktivierung "live" gesehen haben zu müssen). - -Ed25519 statt RSA: kleine Schlüssel/Signaturen (wichtig, da Aktivierungs- -Anfragen/-Antworten für den Offline-Fall als von Hand kopierbarer Code -dargestellt werden), fest vorgegebene, sichere Parameter (keine -Padding-/Hash-Wahl wie bei RSA-PSS nötig). -""" -import base64 -import datetime -import hashlib -import json -import socket -import time -import uuid - -from cryptography.exceptions import InvalidSignature -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey - -LICENSE_TYPES = ("trial", "standard", "custom", "enterprise") -ALL_MODULES = ("dhcp", "fileshare", "maintenance") - -# Rolle der lizenzierten Instanz, eingebettet in JEDE ausgestellte -# Lizenzdatei (signiert, siehe issue_license) -- NICHT nur DB-seitige -# Buchfuehrung wie das gleichnamige, bislang ungenutzte licenses.role-Feld -# in der Master-DB vorher. Ermoeglicht einer lizenzierten Instanz, anhand -# ihrer EIGENEN, geladenen Lizenz strukturell zu erkennen, was sie darf: -# "master" -- die Wurzel-Instanz. Wird AUSSCHLIESSLICH lokal per -# create_master_license.py erzeugt, NIE ueber die -# normale Ticket-/Ausstellungs-UI -- das waere sonst -# eine Moeglichkeit, sich selbst zur Wurzel zu -# erklaeren. Einzige Rolle, die weitere -# "license_server"-Lizenzen ausstellen darf. -# "license_server" -- ein vom Master (oder einer anderen "master"-Instanz) -# ausgestellter Lizenzserver ("Sub-Lizenzserver"). -# Darf alles, was ein Lizenzserver koennen soll -# (Kunden/Tickets/Lizenzen fuer eigene TESM-Kunden -# ausstellen, Benutzer/Gruppen/Einstellungen -# verwalten) -- AUSSER selbst weitere -# "license_server"-Lizenzen ausstellen. Diese Sperre -# ist strukturell (haengt an der eigenen Rolle, nicht -# an einem vergebbaren Recht) und daher auch von einem -# lokalen Admin dieser Instanz nicht aufhebbar. -# "customer" -- eine gewoehnliche TESM-Kundeninstanz (Default, -# unveraendertes Verhalten). -LICENSE_ROLES = ("master", "license_server", "customer") - -GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden -EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige -HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab) - - -# ============================================================== Schlüssel == - -def generate_keypair(): - """Erzeugt ein neues Ed25519-Schlüsselpaar. Rückgabe: (private_b64, public_b64).""" - priv = Ed25519PrivateKey.generate() - return _encode_private_key(priv), _encode_public_key(priv.public_key()) - - -def _encode_private_key(priv: Ed25519PrivateKey) -> str: - return base64.urlsafe_b64encode(priv.private_bytes_raw()).decode("ascii") - - -def _encode_public_key(pub: Ed25519PublicKey) -> str: - return base64.urlsafe_b64encode(pub.public_bytes_raw()).decode("ascii") - - -def _decode_private_key(s: str) -> Ed25519PrivateKey: - return Ed25519PrivateKey.from_private_bytes(base64.urlsafe_b64decode(s.encode("ascii"))) - - -def _decode_public_key(s: str) -> Ed25519PublicKey: - return Ed25519PublicKey.from_public_bytes(base64.urlsafe_b64decode(s.encode("ascii"))) - - -# ===================================================== Signieren/Prüfen == - -def _canonical_bytes(payload: dict) -> bytes: - """Deterministische JSON-Kodierung (sortierte Keys, kompakte Trenner) -- - Voraussetzung dafür, dass Signieren und Verifizieren exakt dieselben - Bytes sehen, unabhängig von der Dict-Einfügereihenfolge.""" - return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") - - -def sign_payload(payload: dict, private_key_b64: str) -> str: - priv = _decode_private_key(private_key_b64) - return base64.urlsafe_b64encode(priv.sign(_canonical_bytes(payload))).decode("ascii") - - -def verify_payload(payload: dict, signature_b64: str, public_key_b64: str) -> bool: - """Gibt bewusst nur True/False zurück (nie eine Exception nach außen) -- - ein Aufrufer soll "ungültig" nie mit einem Absturz verwechseln können.""" - try: - pub = _decode_public_key(public_key_b64) - pub.verify(base64.urlsafe_b64decode(signature_b64.encode("ascii")), _canonical_bytes(payload)) - return True - except (InvalidSignature, ValueError, TypeError, KeyError): - return False - - -# ============================================================ Zeitformat == - -def _iso(ts: float) -> str: - return datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -def _parse_iso(s: str) -> float: - return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp() - - -# Sentinel-Ablaufdatum fuer "Lifetime"-Lizenzen. Bewusst ein fixes, weit in -# der Zukunft liegendes Datum statt eines Sonderwerts wie None/0 fuer -# expires_at -- dadurch bleibt JEDE bestehende Datumsvergleichs-/ -# Differenzrechnung (hier, im Master-Dashboard, UND im TESM-Client) ohne -# jede Sonderbehandlung korrekt: "in > 90 Tagen faellig" o.ae. ist fuer ein -# Datum im Jahr 2099 schlicht immer wahr. Ob eine Lizenz als "Lifetime" -# geflaggt ist, wird zusaetzlich explizit in der Master-DB gespeichert -# (licenses.lifetime) -- dieses Sentinel-Datum ist nur die tatsaechlich in -# die signierte Lizenzdatei eingebettete, fuer den Client sichtbare Reprae- -# sentation davon. -LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z" - - -# ================================================================ Lizenz == - -def issue_license(*, customer, license_type, modules, valid_days, - master_private_key_b64, master_public_key_b64, - master_endpoint, vendor, license_id=None, now=None, lifetime=False, - role="customer"): - """Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt - frischem Pro-Lizenz-Schlüsselpaar. - - lifetime=True ignoriert valid_days komplett und setzt stattdessen das - feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar. - - role (siehe LICENSE_ROLES): "customer" (Default, unverändertes - Verhalten) für gewöhnliche TESM-Kundeninstanzen, "license_server" für - einen vom Aufrufer ausgestellten Sub-Lizenzserver. role="master" darf - NUR von create_master_license.py verwendet werden (lokal, ohne diese - Funktion über die normale Ticket-UI aufzurufen) -- diese Funktion prüft - das nicht selbst, das Verbot ist stattdessen dadurch durchgesetzt, dass - die App-seitige Ausstellungsfunktion role="master" gar nicht erst als - Option anbietet (siehe app.py can_issue_license_server_licenses()). - - Rückgabe: (license_file, license_pubkey) -- license_file ist die - komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN - Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank - bestimmt (Verifikation künftiger Aktivierungs-/Heartbeat-Anfragen - dieser Lizenz) und wird nicht an den Kunden weitergegeben.""" - if license_type not in LICENSE_TYPES: - raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})") - if role not in LICENSE_ROLES: - raise ValueError(f"Unbekannte Rolle: {role!r} (erlaubt: {LICENSE_ROLES})") - unknown = set(modules) - set(ALL_MODULES) - if unknown: - raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})") - - license_priv, license_pub = generate_keypair() - now = now if now is not None else time.time() - payload = { - "license_id": license_id or str(uuid.uuid4()), - "customer": customer, - "type": license_type, - "modules": sorted(modules), - "issued_at": _iso(now), - "expires_at": LIFETIME_EXPIRES_AT if lifetime else _iso(now + valid_days * 86400), - "license_pubkey": license_pub, - "master_pubkey": master_public_key_b64, - "master_endpoint": master_endpoint, - "vendor": vendor, - "role": role, - } - signature = sign_payload(payload, master_private_key_b64) - license_file = {**payload, "license_privkey": license_priv, "signature": signature} - return license_file, license_pub - - -def verify_license_file(license_file: dict) -> bool: - """Prüft die Master-Signatur über die Lizenz-Nutzdaten. license_privkey - und signature selbst sind nicht Teil der signierten Nutzlast (die - Signatur wurde ja gerade über den Rest gebildet, siehe issue_license).""" - payload = {k: v for k, v in license_file.items() if k not in ("license_privkey", "signature")} - return verify_payload(payload, license_file.get("signature", ""), license_file.get("master_pubkey", "")) - - -def license_status(license_file: dict, now=None) -> dict: - """Berechnet den aktuellen Anzeige-/Gate-Status einer (bereits als - signaturgültig geprüften!) Lizenz -- ruft NICHT selbst verify_license_file - auf, das bleibt bewusst Sache des Aufrufers, damit hier niemand versehentlich - den Status einer manipulierten Datei berechnet, ohne die Prüfung - überhaupt gemacht zu haben.""" - now = now if now is not None else time.time() - expires_at = _parse_iso(license_file["expires_at"]) - days_left = (expires_at - now) / 86400 - expired = days_left < 0 - days_since_expiry = -days_left if expired else 0.0 - grace_active = expired and days_since_expiry <= GRACE_PERIOD_DAYS - modules_active = (not expired) or grace_active - - return { - "expired": expired, - "days_left": days_left, - "days_since_expiry": days_since_expiry, - "expiring_soon": (not expired) and days_left <= EXPIRY_WARNING_DAYS, - "grace_active": grace_active, - "modules_active": modules_active, - "modules": set(license_file.get("modules", [])) if modules_active else set(), - "type": license_file.get("type"), - "customer": license_file.get("customer"), - "expires_at": license_file.get("expires_at"), - # .get(..., "customer"): aeltere, vor Einfuehrung von role signierte - # Lizenzdateien haben dieses Feld noch nicht -- fehlend bedeutet - # unveraendert "gewoehnliche Kundeninstanz", nie "master"/ - # "license_server" (sonst koennte eine alte Datei rueckwirkend - # unbeabsichtigt privilegiert erscheinen). - "role": license_file.get("role", "customer"), - } - - -def heartbeat_stale_warning(last_heartbeat_ts, now=None) -> bool: - """True, wenn der letzte erfolgreiche Heartbeat HEARTBEAT_WARNING_DAYS - oder länger zurückliegt. Rein informativ (siehe Moduldocstring) -- ein - fehlender/alter Heartbeat schaltet nie ein Modul ab, nur das - eingebettete Ablaufdatum selbst zählt dafür (siehe license_status).""" - if last_heartbeat_ts is None: - return False # noch nie verbunden gewesen ist der normale Ausgangszustand, kein Ausfall - now = now if now is not None else time.time() - return (now - last_heartbeat_ts) / 86400 >= HEARTBEAT_WARNING_DAYS - - -# ========================================== Fingerprint (System-Bindung) == - -def system_fingerprint(): - """Stabiler Identifikator dieses Hosts: /etc/machine-id (von systemd bei - der Erstinstallation einmalig erzeugt, übersteht Reboots und normale - Updates) kombiniert mit dem Hostnamen, gehasht damit die rohe - machine-id nie im Klartext übertragen/gespeichert wird. Unter - Windows/ohne /etc/machine-id fällt dies auf den Hostnamen allein - zurück (nur für lokale Tests relevant, Produktivsysteme sind Linux).""" - machine_id = "" - try: - with open("/etc/machine-id", "r", encoding="utf-8") as f: - machine_id = f.read().strip() - except OSError: - pass - raw = f"{machine_id}:{socket.gethostname()}".encode("utf-8") - return hashlib.sha256(raw).hexdigest() - - -# ============================ Aktivierung / Deaktivierung / Heartbeat ===== -# Ein Protokoll, zwei Transportwege: online automatisch per HTTPS-API, -# offline exakt dasselbe Anfrage/Antwort-Paar als von Hand kopierbarer Code -# (encode_code/decode_code) -- der Master ist dadurch in BEIDEN Fällen die -# einzige Quelle der Wahrheit dafür, an welches System eine Lizenz gerade -# gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam -# verhindert". - -def build_client_request(action, license_file, nonce=None, report=None) -> dict: - """Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action: - "activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für - einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es - zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt. - - report: NUR für Heartbeats einer role="license_server"-Lizenz gedacht - (siehe Lizenzserver-app.py _build_own_issuance_report()) -- ein - optionaler Schnappschuss der eigenen Kunden/Tickets/Lizenzen dieses - Sub-Lizenzservers, den der Master beim Empfang in seine - reported_sub_*-Tabellen übernimmt, um einen Gesamtüberblick zu haben. - Bewusst Teil DIESER generischen, gemeinsam genutzten Funktion (nicht - ein eigenes Protokoll-Feature) -- ein normaler TESM-Client/eine - "customer"-Lizenz übergibt hier einfach nie einen Wert, das Feld fehlt - dann in der Payload wie bisher (keine Verhaltensänderung für alle - bestehenden Aufrufer). Wie jedes andere Feld wird auch report - mitsigniert -- ein Angreifer ohne den privaten Lizenzschlüssel kann - keine gefälschten Berichte unterschieben.""" - if action not in ("activate", "deactivate", "heartbeat"): - raise ValueError(f"Unbekannte Aktion: {action!r}") - payload = { - "license_id": license_file["license_id"], - "fingerprint": system_fingerprint(), - # Rein informativ fuer die Dashboard-Kacheln des Masters (siehe - # dortiges index()) -- fliesst NICHT in irgendeine Sicherheits- - # entscheidung ein (die bleibt allein Sache des Fingerprints), wird - # aber wie alle anderen Felder mitsigniert, damit ein Angreifer - # ohne den privaten Lizenzschluessel keinen falschen Hostnamen - # unterschieben kann. - "hostname": socket.gethostname(), - "action": action, - "nonce": nonce or uuid.uuid4().hex, - "timestamp": _iso(time.time()), - } - if report is not None: - payload["report"] = report - signature = sign_payload(payload, license_file["license_privkey"]) - return {**payload, "signature": signature} - - -def verify_client_request(request: dict, license_public_key_b64: str) -> bool: - """Vom MASTER aufgerufen: prüft eine Client-Anfrage gegen den bei - Ausstellung dieser Lizenz in der Master-DB gespeicherten Public-Key.""" - payload = {k: v for k, v in request.items() if k != "signature"} - return verify_payload(payload, request.get("signature", ""), license_public_key_b64) - - -def build_master_response(action, license_id, fingerprint, master_private_key_b64, - status="ok", license_update=None, now=None) -> dict: - """Vom MASTER aufgerufen: erzeugt eine signierte Antwort/Bestätigung - (z.B. "activated", "deactivated", oder das Ergebnis eines Heartbeats). - license_update: optional eine komplette, neu signierte Lizenzdatei - (z.B. nach nachträglicher Modul-Änderung durch den Admin) -- der Client - übernimmt sie nur nach eigener, erfolgreicher Signaturprüfung.""" - payload = { - "license_id": license_id, - "fingerprint": fingerprint, - "action": action, - "status": status, - "timestamp": _iso(now if now is not None else time.time()), - } - if license_update is not None: - payload["license_update"] = license_update - signature = sign_payload(payload, master_private_key_b64) - return {**payload, "signature": signature} - - -def verify_master_response(response: dict, master_public_key_b64: str) -> bool: - """Vom CLIENT aufgerufen: prüft eine Master-Antwort komplett OFFLINE - gegen den in der eigenen Lizenzdatei eingebetteten master_pubkey.""" - payload = {k: v for k, v in response.items() if k != "signature"} - return verify_payload(payload, response.get("signature", ""), master_public_key_b64) - - -# ==================================================== Codes für Offline == - -def encode_code(data: dict) -> str: - """Kodiert ein Anfrage-/Antwort-dict als kompakten, per Copy-Paste - übertragbaren Code (Base64 einer kanonischen JSON-Darstellung, keine - Zeilenumbrüche/Sonderzeichen, damit Kopieren aus/in ein Textfeld nichts - kaputt macht).""" - return base64.urlsafe_b64encode(_canonical_bytes(data)).decode("ascii") - - -def decode_code(code: str) -> dict: - return json.loads(base64.urlsafe_b64decode(code.strip().encode("ascii")).decode("utf-8")) diff --git a/srv/tesm/motd_banner.py b/srv/tesm/motd_banner.py deleted file mode 100644 index 6c4939b..0000000 --- a/srv/tesm/motd_banner.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -"""Live-Flotten-Banner für die SSH-Login-Message (MOTD). - -Liest denselben Live-Status wie das Web-Dashboard, direkt aus den -gleichen Quellen (siehe app.py): - - Online/Offline: get_device_status() parst dieselbe Check-Log-Datei - (live.log) wie das Dashboard, statt selbst zu pingen -- ein Login - soll niemals eine eigene Prüfung anstoßen. - - DHCP: _detect_dhcp_status()/_dhcp_read_leases() lesen Dienststatus - bzw. Kea-Lease-CSV direkt, kein eigener Zustand. - -Eingebunden über /etc/update-motd.d/50-tesm, das dieses Skript per -venv/bin/python3 aufruft -- app.py importiert Flask u.a., die nur in -der venv installiert sind (dasselbe Muster wie generate_ips.py, das -poe.sh auf demselben Weg konsumiert). - -Bewusst NICHT enthalten: PXE-Status. Anders als überwachen/booten/ -DHCP ist PXE (noch) keine echte, mit Daten hinterlegte Funktion der -App (bislang nur Teil der Tagline) -- ein erfundener "PXE: idle"-Wert -wäre keine Live-Daten, sondern eine Behauptung. -""" -from app import _detect_dhcp_status, _dhcp_read_leases, get_db_connection, get_device_status - -AMBER = "\033[38;2;226;166;60m" -DIM = "\033[2m" -BOLD = "\033[1m" -RESET = "\033[0m" - - -def _fleet_line(amber, dim, bold, reset): - try: - conn = get_db_connection() - devices = conn.execute( - "SELECT mac, name, is_active FROM devices WHERE deleted_at IS NULL" - ).fetchall() - conn.close() - status, _, _, _ = get_device_status(devices) - active = [d for d in devices if d["is_active"]] - online = sum(1 for d in active if status.get(d["mac"]) == "online") - down = len(active) - online - return f"{bold}{online}{reset} Clients online · {bold}{down}{reset} down" - except Exception: - return f"{dim}Geräte-Status nicht verfügbar{reset}" - - -def _dhcp_line(): - try: - dhcp = _detect_dhcp_status() - if not dhcp["installed"]: - return "nicht installiert" - if not dhcp["active"]: - return "installiert, gestoppt" - leases = _dhcp_read_leases() - return f"{len(leases)} Leases aktiv" if leases is not None else "läuft, keine Lease-Datei" - except Exception: - return "Status nicht verfügbar" - - -def main(): - # KEIN sys.stdout.isatty()-Check: dieses Skript schreibt beim Login - # nicht direkt auf ein Terminal, sondern über run-parts in - # /run/motd.dynamic (eine normale Datei -- isatty() wäre hier immer - # False). Der eigentliche Betrachter ist trotzdem ein echtes Terminal - # (die SSH-Session, der PAM den Inhalt danach ausgibt), daher immer - # Farbe ausgeben. - amber, dim, bold, reset = AMBER, DIM, BOLD, RESET - - print(f"{bold}{amber}TESM{reset} · TimEShepManager") - print(f"{dim}überwachen · booten · anbinden (DHCP) · Betriebssystem geben (PXE){reset}") - print() - print(f"{_fleet_line(amber, dim, bold, reset)} · DHCP: {_dhcp_line()}") - print() - - -if __name__ == "__main__": - try: - main() - except Exception: - # Ein MOTD-Banner darf einen SSH-Login niemals stören -- lieber - # gar keine Ausgabe als ein Traceback vor dem Shell-Prompt. - pass diff --git a/srv/tesm/poe_wrapper.py b/srv/tesm/poe_wrapper.py deleted file mode 100644 index 85a42af..0000000 --- a/srv/tesm/poe_wrapper.py +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env python3 -"""Startet poe.sh innerhalb der venv des PoE Managers (z.B. für Cron/Timer).""" -import os -import subprocess - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -POE_SCRIPT = os.environ.get("POE_SCRIPT", "/usr/local/bin/custom/poe.sh") - -subprocess.run( - ["/bin/bash", POE_SCRIPT], - env={"PATH": os.path.join(BASE_DIR, "venv", "bin") + ":" + os.environ.get("PATH", "")}, -) diff --git a/srv/tesm/requirements.txt b/srv/tesm/requirements.txt deleted file mode 100644 index ccf1ecf..0000000 --- a/srv/tesm/requirements.txt +++ /dev/null @@ -1,46 +0,0 @@ -bcrypt==5.0.0 -blinker==1.9.0 -cffi==2.0.0 -click==8.3.0 -cryptography==46.0.1 -Flask==3.1.2 -Flask-Bcrypt==1.0.1 -Flask-Login==0.6.3 -Flask-Sock==0.7.0 -gunicorn==23.0.0 -itsdangerous==2.2.0 -Jinja2==3.1.6 -ldap3==2.9.1 -MarkupSafe==3.0.2 -paramiko==3.5.1 -pyasn1==0.6.4 -pycparser==2.23 -PyNaCl==1.5.0 -PyYAML==6.0.2 -simple-websocket==1.1.0 -Werkzeug==3.1.3 - -# Hinweis: Das Browser-SSH-Terminal (Switch-Verbindungstest) nutzt paramiko -# (reines Python) statt eines lokalen pty + System-ssh-Client und läuft -# damit plattformunabhängig — auch unter Windows. Die SSH-Verbindung wird -# dabei immer serverseitig (vom PoE-Server) aufgebaut, nie vom Browser aus. -# -# PyYAML wird für das netplan-Backend der Host-Netzwerkeinstellungen -# gebraucht (Systemeinstellungen → Netzwerkeinstellungen) — schreibt/liest -# die eigene Override-Datei unter /etc/netplan/. -# -# ldap3 (reines Python, keine System-Bibliothek wie libldap nötig) für die -# optionale Active-Directory/LDAP-Anmeldung (Systemeinstellungen → LDAP) — -# Search+Bind gegen einen AD-Domain-Controller oder generischen LDAP-Server. -# -# gunicorn: produktiver WSGI-Server für tesm.service (siehe dort) statt -# Flasks eigenem app.run()-Entwicklungsserver ("WARNING: This is a -# development server..."). Läuft bewusst mit genau EINEM Worker-Prozess, -# aber mehreren Threads (--worker-class gthread) -- mehrere Prozesse -# würden den In-Memory-Zustand für laufende Wartungs-Jobs/Import- -# Vorschauen (_maintenance_jobs/_pending_imports/...) auf mehrere, -# voneinander isolierte Prozesse verteilen, wodurch eine Anfrage, die vom -# jeweils ANDEREN Worker beantwortet wird, diesen Zustand nie sehen würde. -# Mehrere Threads (statt eines async-Workers) sind nötig, weil das -# Browser-SSH-Terminal (flask-sock) pro offener WebSocket-Verbindung einen -# blockierenden Thread belegt. diff --git a/srv/tesm/static/css/style.css b/srv/tesm/static/css/style.css deleted file mode 100644 index 2284d91..0000000 --- a/srv/tesm/static/css/style.css +++ /dev/null @@ -1,1568 +0,0 @@ -/* ========================================================================== - PoE Manager — Design System - ========================================================================== */ - -:root { - --bg: #0e1015; - --bg-elevated: #14171e; - --bg-card: #1a1e27; - --bg-card-hover: #20242f; - --bg-input: #12151b; - --border: #2a2f3b; - --border-soft: #21252f; - --text: #eef0f4; - --text-dim: #9aa2b4; - --text-faint: #656d80; - /* #ff7100 = exakte Farbe aus dem WiS-Logo (static/images/logo.png, - per Pixel-Sampling ermittelt) — Buttons/Akzente sollen genau dieses - Orange treffen statt einer nur ähnlichen, frei gewählten Nuance. */ - --accent: #ff7100; - --accent-strong: #ff8d33; - --accent-dim: rgba(255, 113, 0, 0.14); - --accent-ring: rgba(255, 113, 0, 0.35); - --success: #2fd07a; - --success-dim: rgba(47, 208, 122, 0.14); - --danger: #f0475c; - --danger-dim: rgba(240, 71, 92, 0.14); - --warning: #f5a623; - --warning-dim: rgba(245, 166, 35, 0.14); - --muted: #6b7280; - --muted-dim: rgba(107, 114, 128, 0.16); - --radius-sm: 8px; - --radius: 14px; - --radius-lg: 20px; - --shadow: 0 10px 30px -12px rgba(0, 0, 0, 0.55); - --shadow-sm: 0 4px 14px -6px rgba(0, 0, 0, 0.4); - --sidebar-w: 244px; - --topbar-h: 64px; - --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, "Helvetica Neue", Arial, sans-serif; - --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; - color-scheme: dark; -} - -[data-theme="light"] { - --bg: #f3f4f7; - --bg-elevated: #ffffff; - --bg-card: #ffffff; - --bg-card-hover: #f8f8fb; - --bg-input: #ffffff; - --border: #e3e5ec; - --border-soft: #ebedf2; - --text: #1a1d24; - --text-dim: #565d6d; - --text-faint: #8a90a0; - --accent-dim: rgba(255, 113, 0, 0.1); - --muted-dim: rgba(107, 114, 128, 0.12); - --shadow: 0 10px 30px -14px rgba(20, 20, 30, 0.18); - --shadow-sm: 0 4px 14px -8px rgba(20, 20, 30, 0.14); - color-scheme: light; -} - -* { box-sizing: border-box; } - -html, body { - height: 100%; -} - -body { - margin: 0; - background: var(--bg); - color: var(--text); - font-family: var(--font); - font-size: 14.5px; - line-height: 1.5; - -webkit-font-smoothing: antialiased; -} - -a { color: inherit; text-decoration: none; } - -h1, h2, h3, h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; } - -p { margin: 0; } - -button { font-family: inherit; } - -::selection { background: var(--accent-dim); } - -/* Scrollbars */ -::-webkit-scrollbar { width: 10px; height: 10px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 8px; border: 2px solid transparent; background-clip: content-box; } -::-webkit-scrollbar-thumb:hover { background: var(--text-faint); background-clip: content-box; } - -/* ========================================================================== - App shell - ========================================================================== */ - -.app-shell { - display: flex; - min-height: 100vh; -} - -/* ---- Sidebar ---- */ - -.sidebar { - width: var(--sidebar-w); - flex-shrink: 0; - background: var(--bg-elevated); - border-right: 1px solid var(--border-soft); - display: flex; - flex-direction: column; - position: fixed; - top: 0; bottom: 0; left: 0; - z-index: 40; - transition: transform 0.25s ease; -} - -.sidebar-brand { - display: flex; - align-items: center; - gap: 10px; - padding: 22px 20px 16px; -} - -.sidebar-brand .brand-name { - font-weight: 700; - font-size: 15px; - color: var(--text); - letter-spacing: -0.01em; -} -.sidebar-brand .brand-sub { - display: block; - font-size: 11px; - color: var(--text-faint); - font-weight: 500; -} - -.sidebar-nav { - flex: 1; - padding: 8px 12px; - display: flex; - flex-direction: column; - gap: 2px; - overflow-y: auto; -} - -.nav-item { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 12px; - border-radius: var(--radius-sm); - color: var(--text-dim); - font-weight: 550; - font-size: 13.5px; - transition: background 0.15s ease, color 0.15s ease; -} - -.nav-item svg { width: 18px; height: 18px; flex-shrink: 0; opacity: 0.85; } - -.nav-item:hover { background: var(--bg-card-hover); color: var(--text); } - -.nav-item.active { - background: var(--accent-dim); - color: var(--accent-strong); -} -.nav-item.active svg { opacity: 1; } - -/* Nav-Gruppe mit Untermenü (z.B. "Logs" -> Live-Log / Änderungslog) */ -.nav-group-toggle { - display: flex; - align-items: center; - gap: 12px; - width: 100%; - padding: 10px 12px; - border: none; - background: none; - color: var(--text-dim); - font-weight: 550; - font-size: 13.5px; - font-family: inherit; - border-radius: var(--radius-sm); - cursor: pointer; - transition: background 0.15s ease, color 0.15s ease; -} -.nav-group-toggle svg { width: 18px; height: 18px; flex-shrink: 0; opacity: 0.85; } -.nav-group-toggle:hover { background: var(--bg-card-hover); color: var(--text); } -.nav-group-toggle .nav-chevron { margin-left: auto; width: 14px; height: 14px; transition: transform 0.15s ease; opacity: 0.6; } -.nav-group.expanded .nav-group-toggle .nav-chevron { transform: rotate(90deg); } -.nav-group.active-group .nav-group-toggle { color: var(--accent-strong); } -.nav-group-children { - display: none; - flex-direction: column; - gap: 2px; - padding-left: 30px; - margin-top: 2px; -} -.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). 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(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 { - list-style: none; - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 16px; -} -.nav-order-list li { - padding: 8px 12px; - background: var(--bg-card); - border: 1px solid var(--border-soft); - border-radius: var(--radius-sm); - font-size: 13.5px; - font-weight: 550; -} -.nav-order-row { display: flex; align-items: center; justify-content: space-between; } -.nav-order-list .nav-order-actions { display: flex; gap: 4px; } -.nav-order-list .icon-btn { width: 28px; height: 28px; } -/* Unterpunkte je Gruppe — eingerückt, innerhalb ihrer Gruppe umsortierbar - (moveNavItem() bewegt nur innerhalb der jeweiligen
    , mischt also nie - Unterpunkte verschiedener Gruppen). */ -.nav-order-sublist { - list-style: none; - display: flex; - flex-direction: column; - gap: 6px; - margin: 10px 0 2px 22px; -} -.nav-order-sublist li { font-weight: 500; font-size: 12.5px; background: var(--bg-elevated); } - -.sidebar-footer { - padding: 14px 12px 18px; - border-top: 1px solid var(--border-soft); - display: flex; - flex-direction: column; - gap: 8px; -} - -.user-chip { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - border-radius: var(--radius-sm); -} - -.user-avatar { - width: 30px; height: 30px; - border-radius: 50%; - background: linear-gradient(135deg, var(--accent), #ff4d6d); - display: flex; align-items: center; justify-content: center; - color: #fff; font-weight: 700; font-size: 12.5px; - flex-shrink: 0; -} - -.user-meta { flex: 1; min-width: 0; overflow: hidden; } -.user-chip .icon-btn { flex-shrink: 0; width: 30px; height: 30px; } -.user-chip .icon-btn svg { width: 15px; height: 15px; } -.user-meta .u-name { font-size: 13px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.user-meta .u-role { font-size: 11px; color: var(--text-faint); } - -.footer-actions { display: flex; gap: 6px; } -.footer-actions .icon-btn { flex: 1; } - -.sidebar-toggle { - display: none; -} - -.sidebar-backdrop { - display: none; - position: fixed; inset: 0; - background: rgba(0,0,0,0.5); - z-index: 39; -} - -/* ---- Main area ---- */ - -.main { - margin-left: var(--sidebar-w); - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; -} - -.main.no-sidebar { margin-left: 0; } - -.topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 10px 28px; - min-height: var(--topbar-h); - position: sticky; - top: 0; - z-index: 20; - background: color-mix(in srgb, var(--bg) 82%, transparent); - backdrop-filter: blur(10px); - border-bottom: 1px solid var(--border-soft); -} - -.topbar-left { display: flex; align-items: center; gap: 14px; min-width: 0; flex: 1; z-index: 1; } -.topbar-title { font-size: 19px; font-weight: 700; letter-spacing: -0.015em; } -.topbar-sub { color: var(--text-faint); font-size: 12.5px; margin-top: 2px; } - -.topbar-right { display: flex; align-items: center; gap: 10px; flex: 1; justify-content: flex-end; z-index: 1; } - -/* Logo mittig über die volle Höhe der Topbar, unabhängig vom Padding — - passt sich per height:100% der Balkenhöhe an, egal wie breit es dabei - wird. pointer-events:none, damit es Klicks auf Titel/Buttons nicht - blockiert, falls es auf schmalen Screens überlappt. */ -.topbar-logo { - position: absolute; - left: 50%; - top: 0; bottom: 0; - transform: translateX(-50%); - display: flex; - align-items: center; - padding: 9px 0; - pointer-events: none; -} -.topbar-logo img { - height: 100%; - width: auto; - display: block; -} - -.hamburger { - display: inline-flex; - width: 36px; height: 36px; - align-items: center; justify-content: center; - border-radius: var(--radius-sm); - border: 1px solid var(--border); - background: var(--bg-card); - color: var(--text); - cursor: pointer; - flex-shrink: 0; -} - -/* Desktop: dauerhaftes Einklappen der Sidebar (nicht die mobile - Off-Canvas-Logik unten, die bleibt für schmale Screens bestehen). */ -@media (min-width: 901px) { - .sidebar.collapsed { transform: translateX(-100%); } - .main.sidebar-collapsed { margin-left: 0; } -} - -.content { - padding: 24px 28px 48px; - width: 100%; -} - -/* ========================================================================== - Buttons - ========================================================================== */ - -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 9px 16px; - font-size: 13.5px; - font-weight: 600; - border-radius: 10px; - border: 1px solid transparent; - cursor: pointer; - transition: transform 0.06s ease, background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; - white-space: nowrap; - user-select: none; -} -.btn:active { transform: translateY(1px); } -.btn svg { width: 15px; height: 15px; } - -.btn-primary { - background: var(--accent); - color: #1a0e03; - box-shadow: 0 6px 18px -6px rgba(255, 113, 0, 0.55); -} -.btn-primary:hover { background: var(--accent-strong); } - -.btn-secondary { - background: var(--bg-card); - color: var(--text); - border-color: var(--border); -} -.btn-secondary:hover { background: var(--bg-card-hover); border-color: var(--text-faint); } - -.btn-success { background: var(--success); color: #06210f; } -.btn-success:hover { filter: brightness(1.08); } - -.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger-dim); } -.btn-danger:hover { background: var(--danger-dim); } - -.btn-sm { padding: 6px 12px; font-size: 12.5px; border-radius: 8px; } -.btn-block { width: 100%; } -.btn:disabled { - opacity: 0.65; - cursor: not-allowed; - background: var(--bg-card-hover) !important; - color: var(--text-dim) !important; - border-color: var(--border) !important; - box-shadow: none !important; -} - -.icon-btn { - display: inline-flex; align-items: center; justify-content: center; - width: 34px; height: 34px; - border-radius: 9px; - border: 1px solid var(--border); - background: var(--bg-card); - color: var(--text-dim); - cursor: pointer; -} -.icon-btn:hover { color: var(--text); border-color: var(--text-faint); } -.icon-btn svg { width: 16px; height: 16px; } - -/* ========================================================================== - Cards / surfaces - ========================================================================== */ - -.card { - background: var(--bg-card); - border: 1px solid var(--border-soft); - border-radius: var(--radius); - box-shadow: var(--shadow-sm); -} - -.card-pad { padding: 20px 22px; } - -.notice-banner { - padding: 12px 16px; - border-radius: var(--radius); - font-size: 12.5px; - line-height: 1.5; -} -.notice-banner--warning { - background: var(--warning-dim); - color: var(--warning); - border: 1px solid var(--warning); -} - -.section-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 18px; -} -/* Beschreibungstext darf mehrzeilig umbrechen, ohne dass der Button - (z.B. "+ Neue Zugangsdaten") mit in die nächste Zeile rutscht — der - bleibt immer rechts oben in derselben Zeile. */ -.section-head > div:first-child { flex: 1 1 auto; min-width: 0; } -.section-head > .btn { flex-shrink: 0; } - -.section-head h2 { font-size: 20px; } -.section-head .hint { color: var(--text-faint); font-size: 12.5px; margin-top: 3px; } - -.stat-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 12px; - margin-bottom: 22px; -} - -.stat-card { - background: var(--bg-card); - border: 1px solid var(--border-soft); - border-radius: var(--radius); - padding: 16px 18px; - cursor: pointer; - user-select: none; - transition: border-color 0.12s ease, background 0.12s ease; -} -.stat-card:hover { background: var(--bg-card-hover); } -.stat-card.active { border-color: var(--accent); background: var(--accent-dim); } -.stat-card .stat-label { font-size: 12px; color: var(--text-faint); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } -.stat-card .stat-value { font-size: 26px; font-weight: 700; margin-top: 6px; letter-spacing: -0.02em; } -.stat-card .stat-value.online { color: var(--success); } -.stat-card .stat-value.offline { color: var(--danger); } -.stat-card .stat-value.disabled { color: var(--muted); } - -/* ========================================================================== - Badges / pills - ========================================================================== */ - -.pill { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border-radius: 999px; - font-size: 12px; - font-weight: 650; - white-space: nowrap; -} -.pill svg { width: 12px; height: 12px; } -.pill::before { - content: ""; - width: 6px; height: 6px; - border-radius: 50%; - background: currentColor; - flex-shrink: 0; -} -.pill.online { background: var(--success-dim); color: var(--success); } -.pill.offline { background: var(--danger-dim); color: var(--danger); } -.pill.unknown { background: var(--warning-dim); color: var(--warning); } -.pill.disabled { background: var(--muted-dim); color: var(--muted); } -.pill.admin { background: var(--accent-dim); color: var(--accent-strong); } -.pill.user { background: var(--muted-dim); color: var(--text-dim); } -.pill.action-pill { background: var(--muted-dim); color: var(--text-dim); } -.pill.action-pill::before { display: none; } -.pill.action-pill 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; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - border-radius: 50%; - object-fit: cover; - background: var(--muted-dim); - color: var(--text-dim); - font-size: 11.5px; - font-weight: 700; - flex-shrink: 0; -} -.avatar-placeholder { text-transform: uppercase; } - -.timer-pill { - display: inline-flex; - align-items: center; - gap: 7px; - padding: 6px 13px; - border-radius: 999px; - background: var(--bg-card); - border: 1px solid var(--border); - font-size: 12.5px; - font-weight: 600; - color: var(--text-dim); -} -.timer-pill .dot { - width: 7px; height: 7px; border-radius: 50%; - background: var(--success); - box-shadow: 0 0 0 0 var(--success-dim); - animation: pulse-dot 2s infinite; -} -@keyframes pulse-dot { - 0% { box-shadow: 0 0 0 0 rgba(47,208,122,0.5); } - 70% { box-shadow: 0 0 0 6px rgba(47,208,122,0); } - 100% { box-shadow: 0 0 0 0 rgba(47,208,122,0); } -} -/* Lizenz-Topbar-Pill: pulse-dot ist fest auf Grün (Erfolg) verdrahtet -- - eigene Varianten für die rot/orange blinkenden Lizenz-Zustände, sonst - würde die Halo-Animation weiter grün pulsieren, egal welche - Hintergrundfarbe der Punkt selbst per Inline-Style bekommt. */ -.timer-pill .dot.dot--blink-danger { animation: pulse-dot-danger 1.4s infinite; } -.timer-pill .dot.dot--blink-warning { animation: pulse-dot-warning 1.4s infinite; } -@keyframes pulse-dot-danger { - 0% { box-shadow: 0 0 0 0 rgba(240,71,92,0.5); } - 70% { box-shadow: 0 0 0 6px rgba(240,71,92,0); } - 100% { box-shadow: 0 0 0 0 rgba(240,71,92,0); } -} -@keyframes pulse-dot-warning { - 0% { box-shadow: 0 0 0 0 rgba(245,166,35,0.5); } - 70% { box-shadow: 0 0 0 6px rgba(245,166,35,0); } - 100% { box-shadow: 0 0 0 0 rgba(245,166,35,0); } -} - -.timer-pill-refresh { - display: inline-flex; align-items: center; justify-content: center; - width: 18px; height: 18px; - margin-left: 2px; - padding: 0; - border: none; - background: none; - color: var(--text-faint); - cursor: pointer; - border-radius: 50%; -} -.timer-pill-refresh svg { width: 12px; height: 12px; } -.timer-pill-refresh:hover { color: var(--accent-strong); background: var(--accent-dim); } -.timer-pill-refresh.spinning svg { animation: spin 0.8s linear infinite; } -@keyframes spin { to { transform: rotate(360deg); } } - -/* ========================================================================== - Device grid (dashboard) - ========================================================================== */ - -/* Bootstrap-artiges Raster: feste Spaltenzahl je Breakpoint statt frei - fließendem auto-fill — dadurch maximal 6 Kacheln nebeneinander auf - breiten Screens, dafür beliebig viele Zeilen untereinander. */ -.device-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 14px; -} -@media (min-width: 480px) { .device-grid { grid-template-columns: repeat(3, 1fr); } } -@media (min-width: 720px) { .device-grid { grid-template-columns: repeat(4, 1fr); } } -@media (min-width: 960px) { .device-grid { grid-template-columns: repeat(5, 1fr); } } -@media (min-width: 1200px) { .device-grid { grid-template-columns: repeat(6, 1fr); } } -@media (min-width: 1600px) { .device-grid { grid-template-columns: repeat(8, 1fr); } } -@media (min-width: 2000px) { .device-grid { grid-template-columns: repeat(10, 1fr); } } - -.dash-section { margin-bottom: 26px; } -.dash-section-title { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - font-weight: 650; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.04em; - margin-bottom: 12px; - cursor: pointer; - user-select: none; - width: fit-content; -} -.dash-section-title:hover { color: var(--text); } -.dash-section-title .chevron { - width: 14px; height: 14px; - transition: transform 0.15s ease; - color: var(--text-faint); -} -.dash-section.collapsed .dash-section-title .chevron { transform: rotate(-90deg); } -.dash-section.collapsed .device-grid { display: none; } - -.device-card { - position: relative; - background: var(--bg-card); - border: 1px solid var(--border-soft); - border-radius: var(--radius); - padding: 16px 16px 14px; - cursor: pointer; - transition: border-color 0.15s ease, transform 0.1s ease, background 0.15s ease; - overflow: hidden; -} -.device-card:hover { border-color: var(--text-faint); background: var(--bg-card-hover); transform: translateY(-2px); } -.device-card:active { transform: translateY(0); } -.device-card.is-disabled { cursor: default; opacity: 0.65; } -.device-card.is-disabled:hover { transform: none; border-color: var(--border-soft); background: var(--bg-card); } -.device-card.is-readonly { cursor: default; } -.device-card.is-readonly:hover { transform: none; border-color: var(--border-soft); background: var(--bg-card); } - -.device-card::before { - content: ""; - position: absolute; - left: 0; top: 0; bottom: 0; - width: 3px; - background: var(--muted); -} -.device-card[data-status="online"]::before { background: var(--success); } -.device-card[data-status="offline"]::before { background: var(--danger); } -.device-card[data-status="unbekannt"]::before { background: var(--warning); } -.device-card[data-status="disabled"]::before { background: var(--muted); } - -.device-card .dc-top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 8px; -} - -.device-card .dc-icon { - width: 34px; height: 34px; - border-radius: 9px; - display: flex; align-items: center; justify-content: center; - background: var(--muted-dim); - color: var(--muted); - flex-shrink: 0; -} -.device-card[data-status="online"] .dc-icon { background: var(--success-dim); color: var(--success); } -.device-card[data-status="offline"] .dc-icon { background: var(--danger-dim); color: var(--danger); } -.device-card[data-status="disabled"] .dc-icon { background: var(--muted-dim); color: var(--muted); } -.device-card .dc-icon svg { width: 17px; height: 17px; } - -.device-card .dc-name { - margin-top: 12px; - font-weight: 650; - font-size: 14px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.device-card .dc-ip { color: var(--text-faint); font-size: 11.5px; margin-top: 2px; font-family: var(--font-mono); } - -.spinner { - width: 13px; height: 13px; - border-radius: 50%; - border: 2px solid currentColor; - border-right-color: transparent; - display: inline-block; - animation: spin 0.65s linear infinite; -} -@keyframes spin { to { transform: rotate(360deg); } } - -/* ========================================================================== - Tables - ========================================================================== */ - -.table-wrap { - 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 -
    exakt um die 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; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 14px 16px; - border-bottom: 1px solid var(--border-soft); - flex-wrap: wrap; -} - -.search-input { - position: relative; - display: flex; - align-items: center; - min-width: 220px; -} -.search-input svg { - position: absolute; left: 11px; - width: 15px; height: 15px; - color: var(--text-faint); - pointer-events: none; -} -.search-input input[type="text"] { padding-left: 34px; } - -table.data-table { - width: 100%; - border-collapse: collapse; - font-size: 13.5px; -} - -.data-table thead th.sortable-col { - cursor: pointer; - user-select: none; - white-space: nowrap; -} -.data-table thead th.sortable-col:hover { color: var(--text); } -.data-table thead th.sortable-col::after { - content: "⇅"; - display: inline-block; - margin-left: 5px; - font-size: 10px; - opacity: 0.35; -} -.data-table thead th.sortable-col.sort-asc::after { content: "▲"; opacity: 0.85; } -.data-table thead th.sortable-col.sort-desc::after { content: "▼"; opacity: 0.85; } - -.data-table thead th { - text-align: left; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-faint); - font-weight: 650; - padding: 11px 16px; - border-bottom: 1px solid var(--border-soft); - white-space: nowrap; -} - -.data-table tbody td { - padding: 12px 16px; - border-bottom: 1px solid var(--border-soft); - vertical-align: middle; -} - -.data-table tbody tr:last-child td { border-bottom: none; } -.data-table tbody tr { transition: background 0.12s ease; } -.data-table tbody tr:hover { background: var(--bg-card-hover); } - -.mono { font-family: var(--font-mono); font-size: 12.5px; color: var(--text-dim); } -.cell-name { font-weight: 600; } -.row-actions { display: flex; align-items: center; gap: 6px; flex-wrap: nowrap; } -.row-actions form { display: flex; } - -.job-output-pre { - font-family: var(--font-mono); - font-size: 12px; - color: var(--text-dim); - background: var(--bg-input); - border: 1px solid var(--border-soft); - border-radius: 8px; - padding: 12px 14px; - margin: 6px 0; - max-height: 320px; - overflow: auto; - white-space: pre-wrap; - word-break: break-word; -} - -.empty-row td { - text-align: center; - padding: 40px 16px; - color: var(--text-faint); -} - -.group-detail-row td { - padding: 20px 24px 22px; - background: var(--bg-elevated); -} -.group-detail-row:hover { background: transparent; } -#chev-admin, [id^="chev-"] { transition: transform 0.15s ease; } - -/* ========================================================================== - Forms - ========================================================================== */ - -.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; - font-weight: 650; - color: var(--text-dim); - margin-bottom: 6px; -} -.field .field-hint { font-size: 11.5px; color: var(--text-faint); margin-top: 5px; } - -input[type="text"], input[type="password"], input[type="number"], input[type="email"], select, textarea { - width: 100%; - background: var(--bg-input); - border: 1px solid var(--border); - color: var(--text); - padding: 9px 12px; - border-radius: 9px; - font-size: 13.5px; - font-family: inherit; - transition: border-color 0.15s ease, box-shadow 0.15s ease; -} -input::placeholder { color: var(--text-faint); } - -/* Datei-Auswahl (z.B. Import/Export) — der native Button/"keine Datei - ausgewählt"-Text folgt sonst nicht dem Farbschema und ist im Dark Mode - kaum lesbar (dunkler Text auf dunklem Grund). */ -input[type="file"] { - width: 100%; - background: var(--bg-input); - border: 1px solid var(--border); - color: var(--text); - border-radius: 9px; - font-size: 13px; - padding: 6px 8px; -} -input[type="file"]::file-selector-button, -input[type="file"]::-webkit-file-upload-button { - -webkit-appearance: none; - appearance: none; - background: var(--bg-card-hover); - color: var(--text); - border: 1px solid var(--border); - border-radius: 7px; - padding: 7px 12px; - margin-right: 10px; - font-size: 12.5px; - font-weight: 600; - font-family: inherit; - cursor: pointer; - transition: background 0.15s ease, border-color 0.15s ease; -} -input[type="file"]::file-selector-button:hover, -input[type="file"]::-webkit-file-upload-button:hover { - background: var(--bg-card); - border-color: var(--text-faint); -} -input:focus, select:focus, textarea:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-dim); -} -input.is-invalid { border-color: var(--danger) !important; box-shadow: 0 0 0 3px var(--danger-dim) !important; } -.invalid-feedback { display: none; color: var(--danger); font-size: 11.5px; margin-top: 5px; } -input.is-invalid + .invalid-feedback, -input.is-invalid ~ .invalid-feedback { display: block; } - -select { - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%239aa2b4'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z' clip-rule='evenodd'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 10px center; - padding-right: 32px; -} - -.switch-check { - display: inline-flex; - align-items: center; - gap: 9px; - cursor: pointer; - user-select: none; -} -.switch-check input { display: none; } -.switch-check .track { - width: 38px; height: 22px; - border-radius: 999px; - background: var(--muted-dim); - border: 1px solid var(--border); - position: relative; - transition: background 0.15s ease; - flex-shrink: 0; -} -.switch-check .track::after { - content: ""; - position: absolute; - top: 2px; left: 2px; - width: 16px; height: 16px; - border-radius: 50%; - background: var(--text-faint); - transition: transform 0.15s ease, background 0.15s ease; -} -.switch-check input:checked + .track { background: var(--success-dim); border-color: var(--success); } -.switch-check input:checked + .track::after { transform: translateX(16px); background: var(--success); } -.switch-check input:disabled + .track { opacity: 0.5; cursor: not-allowed; } - -/* ========================================================================== - Modal - ========================================================================== */ - -.modal-overlay { - position: fixed; inset: 0; - background: rgba(6, 7, 10, 0.6); - backdrop-filter: blur(3px); - display: none; - align-items: center; - justify-content: center; - z-index: 100; - padding: 20px; -} -.modal-overlay.open { display: flex; } - -.modal { - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow); - width: 100%; - max-width: 440px; - max-height: 88vh; - display: flex; - flex-direction: column; - animation: modal-in 0.16s ease; -} - -/* ---- Hinweis-Icons (Hover-Tooltip + Klick-Modal), siehe _hint_icon.html - und initHintIcons() in app.js ---- */ -.hint-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 15px; - height: 15px; - flex: 0 0 auto; - border-radius: 50%; - border: 1.5px solid var(--text-faint); - background: transparent; - color: var(--text-faint); - font-size: 10px; - font-weight: 700; - font-style: italic; - font-family: Georgia, "Times New Roman", serif; - line-height: 1; - cursor: help; - vertical-align: middle; - margin-left: 6px; - padding: 0; - transition: border-color 0.15s, color 0.15s; -} -.hint-icon:hover, .hint-icon:focus-visible { border-color: var(--accent); color: var(--accent); } - -#hint-tooltip { - position: fixed; - z-index: 10000; - max-width: 280px; - padding: 8px 12px; - border-radius: 8px; - font-size: 12px; - line-height: 1.5; - background: var(--bg-elevated); - color: var(--text); - border: 1px solid var(--border); - box-shadow: var(--shadow); - pointer-events: none; - display: none; -} -@keyframes modal-in { - from { opacity: 0; transform: translateY(8px) scale(0.98); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.modal-header { - display: flex; align-items: center; justify-content: space-between; - padding: 18px 20px; - border-bottom: 1px solid var(--border-soft); -} -.modal-header h3 { font-size: 16px; } -.modal-body { padding: 20px; overflow-y: auto; } -.modal-footer { - display: flex; justify-content: flex-end; gap: 8px; - padding: 16px 20px; - border-top: 1px solid var(--border-soft); -} - -.modal-close { - width: 28px; height: 28px; - display: flex; align-items: center; justify-content: center; - border-radius: 8px; - color: var(--text-faint); - cursor: pointer; - border: none; background: transparent; -} -.modal-close:hover { background: var(--bg-card-hover); color: var(--text); } - -.detail-list { display: flex; flex-direction: column; gap: 12px; } -.detail-row { display: flex; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-soft); } -.detail-row:last-child { border-bottom: none; padding-bottom: 0; } -.detail-row .k { color: var(--text-faint); font-size: 12.5px; font-weight: 600; } -.detail-row .v { font-weight: 600; text-align: right; } - -/* ========================================================================== - Toasts - ========================================================================== */ - -.toast-stack { - position: fixed; - top: 18px; right: 18px; - z-index: 200; - display: flex; - flex-direction: column; - gap: 10px; - max-width: 340px; -} - -.toast { - background: var(--bg-elevated); - border: 1px solid var(--border); - border-left: 3px solid var(--accent); - border-radius: 10px; - padding: 12px 14px; - box-shadow: var(--shadow); - font-size: 13px; - color: var(--text); - display: flex; - align-items: flex-start; - gap: 10px; - animation: toast-in 0.18s ease; -} -.toast.success { border-left-color: var(--success); } -.toast.danger { border-left-color: var(--danger); } -.toast.info { border-left-color: var(--accent); } -.toast .toast-close { margin-left: auto; cursor: pointer; color: var(--text-faint); flex-shrink: 0; } -.toast.hide { animation: toast-out 0.18s ease forwards; } -@keyframes toast-in { from { opacity: 0; transform: translateX(16px); } to { opacity: 1; transform: translateX(0); } } -@keyframes toast-out { to { opacity: 0; transform: translateX(16px); } } - -/* ========================================================================== - Login page - ========================================================================== */ - -.login-page { - min-height: 100vh; - position: relative; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - padding: 40px 20px; - background: - radial-gradient(circle at 15% 15%, rgba(255, 113, 0, 0.14), transparent 45%), - radial-gradient(circle at 85% 85%, rgba(255, 113, 0, 0.08), transparent 40%), - var(--bg); -} - -/* TESM-Wortmarke mit Funktionszeile als tatsächlicher Seitenhintergrund -- - absolut positioniert und hinter allem (z-index 0), kein eigenes Panel und - kein bloßes "Logo über der Box"-Stacking mehr. Die Login-Box (z-index 1) - liegt bewusst weit oben auf der Seite, dadurch sichtbar VOR dem oberen - Teil des Hintergrundmotivs statt klassisch vertikal zentriert. */ -.login-bg { - position: absolute; - top: 4vh; - left: 50%; - transform: translateX(-50%); - width: min(72vw, 880px); - pointer-events: none; - z-index: 0; -} -.login-bg img { width: 100%; height: auto; display: block; } - -@media (max-width: 640px) { - .login-bg { width: min(90vw, 460px); } -} - -.login-card { - position: relative; - z-index: 1; - width: 100%; - max-width: 380px; - background: var(--bg-card); - border: 1px solid var(--border-soft); - border-radius: var(--radius-lg); - box-shadow: var(--shadow); - padding: 34px 32px 28px; -} - -.login-card h1 { font-size: 18px; text-align: center; margin-bottom: 4px; } - -/* ========================================================================== - Logs page - ========================================================================== */ - -.log-shell { - background: #0a0c10; - border: 1px solid var(--border-soft); - border-radius: var(--radius); - overflow: hidden; - display: flex; - flex-direction: column; - height: calc(100vh - 210px); - min-height: 320px; -} -[data-theme="light"] .log-shell { background: #0e1116; } - -.log-toolbar { - display: flex; align-items: center; justify-content: space-between; - padding: 10px 14px; - border-bottom: 1px solid #1f232c; - background: #101319; -} -.log-dots { display: flex; gap: 6px; } -.log-dots span { width: 10px; height: 10px; border-radius: 50%; } -.log-dots span:nth-child(1) { background: #f0475c; } -.log-dots span:nth-child(2) { background: #f5a623; } -.log-dots span:nth-child(3) { background: #2fd07a; } - -#log-box { - flex: 1; - overflow-y: auto; - padding: 14px 18px; - font-family: var(--font-mono); - font-size: 12.5px; - line-height: 1.7; - white-space: pre-wrap; - word-break: break-word; - color: #c7ccd6; -} -.code-preview { - background: #0a0c10; - border: 1px solid var(--border-soft); - border-radius: 8px; - padding: 14px 18px; - font-family: var(--font-mono); - font-size: 12.5px; - line-height: 1.6; - color: #c7ccd6; - max-height: 360px; - overflow: auto; - white-space: pre-wrap; - word-break: break-word; -} -.log-line { display: block; } -.log-line.online { color: #4ade80; } -.log-line.offline { color: #f87171; } -.log-line.sep { color: #3a4050; } -.log-line.restart { color: #ffb454; } - -.raw-log-content { - flex: 1; - overflow: auto; - margin: 0; - padding: 14px 18px; - font-family: var(--font-mono); - font-size: 12px; - line-height: 1.6; - white-space: pre-wrap; - word-break: break-word; - color: #c7ccd6; - background: #0a0c10; -} -[data-theme="light"] .raw-log-content { background: #0e1116; } - -/* Zeilennummern im RAW-Log-Popup (aktuell nur "Verlauf") -- Nummer kommt - rein aus CSS-Countern (kein Extra-Markup pro Zeile mit fest eingebrannter - Zahl), damit Filtern/Kopieren des reinen Logtexts unverändert bleibt. */ -.raw-log-content.with-line-numbers { - counter-reset: raw-line; - white-space: normal; -} -.raw-log-content.with-line-numbers .raw-log-line { - display: flex; - white-space: pre-wrap; - word-break: break-word; -} -.raw-log-content.with-line-numbers .raw-log-line::before { - counter-increment: raw-line; - content: counter(raw-line); - flex: 0 0 auto; - min-width: 3.5em; - margin-right: 14px; - padding-right: 10px; - border-right: 1px solid var(--border); - text-align: right; - color: var(--text-faint); - user-select: none; -} - -/* ========================================================================== - Permission checklists (Gruppen) - ========================================================================== */ - -.permission-group-title { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-faint); - font-weight: 650; - margin-bottom: 8px; -} - -/* Rechte-Übersicht (Gruppen-Detailansicht): die Top-Level-Bereiche - (Geräte/Logs/Einstellungen) stehen als kompakte, schmale Spalten - nebeneinander statt untereinander. Kopfzeile der Tabelle = Bereichsname - + Kill-Switch-Kästchen (eng beieinander, linksbündig) in der ERSTEN - Zelle, gefolgt von den Buchstaben-Spalten (R/W/E/D) — Name/Kästchen und - Buchstaben stehen also in derselben Zeile. Tabellenkörper = Unterpunkte - als eingerückte Zeilen, Spalten je Bereich nur die dort tatsächlich - genutzten Rechte. Legende steht direkt unter der Zeile (.permission-legend). */ -.permission-legend { - font-size: 12px; - color: var(--text-faint); - margin-top: 14px; -} -.permission-groups-row { - display: flex; - flex-wrap: wrap; - gap: 28px; -} -.permission-group-col { - /* Nicht auf eine feste Breite zwingen — Bereiche mit mehr Spalten (z.B. - Geräte mit R/W/E/D) brauchen mehr Platz als welche mit weniger (z.B. - 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; } -.permission-group-toggle { - display: inline-flex; - align-items: center; - gap: 7px; - cursor: pointer; -} -.permission-group-name { font-size: 14px; font-weight: 650; color: var(--text); text-transform: none; letter-spacing: normal; font-family: inherit; } -.permission-table { - border-collapse: collapse; - font-size: 13px; - white-space: nowrap; -} -.permission-table th, .permission-table td { - padding: 5px 10px; - border-bottom: 1px solid var(--border-soft); -} -.permission-table th:not(:first-child), .permission-table td:not(:first-child) { - text-align: center; -} -.permission-table thead th { padding-bottom: 10px; border-bottom: 1px solid var(--border-soft); } -.permission-table th { - font-size: 11.5px; - font-weight: 650; - color: var(--text-faint); - text-transform: uppercase; - letter-spacing: 0.03em; - font-family: var(--font-mono); -} -.permission-table th:first-child { text-transform: none; font-family: inherit; letter-spacing: normal; } -.permission-table td:first-child, .permission-table th:first-child { text-align: left; padding-right: 14px; padding-left: 0; } -.permission-table tbody tr:last-child td { border-bottom: none; } -.permission-row-label { color: var(--text-dim); padding-left: 48px !important; } -/* Kompakte Variante nur für das Gruppen-Dropdown auf der Hauptseite - (permission_tree(..., compact=true)) — schmalere Zellen und niedrigere - Zeilen als im "Neue Gruppe"-Modal, das unverändert bleibt. */ -.permission-groups-row--compact { gap: 20px; } -.permission-groups-row--compact .permission-group-col { padding-right: 20px; } -.permission-groups-row--compact .permission-table th, -.permission-groups-row--compact .permission-table td { padding: 3px 7px; } -.permission-groups-row--compact .permission-table td:first-child, -.permission-groups-row--compact .permission-table th:first-child { padding-right: 10px; } -.permission-groups-row--compact .permission-row-label { padding-left: 32px !important; } -/* Sichtbares Ausgrauen, solange der Kill-Switch des Bereichs aus ist — - zusätzlich zum disabled-Attribut, das der Browser nur dezent abblendet - (siehe applyPermissionGating() im Script-Block). */ -.permission-table tbody.permission-locked { opacity: 0.35; } -.permission-table input[type="checkbox"] { - width: 16px; height: 16px; - accent-color: var(--accent); - cursor: pointer; -} -/* Explizit zentrieren (statt sich auf text-align + native Checkbox-Ränder - zu verlassen, die je nach Browser/OS leicht asymmetrisch sind) — nur für - die Rechte-Checkboxen im Tabellenkörper, nicht für das Bereich-Kästchen - in der Kopfzeile (das steht bewusst inline neben dem Namen). */ -.permission-table tbody input[type="checkbox"] { - display: block; - margin: 0 auto; -} -.permission-table input[type="checkbox"]:disabled { cursor: not-allowed; } -.permission-cb-na { opacity: 0.25; accent-color: var(--text-faint) !important; } - -.check-list { display: flex; flex-direction: column; gap: 9px; } -.check-row { - display: flex; - align-items: center; - gap: 9px; - font-size: 13px; - color: var(--text-dim); - cursor: pointer; -} -.check-row input[type="checkbox"] { - width: 16px; height: 16px; - accent-color: var(--accent); - flex-shrink: 0; - cursor: pointer; -} - -/* ========================================================================== - SSH terminal panel (switch connection test) - ========================================================================== */ - -.term-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 12px 18px; - border-bottom: 1px solid var(--border-soft); -} - -.xterm-container { - height: 380px; - background: #0a0c10; - padding: 10px 12px; -} -.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 - ========================================================================== */ - -.flex { display: flex; } -.gap-2 { gap: 8px; } -.mt-3 { margin-top: 14px; } -.text-dim { color: var(--text-dim); } -.text-faint { color: var(--text-faint); } -.text-danger { color: var(--danger); } -.hidden { display: none !important; } - -/* ========================================================================== - Responsive - ========================================================================== */ - -@media (max-width: 900px) { - .sidebar { - transform: translateX(-100%); - } - .sidebar.open { transform: translateX(0); box-shadow: var(--shadow); } - .sidebar-backdrop.open { display: block; } - .main { margin-left: 0; } - .hamburger { display: inline-flex; } - .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; } -} diff --git a/srv/tesm/static/css/vendor/xterm.css b/srv/tesm/static/css/vendor/xterm.css deleted file mode 100644 index e97b643..0000000 --- a/srv/tesm/static/css/vendor/xterm.css +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Copyright (c) 2014 The xterm.js authors. All rights reserved. - * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) - * https://github.com/chjj/term.js - * @license MIT - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - * Originally forked from (with the author's permission): - * Fabrice Bellard's javascript vt100 for jslinux: - * http://bellard.org/jslinux/ - * Copyright (c) 2011 Fabrice Bellard - * The original design remains. The terminal itself - * has been extended to include xterm CSI codes, among - * other features. - */ - -/** - * Default styles for xterm.js - */ - -.xterm { - cursor: text; - position: relative; - user-select: none; - -ms-user-select: none; - -webkit-user-select: none; -} - -.xterm.focus, -.xterm:focus { - outline: none; -} - -.xterm .xterm-helpers { - position: absolute; - top: 0; - /** - * The z-index of the helpers must be higher than the canvases in order for - * IMEs to appear on top. - */ - z-index: 5; -} - -.xterm .xterm-helper-textarea { - padding: 0; - border: 0; - margin: 0; - /* Move textarea out of the screen to the far left, so that the cursor is not visible */ - position: absolute; - opacity: 0; - left: -9999em; - top: 0; - width: 0; - height: 0; - z-index: -5; - /** Prevent wrapping so the IME appears against the textarea at the correct position */ - white-space: nowrap; - overflow: hidden; - resize: none; -} - -.xterm .composition-view { - /* TODO: Composition position got messed up somewhere */ - background: #000; - color: #FFF; - display: none; - position: absolute; - white-space: nowrap; - z-index: 1; -} - -.xterm .composition-view.active { - display: block; -} - -.xterm .xterm-viewport { - /* On OS X this is required in order for the scroll bar to appear fully opaque */ - background-color: #000; - overflow-y: scroll; - cursor: default; - position: absolute; - right: 0; - left: 0; - top: 0; - bottom: 0; -} - -.xterm .xterm-screen { - position: relative; -} - -.xterm .xterm-screen canvas { - position: absolute; - left: 0; - top: 0; -} - -.xterm .xterm-scroll-area { - visibility: hidden; -} - -.xterm-char-measure-element { - display: inline-block; - visibility: hidden; - position: absolute; - top: 0; - left: -9999em; - line-height: normal; -} - -.xterm.enable-mouse-events { - /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ - cursor: default; -} - -.xterm.xterm-cursor-pointer, -.xterm .xterm-cursor-pointer { - cursor: pointer; -} - -.xterm.column-select.focus { - /* Column selection mode */ - cursor: crosshair; -} - -.xterm .xterm-accessibility:not(.debug), -.xterm .xterm-message { - position: absolute; - left: 0; - top: 0; - bottom: 0; - right: 0; - z-index: 10; - color: transparent; - pointer-events: none; -} - -.xterm .xterm-accessibility-tree:not(.debug) *::selection { - color: transparent; -} - -.xterm .xterm-accessibility-tree { - user-select: text; - white-space: pre; -} - -.xterm .live-region { - position: absolute; - left: -9999px; - width: 1px; - height: 1px; - overflow: hidden; -} - -.xterm-dim { - /* Dim should not apply to background, so the opacity of the foreground color is applied - * explicitly in the generated class and reset to 1 here */ - opacity: 1 !important; -} - -.xterm-underline-1 { text-decoration: underline; } -.xterm-underline-2 { text-decoration: double underline; } -.xterm-underline-3 { text-decoration: wavy underline; } -.xterm-underline-4 { text-decoration: dotted underline; } -.xterm-underline-5 { text-decoration: dashed underline; } - -.xterm-overline { - text-decoration: overline; -} - -.xterm-overline.xterm-underline-1 { text-decoration: overline underline; } -.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } -.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } -.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } -.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } - -.xterm-strikethrough { - text-decoration: line-through; -} - -.xterm-screen .xterm-decoration-container .xterm-decoration { - z-index: 6; - position: absolute; -} - -.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { - z-index: 7; -} - -.xterm-decoration-overview-ruler { - z-index: 8; - position: absolute; - top: 0; - right: 0; - pointer-events: none; -} - -.xterm-decoration-top { - z-index: 2; - position: relative; -} diff --git a/srv/tesm/static/images/icon-light.svg b/srv/tesm/static/images/icon-light.svg deleted file mode 100644 index 67cbfd9..0000000 --- a/srv/tesm/static/images/icon-light.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - TESM - - - diff --git a/srv/tesm/static/images/logo-dark-subline.svg b/srv/tesm/static/images/logo-dark-subline.svg deleted file mode 100644 index e5fe857..0000000 --- a/srv/tesm/static/images/logo-dark-subline.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - TESM - - - ÜBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE) - diff --git a/srv/tesm/static/images/logo-dark.svg b/srv/tesm/static/images/logo-dark.svg deleted file mode 100644 index 921f6ff..0000000 --- a/srv/tesm/static/images/logo-dark.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - TESM - - diff --git a/srv/tesm/static/images/logo-light-subline.svg b/srv/tesm/static/images/logo-light-subline.svg deleted file mode 100644 index fdce623..0000000 --- a/srv/tesm/static/images/logo-light-subline.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - TESM - - - ÜBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE) - diff --git a/srv/tesm/static/images/logo-light.svg b/srv/tesm/static/images/logo-light.svg deleted file mode 100644 index 1c67792..0000000 --- a/srv/tesm/static/images/logo-light.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - TESM - - diff --git a/srv/tesm/static/images/logo.png b/srv/tesm/static/images/logo.png deleted file mode 100644 index fed2463..0000000 Binary files a/srv/tesm/static/images/logo.png and /dev/null differ diff --git a/srv/tesm/static/js/app.js b/srv/tesm/static/js/app.js deleted file mode 100644 index b10e85f..0000000 --- a/srv/tesm/static/js/app.js +++ /dev/null @@ -1,762 +0,0 @@ -/* ========================================================================== - PoE Manager — shared UI behaviour (sidebar, theme, modals, toasts) - ========================================================================== */ - -(function () { - "use strict"; - - /* ---------------- Theme ---------------- */ - - const THEME_KEY = "poe-theme"; - - function applyTheme(theme) { - document.documentElement.setAttribute("data-theme", theme); - document.querySelectorAll("[data-theme-icon]").forEach((el) => { - el.innerHTML = theme === "light" ? ICONS.moon : ICONS.sun; - }); - // Favicon + Sidebar-Logo folgen demselben Theme wie der Rest der App - // (auch bei manuellem Umschalten, nicht nur bei System-Präferenz) -- - // "dunkel"/"hell" bezeichnen hier die für den jeweiligen Modus gedachte - // Logo-Variante (helle Farben für Dark Mode, dunkle für Light Mode). - const suffix = theme === "light" ? "light" : "dark"; - const favicon = document.getElementById("app-favicon"); - if (favicon) favicon.href = "/static/images/icon-" + suffix + ".svg"; - const sidebarLogo = document.getElementById("sidebar-logo"); - if (sidebarLogo) sidebarLogo.src = "/static/images/logo-" + suffix + ".svg"; - const loginBgLogo = document.getElementById("login-bg-logo"); - if (loginBgLogo) loginBgLogo.src = "/static/images/logo-" + suffix + "-subline.svg"; - } - - function initTheme() { - const saved = localStorage.getItem(THEME_KEY) || - (window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"); - applyTheme(saved); - } - - function toggleTheme() { - const current = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; - const next = current === "light" ? "dark" : "light"; - localStorage.setItem(THEME_KEY, next); - applyTheme(next); - } - - const ICONS = { - sun: '', - moon: '', - }; - - /* ---------------- Sidebar (mobile) ---------------- */ - - function initSidebar() { - const sidebar = document.querySelector(".sidebar"); - const backdrop = document.querySelector(".sidebar-backdrop"); - const main = document.querySelector(".main"); - const toggles = document.querySelectorAll("[data-sidebar-toggle]"); - if (!sidebar) return; - - const DESKTOP_BREAKPOINT = 900; - const COLLAPSE_KEY = "poe-sidebar-collapsed"; - - // Mobile: temporäres Überlagern per Hamburger + Backdrop. - function open() { - sidebar.classList.add("open"); - backdrop && backdrop.classList.add("open"); - } - function close() { - sidebar.classList.remove("open"); - backdrop && backdrop.classList.remove("open"); - } - - // Desktop: dauerhaftes Ein-/Ausklappen, über Neuladen hinweg gemerkt. - function setCollapsed(collapsed) { - sidebar.classList.toggle("collapsed", collapsed); - main && main.classList.toggle("sidebar-collapsed", collapsed); - localStorage.setItem(COLLAPSE_KEY, collapsed ? "1" : "0"); - } - - if (localStorage.getItem(COLLAPSE_KEY) === "1") setCollapsed(true); - - toggles.forEach((btn) => btn.addEventListener("click", () => { - if (window.innerWidth <= DESKTOP_BREAKPOINT) { - sidebar.classList.contains("open") ? close() : open(); - } else { - setCollapsed(!sidebar.classList.contains("collapsed")); - } - })); - backdrop && backdrop.addEventListener("click", close); - document.querySelectorAll(".nav-item").forEach((a) => a.addEventListener("click", close)); - } - - /* ---------------- Modals ---------------- */ - - function openModal(id) { - const el = document.getElementById(id); - if (!el) return; - el.classList.add("open"); - document.body.style.overflow = "hidden"; - } - - function closeModal(el) { - const overlay = el.closest ? el.closest(".modal-overlay") : el; - if (!overlay) return; - overlay.classList.remove("open"); - document.body.style.overflow = ""; - } - - function initModals() { - document.querySelectorAll("[data-open-modal]").forEach((trigger) => { - trigger.addEventListener("click", () => openModal(trigger.getAttribute("data-open-modal"))); - }); - document.querySelectorAll("[data-close-modal]").forEach((btn) => { - btn.addEventListener("click", () => closeModal(btn)); - }); - document.querySelectorAll(".modal-overlay").forEach((overlay) => { - overlay.addEventListener("click", (e) => { - if (e.target === overlay) closeModal(overlay); - }); - }); - document.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - document.querySelectorAll(".modal-overlay.open").forEach((o) => closeModal(o)); - } - }); - } - - /* ---------------- Confirm dialog (replaces window.confirm) ---------------- */ - - function ensureConfirmModal() { - if (document.getElementById("confirm-modal")) return; - const html = ` - `; - document.body.insertAdjacentHTML("beforeend", html); - document.querySelectorAll("#confirm-modal [data-close-modal]").forEach((btn) => { - btn.addEventListener("click", () => closeModal(btn)); - }); - document.getElementById("confirm-modal").addEventListener("click", (e) => { - if (e.target.id === "confirm-modal") closeModal(e.target); - }); - } - - window.confirmAction = function (message, onConfirm, title) { - ensureConfirmModal(); - document.getElementById("confirm-title").innerText = title || "Bist du sicher?"; - document.getElementById("confirm-message").innerText = message; - const okBtn = document.getElementById("confirm-ok"); - const freshBtn = okBtn.cloneNode(true); - okBtn.parentNode.replaceChild(freshBtn, okBtn); - freshBtn.addEventListener("click", () => { - closeModal(freshBtn); - onConfirm(); - }); - openModal("confirm-modal"); - }; - - /* ---------------- Ja/Nein-Dialog für ungespeicherte Änderungen ---------------- */ - /* Eigener Dialog statt confirmAction (dort ist "Abbrechen" = beim Bleiben, - hier gibt es bewusst nur die zwei angefragten Optionen: speichern oder - verwerfen — beide verlassen die Seite, nur "Abbrechen"/Escape/Backdrop - bricht das Verlassen selbst ab und lässt die Seite unverändert offen. */ - function ensureSaveDiscardModal() { - if (document.getElementById("save-discard-modal")) return; - const html = ` - `; - document.body.insertAdjacentHTML("beforeend", html); - document.querySelectorAll("#save-discard-modal [data-close-modal]").forEach((btn) => { - btn.addEventListener("click", () => closeModal(btn)); - }); - document.getElementById("save-discard-modal").addEventListener("click", (e) => { - if (e.target.id === "save-discard-modal") closeModal(e.target); - }); - } - - window.confirmSaveDiscard = function (message, onSave, onDiscard) { - ensureSaveDiscardModal(); - document.getElementById("save-discard-message").innerText = message; - const yesBtn = document.getElementById("save-discard-yes"); - const noBtn = document.getElementById("save-discard-no"); - const freshYes = yesBtn.cloneNode(true); - yesBtn.parentNode.replaceChild(freshYes, yesBtn); - const freshNo = noBtn.cloneNode(true); - noBtn.parentNode.replaceChild(freshNo, noBtn); - freshYes.addEventListener("click", () => { closeModal(freshYes); onSave(); }); - freshNo.addEventListener("click", () => { closeModal(freshNo); onDiscard(); }); - openModal("save-discard-modal"); - }; - - /* ---------------- RAW-Log-Popup (Live-Log & Verlauf) ---------------- */ - /* Gleiches Modal-Grundgerüst wie der Ja/Nein-Dialog für ungespeicherte - Änderungen (modal-overlay/modal-header/modal-body/modal-footer), nur - breiter/höher und mit lazy nachgeladenem Inhalt -- der komplette - Log-Inhalt wird bewusst erst beim tatsächlichen Öffnen per fetch - nachgeladen, nicht schon beim Seitenaufbau, damit genau das Problem - (unnötig große Ladezeiten) nicht durch das Popup selbst zurückkommt. */ - function ensureRawLogModal() { - if (document.getElementById("raw-log-modal")) return; - const html = ` - `; - document.body.insertAdjacentHTML("beforeend", html); - document.querySelectorAll("#raw-log-modal [data-close-modal]").forEach((btn) => { - btn.addEventListener("click", () => closeModal(btn)); - }); - document.getElementById("raw-log-modal").addEventListener("click", (e) => { - if (e.target.id === "raw-log-modal") closeModal(e.target); - }); - } - - function renderRawLogLines(container, text) { - // Eine Zeile = ein
    , Nummer kommt per CSS-Counter (::before) -- - // so bleibt der eigentliche Zeileninhalt reiner textContent (kein XSS- - // Risiko) und die Nummerierung muss nirgends von Hand mitgezählt werden. - const lines = text.split("\n"); - if (lines.length && lines[lines.length - 1] === "") lines.pop(); // trailing \n erzeugt sonst eine Phantomzeile - container.textContent = ""; - const frag = document.createDocumentFragment(); - lines.forEach((line) => { - const row = document.createElement("div"); - row.className = "raw-log-line"; - row.textContent = line; - frag.appendChild(row); - }); - container.appendChild(frag); - } - - window.openRawLogModal = function (url, title, withLineNumbers) { - if (!url) return; - ensureRawLogModal(); - document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)"; - const content = document.getElementById("raw-log-modal-content"); - const meta = document.getElementById("raw-log-modal-meta"); - content.classList.toggle("with-line-numbers", !!withLineNumbers); - content.textContent = "Lade …"; - meta.textContent = ""; - openModal("raw-log-modal"); - fetch(url) - .then((r) => { - const logName = r.headers.get("X-Log-Name"); - if (logName) meta.textContent = logName; - return r.text(); - }) - .then((text) => { - if (withLineNumbers) renderRawLogLines(content, text); - else content.textContent = text; - }) - .catch(() => { content.textContent = "Fehler beim Laden des Logs."; }); - }; - - /* ---------------- Hinweis-Icons (Hover-Tooltip + Klick-Modal) ---------------- */ - /* Zentraler Mechanismus für ALLE "i"-Icons (siehe _hint_icon.html) app-weit - -- ein einziges delegiertes Set von Listenern statt pro Icon eigener - Handler, damit auch nachträglich per JS eingefügte Icons (z.B. in - dynamisch nachgeladenen Tabellenzeilen) ohne weiteres Zutun funktionieren. - Hover zeigt den Text als Tooltip neben dem Mauszeiger -- aber NUR auf - Geräten, die tatsächlich sinnvoll hovern können (matchMedia-Check bei - JEDEM mouseenter neu ausgewertet, nicht einmalig beim Laden gecacht, - damit z.B. ein Convertible/Tablet beim Umschalten Maus/Touch korrekt - reagiert). Auf reinen Touch-Geräten bleibt so nur Tap -> Modal übrig, - da dort ohnehin kein echtes mouseenter/mousemove-Hover stattfindet. */ - function ensureHintTooltip() { - if (document.getElementById("hint-tooltip")) return; - const el = document.createElement("div"); - el.id = "hint-tooltip"; - document.body.appendChild(el); - } - - function ensureHintModal() { - if (document.getElementById("hint-modal")) return; - const html = ` - `; - document.body.insertAdjacentHTML("beforeend", html); - document.querySelectorAll("#hint-modal [data-close-modal]").forEach((btn) => { - btn.addEventListener("click", () => closeModal(btn)); - }); - document.getElementById("hint-modal").addEventListener("click", (e) => { - if (e.target.id === "hint-modal") closeModal(e.target); - }); - } - - function canHover() { - return window.matchMedia && window.matchMedia("(hover: hover) and (pointer: fine)").matches; - } - - function initHintIcons() { - if (!document.querySelector(".hint-icon")) return; - ensureHintTooltip(); - const tooltip = document.getElementById("hint-tooltip"); - - function positionTooltip(e) { - const offset = 14; - const rect = tooltip.getBoundingClientRect(); - let x = e.clientX + offset; - let y = e.clientY + offset; - if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - offset; - if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offset; - tooltip.style.left = Math.max(8, x) + "px"; - tooltip.style.top = Math.max(8, y) + "px"; - } - - // mouseenter/mouseleave bubbeln nicht -- Delegation via Capture-Phase - // auf document funktioniert dafür trotzdem zuverlässig. - document.addEventListener("mouseenter", function (e) { - const icon = e.target.closest && e.target.closest(".hint-icon"); - if (!icon || !canHover()) return; - tooltip.textContent = icon.dataset.hint || ""; - tooltip.style.display = "block"; - positionTooltip(e); - }, true); - - document.addEventListener("mouseleave", function (e) { - const icon = e.target.closest && e.target.closest(".hint-icon"); - if (!icon) return; - tooltip.style.display = "none"; - }, true); - - document.addEventListener("mousemove", function (e) { - if (tooltip.style.display === "block") positionTooltip(e); - }); - - document.addEventListener("click", function (e) { - const icon = e.target.closest && e.target.closest(".hint-icon"); - if (!icon) return; - e.preventDefault(); - tooltip.style.display = "none"; - ensureHintModal(); - document.getElementById("hint-modal-title").textContent = icon.dataset.hintTitle || "Hinweis"; - document.getElementById("hint-modal-body").textContent = icon.dataset.hint || ""; - openModal("hint-modal"); - }); - } - - /* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */ - /* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten - Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/ - Toggle-Buttons) seit dem Laden der Seite verändert wurde, und fragt vor - dem Verlassen (Klick auf einen Link, z.B. in der Navigation) per Ja/Nein, - ob zuerst gespeichert werden soll — statt Änderungen stillschweigend zu - verwerfen. "Ja" sendet das Formular ganz normal ab (echter POST, kein - AJAX-Nachbau nötig) und merkt sich das eigentliche Ziel in - sessionStorage, um nach dem Speichern automatisch dorthin - weiterzuleiten, statt dass der Link ein zweites Mal angeklickt werden - muss. Für Browser-eigene Navigation (Tab schließen, Reload, Adresszeile) - gibt es zusätzlich beforeunload — dort erlaubt der Browser aus - Sicherheitsgründen aber nur eine generische Warnung, kein eigenes - Ja/Nein/Speichern-Dialogfeld. */ - const PENDING_NAV_KEY = "poe-pending-nav-after-save"; - - function serializeForm(form) { - return new URLSearchParams(new FormData(form)).toString(); - } - - function initUnsavedChangesGuard() { - const pendingNav = sessionStorage.getItem(PENDING_NAV_KEY); - if (pendingNav) { - sessionStorage.removeItem(PENDING_NAV_KEY); - window.location.href = pendingNav; - return; - } - - const trackable = []; - document.querySelectorAll("form[method='post' i]").forEach((form) => { - if (form.hasAttribute("data-no-unsaved-guard")) return; - const fields = form.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]), select, textarea"); - if (!fields.length) return; // reine Aktions-Formulare (Löschen/Toggle) ohne Eingabefeld - trackable.push({ form, initial: serializeForm(form) }); - }); - if (!trackable.length) return; - - // Verhindert die native Browser-Warnung (beforeunload), wenn die Seite - // ohnehin schon bewusst verlassen wird — entweder weil eines der - // beobachteten Formulare ganz normal abgeschickt wurde (eigener - // "Speichern"/"Anlegen"-Button auf der Seite) oder weil unser eigener - // Ja/Nein-Dialog die Navigation ausgelöst hat. Ohne das würde beim - // normalen Speichern zusätzlich zum eigentlichen Erfolg immer noch die - // generische Browser-Meldung aufpoppen — die soll ausschließlich dann - // erscheinen, wenn tatsächlich UNGEFRAGT navigiert wird (Tab schließen, - // Reload, Adresszeile), nicht bei einem bewussten Speichern. - let navigatingAway = false; - trackable.forEach(({ form }) => { - form.addEventListener("submit", () => { navigatingAway = true; }); - }); - - function dirtyForm() { - return trackable.find(({ form, initial }) => serializeForm(form) !== initial) || null; - } - - document.addEventListener("click", (e) => { - const link = e.target.closest("a[href]"); - if (!link || link.target === "_blank" || link.href.startsWith("javascript:") || link.hasAttribute("data-no-unsaved-guard")) return; - const dirty = dirtyForm(); - if (!dirty) return; - e.preventDefault(); - const href = link.href; - window.confirmSaveDiscard( - "Es gibt ungespeicherte Änderungen auf dieser Seite. Vor dem Verlassen speichern?", - () => { - navigatingAway = true; - sessionStorage.setItem(PENDING_NAV_KEY, href); - dirty.form.requestSubmit ? dirty.form.requestSubmit() : dirty.form.submit(); - }, - () => { - navigatingAway = true; - window.location.href = href; - } - ); - }); - - window.addEventListener("beforeunload", (e) => { - if (!navigatingAway && dirtyForm()) { - e.preventDefault(); - e.returnValue = ""; - } - }); - } - - /* Intercept forms/buttons marked with data-confirm="message" */ - function initConfirmables() { - document.querySelectorAll("form[data-confirm]").forEach((form) => { - form.addEventListener("submit", function (e) { - if (form.dataset.confirmed === "1") return; - e.preventDefault(); - // Welcher Button hat den Submit ausgelöst? Muss beim erneuten - // requestSubmit() explizit mitgegeben werden — sonst geht bei - // Formularen, die ihre ID über name/value des Submit-Buttons - // transportieren (statt über ein ), dieses - // Feld beim Neu-Absenden stillschweigend verloren (requestSubmit() - // ohne Argument zählt als "kein Button aktiviert"). - const submitter = e.submitter; - window.confirmAction(form.getAttribute("data-confirm"), () => { - form.dataset.confirmed = "1"; - form.requestSubmit ? form.requestSubmit(submitter) : form.submit(); - }, form.getAttribute("data-confirm-title")); - }); - }); - } - - /* ---------------- Toasts ---------------- */ - - function ensureToastStack() { - let stack = document.querySelector(".toast-stack"); - if (!stack) { - stack = document.createElement("div"); - stack.className = "toast-stack"; - document.body.appendChild(stack); - } - return stack; - } - - window.showToast = function (message, type) { - const stack = ensureToastStack(); - const toast = document.createElement("div"); - toast.className = "toast " + (type || "info"); - toast.innerHTML = `${message}×`; - stack.appendChild(toast); - const remove = () => { - toast.classList.add("hide"); - setTimeout(() => toast.remove(), 180); - }; - toast.querySelector(".toast-close").addEventListener("click", remove); - setTimeout(remove, 5000); - }; - - /* Zeigt ein per Dropdown ausgewähltes, bis dahin verstecktes DHCP-Options- - Feld (siehe settings_dhcp.html, "+ Option hinzufügen"-Auswahl bei - Reservierungen) — so sind nur tatsächlich genutzte Options-Felder - sichtbar, statt jede einzeln immer als leeres Eingabefeld anzuzeigen. - Verschiebt das Feld dafür direkt unter das Dropdown (in den Container - mit der Klasse "poe-added-options", der im Markup direkt danach steht) - statt es nur an seiner ursprünglichen Stelle einzublenden — bei bis zu - ~60 möglichen Optionen läge diese sonst weit unterhalb des sichtbaren - Modal-Ausschnitts und es wirkte so, als wäre gar nichts passiert. Jede - weitere Auswahl hängt ihr Feld unten an, sodass alle bereits - hinzugefügten Options sichtbar aufgereiht bleiben. */ - window.poeShowOptionField = function (select) { - const id = select.value; - if (id) { - const field = document.getElementById(id); - const container = select.nextElementSibling; - if (field && container && container.classList.contains("poe-added-options")) { - container.appendChild(field); - field.style.display = ""; - const input = field.querySelector("input"); - if (input) input.focus(); - } - select.value = ""; - } - }; - - function initFlashedMessages() { - const data = document.getElementById("flashed-data"); - if (!data) return; - try { - const messages = JSON.parse(data.textContent); - messages.forEach(([category, msg]) => { - const type = category === "danger" ? "danger" : category === "success" ? "success" : "info"; - window.showToast(msg, type); - }); - } catch (e) { /* noop */ } - } - - /* Nav-Gruppen mit Untermenü (z.B. "Logs") auf-/zuklappen — der manuell - aufgeklappte Zustand bleibt über Seitenwechsel hinweg erhalten - (localStorage), zusätzlich zur automatischen Aufklappung der Gruppe - der aktuell aktiven Seite (server-seitig via "expanded"-Klasse). */ - const NAV_EXPANDED_KEY = "poe-nav-expanded"; - - function getExpandedNavGroups() { - try { - return new Set(JSON.parse(localStorage.getItem(NAV_EXPANDED_KEY) || "[]")); - } catch (e) { - return new Set(); - } - } - - function saveExpandedNavGroups(set) { - localStorage.setItem(NAV_EXPANDED_KEY, JSON.stringify(Array.from(set))); - } - - function initNavGroups() { - const expanded = getExpandedNavGroups(); - document.querySelectorAll("[data-nav-group]").forEach((group) => { - const key = group.dataset.navGroupKey; - if (key && expanded.has(key)) group.classList.add("expanded"); - }); - document.querySelectorAll("[data-nav-group-toggle]").forEach((btn) => { - btn.addEventListener("click", () => { - const group = btn.closest("[data-nav-group]"); - const isExpanded = group.classList.toggle("expanded"); - const key = group.dataset.navGroupKey; - if (!key) return; - const set = getExpandedNavGroups(); - if (isExpanded) set.add(key); else set.delete(key); - saveExpandedNavGroups(set); - }); - }); - } - - /* ---------------- Globaler "Nächste Prüfung"-Timer (Topbar, jede Seite) ---------------- */ - - function initCheckTimer() { - const pill = document.getElementById("global-timer-pill"); - const timerEl = document.getElementById("global-timer"); - if (!pill || !timerEl) return; - - // lastRunMs/intervalMs werden bei JEDEM Tick frisch aus dem DOM gelesen - // (nicht einmalig gecacht) — so kann z.B. das Dashboard nach einem - // Live-Update (siehe dashboard-tiles.js) oder ein manueller "Jetzt - // prüfen"-Klick das data-last-run-ms-Attribut aktualisieren und der - // global laufende Countdown übernimmt das automatisch. - function update() { - const lastRunMs = parseInt(pill.dataset.lastRunMs, 10); - const intervalMs = parseInt(pill.dataset.intervalMs, 10); - if (!intervalMs) { timerEl.textContent = "--"; return; } - let remainingMs = intervalMs; - if (!isNaN(lastRunMs) && lastRunMs > 0) { - const elapsed = Date.now() - lastRunMs; - remainingMs = intervalMs - (((elapsed % intervalMs) + intervalMs) % intervalMs); - } - timerEl.textContent = Math.max(0, Math.ceil(remainingMs / 1000)); - } - update(); - setInterval(update, 1000); - } - - /* "Jetzt prüfen"-Button neben dem globalen Timer (nur Admins): startet - tesm-check.service neu (frisches Logfile + sofortiger Durchlauf) und - meldet das per Custom Event, damit Dashboard/Live-Log sich selbst - aktualisieren können, ohne dass app.js die Seiteninhalte kennen muss. */ - function initRunCheckNow() { - const btn = document.getElementById("run-check-now"); - if (!btn) return; - btn.addEventListener("click", () => { - btn.disabled = true; - btn.classList.add("spinning"); - fetch("/check/run-now", { method: "POST" }) - .then((r) => r.json()) - .then((data) => { - if (!data.success) { - window.showToast(data.msg || "Fehler beim Starten der Prüfung.", "danger"); - btn.disabled = false; - btn.classList.remove("spinning"); - return; - } - window.showToast(data.msg || "Prüfung gestartet.", "success"); - document.dispatchEvent(new CustomEvent("poe:check-triggered")); - // Topbar-Countdown auf JEDER Seite aktualisieren, nicht nur auf - // dem Dashboard — vorher lief er auf allen anderen Seiten mit dem - // alten Zeitpunkt weiter, obwohl im Hintergrund längst neu geprüft - // wurde (die Prüfung lief trotzdem korrekt, nur sichtbar tat sich - // nichts). Der Server liefert den frischen Zeitpunkt direkt in - // dieser Antwort mit (siehe run_check_now) statt eines zweiten - // Requests dafür. - const pill = document.getElementById("global-timer-pill"); - if (pill && data.last_run_epoch_ms) { - pill.dataset.lastRunMs = data.last_run_epoch_ms; - } - btn.disabled = false; - btn.classList.remove("spinning"); - }) - .catch(() => { - window.showToast("Fehler beim Starten der Prüfung.", "danger"); - btn.disabled = false; - btn.classList.remove("spinning"); - }); - }); - } - - /* ---------------- Sortierbare Tabellen ---------------- */ - /* - * Klick auf
    -Zeilen innerhalb des einzigen - * anhand von data-sort- auf der jeweiligen . - * - Akkordeon-Tabellen (mehrere , z.B. Gruppen mit Detail-Zeile): - * sortiert ganze -Blöcke anhand von data-sort- auf dem - * jeweiligen , damit Haupt- und Detail-Zeile zusammenbleiben. - * (z.B. die virtuelle "Admin"-Zeile) bleibt - * dabei immer an ihrer Position. - */ - function compareSortValues(a, b, asc) { - a = (a === null || a === undefined) ? "" : String(a); - b = (b === null || b === undefined) ? "" : String(b); - const na = parseFloat(a), nb = parseFloat(b); - const bothNumeric = a !== "" && b !== "" && !isNaN(na) && !isNaN(nb); - const cmp = bothNumeric ? (na - nb) : a.toLowerCase().localeCompare(b.toLowerCase(), "de"); - return asc ? cmp : -cmp; - } - - function sortTable(table, th) { - const key = th.dataset.sortKey; - const asc = th.dataset.sortDir !== "asc"; - table.querySelectorAll("thead th[data-sort-key]").forEach((h) => { - delete h.dataset.sortDir; - h.classList.remove("sort-asc", "sort-desc"); - }); - th.dataset.sortDir = asc ? "asc" : "desc"; - th.classList.add(asc ? "sort-asc" : "sort-desc"); - - if (table.tBodies.length > 1) { - const movable = Array.prototype.filter.call(table.tBodies, (tb) => !tb.hasAttribute("data-sort-pinned")); - movable.sort((a, b) => compareSortValues( - a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc - )); - movable.forEach((tb) => table.appendChild(tb)); - } else { - const tbody = table.tBodies[0]; - const rows = Array.prototype.filter.call(tbody.rows, (r) => !r.classList.contains("empty-row")); - rows.sort((a, b) => compareSortValues( - a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc - )); - rows.forEach((r) => tbody.appendChild(r)); - } - } - - function initSortableTables() { - document.querySelectorAll("table[data-sortable] thead th[data-sort-key]").forEach((th) => { - th.classList.add("sortable-col"); - th.addEventListener("click", () => sortTable(th.closest("table"), th)); - }); - } - - /* ---------------- Init ---------------- */ - - document.addEventListener("DOMContentLoaded", function () { - initTheme(); - initSidebar(); - initModals(); - initConfirmables(); - initFlashedMessages(); - initNavGroups(); - initCheckTimer(); - initRunCheckNow(); - initLeaseCountdowns(); - initUnsavedChangesGuard(); - initSortableTables(); - initHintIcons(); - document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); - }); - - /* ---------------- DHCP-Lease-Countdown (settings_dhcp.html) ---------------- */ - /* Ergänzt die absolute (UTC-)Ablaufzeit einer Lease um einen live tickenden - Countdown — bei den kurzen Standard-Lease-Zeiten (600s) wirkt eine reine - Absolutzeit sonst schnell "schon abgelaufen", sobald etwas Zeit vergangen - ist, bis man die (nicht selbst aktualisierende) Seite tatsächlich - anschaut. Gleiches Tick-Prinzip wie initCheckTimer oben. */ - function formatLeaseCountdown(remainingMs) { - const abs = Math.abs(Math.round(remainingMs / 1000)); - const m = Math.floor(abs / 60); - const s = abs % 60; - const duration = m > 0 ? `${m}m ${s}s` : `${s}s`; - return remainingMs >= 0 ? `läuft ab in ${duration}` : `abgelaufen vor ${duration}`; - } - - function initLeaseCountdowns() { - const els = document.querySelectorAll(".lease-countdown"); - if (!els.length) return; - function tick() { - const now = Date.now(); - els.forEach((el) => { - const expireMs = parseInt(el.dataset.expireMs, 10); - if (isNaN(expireMs)) return; - el.textContent = formatLeaseCountdown(expireMs - now); - el.classList.toggle("text-danger", expireMs < now); - }); - } - tick(); - setInterval(tick, 1000); - } - - window.PoeUI = { openModal, closeModal }; -})(); diff --git a/srv/tesm/static/js/vendor/mammoth.browser.min.js b/srv/tesm/static/js/vendor/mammoth.browser.min.js deleted file mode 100644 index d79e104..0000000 --- a/srv/tesm/static/js/vendor/mammoth.browser.min.js +++ /dev/null @@ -1,21 +0,0 @@ -!function(f){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=f();else if("function"==typeof define&&define.amd)define([],f);else{var g;g="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,g.mammoth=f()}}(function(){var define,module,exports;return function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a="function"==typeof require&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}for(var i="function"==typeof require&&require,o=0;o0&&(childrenXml=deletedParagraphContents.concat(childrenXml),deletedParagraphContents=[]),ReadResult.map(readParagraphProperties(paragraphPropertiesElement),readXmlElements(childrenXml),function(properties,children){return new documents.Paragraph(children,properties)}).insertExtra()},"w:r":function(element){return ReadResult.map(readRunProperties(element.firstOrEmpty("w:rPr")),readXmlElements(element.children),function(properties,children){var hyperlinkOptions=currentHyperlinkOptions();return null!==hyperlinkOptions&&(children=[new documents.Hyperlink(children,hyperlinkOptions)]),new documents.Run(children,properties)})},"w:fldChar":readFldChar,"w:instrText":readInstrText,"w:t":function(element){return elementResult(new documents.Text(element.text()))},"w:tab":function(element){return elementResult(new documents.Tab)},"w:noBreakHyphen":function(){return elementResult(new documents.Text("‑"))},"w:softHyphen":function(element){return elementResult(new documents.Text("­"))},"w:sym":readSymbol,"w:hyperlink":function(element){var relationshipId=element.attributes["r:id"],anchor=element.attributes["w:anchor"];return readXmlElements(element.children).map(function(children){function create(options){var targetFrame=element.attributes["w:tgtFrame"]||null;return new documents.Hyperlink(children,_.extend({targetFrame:targetFrame},options))}if(relationshipId){var href=relationships.findTargetByRelationshipId(relationshipId);return anchor&&(href=uris.replaceFragment(href,anchor)),create({href:href})}return anchor?create({anchor:anchor}):children})},"w:tbl":readTable,"w:tr":readTableRow,"w:tc":readTableCell,"w:footnoteReference":noteReferenceReader("footnote"),"w:endnoteReference":noteReferenceReader("endnote"),"w:commentReference":readCommentReference,"w:br":function(element){var breakType=element.attributes["w:type"];return null==breakType||"textWrapping"===breakType?elementResult(documents.lineBreak):"page"===breakType?elementResult(documents.pageBreak):"column"===breakType?elementResult(documents.columnBreak):emptyResultWithMessages([warning("Unsupported break type: "+breakType)])},"w:bookmarkStart":function(element){var name=element.attributes["w:name"];return"_GoBack"===name?emptyResult():elementResult(new documents.BookmarkStart({name:name}))},"mc:AlternateContent":function(element){return readChildElements(element.first("mc:Fallback"))},"w:sdt":function(element){return readXmlElements(element.firstOrEmpty("w:sdtContent").children)},"w:ins":readChildElements,"w:object":readChildElements,"w:smartTag":readChildElements,"w:drawing":readChildElements,"w:pict":function(element){return readChildElements(element).toExtra()},"v:roundrect":readChildElements,"v:shape":readChildElements,"v:textbox":readChildElements,"w:txbxContent":readChildElements,"wp:inline":readDrawingElement,"wp:anchor":readDrawingElement,"v:imagedata":readImageData,"v:group":readChildElements,"v:rect":readChildElements};return{readXmlElement:readXmlElement,readXmlElements:readXmlElements}}function readNumberingProperties(styleId,element,numbering){var level=element.firstOrEmpty("w:ilvl").attributes["w:val"],numId=element.firstOrEmpty("w:numId").attributes["w:val"];if(void 0!==level&&void 0!==numId)return numbering.findLevel(numId,level);if(null!=styleId){var levelByStyleId=numbering.findLevelByParagraphStyleId(styleId);if(null!=levelByStyleId)return levelByStyleId}return null}function emptyResultWithMessages(messages){return new ReadResult(null,null,messages)}function emptyResult(){return new ReadResult(null)}function elementResult(element){return new ReadResult(element)}function elementResultWithMessages(element,messages){return new ReadResult(element,null,messages)}function ReadResult(element,extra,messages){this.value=element||[],this.extra=extra||[],this._result=new Result({element:this.value,extra:extra},messages),this.messages=this._result.messages}function combineResults(results){var result=Result.combine(_.pluck(results,"_result"));return new ReadResult(_.flatten(_.pluck(result.value,"element")),_.filter(_.flatten(_.pluck(result.value,"extra")),identity),result.messages)}function joinElements(first,second){return _.flatten([first,second])}function identity(value){return value}exports.createBodyReader=createBodyReader,exports._readNumberingProperties=readNumberingProperties;var dingbatToUnicode=require("dingbat-to-unicode"),_=require("underscore"),documents=require("../documents"),Result=require("../results").Result,warning=require("../results").warning,uris=require("./uris"),supportedImageTypes={"image/png":!0,"image/gif":!0,"image/jpeg":!0,"image/svg+xml":!0,"image/tiff":!0},ignoreElements={"office-word:wrap":!0,"v:shadow":!0,"v:shapetype":!0,"w:annotationRef":!0,"w:bookmarkEnd":!0,"w:sectPr":!0,"w:proofErr":!0,"w:lastRenderedPageBreak":!0,"w:commentRangeStart":!0,"w:commentRangeEnd":!0,"w:del":!0,"w:footnoteRef":!0,"w:endnoteRef":!0,"w:pPr":!0,"w:rPr":!0,"w:tblPr":!0,"w:tblGrid":!0,"w:trPr":!0,"w:tcPr":!0};ReadResult.prototype.toExtra=function(){return new ReadResult(null,joinElements(this.extra,this.value),this.messages)},ReadResult.prototype.insertExtra=function(){var extra=this.extra;return extra&&extra.length?new ReadResult(joinElements(this.value,extra),null,this.messages):this},ReadResult.prototype.map=function(func){var result=this._result.map(function(value){return func(value.element)});return new ReadResult(result.value,this.extra,result.messages)},ReadResult.prototype.flatMap=function(func){var result=this._result.flatMap(function(value){return func(value.element)._result});return new ReadResult(result.value.element,joinElements(this.extra,result.value.extra),result.messages)},ReadResult.map=function(first,second,func){ -return new ReadResult(func(first.value,second.value),joinElements(first.extra,second.extra),first.messages.concat(second.messages))}},{"../documents":4,"../results":25,"./uris":16,"dingbat-to-unicode":85,underscore:102}],6:[function(require,module,exports){function createCommentsReader(bodyReader){function readCommentsXml(element){return Result.combine(element.getElementsByTagName("w:comment").map(readCommentElement))}function readCommentElement(element){function readOptionalAttribute(name){return(element.attributes[name]||"").trim()||null}var id=element.attributes["w:id"];return bodyReader.readXmlElements(element.children).map(function(body){return documents.comment({commentId:id,body:body,authorName:readOptionalAttribute("w:author"),authorInitials:readOptionalAttribute("w:initials")})})}return readCommentsXml}var documents=require("../documents"),Result=require("../results").Result;exports.createCommentsReader=createCommentsReader},{"../documents":4,"../results":25}],7:[function(require,module,exports){function readContentTypesFromXml(element){var extensionDefaults={},overrides={};return element.children.forEach(function(child){if("content-types:Default"===child.name&&(extensionDefaults[child.attributes.Extension]=child.attributes.ContentType),"content-types:Override"===child.name){var name=child.attributes.PartName;"/"===name.charAt(0)&&(name=name.substring(1)),overrides[name]=child.attributes.ContentType}}),contentTypes(overrides,extensionDefaults)}function contentTypes(overrides,extensionDefaults){return{findContentType:function(path){var overrideContentType=overrides[path];if(overrideContentType)return overrideContentType;var pathParts=path.split("."),extension=pathParts[pathParts.length-1];if(extensionDefaults.hasOwnProperty(extension))return extensionDefaults[extension];var fallback=fallbackContentTypes[extension.toLowerCase()];return fallback?"image/"+fallback:null}}}exports.readContentTypesFromXml=readContentTypesFromXml;var fallbackContentTypes={png:"png",gif:"gif",jpeg:"jpeg",jpg:"jpeg",tif:"tiff",tiff:"tiff",bmp:"bmp"};exports.defaultContentTypes=contentTypes({},{})},{}],8:[function(require,module,exports){function DocumentXmlReader(options){function convertXmlToDocument(element){var body=element.first("w:body");if(null==body)throw new Error("Could not find the body element: are you sure this is a docx file?");var result=bodyReader.readXmlElements(body.children).map(function(children){return new documents.Document(children,{notes:options.notes,comments:options.comments})});return new Result(result.value,result.messages)}var bodyReader=options.bodyReader;return{convertXmlToDocument:convertXmlToDocument}}exports.DocumentXmlReader=DocumentXmlReader;var documents=require("../documents"),Result=require("../results").Result},{"../documents":4,"../results":25}],9:[function(require,module,exports){function read(docxFile,input){return input=input||{},promises.props({contentTypes:readContentTypesFromZipFile(docxFile),partPaths:findPartPaths(docxFile),docxFile:docxFile,files:input.path?Files.relativeToFile(input.path):new Files(null)}).also(function(result){return{styles:readStylesFromZipFile(docxFile,result.partPaths.styles)}}).also(function(result){return{numbering:readNumberingFromZipFile(docxFile,result.partPaths.numbering,result.styles)}}).also(function(result){return{footnotes:readXmlFileWithBody(result.partPaths.footnotes,result,function(bodyReader,xml){return xml?notesReader.createFootnotesReader(bodyReader)(xml):new Result([])}),endnotes:readXmlFileWithBody(result.partPaths.endnotes,result,function(bodyReader,xml){return xml?notesReader.createEndnotesReader(bodyReader)(xml):new Result([])}),comments:readXmlFileWithBody(result.partPaths.comments,result,function(bodyReader,xml){return xml?commentsReader.createCommentsReader(bodyReader)(xml):new Result([])})}}).also(function(result){return{notes:result.footnotes.flatMap(function(footnotes){return result.endnotes.map(function(endnotes){return new documents.Notes(footnotes.concat(endnotes))})})}}).then(function(result){return readXmlFileWithBody(result.partPaths.mainDocument,result,function(bodyReader,xml){return result.notes.flatMap(function(notes){return result.comments.flatMap(function(comments){var reader=new DocumentXmlReader({bodyReader:bodyReader,notes:notes,comments:comments});return reader.convertXmlToDocument(xml)})})})})}function findPartPaths(docxFile){return readPackageRelationships(docxFile).then(function(packageRelationships){var mainDocumentPath=findPartPath({docxFile:docxFile,relationships:packageRelationships,relationshipType:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",basePath:"",fallbackPath:"word/document.xml"});if(!docxFile.exists(mainDocumentPath))throw new Error("Could not find main document part. Are you sure this is a valid .docx file?");return xmlFileReader({filename:relationshipsFilename(mainDocumentPath),readElement:relationshipsReader.readRelationships,defaultValue:relationshipsReader.defaultValue})(docxFile).then(function(documentRelationships){function findPartRelatedToMainDocument(name){return findPartPath({docxFile:docxFile,relationships:documentRelationships,relationshipType:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/"+name,basePath:zipfile.splitPath(mainDocumentPath).dirname,fallbackPath:"word/"+name+".xml"})}return{mainDocument:mainDocumentPath,comments:findPartRelatedToMainDocument("comments"),endnotes:findPartRelatedToMainDocument("endnotes"),footnotes:findPartRelatedToMainDocument("footnotes"),numbering:findPartRelatedToMainDocument("numbering"),styles:findPartRelatedToMainDocument("styles")}})})}function findPartPath(options){var docxFile=options.docxFile,relationships=options.relationships,relationshipType=options.relationshipType,basePath=options.basePath,fallbackPath=options.fallbackPath,targets=relationships.findTargetsByType(relationshipType),normalisedTargets=targets.map(function(target){return stripPrefix(zipfile.joinPath(basePath,target),"/")}),validTargets=normalisedTargets.filter(function(target){return docxFile.exists(target)});return 0===validTargets.length?fallbackPath:validTargets[0]}function stripPrefix(value,prefix){return value.substring(0,prefix.length)===prefix?value.substring(prefix.length):value}function xmlFileReader(options){return function(zipFile){return readXmlFromZipFile(zipFile,options.filename).then(function(element){return element?options.readElement(element):options.defaultValue})}}function readXmlFileWithBody(filename,options,func){var readRelationshipsFromZipFile=xmlFileReader({filename:relationshipsFilename(filename),readElement:relationshipsReader.readRelationships,defaultValue:relationshipsReader.defaultValue});return readRelationshipsFromZipFile(options.docxFile).then(function(relationships){var bodyReader=new createBodyReader({relationships:relationships,contentTypes:options.contentTypes,docxFile:options.docxFile,numbering:options.numbering,styles:options.styles,files:options.files});return readXmlFromZipFile(options.docxFile,filename).then(function(xml){return func(bodyReader,xml)})})}function relationshipsFilename(filename){var split=zipfile.splitPath(filename);return zipfile.joinPath(split.dirname,"_rels",split.basename+".rels")}function readNumberingFromZipFile(zipFile,path,styles){return xmlFileReader({filename:path,readElement:function(element){return numberingXml.readNumberingXml(element,{styles:styles})},defaultValue:numberingXml.defaultNumbering})(zipFile)}function readStylesFromZipFile(zipFile,path){return xmlFileReader({filename:path,readElement:stylesReader.readStylesXml,defaultValue:stylesReader.defaultStyles})(zipFile)}exports.read=read,exports._findPartPaths=findPartPaths;var promises=require("../promises"),documents=require("../documents"),Result=require("../results").Result,zipfile=require("../zipfile"),readXmlFromZipFile=require("./office-xml-reader").readXmlFromZipFile,createBodyReader=require("./body-reader").createBodyReader,DocumentXmlReader=require("./document-xml-reader").DocumentXmlReader,relationshipsReader=require("./relationships-reader"),contentTypesReader=require("./content-types-reader"),numberingXml=require("./numbering-xml"),stylesReader=require("./styles-reader"),notesReader=require("./notes-reader"),commentsReader=require("./comments-reader"),Files=require("./files").Files,readContentTypesFromZipFile=xmlFileReader({filename:"[Content_Types].xml",readElement:contentTypesReader.readContentTypesFromXml,defaultValue:contentTypesReader.defaultContentTypes}),readPackageRelationships=xmlFileReader({filename:"_rels/.rels",readElement:relationshipsReader.readRelationships,defaultValue:relationshipsReader.defaultValue})},{"../documents":4,"../promises":23,"../results":25,"../zipfile":40,"./body-reader":5,"./comments-reader":6,"./content-types-reader":7,"./document-xml-reader":8,"./files":1,"./notes-reader":10,"./numbering-xml":11,"./office-xml-reader":12,"./relationships-reader":13,"./styles-reader":15}],10:[function(require,module,exports){function createReader(noteType,bodyReader){function readNotesXml(element){return Result.combine(element.getElementsByTagName("w:"+noteType).filter(isFootnoteElement).map(readFootnoteElement))}function isFootnoteElement(element){var type=element.attributes["w:type"];return"continuationSeparator"!==type&&"separator"!==type}function readFootnoteElement(footnoteElement){var id=footnoteElement.attributes["w:id"];return bodyReader.readXmlElements(footnoteElement.children).map(function(body){return documents.Note({noteType:noteType,noteId:id,body:body})})}return readNotesXml}var documents=require("../documents"),Result=require("../results").Result;exports.createFootnotesReader=createReader.bind(this,"footnote"),exports.createEndnotesReader=createReader.bind(this,"endnote")},{"../documents":4,"../results":25}],11:[function(require,module,exports){function Numbering(nums,abstractNums,styles){function findLevel(numId,level){var num=nums[numId];if(num){var abstractNum=abstractNums[num.abstractNumId];if(abstractNum){if(null==abstractNum.numStyleLink)return abstractNums[num.abstractNumId].levels[level];var style=styles.findNumberingStyleById(abstractNum.numStyleLink);return findLevel(style.numId,level)}return null}return null}function findLevelByParagraphStyleId(styleId){return levelsByParagraphStyleId[styleId]||null}var allLevels=_.flatten(_.values(abstractNums).map(function(abstractNum){return _.values(abstractNum.levels)})),levelsByParagraphStyleId=_.indexBy(allLevels.filter(function(level){return null!=level.paragraphStyleId}),"paragraphStyleId");return{findLevel:findLevel,findLevelByParagraphStyleId:findLevelByParagraphStyleId}}function readNumberingXml(root,options){if(!options||!options.styles)throw new Error("styles is missing");var abstractNums=readAbstractNums(root),nums=readNums(root,abstractNums);return new Numbering(nums,abstractNums,options.styles)}function readAbstractNums(root){var abstractNums={};return root.getElementsByTagName("w:abstractNum").forEach(function(element){var id=element.attributes["w:abstractNumId"];abstractNums[id]=readAbstractNum(element)}),abstractNums}function readAbstractNum(element){var levels={};element.getElementsByTagName("w:lvl").forEach(function(levelElement){var levelIndex=levelElement.attributes["w:ilvl"],numFmt=levelElement.firstOrEmpty("w:numFmt").attributes["w:val"],paragraphStyleId=levelElement.firstOrEmpty("w:pStyle").attributes["w:val"];levels[levelIndex]={isOrdered:"bullet"!==numFmt,level:levelIndex,paragraphStyleId:paragraphStyleId}});var numStyleLink=element.firstOrEmpty("w:numStyleLink").attributes["w:val"];return{levels:levels,numStyleLink:numStyleLink}}function readNums(root){var nums={};return root.getElementsByTagName("w:num").forEach(function(element){var numId=element.attributes["w:numId"],abstractNumId=element.first("w:abstractNumId").attributes["w:val"];nums[numId]={abstractNumId:abstractNumId}}),nums}var _=require("underscore");exports.readNumberingXml=readNumberingXml,exports.Numbering=Numbering,exports.defaultNumbering=new Numbering({},{})},{underscore:102}],12:[function(require,module,exports){function read(xmlString){return xml.readString(xmlString,xmlNamespaceMap).then(function(document){return collapseAlternateContent(document)[0]})}function readXmlFromZipFile(docxFile,path){return docxFile.exists(path)?docxFile.read(path,"utf-8").then(stripUtf8Bom).then(read):promises.resolve(null)}function stripUtf8Bom(xmlString){return xmlString.replace(/^\uFEFF/g,"")}function collapseAlternateContent(node){return"element"===node.type?"mc:AlternateContent"===node.name?node.first("mc:Fallback").children:(node.children=_.flatten(node.children.map(collapseAlternateContent,!0)),[node]):[node]}var _=require("underscore"),promises=require("../promises"),xml=require("../xml");exports.read=read,exports.readXmlFromZipFile=readXmlFromZipFile;var xmlNamespaceMap={"http://schemas.openxmlformats.org/wordprocessingml/2006/main":"w","http://schemas.openxmlformats.org/officeDocument/2006/relationships":"r","http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing":"wp","http://schemas.openxmlformats.org/drawingml/2006/main":"a","http://schemas.openxmlformats.org/drawingml/2006/picture":"pic","http://purl.oclc.org/ooxml/wordprocessingml/main":"w","http://purl.oclc.org/ooxml/officeDocument/relationships":"r","http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing":"wp","http://purl.oclc.org/ooxml/drawingml/main":"a","http://purl.oclc.org/ooxml/drawingml/picture":"pic","http://schemas.openxmlformats.org/package/2006/content-types":"content-types","http://schemas.openxmlformats.org/package/2006/relationships":"relationships","http://schemas.openxmlformats.org/markup-compatibility/2006":"mc","urn:schemas-microsoft-com:vml":"v","urn:schemas-microsoft-com:office:word":"office-word"}},{"../promises":23,"../xml":35,underscore:102}],13:[function(require,module,exports){function readRelationships(element){var relationships=[];return element.children.forEach(function(child){if("relationships:Relationship"===child.name){var relationship={relationshipId:child.attributes.Id,target:child.attributes.Target,type:child.attributes.Type};relationships.push(relationship)}}),new Relationships(relationships)}function Relationships(relationships){var targetsByRelationshipId={};relationships.forEach(function(relationship){targetsByRelationshipId[relationship.relationshipId]=relationship.target});var targetsByType={};return relationships.forEach(function(relationship){targetsByType[relationship.type]||(targetsByType[relationship.type]=[]),targetsByType[relationship.type].push(relationship.target)}),{findTargetByRelationshipId:function(relationshipId){return targetsByRelationshipId[relationshipId]},findTargetsByType:function(type){return targetsByType[type]||[]}}}exports.readRelationships=readRelationships,exports.defaultValue=new Relationships([]),exports.Relationships=Relationships},{}],14:[function(require,module,exports){function writeStyleMap(docxFile,styleMap){return docxFile.write(styleMapPath,styleMap),updateRelationships(docxFile).then(function(){return updateContentTypes(docxFile)})}function updateRelationships(docxFile){var path="word/_rels/document.xml.rels",relationshipsUri="http://schemas.openxmlformats.org/package/2006/relationships",relationshipElementName="{"+relationshipsUri+"}Relationship";return docxFile.read(path,"utf8").then(xml.readString).then(function(relationshipsContainer){var relationships=relationshipsContainer.children;addOrUpdateElement(relationships,relationshipElementName,"Id",{Id:"rMammothStyleMap",Type:schema,Target:styleMapAbsolutePath});var namespaces={"":relationshipsUri};return docxFile.write(path,xml.writeString(relationshipsContainer,namespaces))})}function updateContentTypes(docxFile){var path="[Content_Types].xml",contentTypesUri="http://schemas.openxmlformats.org/package/2006/content-types",overrideName="{"+contentTypesUri+"}Override";return docxFile.read(path,"utf8").then(xml.readString).then(function(typesElement){var children=typesElement.children;addOrUpdateElement(children,overrideName,"PartName",{PartName:styleMapAbsolutePath,ContentType:"text/prs.mammoth.style-map"});var namespaces={"":contentTypesUri};return docxFile.write(path,xml.writeString(typesElement,namespaces))})}function addOrUpdateElement(elements,name,identifyingAttribute,attributes){var existingElement=_.find(elements,function(element){return element.name===name&&element.attributes[identifyingAttribute]===attributes[identifyingAttribute]});existingElement?existingElement.attributes=attributes:elements.push(xml.element(name,attributes))}function readStyleMap(docxFile){return docxFile.exists(styleMapPath)?docxFile.read(styleMapPath,"utf8"):promises.resolve(null)}var _=require("underscore"),promises=require("../promises"),xml=require("../xml");exports.writeStyleMap=writeStyleMap,exports.readStyleMap=readStyleMap;var schema="http://schemas.zwobble.org/mammoth/style-map",styleMapPath="mammoth/style-map",styleMapAbsolutePath="/"+styleMapPath},{"../promises":23,"../xml":35,underscore:102}],15:[function(require,module,exports){function Styles(paragraphStyles,characterStyles,tableStyles,numberingStyles){return{findParagraphStyleById:function(styleId){return paragraphStyles[styleId]},findCharacterStyleById:function(styleId){return characterStyles[styleId]},findTableStyleById:function(styleId){return tableStyles[styleId]},findNumberingStyleById:function(styleId){return numberingStyles[styleId]}}}function readStylesXml(root){var paragraphStyles={},characterStyles={},tableStyles={},numberingStyles={},styles={paragraph:paragraphStyles,character:characterStyles,table:tableStyles};return root.getElementsByTagName("w:style").forEach(function(styleElement){var style=readStyleElement(styleElement);if("numbering"===style.type)numberingStyles[style.styleId]=readNumberingStyleElement(styleElement);else{var styleSet=styles[style.type];styleSet&&(styleSet[style.styleId]=style)}}),new Styles(paragraphStyles,characterStyles,tableStyles,numberingStyles)}function readStyleElement(styleElement){var type=styleElement.attributes["w:type"],styleId=styleElement.attributes["w:styleId"],name=styleName(styleElement);return{type:type,styleId:styleId,name:name}}function styleName(styleElement){var nameElement=styleElement.first("w:name");return nameElement?nameElement.attributes["w:val"]:null}function readNumberingStyleElement(styleElement){var numId=styleElement.firstOrEmpty("w:pPr").firstOrEmpty("w:numPr").firstOrEmpty("w:numId").attributes["w:val"];return{numId:numId}}exports.readStylesXml=readStylesXml,exports.Styles=Styles,exports.defaultStyles=new Styles({},{}),Styles.EMPTY=new Styles({},{},{},{})},{}],16:[function(require,module,exports){function uriToZipEntryName(base,uri){return"/"===uri.charAt(0)?uri.substr(1):base+"/"+uri}function replaceFragment(uri,fragment){var hashIndex=uri.indexOf("#");return-1!==hashIndex&&(uri=uri.substring(0,hashIndex)),uri+"#"+fragment}exports.uriToZipEntryName=uriToZipEntryName,exports.replaceFragment=replaceFragment},{}],17:[function(require,module,exports){function nonFreshElement(tagName,attributes,children){return elementWithTag(htmlPaths.element(tagName,attributes,{fresh:!1}),children)}function freshElement(tagName,attributes,children){var tag=htmlPaths.element(tagName,attributes,{fresh:!0});return elementWithTag(tag,children)}function elementWithTag(tag,children){return{type:"element",tag:tag,children:children||[]}}function text(value){return{type:"text",value:value}}function isVoidElement(node){return 0===node.children.length&&voidTagNames[node.tag.tagName]}var htmlPaths=require("../styles/html-paths"),forceWrite={type:"forceWrite"};exports.freshElement=freshElement,exports.nonFreshElement=nonFreshElement,exports.elementWithTag=elementWithTag,exports.text=text,exports.forceWrite=forceWrite;var voidTagNames={br:!0,hr:!0,img:!0};exports.isVoidElement=isVoidElement},{"../styles/html-paths":28}],18:[function(require,module,exports){function write(writer,nodes){nodes.forEach(function(node){writeNode(writer,node)})}function writeNode(writer,node){toStrings[node.type](writer,node)}function generateElementString(writer,node){ast.isVoidElement(node)?writer.selfClosing(node.tag.tagName,node.tag.attributes):(writer.open(node.tag.tagName,node.tag.attributes),write(writer,node.children),writer.close(node.tag.tagName))}function generateTextString(writer,node){writer.text(node.value)}var ast=require("./ast");exports.freshElement=ast.freshElement,exports.nonFreshElement=ast.nonFreshElement,exports.elementWithTag=ast.elementWithTag,exports.text=ast.text,exports.forceWrite=ast.forceWrite,exports.simplify=require("./simplify");var toStrings={element:generateElementString,text:generateTextString,forceWrite:function(){}};exports.write=write},{"./ast":17,"./simplify":19}],19:[function(require,module,exports){function simplify(nodes){return collapse(removeEmpty(nodes))}function collapse(nodes){var children=[];return nodes.map(collapseNode).forEach(function(child){appendChild(children,child)}),children}function collapseNode(node){return collapsers[node.type](node)}function collapseElement(node){return ast.elementWithTag(node.tag,collapse(node.children))}function identity(value){return value}function appendChild(children,child){var lastChild=children[children.length-1];"element"===child.type&&!child.tag.fresh&&lastChild&&"element"===lastChild.type&&child.tag.matchesElement(lastChild.tag)?(child.tag.separator&&appendChild(lastChild.children,ast.text(child.tag.separator)),child.children.forEach(function(grandChild){appendChild(lastChild.children,grandChild)})):children.push(child)}function removeEmpty(nodes){return flatMap(nodes,function(node){return emptiers[node.type](node)})}function flatMap(values,func){return _.flatten(_.map(values,func),!0)}function neverEmpty(node){return[node]}function elementEmptier(element){var children=removeEmpty(element.children);return 0!==children.length||ast.isVoidElement(element)?[ast.elementWithTag(element.tag,children)]:[]}function textEmptier(node){return 0===node.value.length?[]:[node]}var _=require("underscore"),ast=require("./ast"),collapsers={element:collapseElement,text:identity,forceWrite:identity},emptiers={element:elementEmptier,text:textEmptier,forceWrite:neverEmpty};module.exports=simplify},{"./ast":17,underscore:102}],20:[function(require,module,exports){function imgElement(func){return function(element,messages){return promises.when(func(element)).then(function(result){var attributes={};return element.altText&&(attributes.alt=element.altText),_.extend(attributes,result),[Html.freshElement("img",attributes)]})}}var _=require("underscore"),promises=require("./promises"),Html=require("./html");exports.imgElement=imgElement,exports.inline=exports.imgElement,exports.dataUri=imgElement(function(element){return element.readAsBase64String().then(function(imageBuffer){return{src:"data:"+element.contentType+";base64,"+imageBuffer}})})},{"./html":18,"./promises":23,underscore:102}],21:[function(require,module,exports){(function(Buffer){function convertToHtml(input,options){return convert(input,options)}function convertToMarkdown(input,options){var markdownOptions=Object.create(options||{});return markdownOptions.outputFormat="markdown",convert(input,markdownOptions)}function convert(input,options){return options=readOptions(options),unzip.openZip(input).tap(function(docxFile){return docxStyleMap.readStyleMap(docxFile).then(function(styleMap){options.embeddedStyleMap=styleMap})}).then(function(docxFile){return docxReader.read(docxFile,input).then(function(documentResult){return documentResult.map(options.transformDocument)}).then(function(documentResult){return convertDocumentToHtml(documentResult,options)})})}function readEmbeddedStyleMap(input){return unzip.openZip(input).then(docxStyleMap.readStyleMap)}function convertDocumentToHtml(documentResult,options){var styleMapResult=parseStyleMap(options.readStyleMap()),parsedOptions=_.extend({},options,{styleMap:styleMapResult.value}),documentConverter=new DocumentConverter(parsedOptions);return documentResult.flatMapThen(function(document){return styleMapResult.flatMapThen(function(styleMap){return documentConverter.convertToHtml(document)})})}function parseStyleMap(styleMap){return Result.combine((styleMap||[]).map(readStyle)).map(function(styleMap){return styleMap.filter(function(styleMapping){return!!styleMapping})})}function extractRawText(input){return unzip.openZip(input).then(docxReader.read).then(function(documentResult){return documentResult.map(convertElementToRawText)})}function embedStyleMap(input,styleMap){return unzip.openZip(input).tap(function(docxFile){return docxStyleMap.writeStyleMap(docxFile,styleMap)}).then(function(docxFile){return docxFile.toArrayBuffer()}).then(function(arrayBuffer){return{toArrayBuffer:function(){return arrayBuffer},toBuffer:function(){return Buffer.from(arrayBuffer)}}})}var _=require("underscore"),docxReader=require("./docx/docx-reader"),docxStyleMap=require("./docx/style-map"),DocumentConverter=require("./document-to-html").DocumentConverter,convertElementToRawText=require("./raw-text").convertElementToRawText,readStyle=require("./style-reader").readStyle,readOptions=require("./options-reader").readOptions,unzip=require("./unzip"),Result=require("./results").Result;exports.convertToHtml=convertToHtml,exports.convertToMarkdown=convertToMarkdown,exports.convert=convert,exports.extractRawText=extractRawText,exports.images=require("./images"),exports.transforms=require("./transforms"),exports.underline=require("./underline"),exports.embedStyleMap=embedStyleMap,exports.readEmbeddedStyleMap=readEmbeddedStyleMap,exports.styleMapping=function(){throw new Error("Use a raw string instead of mammoth.styleMapping e.g. \"p[style-name='Title'] => h1\" instead of mammoth.styleMapping(\"p[style-name='Title'] => h1\")")}}).call(this,require("buffer").Buffer)},{"./document-to-html":3,"./docx/docx-reader":9,"./docx/style-map":14,"./images":20,"./options-reader":22,"./raw-text":24,"./results":25,"./style-reader":26,"./transforms":30,"./underline":31,"./unzip":2,buffer:83,underscore:102}],22:[function(require,module,exports){function readOptions(options){return options=options||{},_.extend({},standardOptions,options,{customStyleMap:readStyleMap(options.styleMap),readStyleMap:function(){var styleMap=this.customStyleMap;return this.includeEmbeddedStyleMap&&(styleMap=styleMap.concat(readStyleMap(this.embeddedStyleMap))),this.includeDefaultStyleMap&&(styleMap=styleMap.concat(defaultStyleMap)),styleMap}})}function readStyleMap(styleMap){return styleMap?_.isString(styleMap)?styleMap.split("\n").map(function(line){return line.trim()}).filter(function(line){return""!==line&&"#"!==line.charAt(0)}):styleMap:[]}function identity(value){return value}exports.readOptions=readOptions;var _=require("underscore"),defaultStyleMap=exports._defaultStyleMap=["p.Heading1 => h1:fresh","p.Heading2 => h2:fresh","p.Heading3 => h3:fresh","p.Heading4 => h4:fresh","p.Heading5 => h5:fresh","p.Heading6 => h6:fresh","p[style-name='Heading 1'] => h1:fresh","p[style-name='Heading 2'] => h2:fresh","p[style-name='Heading 3'] => h3:fresh","p[style-name='Heading 4'] => h4:fresh","p[style-name='Heading 5'] => h5:fresh","p[style-name='Heading 6'] => h6:fresh","p[style-name='heading 1'] => h1:fresh","p[style-name='heading 2'] => h2:fresh","p[style-name='heading 3'] => h3:fresh","p[style-name='heading 4'] => h4:fresh","p[style-name='heading 5'] => h5:fresh","p[style-name='heading 6'] => h6:fresh","r[style-name='Strong'] => strong","p[style-name='footnote text'] => p:fresh","r[style-name='footnote reference'] =>","p[style-name='endnote text'] => p:fresh","r[style-name='endnote reference'] =>","p[style-name='annotation text'] => p:fresh","r[style-name='annotation reference'] =>","p[style-name='Footnote'] => p:fresh","r[style-name='Footnote anchor'] =>","p[style-name='Endnote'] => p:fresh","r[style-name='Endnote anchor'] =>","p:unordered-list(1) => ul > li:fresh","p:unordered-list(2) => ul|ol > li > ul > li:fresh","p:unordered-list(3) => ul|ol > li > ul|ol > li > ul > li:fresh","p:unordered-list(4) => ul|ol > li > ul|ol > li > ul|ol > li > ul > li:fresh","p:unordered-list(5) => ul|ol > li > ul|ol > li > ul|ol > li > ul|ol > li > ul > li:fresh","p:ordered-list(1) => ol > li:fresh","p:ordered-list(2) => ul|ol > li > ol > li:fresh","p:ordered-list(3) => ul|ol > li > ul|ol > li > ol > li:fresh","p:ordered-list(4) => ul|ol > li > ul|ol > li > ul|ol > li > ol > li:fresh","p:ordered-list(5) => ul|ol > li > ul|ol > li > ul|ol > li > ul|ol > li > ol > li:fresh","r[style-name='Hyperlink'] =>","p[style-name='Normal'] => p:fresh"],standardOptions=exports._standardOptions={transformDocument:identity,includeDefaultStyleMap:!0,includeEmbeddedStyleMap:!0}},{underscore:102}],23:[function(require,module,exports){function defer(){var resolve,reject,promise=new bluebird.Promise(function(resolveArg,rejectArg){resolve=resolveArg,reject=rejectArg});return{resolve:resolve,reject:reject,promise:promise}}var _=require("underscore"),bluebird=require("bluebird/js/release/promise")();exports.defer=defer,exports.when=bluebird.resolve,exports.resolve=bluebird.resolve,exports.all=bluebird.all,exports.props=bluebird.props,exports.reject=bluebird.reject,exports.promisify=bluebird.promisify,exports.mapSeries=bluebird.mapSeries,exports.attempt=bluebird.attempt,exports.nfcall=function(func){var args=Array.prototype.slice.call(arguments,1),promisedFunc=bluebird.promisify(func);return promisedFunc.apply(null,args)},bluebird.prototype.fail=bluebird.prototype.caught,bluebird.prototype.also=function(func){return this.then(function(value){var returnValue=_.extend({},value,func(value));return bluebird.props(returnValue)})}},{"bluebird/js/release/promise":68,underscore:102}],24:[function(require,module,exports){function convertElementToRawText(element){if("text"===element.type)return element.value;if(element.type===documents.types.tab)return" ";var tail="paragraph"===element.type?"\n\n":"";return(element.children||[]).map(convertElementToRawText).join("")+tail}var documents=require("./documents");exports.convertElementToRawText=convertElementToRawText},{"./documents":4}],25:[function(require,module,exports){function Result(value,messages){this.value=value,this.messages=messages||[]}function success(value){return new Result(value,[])}function warning(message){return{type:"warning",message:message}}function error(exception){return{type:"error",message:exception.message,error:exception}}function combineMessages(results){var messages=[];return _.flatten(_.pluck(results,"messages"),!0).forEach(function(message){containsMessage(messages,message)||messages.push(message)}),messages}function containsMessage(messages,message){return void 0!==_.find(messages,isSameMessage.bind(null,message))}function isSameMessage(first,second){return first.type===second.type&&first.message===second.message}var _=require("underscore");exports.Result=Result,exports.success=success,exports.warning=warning,exports.error=error,Result.prototype.map=function(func){return new Result(func(this.value),this.messages)},Result.prototype.flatMap=function(func){var funcResult=func(this.value);return new Result(funcResult.value,combineMessages([this,funcResult]))},Result.prototype.flatMapThen=function(func){var that=this;return func(this.value).then(function(otherResult){return new Result(otherResult.value,combineMessages([that,otherResult]))})},Result.combine=function(results){var values=_.flatten(_.pluck(results,"value")),messages=combineMessages(results);return new Result(values,messages)}},{underscore:102}],26:[function(require,module,exports){function readStyle(string){return parseString(styleRule,string)}function createStyleRule(){return lop.rules.sequence(lop.rules.sequence.capture(documentMatcherRule()),lop.rules.tokenOfType("whitespace"),lop.rules.tokenOfType("arrow"),lop.rules.sequence.capture(lop.rules.optional(lop.rules.sequence(lop.rules.tokenOfType("whitespace"),lop.rules.sequence.capture(htmlPathRule())).head())),lop.rules.tokenOfType("end")).map(function(documentMatcher,htmlPath){ -return{from:documentMatcher,to:htmlPath.valueOrElse(htmlPaths.empty)}})}function readDocumentMatcher(string){return parseString(documentMatcherRule(),string)}function documentMatcherRule(){function createMatcherSuffixesRule(rules){var matcherSuffix=lop.rules.firstOf.apply(lop.rules.firstOf,["matcher suffix"].concat(rules)),matcherSuffixes=lop.rules.zeroOrMore(matcherSuffix);return lop.rules.then(matcherSuffixes,function(suffixes){var matcherOptions={};return suffixes.forEach(function(suffix){_.extend(matcherOptions,suffix)}),matcherOptions})}var sequence=lop.rules.sequence,identifierToConstant=function(identifier,constant){return lop.rules.then(lop.rules.token("identifier",identifier),function(){return constant})},paragraphRule=identifierToConstant("p",documentMatchers.paragraph),runRule=identifierToConstant("r",documentMatchers.run),elementTypeRule=lop.rules.firstOf("p or r or table",paragraphRule,runRule),styleIdRule=lop.rules.sequence(lop.rules.tokenOfType("dot"),lop.rules.sequence.cut(),lop.rules.sequence.capture(identifierRule)).map(function(styleId){return{styleId:styleId}}),styleNameMatcherRule=lop.rules.firstOf("style name matcher",lop.rules.then(lop.rules.sequence(lop.rules.tokenOfType("equals"),lop.rules.sequence.cut(),lop.rules.sequence.capture(stringRule)).head(),function(styleName){return{styleName:documentMatchers.equalTo(styleName)}}),lop.rules.then(lop.rules.sequence(lop.rules.tokenOfType("startsWith"),lop.rules.sequence.cut(),lop.rules.sequence.capture(stringRule)).head(),function(styleName){return{styleName:documentMatchers.startsWith(styleName)}})),styleNameRule=lop.rules.sequence(lop.rules.tokenOfType("open-square-bracket"),lop.rules.sequence.cut(),lop.rules.token("identifier","style-name"),lop.rules.sequence.capture(styleNameMatcherRule),lop.rules.tokenOfType("close-square-bracket")).head(),listTypeRule=lop.rules.firstOf("list type",identifierToConstant("ordered-list",{isOrdered:!0}),identifierToConstant("unordered-list",{isOrdered:!1})),listRule=sequence(lop.rules.tokenOfType("colon"),sequence.capture(listTypeRule),sequence.cut(),lop.rules.tokenOfType("open-paren"),sequence.capture(integerRule),lop.rules.tokenOfType("close-paren")).map(function(listType,levelNumber){return{list:{isOrdered:listType.isOrdered,levelIndex:levelNumber-1}}}),paragraphOrRun=sequence(sequence.capture(elementTypeRule),sequence.capture(createMatcherSuffixesRule([styleIdRule,styleNameRule,listRule]))).map(function(createMatcher,matcherOptions){return createMatcher(matcherOptions)}),table=sequence(lop.rules.token("identifier","table"),sequence.capture(createMatcherSuffixesRule([styleIdRule,styleNameRule]))).map(function(options){return documentMatchers.table(options)}),bold=identifierToConstant("b",documentMatchers.bold),italic=identifierToConstant("i",documentMatchers.italic),underline=identifierToConstant("u",documentMatchers.underline),strikethrough=identifierToConstant("strike",documentMatchers.strikethrough),allCaps=identifierToConstant("all-caps",documentMatchers.allCaps),smallCaps=identifierToConstant("small-caps",documentMatchers.smallCaps),highlight=sequence(lop.rules.token("identifier","highlight"),lop.rules.sequence.capture(lop.rules.optional(lop.rules.sequence(lop.rules.tokenOfType("open-square-bracket"),lop.rules.sequence.cut(),lop.rules.token("identifier","color"),lop.rules.tokenOfType("equals"),lop.rules.sequence.capture(stringRule),lop.rules.tokenOfType("close-square-bracket")).head()))).map(function(color){return documentMatchers.highlight({color:color.valueOrElse(void 0)})}),commentReference=identifierToConstant("comment-reference",documentMatchers.commentReference),breakMatcher=sequence(lop.rules.token("identifier","br"),sequence.cut(),lop.rules.tokenOfType("open-square-bracket"),lop.rules.token("identifier","type"),lop.rules.tokenOfType("equals"),sequence.capture(stringRule),lop.rules.tokenOfType("close-square-bracket")).map(function(breakType){switch(breakType){case"line":return documentMatchers.lineBreak;case"page":return documentMatchers.pageBreak;case"column":return documentMatchers.columnBreak}});return lop.rules.firstOf("element type",paragraphOrRun,table,bold,italic,underline,strikethrough,allCaps,smallCaps,highlight,commentReference,breakMatcher)}function readHtmlPath(string){return parseString(htmlPathRule(),string)}function htmlPathRule(){var capture=lop.rules.sequence.capture,whitespaceRule=lop.rules.tokenOfType("whitespace"),freshRule=lop.rules.then(lop.rules.optional(lop.rules.sequence(lop.rules.tokenOfType("colon"),lop.rules.token("identifier","fresh"))),function(option){return option.map(function(){return!0}).valueOrElse(!1)}),separatorRule=lop.rules.then(lop.rules.optional(lop.rules.sequence(lop.rules.tokenOfType("colon"),lop.rules.token("identifier","separator"),lop.rules.tokenOfType("open-paren"),capture(stringRule),lop.rules.tokenOfType("close-paren")).head()),function(option){return option.valueOrElse("")}),tagNamesRule=lop.rules.oneOrMoreWithSeparator(identifierRule,lop.rules.tokenOfType("choice")),styleElementRule=lop.rules.sequence(capture(tagNamesRule),capture(lop.rules.zeroOrMore(attributeOrClassRule)),capture(freshRule),capture(separatorRule)).map(function(tagName,attributesList,fresh,separator){var attributes={},options={};return attributesList.forEach(function(attribute){attribute.append&&attributes[attribute.name]?attributes[attribute.name]+=" "+attribute.value:attributes[attribute.name]=attribute.value}),fresh&&(options.fresh=!0),separator&&(options.separator=separator),htmlPaths.element(tagName,attributes,options)});return lop.rules.firstOf("html path",lop.rules.then(lop.rules.tokenOfType("bang"),function(){return htmlPaths.ignore}),lop.rules.then(lop.rules.zeroOrMoreWithSeparator(styleElementRule,lop.rules.sequence(whitespaceRule,lop.rules.tokenOfType("gt"),whitespaceRule)),htmlPaths.elements))}function decodeEscapeSequences(value){return value.replace(/\\(.)/g,function(match,code){return escapeSequences[code]||code})}function parseString(rule,string){var tokens=tokenise(string),parser=lop.Parser(),parseResult=parser.parseTokens(rule,tokens);return parseResult.isSuccess()?results.success(parseResult.value()):new results.Result(null,[results.warning(describeFailure(string,parseResult))])}function describeFailure(input,parseResult){return"Did not understand this style mapping, so ignored it: "+input+"\n"+parseResult.errors().map(describeError).join("\n")}function describeError(error){return"Error was at character number "+error.characterNumber()+": Expected "+error.expected+" but got "+error.actual}var _=require("underscore"),lop=require("lop"),documentMatchers=require("./styles/document-matchers"),htmlPaths=require("./styles/html-paths"),tokenise=require("./styles/parser/tokeniser").tokenise,results=require("./results");exports.readHtmlPath=readHtmlPath,exports.readDocumentMatcher=readDocumentMatcher,exports.readStyle=readStyle;var identifierRule=lop.rules.then(lop.rules.tokenOfType("identifier"),decodeEscapeSequences),integerRule=lop.rules.tokenOfType("integer"),stringRule=lop.rules.then(lop.rules.tokenOfType("string"),decodeEscapeSequences),escapeSequences={n:"\n",r:"\r",t:" "},attributeRule=lop.rules.sequence(lop.rules.tokenOfType("open-square-bracket"),lop.rules.sequence.cut(),lop.rules.sequence.capture(identifierRule),lop.rules.tokenOfType("equals"),lop.rules.sequence.capture(stringRule),lop.rules.tokenOfType("close-square-bracket")).map(function(name,value){return{name:name,value:value,append:!1}}),classRule=lop.rules.sequence(lop.rules.tokenOfType("dot"),lop.rules.sequence.cut(),lop.rules.sequence.capture(identifierRule)).map(function(className){return{name:"class",value:className,append:!0}}),attributeOrClassRule=lop.rules.firstOf("attribute or class",attributeRule,classRule),styleRule=createStyleRule()},{"./results":25,"./styles/document-matchers":27,"./styles/html-paths":28,"./styles/parser/tokeniser":29,lop:89,underscore:102}],27:[function(require,module,exports){function paragraph(options){return new Matcher("paragraph",options)}function run(options){return new Matcher("run",options)}function table(options){return new Matcher("table",options)}function highlight(options){return new HighlightMatcher(options)}function Matcher(elementType,options){options=options||{},this._elementType=elementType,this._styleId=options.styleId,this._styleName=options.styleName,options.list&&(this._listIndex=options.list.levelIndex,this._listIsOrdered=options.list.isOrdered)}function HighlightMatcher(options){options=options||{},this._color=options.color}function BreakMatcher(options){options=options||{},this._breakType=options.breakType}function isList(element,levelIndex,isOrdered){return element.numbering&&element.numbering.level==levelIndex&&element.numbering.isOrdered==isOrdered}function equalTo(value){return{operator:operatorEqualTo,operand:value}}function startsWith(value){return{operator:operatorStartsWith,operand:value}}function operatorEqualTo(first,second){return first.toUpperCase()===second.toUpperCase()}function operatorStartsWith(first,second){return 0===second.toUpperCase().indexOf(first.toUpperCase())}exports.paragraph=paragraph,exports.run=run,exports.table=table,exports.bold=new Matcher("bold"),exports.italic=new Matcher("italic"),exports.underline=new Matcher("underline"),exports.strikethrough=new Matcher("strikethrough"),exports.allCaps=new Matcher("allCaps"),exports.smallCaps=new Matcher("smallCaps"),exports.highlight=highlight,exports.commentReference=new Matcher("commentReference"),exports.lineBreak=new BreakMatcher({breakType:"line"}),exports.pageBreak=new BreakMatcher({breakType:"page"}),exports.columnBreak=new BreakMatcher({breakType:"column"}),exports.equalTo=equalTo,exports.startsWith=startsWith,Matcher.prototype.matches=function(element){return element.type===this._elementType&&(void 0===this._styleId||element.styleId===this._styleId)&&(void 0===this._styleName||element.styleName&&this._styleName.operator(this._styleName.operand,element.styleName))&&(void 0===this._listIndex||isList(element,this._listIndex,this._listIsOrdered))&&(void 0===this._breakType||this._breakType===element.breakType)},HighlightMatcher.prototype.matches=function(element){return"highlight"===element.type&&(void 0===this._color||element.color===this._color)},BreakMatcher.prototype.matches=function(element){return"break"===element.type&&(void 0===this._breakType||element.breakType===this._breakType)}},{}],28:[function(require,module,exports){function topLevelElement(tagName,attributes){return elements([element(tagName,attributes,{fresh:!0})])}function elements(elementStyles){return new HtmlPath(elementStyles.map(function(elementStyle){return _.isString(elementStyle)?element(elementStyle):elementStyle}))}function HtmlPath(elements){this._elements=elements}function element(tagName,attributes,options){return options=options||{},new Element(tagName,attributes,options)}function Element(tagName,attributes,options){var tagNames={};_.isArray(tagName)?(tagName.forEach(function(tagName){tagNames[tagName]=!0}),tagName=tagName[0]):tagNames[tagName]=!0,this.tagName=tagName,this.tagNames=tagNames,this.attributes=attributes||{},this.fresh=options.fresh,this.separator=options.separator}var _=require("underscore"),html=require("../html");exports.topLevelElement=topLevelElement,exports.elements=elements,exports.element=element,HtmlPath.prototype.wrap=function(children){for(var result=children(),index=this._elements.length-1;index>=0;index--)result=this._elements[index].wrapNodes(result);return result},Element.prototype.matchesElement=function(element){return this.tagNames[element.tagName]&&_.isEqual(this.attributes||{},element.attributes||{})},Element.prototype.wrap=function(generateNodes){return this.wrapNodes(generateNodes())},Element.prototype.wrapNodes=function(nodes){return[html.elementWithTag(this,nodes)]},exports.empty=elements([]),exports.ignore={wrap:function(){return[]}}},{"../html":18,underscore:102}],29:[function(require,module,exports){function tokenise(string){var identifierCharacter="(?:[a-zA-Z\\-_]|\\\\.)",tokeniser=new RegexTokeniser([{name:"identifier",regex:new RegExp("("+identifierCharacter+"(?:"+identifierCharacter+"|[0-9])*)")},{name:"dot",regex:/\./},{name:"colon",regex:/:/},{name:"gt",regex:/>/},{name:"whitespace",regex:/\s+/},{name:"arrow",regex:/=>/},{name:"equals",regex:/=/},{name:"startsWith",regex:/\^=/},{name:"open-paren",regex:/\(/},{name:"close-paren",regex:/\)/},{name:"open-square-bracket",regex:/\[/},{name:"close-square-bracket",regex:/\]/},{name:"string",regex:new RegExp(stringPrefix+"'")},{name:"unterminated-string",regex:new RegExp(stringPrefix)},{name:"integer",regex:/([0-9]+)/},{name:"choice",regex:/\|/},{name:"bang",regex:/(!)/}]);return tokeniser.tokenise(string)}var lop=require("lop"),RegexTokeniser=lop.RegexTokeniser;exports.tokenise=tokenise;var stringPrefix="'((?:\\\\.|[^'])*)"},{lop:89}],30:[function(require,module,exports){function paragraph(transform){return elementsOfType("paragraph",transform)}function run(transform){return elementsOfType("run",transform)}function elementsOfType(elementType,transform){return elements(function(element){return element.type===elementType?transform(element):element})}function elements(transform){return function transformElement(element){if(element.children){var children=_.map(element.children,transformElement);element=_.extend(element,{children:children})}return transform(element)}}function getDescendantsOfType(element,type){return getDescendants(element).filter(function(descendant){return descendant.type===type})}function getDescendants(element){var descendants=[];return visitDescendants(element,function(descendant){descendants.push(descendant)}),descendants}function visitDescendants(element,visit){element.children&&element.children.forEach(function(child){visitDescendants(child,visit),visit(child)})}var _=require("underscore");exports.paragraph=paragraph,exports.run=run,exports._elements=elements,exports.getDescendantsOfType=getDescendantsOfType,exports.getDescendants=getDescendants},{underscore:102}],31:[function(require,module,exports){function element(name){return function(html){return Html.elementWithTag(htmlPaths.element(name),[html])}}var htmlPaths=require("./styles/html-paths"),Html=require("./html");exports.element=element},{"./html":18,"./styles/html-paths":28}],32:[function(require,module,exports){function writer(options){return options=options||{},options.prettyPrint?prettyWriter():simpleWriter()}function prettyWriter(){function open(tagName,attributes){indentedElements[tagName]&&indent(),stack.push(tagName),writer.open(tagName,attributes),indentedElements[tagName]&&indentationLevel++,start=!1}function close(tagName){indentedElements[tagName]&&(indentationLevel--,indent()),stack.pop(),writer.close(tagName)}function text(value){startText();var text=isInPre()?value:value.replace("\n","\n"+indentation);writer.text(text)}function selfClosing(tagName,attributes){indent(),writer.selfClosing(tagName,attributes)}function insideIndentedElement(){return 0===stack.length||indentedElements[stack[stack.length-1]]}function startText(){inText||(indent(),inText=!0)}function indent(){if(inText=!1,!start&&insideIndentedElement()&&!isInPre()){writer._append("\n");for(var i=0;indentationLevel>i;i++)writer._append(indentation)}}function isInPre(){return _.some(stack,function(tagName){return"pre"===tagName})}var indentationLevel=0,indentation=" ",stack=[],start=!0,inText=!1,writer=simpleWriter();return{asString:writer.asString,open:open,close:close,text:text,selfClosing:selfClosing}}function simpleWriter(){function open(tagName,attributes){var attributeString=generateAttributeString(attributes);fragments.push("<"+tagName+attributeString+">")}function close(tagName){fragments.push("")}function selfClosing(tagName,attributes){var attributeString=generateAttributeString(attributes);fragments.push("<"+tagName+attributeString+" />")}function generateAttributeString(attributes){return _.map(attributes,function(value,key){return" "+key+'="'+escapeHtmlAttribute(value)+'"'}).join("")}function text(value){fragments.push(escapeHtmlText(value))}function append(html){fragments.push(html)}function asString(){return fragments.join("")}var fragments=[];return{asString:asString,open:open,close:close,text:text,selfClosing:selfClosing,_append:append}}function escapeHtmlText(value){return value.replace(/&/g,"&").replace(//g,">")}function escapeHtmlAttribute(value){return value.replace(/&/g,"&").replace(/"/g,""").replace(//g,">")}var _=require("underscore");exports.writer=writer;var indentedElements={div:!0,p:!0,ul:!0,li:!0}},{underscore:102}],33:[function(require,module,exports){function writer(options){return options=options||{},"markdown"===options.outputFormat?markdownWriter.writer():htmlWriter.writer(options)}var htmlWriter=require("./html-writer"),markdownWriter=require("./markdown-writer");exports.writer=writer},{"./html-writer":32,"./markdown-writer":34}],34:[function(require,module,exports){function symmetricMarkdownElement(end){return markdownElement(end,end)}function markdownElement(start,end){return function(){return{start:start,end:end}}}function markdownLink(attributes){var href=attributes.href||"";return href?{start:"[",end:"]("+href+")",anchorPosition:"before"}:{}}function markdownImage(attributes){var src=attributes.src||"",altText=attributes.alt||"";return src||altText?{start:"!["+altText+"]("+src+")"}:{}}function markdownList(options){return function(attributes,list){return{start:list?"\n":"",end:list?"":"\n",list:{isOrdered:options.isOrdered,indent:list?list.indent+1:0,count:0}}}}function markdownListItem(attributes,list,listItem){list=list||{indent:0,isOrdered:!1,count:0},list.count++,listItem.hasClosed=!1;var bullet=list.isOrdered?list.count+".":"-",start=repeatString(" ",list.indent)+bullet+" ";return{start:start,end:function(){return listItem.hasClosed?void 0:(listItem.hasClosed=!0,"\n")}}}function repeatString(value,count){return new Array(count+1).join(value)}function markdownWriter(){function open(tagName,attributes){attributes=attributes||{};var createElement=htmlToMarkdown[tagName]||function(){return{}},element=createElement(attributes,list,listItem);elementStack.push({end:element.end,list:list}),element.list&&(list=element.list);var anchorBeforeStart="before"===element.anchorPosition;anchorBeforeStart&&writeAnchor(attributes),fragments.push(element.start||""),anchorBeforeStart||writeAnchor(attributes)}function writeAnchor(attributes){attributes.id&&fragments.push('')}function close(tagName){var element=elementStack.pop();list=element.list;var end=_.isFunction(element.end)?element.end():element.end;fragments.push(end||"")}function selfClosing(tagName,attributes){open(tagName,attributes),close(tagName)}function text(value){fragments.push(escapeMarkdown(value))}function asString(){return fragments.join("")}var fragments=[],elementStack=[],list=null,listItem={};return{asString:asString,open:open,close:close,text:text,selfClosing:selfClosing}}function escapeMarkdown(value){return value.replace(/\\/g,"\\\\").replace(/([\`\*_\{\}\[\]\(\)\#\+\-\.\!])/g,"\\$1")}var _=require("underscore"),htmlToMarkdown={p:markdownElement("","\n\n"),br:markdownElement(""," \n"),ul:markdownList({isOrdered:!1}),ol:markdownList({isOrdered:!0}),li:markdownListItem,strong:symmetricMarkdownElement("__"),em:symmetricMarkdownElement("*"),a:markdownLink,img:markdownImage};!function(){for(var i=1;6>=i;i++)htmlToMarkdown["h"+i]=markdownElement(repeatString("#",i)+" ","\n\n")}(),exports.writer=markdownWriter},{underscore:102}],35:[function(require,module,exports){var nodes=require("./nodes");exports.Element=nodes.Element,exports.element=nodes.element,exports.text=nodes.text,exports.readString=require("./reader").readString,exports.writeString=require("./writer").writeString},{"./nodes":36,"./reader":37,"./writer":38}],36:[function(require,module,exports){function Element(name,attributes,children){this.type="element",this.name=name,this.attributes=attributes||{},this.children=children||[]}function toElementList(array){return _.extend(array,elementListPrototype)}var _=require("underscore");exports.Element=Element,exports.element=function(name,attributes,children){return new Element(name,attributes,children)},exports.text=function(value){return{type:"text",value:value}};var emptyElement={first:function(){return null},firstOrEmpty:function(){return emptyElement},attributes:{}};Element.prototype.first=function(name){return _.find(this.children,function(child){return child.name===name})},Element.prototype.firstOrEmpty=function(name){return this.first(name)||emptyElement},Element.prototype.getElementsByTagName=function(name){var elements=_.filter(this.children,function(child){return child.name===name});return toElementList(elements)},Element.prototype.text=function(){if(0===this.children.length)return"";if(1!==this.children.length||"text"!==this.children[0].type)throw new Error("Not implemented");return this.children[0].value};var elementListPrototype={getElementsByTagName:function(name){return toElementList(_.flatten(this.map(function(element){return element.getElementsByTagName(name)},!0)))}}},{underscore:102}],37:[function(require,module,exports){function readString(xmlString,namespaceMap){function convertNode(node){switch(node.nodeType){case Node.ELEMENT_NODE:return convertElement(node);case Node.TEXT_NODE:return nodes.text(node.nodeValue)}}function convertElement(element){var convertedName=convertName(element),convertedChildren=[];_.forEach(element.childNodes,function(childNode){var convertedNode=convertNode(childNode);convertedNode&&convertedChildren.push(convertedNode)});var convertedAttributes={};return _.forEach(element.attributes,function(attribute){convertedAttributes[convertName(attribute)]=attribute.value}),new Element(convertedName,convertedAttributes,convertedChildren)}function convertName(node){if(node.namespaceURI){var prefix,mappedPrefix=namespaceMap[node.namespaceURI];return prefix=mappedPrefix?mappedPrefix+":":"{"+node.namespaceURI+"}",prefix+node.localName}return node.localName}namespaceMap=namespaceMap||{};try{var document=xmldom.parseFromString(xmlString,"text/xml")}catch(error){return promises.reject(error)}return"parsererror"===document.documentElement.tagName?promises.resolve(new Error(document.documentElement.textContent)):promises.resolve(convertNode(document.documentElement))}var promises=require("../promises"),_=require("underscore"),xmldom=require("./xmldom"),nodes=require("./nodes"),Element=nodes.Element;exports.readString=readString;var Node=xmldom.Node},{"../promises":23,"./nodes":36,"./xmldom":39,underscore:102}],38:[function(require,module,exports){function writeString(root,namespaces){function writeNode(builder,node){return nodeWriters[node.type](builder,node)}function writeElement(builder,element){var elementBuilder=builder.element(mapElementName(element.name),element.attributes);element.children.forEach(function(child){writeNode(elementBuilder,child)})}function mapElementName(name){var longFormMatch=/^\{(.*)\}(.*)$/.exec(name);if(longFormMatch){var prefix=uriToPrefix[longFormMatch[1]];return prefix+(""===prefix?"":":")+longFormMatch[2]}return name}function writeDocument(root){var builder=xmlbuilder.create(mapElementName(root.name),{version:"1.0",encoding:"UTF-8",standalone:!0});return _.forEach(namespaces,function(uri,prefix){var key="xmlns"+(""===prefix?"":":"+prefix);builder.attribute(key,uri)}),root.children.forEach(function(child){writeNode(builder,child)}),builder.end()}var uriToPrefix=_.invert(namespaces),nodeWriters={element:writeElement,text:writeTextNode};return writeDocument(root)}function writeTextNode(builder,node){builder.text(node.value)}var _=require("underscore"),xmlbuilder=require("xmlbuilder");exports.writeString=writeString},{underscore:102,xmlbuilder:127}],39:[function(require,module,exports){function parseFromString(string){var error=null,domParser=new xmldom.DOMParser({errorHandler:function(level,message){error={level:level,message:message}}}),document=domParser.parseFromString(string);if(null===error)return document;throw new Error(error.level+": "+error.message)}var xmldom=require("@xmldom/xmldom"),dom=require("@xmldom/xmldom/lib/dom");exports.parseFromString=parseFromString,exports.Node=dom.Node},{"@xmldom/xmldom":45,"@xmldom/xmldom/lib/dom":43}],40:[function(require,module,exports){function openArrayBuffer(arrayBuffer){return JSZip.loadAsync(arrayBuffer).then(function(zipFile){function exists(name){return null!==zipFile.file(name)}function read(name,encoding){return zipFile.file(name).async("uint8array").then(function(array){if("base64"===encoding)return base64js.fromByteArray(array);if(encoding){var decoder=new TextDecoder(encoding);return decoder.decode(array)}return array})}function write(name,contents){zipFile.file(name,contents)}function toArrayBuffer(){return zipFile.generateAsync({type:"arraybuffer"})}return{exists:exists,read:read,write:write,toArrayBuffer:toArrayBuffer}})}function splitPath(path){var lastIndex=path.lastIndexOf("/");return-1===lastIndex?{dirname:"",basename:path}:{dirname:path.substring(0,lastIndex),basename:path.substring(lastIndex+1)}}function joinPath(){var nonEmptyPaths=Array.prototype.filter.call(arguments,function(path){return path}),relevantPaths=[];return nonEmptyPaths.forEach(function(path){/^\//.test(path)?relevantPaths=[path]:relevantPaths.push(path)}),relevantPaths.join("/")}var base64js=require("base64-js"),JSZip=require("jszip");exports.openArrayBuffer=openArrayBuffer,exports.splitPath=splitPath,exports.joinPath=joinPath},{"base64-js":47,jszip:88}],41:[function(require,module,exports){"use strict";function find(list,predicate,ac){if(void 0===ac&&(ac=Array.prototype),list&&"function"==typeof ac.find)return ac.find.call(list,predicate);for(var i=0;i=start+length||start?new java.lang.String(chars,start,length)+"":chars}function appendElement(hander,node){hander.currentElement?hander.currentElement.appendChild(node):hander.doc.appendChild(node)}var conventions=require("./conventions"),dom=require("./dom"),entities=require("./entities"),sax=require("./sax"),DOMImplementation=dom.DOMImplementation,NAMESPACE=conventions.NAMESPACE,ParseError=sax.ParseError,XMLReader=sax.XMLReader;DOMParser.prototype.parseFromString=function(source,mimeType){var options=this.options,sax=new XMLReader,domBuilder=options.domBuilder||new DOMHandler,errorHandler=options.errorHandler,locator=options.locator,defaultNSMap=options.xmlns||{},isHTML=/\/x?html?$/.test(mimeType),entityMap=isHTML?entities.HTML_ENTITIES:entities.XML_ENTITIES;locator&&domBuilder.setDocumentLocator(locator),sax.errorHandler=buildErrorHandler(errorHandler,domBuilder,locator),sax.domBuilder=options.domBuilder||domBuilder,isHTML&&(defaultNSMap[""]=NAMESPACE.HTML),defaultNSMap.xml=defaultNSMap.xml||NAMESPACE.XML;var normalize=options.normalizeLineEndings||normalizeLineEndings;return source&&"string"==typeof source?sax.parse(normalize(source),defaultNSMap,entityMap):sax.errorHandler.error("invalid doc source"),domBuilder.doc},DOMHandler.prototype={startDocument:function(){this.doc=(new DOMImplementation).createDocument(null,null,null),this.locator&&(this.doc.documentURI=this.locator.systemId)},startElement:function(namespaceURI,localName,qName,attrs){var doc=this.doc,el=doc.createElementNS(namespaceURI,qName||localName),len=attrs.length;appendElement(this,el),this.currentElement=el,this.locator&&position(this.locator,el);for(var i=0;len>i;i++){var namespaceURI=attrs.getURI(i),value=attrs.getValue(i),qName=attrs.getQName(i),attr=doc.createAttributeNS(namespaceURI,qName);this.locator&&position(attrs.getLocator(i),attr),attr.value=attr.nodeValue=value,el.setAttributeNode(attr)}},endElement:function(namespaceURI,localName,qName){var current=this.currentElement;current.tagName;this.currentElement=current.parentNode},startPrefixMapping:function(prefix,uri){},endPrefixMapping:function(prefix){},processingInstruction:function(target,data){var ins=this.doc.createProcessingInstruction(target,data);this.locator&&position(this.locator,ins),appendElement(this,ins)},ignorableWhitespace:function(ch,start,length){},characters:function(chars,start,length){if(chars=_toString.apply(this,arguments)){if(this.cdata)var charNode=this.doc.createCDATASection(chars);else var charNode=this.doc.createTextNode(chars);this.currentElement?this.currentElement.appendChild(charNode):/^\s*$/.test(chars)&&this.doc.appendChild(charNode),this.locator&&position(this.locator,charNode)}},skippedEntity:function(name){},endDocument:function(){this.doc.normalize()},setDocumentLocator:function(locator){(this.locator=locator)&&(locator.lineNumber=0)},comment:function(chars,start,length){chars=_toString.apply(this,arguments);var comm=this.doc.createComment(chars);this.locator&&position(this.locator,comm),appendElement(this,comm)},startCDATA:function(){this.cdata=!0},endCDATA:function(){this.cdata=!1},startDTD:function(name,publicId,systemId){var impl=this.doc.implementation;if(impl&&impl.createDocumentType){var dt=impl.createDocumentType(name,publicId,systemId);this.locator&&position(this.locator,dt),appendElement(this,dt),this.doc.doctype=dt}},warning:function(error){console.warn("[xmldom warning] "+error,_locator(this.locator))},error:function(error){console.error("[xmldom error] "+error,_locator(this.locator))},fatalError:function(error){throw new ParseError(error,this.locator)}},"endDTD,startEntity,endEntity,attributeDecl,elementDecl,externalEntityDecl,internalEntityDecl,resolveEntity,getExternalSubset,notationDecl,unparsedEntityDecl".replace(/\w+/g,function(key){DOMHandler.prototype[key]=function(){return null}}),exports.__DOMHandler=DOMHandler,exports.normalizeLineEndings=normalizeLineEndings,exports.DOMParser=DOMParser; -},{"./conventions":41,"./dom":43,"./entities":44,"./sax":46}],43:[function(require,module,exports){function notEmptyString(input){return""!==input}function splitOnASCIIWhitespace(input){return input?input.split(/[\t\n\f\r ]+/).filter(notEmptyString):[]}function orderedSetReducer(current,element){return current.hasOwnProperty(element)||(current[element]=!0),current}function toOrderedSet(input){if(!input)return[];var list=splitOnASCIIWhitespace(input);return Object.keys(list.reduce(orderedSetReducer,{}))}function arrayIncludes(list){return function(element){return list&&-1!==list.indexOf(element)}}function copy(src,dest){for(var p in src)Object.prototype.hasOwnProperty.call(src,p)&&(dest[p]=src[p])}function _extends(Class,Super){function t(){}var pt=Class.prototype;pt instanceof Super||(t.prototype=Super.prototype,t=new t,copy(pt,t),Class.prototype=pt=t),pt.constructor!=Class&&("function"!=typeof Class&&console.error("unknown Class:"+Class),pt.constructor=Class)}function DOMException(code,message){if(message instanceof Error)var error=message;else error=this,Error.call(this,ExceptionMessage[code]),this.message=ExceptionMessage[code],Error.captureStackTrace&&Error.captureStackTrace(this,DOMException);return error.code=code,message&&(this.message=this.message+": "+message),error}function NodeList(){}function LiveNodeList(node,refresh){this._node=node,this._refresh=refresh,_updateLiveList(this)}function _updateLiveList(list){var inc=list._node._inc||list._node.ownerDocument._inc;if(list._inc!=inc){var ls=list._refresh(list._node);__set__(list,"length",ls.length),copy(ls,list),list._inc=inc}}function NamedNodeMap(){}function _findNodeIndex(list,node){for(var i=list.length;i--;)if(list[i]===node)return i}function _addNamedNode(el,list,newAttr,oldAttr){if(oldAttr?list[_findNodeIndex(list,oldAttr)]=newAttr:list[list.length++]=newAttr,el){newAttr.ownerElement=el;var doc=el.ownerDocument;doc&&(oldAttr&&_onRemoveAttribute(doc,el,oldAttr),_onAddAttribute(doc,el,newAttr))}}function _removeNamedNode(el,list,attr){var i=_findNodeIndex(list,attr);if(!(i>=0))throw new DOMException(NOT_FOUND_ERR,new Error(el.tagName+"@"+attr));for(var lastIndex=list.length-1;lastIndex>i;)list[i]=list[++i];if(list.length=lastIndex,el){var doc=el.ownerDocument;doc&&(_onRemoveAttribute(doc,el,attr),attr.ownerElement=null)}}function DOMImplementation(){}function Node(){}function _xmlEncoder(c){return"<"==c&&"<"||">"==c&&">"||"&"==c&&"&"||'"'==c&&"""||"&#"+c.charCodeAt()+";"}function _visitNode(node,callback){if(callback(node))return!0;if(node=node.firstChild)do if(_visitNode(node,callback))return!0;while(node=node.nextSibling)}function Document(){this.ownerDocument=this}function _onAddAttribute(doc,el,newAttr){doc&&doc._inc++;var ns=newAttr.namespaceURI;ns===NAMESPACE.XMLNS&&(el._nsMap[newAttr.prefix?newAttr.localName:""]=newAttr.value)}function _onRemoveAttribute(doc,el,newAttr,remove){doc&&doc._inc++;var ns=newAttr.namespaceURI;ns===NAMESPACE.XMLNS&&delete el._nsMap[newAttr.prefix?newAttr.localName:""]}function _onUpdateChild(doc,el,newChild){if(doc&&doc._inc){doc._inc++;var cs=el.childNodes;if(newChild)cs[cs.length++]=newChild;else{for(var child=el.firstChild,i=0;child;)cs[i++]=child,child=child.nextSibling;cs.length=i,delete cs[cs.length]}}}function _removeChild(parentNode,child){var previous=child.previousSibling,next=child.nextSibling;return previous?previous.nextSibling=next:parentNode.firstChild=next,next?next.previousSibling=previous:parentNode.lastChild=previous,child.parentNode=null,child.previousSibling=null,child.nextSibling=null,_onUpdateChild(parentNode.ownerDocument,parentNode),child}function hasValidParentNodeType(node){return node&&(node.nodeType===Node.DOCUMENT_NODE||node.nodeType===Node.DOCUMENT_FRAGMENT_NODE||node.nodeType===Node.ELEMENT_NODE)}function hasInsertableNodeType(node){return node&&(isElementNode(node)||isTextNode(node)||isDocTypeNode(node)||node.nodeType===Node.DOCUMENT_FRAGMENT_NODE||node.nodeType===Node.COMMENT_NODE||node.nodeType===Node.PROCESSING_INSTRUCTION_NODE)}function isDocTypeNode(node){return node&&node.nodeType===Node.DOCUMENT_TYPE_NODE}function isElementNode(node){return node&&node.nodeType===Node.ELEMENT_NODE}function isTextNode(node){return node&&node.nodeType===Node.TEXT_NODE}function isElementInsertionPossible(doc,child){var parentChildNodes=doc.childNodes||[];if(find(parentChildNodes,isElementNode)||isDocTypeNode(child))return!1;var docTypeNode=find(parentChildNodes,isDocTypeNode);return!(child&&docTypeNode&&parentChildNodes.indexOf(docTypeNode)>parentChildNodes.indexOf(child))}function isElementReplacementPossible(doc,child){function hasElementChildThatIsNotChild(node){return isElementNode(node)&&node!==child}var parentChildNodes=doc.childNodes||[];if(find(parentChildNodes,hasElementChildThatIsNotChild))return!1;var docTypeNode=find(parentChildNodes,isDocTypeNode);return!(child&&docTypeNode&&parentChildNodes.indexOf(docTypeNode)>parentChildNodes.indexOf(child))}function assertPreInsertionValidity1to5(parent,node,child){if(!hasValidParentNodeType(parent))throw new DOMException(HIERARCHY_REQUEST_ERR,"Unexpected parent node type "+parent.nodeType);if(child&&child.parentNode!==parent)throw new DOMException(NOT_FOUND_ERR,"child not in parent");if(!hasInsertableNodeType(node)||isDocTypeNode(node)&&parent.nodeType!==Node.DOCUMENT_NODE)throw new DOMException(HIERARCHY_REQUEST_ERR,"Unexpected node type "+node.nodeType+" for parent node type "+parent.nodeType)}function assertPreInsertionValidityInDocument(parent,node,child){var parentChildNodes=parent.childNodes||[],nodeChildNodes=node.childNodes||[];if(node.nodeType===Node.DOCUMENT_FRAGMENT_NODE){var nodeChildElements=nodeChildNodes.filter(isElementNode);if(nodeChildElements.length>1||find(nodeChildNodes,isTextNode))throw new DOMException(HIERARCHY_REQUEST_ERR,"More than one element or text in fragment");if(1===nodeChildElements.length&&!isElementInsertionPossible(parent,child))throw new DOMException(HIERARCHY_REQUEST_ERR,"Element in fragment can not be inserted before doctype")}if(isElementNode(node)&&!isElementInsertionPossible(parent,child))throw new DOMException(HIERARCHY_REQUEST_ERR,"Only one element can be added and only after doctype");if(isDocTypeNode(node)){if(find(parentChildNodes,isDocTypeNode))throw new DOMException(HIERARCHY_REQUEST_ERR,"Only one doctype is allowed");var parentElementChild=find(parentChildNodes,isElementNode);if(child&&parentChildNodes.indexOf(parentElementChild)1||find(nodeChildNodes,isTextNode))throw new DOMException(HIERARCHY_REQUEST_ERR,"More than one element or text in fragment");if(1===nodeChildElements.length&&!isElementReplacementPossible(parent,child))throw new DOMException(HIERARCHY_REQUEST_ERR,"Element in fragment can not be inserted before doctype")}if(isElementNode(node)&&!isElementReplacementPossible(parent,child))throw new DOMException(HIERARCHY_REQUEST_ERR,"Only one element can be added and only after doctype");if(isDocTypeNode(node)){if(find(parentChildNodes,hasDoctypeChildThatIsNotChild))throw new DOMException(HIERARCHY_REQUEST_ERR,"Only one doctype is allowed");var parentElementChild=find(parentChildNodes,isElementNode);if(child&&parentChildNodes.indexOf(parentElementChild)=0;nsi--){var namespace=visibleNamespaces[nsi];if(""===namespace.prefix&&namespace.namespace===node.namespaceURI){defaultNS=namespace.namespace;break}}if(defaultNS!==node.namespaceURI)for(var nsi=visibleNamespaces.length-1;nsi>=0;nsi--){var namespace=visibleNamespaces[nsi];if(namespace.namespace===node.namespaceURI){namespace.prefix&&(prefixedNodeName=namespace.prefix+":"+nodeName);break}}}buf.push("<",prefixedNodeName);for(var i=0;len>i;i++){var attr=attrs.item(i);"xmlns"==attr.prefix?visibleNamespaces.push({prefix:attr.localName,namespace:attr.value}):"xmlns"==attr.nodeName&&visibleNamespaces.push({prefix:"",namespace:attr.value})}for(var i=0;len>i;i++){var attr=attrs.item(i);if(needNamespaceDefine(attr,isHTML,visibleNamespaces)){var prefix=attr.prefix||"",uri=attr.namespaceURI;addSerializedAttribute(buf,prefix?"xmlns:"+prefix:"xmlns",uri),visibleNamespaces.push({prefix:prefix,namespace:uri})}serializeToString(attr,buf,isHTML,nodeFilter,visibleNamespaces)}if(nodeName===prefixedNodeName&&needNamespaceDefine(node,isHTML,visibleNamespaces)){var prefix=node.prefix||"",uri=node.namespaceURI;addSerializedAttribute(buf,prefix?"xmlns:"+prefix:"xmlns",uri),visibleNamespaces.push({prefix:prefix,namespace:uri})}if(child||isHTML&&!/^(?:meta|link|img|br|hr|input)$/i.test(nodeName)){if(buf.push(">"),isHTML&&/^script$/i.test(nodeName))for(;child;)child.data?buf.push(child.data):serializeToString(child,buf,isHTML,nodeFilter,visibleNamespaces.slice()),child=child.nextSibling;else for(;child;)serializeToString(child,buf,isHTML,nodeFilter,visibleNamespaces.slice()),child=child.nextSibling;buf.push("")}else buf.push("/>");return;case DOCUMENT_NODE:case DOCUMENT_FRAGMENT_NODE:for(var child=node.firstChild;child;)serializeToString(child,buf,isHTML,nodeFilter,visibleNamespaces.slice()),child=child.nextSibling;return;case ATTRIBUTE_NODE:return addSerializedAttribute(buf,node.name,node.value);case TEXT_NODE:return buf.push(node.data.replace(/[<&>]/g,_xmlEncoder));case CDATA_SECTION_NODE:return buf.push("");case COMMENT_NODE:return buf.push("");case DOCUMENT_TYPE_NODE:var pubid=node.publicId,sysid=node.systemId;if(buf.push("");else if(sysid&&"."!=sysid)buf.push(" SYSTEM ",sysid,">");else{var sub=node.internalSubset;sub&&buf.push(" [",sub,"]"),buf.push(">")}return;case PROCESSING_INSTRUCTION_NODE:return buf.push("");case ENTITY_REFERENCE_NODE:return buf.push("&",node.nodeName,";");default:buf.push("??",node.nodeName)}}function importNode(doc,node,deep){var node2;switch(node.nodeType){case ELEMENT_NODE:node2=node.cloneNode(!1),node2.ownerDocument=doc;case DOCUMENT_FRAGMENT_NODE:break;case ATTRIBUTE_NODE:deep=!0}if(node2||(node2=node.cloneNode(!1)),node2.ownerDocument=doc,node2.parentNode=null,deep)for(var child=node.firstChild;child;)node2.appendChild(importNode(doc,child,deep)),child=child.nextSibling;return node2}function cloneNode(doc,node,deep){var node2=new node.constructor;for(var n in node)if(Object.prototype.hasOwnProperty.call(node,n)){var v=node[n];"object"!=typeof v&&v!=node2[n]&&(node2[n]=v)}switch(node.childNodes&&(node2.childNodes=new NodeList),node2.ownerDocument=doc,node2.nodeType){case ELEMENT_NODE:var attrs=node.attributes,attrs2=node2.attributes=new NamedNodeMap,len=attrs.length;attrs2._ownerElement=node2;for(var i=0;len>i;i++)node2.setAttributeNode(cloneNode(doc,attrs.item(i),!0));break;case ATTRIBUTE_NODE:deep=!0}if(deep)for(var child=node.firstChild;child;)node2.appendChild(cloneNode(doc,child,deep)),child=child.nextSibling;return node2}function __set__(object,key,value){object[key]=value}function getTextContent(node){switch(node.nodeType){case ELEMENT_NODE:case DOCUMENT_FRAGMENT_NODE:var buf=[];for(node=node.firstChild;node;)7!==node.nodeType&&8!==node.nodeType&&buf.push(getTextContent(node)),node=node.nextSibling;return buf.join("");default:return node.nodeValue}}var conventions=require("./conventions"),find=conventions.find,NAMESPACE=conventions.NAMESPACE,NodeType={},ELEMENT_NODE=NodeType.ELEMENT_NODE=1,ATTRIBUTE_NODE=NodeType.ATTRIBUTE_NODE=2,TEXT_NODE=NodeType.TEXT_NODE=3,CDATA_SECTION_NODE=NodeType.CDATA_SECTION_NODE=4,ENTITY_REFERENCE_NODE=NodeType.ENTITY_REFERENCE_NODE=5,ENTITY_NODE=NodeType.ENTITY_NODE=6,PROCESSING_INSTRUCTION_NODE=NodeType.PROCESSING_INSTRUCTION_NODE=7,COMMENT_NODE=NodeType.COMMENT_NODE=8,DOCUMENT_NODE=NodeType.DOCUMENT_NODE=9,DOCUMENT_TYPE_NODE=NodeType.DOCUMENT_TYPE_NODE=10,DOCUMENT_FRAGMENT_NODE=NodeType.DOCUMENT_FRAGMENT_NODE=11,NOTATION_NODE=NodeType.NOTATION_NODE=12,ExceptionCode={},ExceptionMessage={},HIERARCHY_REQUEST_ERR=(ExceptionCode.INDEX_SIZE_ERR=(ExceptionMessage[1]="Index size error",1),ExceptionCode.DOMSTRING_SIZE_ERR=(ExceptionMessage[2]="DOMString size error",2),ExceptionCode.HIERARCHY_REQUEST_ERR=(ExceptionMessage[3]="Hierarchy request error",3)),NOT_FOUND_ERR=(ExceptionCode.WRONG_DOCUMENT_ERR=(ExceptionMessage[4]="Wrong document",4),ExceptionCode.INVALID_CHARACTER_ERR=(ExceptionMessage[5]="Invalid character",5),ExceptionCode.NO_DATA_ALLOWED_ERR=(ExceptionMessage[6]="No data allowed",6),ExceptionCode.NO_MODIFICATION_ALLOWED_ERR=(ExceptionMessage[7]="No modification allowed",7),ExceptionCode.NOT_FOUND_ERR=(ExceptionMessage[8]="Not found",8)),INUSE_ATTRIBUTE_ERR=(ExceptionCode.NOT_SUPPORTED_ERR=(ExceptionMessage[9]="Not supported",9),ExceptionCode.INUSE_ATTRIBUTE_ERR=(ExceptionMessage[10]="Attribute in use",10));ExceptionCode.INVALID_STATE_ERR=(ExceptionMessage[11]="Invalid state",11),ExceptionCode.SYNTAX_ERR=(ExceptionMessage[12]="Syntax error",12),ExceptionCode.INVALID_MODIFICATION_ERR=(ExceptionMessage[13]="Invalid modification",13),ExceptionCode.NAMESPACE_ERR=(ExceptionMessage[14]="Invalid namespace",14),ExceptionCode.INVALID_ACCESS_ERR=(ExceptionMessage[15]="Invalid access",15);DOMException.prototype=Error.prototype,copy(ExceptionCode,DOMException),NodeList.prototype={length:0,item:function(index){return this[index]||null},toString:function(isHTML,nodeFilter){for(var buf=[],i=0;i0},lookupPrefix:function(namespaceURI){for(var el=this;el;){var map=el._nsMap;if(map)for(var n in map)if(Object.prototype.hasOwnProperty.call(map,n)&&map[n]===namespaceURI)return n;el=el.nodeType==ATTRIBUTE_NODE?el.ownerDocument:el.parentNode}return null},lookupNamespaceURI:function(prefix){for(var el=this;el;){var map=el._nsMap;if(map&&Object.prototype.hasOwnProperty.call(map,prefix))return map[prefix];el=el.nodeType==ATTRIBUTE_NODE?el.ownerDocument:el.parentNode}return null},isDefaultNamespace:function(namespaceURI){var prefix=this.lookupPrefix(namespaceURI);return null==prefix}},copy(NodeType,Node),copy(NodeType,Node.prototype),Document.prototype={nodeName:"#document",nodeType:DOCUMENT_NODE,doctype:null,documentElement:null,_inc:1,insertBefore:function(newChild,refChild){if(newChild.nodeType==DOCUMENT_FRAGMENT_NODE){for(var child=newChild.firstChild;child;){var next=child.nextSibling;this.insertBefore(child,refChild),child=next}return newChild}return _insertBefore(this,newChild,refChild),newChild.ownerDocument=this,null===this.documentElement&&newChild.nodeType===ELEMENT_NODE&&(this.documentElement=newChild),newChild},removeChild:function(oldChild){return this.documentElement==oldChild&&(this.documentElement=null),_removeChild(this,oldChild)},replaceChild:function(newChild,oldChild){_insertBefore(this,newChild,oldChild,assertPreReplacementValidityInDocument),newChild.ownerDocument=this,oldChild&&this.removeChild(oldChild),isElementNode(newChild)&&(this.documentElement=newChild)},importNode:function(importedNode,deep){return importNode(this,importedNode,deep)},getElementById:function(id){var rtv=null;return _visitNode(this.documentElement,function(node){return node.nodeType==ELEMENT_NODE&&node.getAttribute("id")==id?(rtv=node,!0):void 0}),rtv},getElementsByClassName:function(classNames){var classNamesSet=toOrderedSet(classNames);return new LiveNodeList(this,function(base){var ls=[];return classNamesSet.length>0&&_visitNode(base.documentElement,function(node){if(node!==base&&node.nodeType===ELEMENT_NODE){var nodeClassNames=node.getAttribute("class");if(nodeClassNames){var matches=classNames===nodeClassNames;if(!matches){var nodeClassNamesSet=toOrderedSet(nodeClassNames);matches=classNamesSet.every(arrayIncludes(nodeClassNamesSet))}matches&&ls.push(node)}}}),ls})},createElement:function(tagName){var node=new Element;node.ownerDocument=this,node.nodeName=tagName,node.tagName=tagName,node.localName=tagName,node.childNodes=new NodeList;var attrs=node.attributes=new NamedNodeMap;return attrs._ownerElement=node,node},createDocumentFragment:function(){var node=new DocumentFragment;return node.ownerDocument=this,node.childNodes=new NodeList,node},createTextNode:function(data){var node=new Text;return node.ownerDocument=this,node.appendData(data),node},createComment:function(data){var node=new Comment;return node.ownerDocument=this,node.appendData(data),node},createCDATASection:function(data){var node=new CDATASection;return node.ownerDocument=this,node.appendData(data),node},createProcessingInstruction:function(target,data){var node=new ProcessingInstruction;return node.ownerDocument=this,node.tagName=node.target=target,node.nodeValue=node.data=data,node},createAttribute:function(name){var node=new Attr;return node.ownerDocument=this,node.name=name,node.nodeName=name,node.localName=name,node.specified=!0,node},createEntityReference:function(name){var node=new EntityReference;return node.ownerDocument=this,node.nodeName=name,node},createElementNS:function(namespaceURI,qualifiedName){var node=new Element,pl=qualifiedName.split(":"),attrs=node.attributes=new NamedNodeMap;return node.childNodes=new NodeList,node.ownerDocument=this,node.nodeName=qualifiedName,node.tagName=qualifiedName,node.namespaceURI=namespaceURI,2==pl.length?(node.prefix=pl[0],node.localName=pl[1]):node.localName=qualifiedName,attrs._ownerElement=node,node},createAttributeNS:function(namespaceURI,qualifiedName){var node=new Attr,pl=qualifiedName.split(":");return node.ownerDocument=this,node.nodeName=qualifiedName,node.name=qualifiedName,node.namespaceURI=namespaceURI,node.specified=!0,2==pl.length?(node.prefix=pl[0],node.localName=pl[1]):node.localName=qualifiedName,node}},_extends(Document,Node),Element.prototype={nodeType:ELEMENT_NODE,hasAttribute:function(name){return null!=this.getAttributeNode(name)},getAttribute:function(name){var attr=this.getAttributeNode(name);return attr&&attr.value||""},getAttributeNode:function(name){return this.attributes.getNamedItem(name)},setAttribute:function(name,value){var attr=this.ownerDocument.createAttribute(name);attr.value=attr.nodeValue=""+value,this.setAttributeNode(attr)},removeAttribute:function(name){var attr=this.getAttributeNode(name);attr&&this.removeAttributeNode(attr)},appendChild:function(newChild){return newChild.nodeType===DOCUMENT_FRAGMENT_NODE?this.insertBefore(newChild,null):_appendSingleChild(this,newChild)},setAttributeNode:function(newAttr){return this.attributes.setNamedItem(newAttr)},setAttributeNodeNS:function(newAttr){return this.attributes.setNamedItemNS(newAttr)},removeAttributeNode:function(oldAttr){return this.attributes.removeNamedItem(oldAttr.nodeName)},removeAttributeNS:function(namespaceURI,localName){var old=this.getAttributeNodeNS(namespaceURI,localName);old&&this.removeAttributeNode(old)},hasAttributeNS:function(namespaceURI,localName){return null!=this.getAttributeNodeNS(namespaceURI,localName)},getAttributeNS:function(namespaceURI,localName){var attr=this.getAttributeNodeNS(namespaceURI,localName);return attr&&attr.value||""},setAttributeNS:function(namespaceURI,qualifiedName,value){var attr=this.ownerDocument.createAttributeNS(namespaceURI,qualifiedName);attr.value=attr.nodeValue=""+value,this.setAttributeNode(attr)},getAttributeNodeNS:function(namespaceURI,localName){return this.attributes.getNamedItemNS(namespaceURI,localName)},getElementsByTagName:function(tagName){return new LiveNodeList(this,function(base){var ls=[];return _visitNode(base,function(node){node===base||node.nodeType!=ELEMENT_NODE||"*"!==tagName&&node.tagName!=tagName||ls.push(node)}),ls})},getElementsByTagNameNS:function(namespaceURI,localName){return new LiveNodeList(this,function(base){var ls=[];return _visitNode(base,function(node){node===base||node.nodeType!==ELEMENT_NODE||"*"!==namespaceURI&&node.namespaceURI!==namespaceURI||"*"!==localName&&node.localName!=localName||ls.push(node)}),ls})}},Document.prototype.getElementsByTagName=Element.prototype.getElementsByTagName,Document.prototype.getElementsByTagNameNS=Element.prototype.getElementsByTagNameNS,_extends(Element,Node),Attr.prototype.nodeType=ATTRIBUTE_NODE,_extends(Attr,Node),CharacterData.prototype={data:"",substringData:function(offset,count){return this.data.substring(offset,offset+count)},appendData:function(text){text=this.data+text,this.nodeValue=this.data=text,this.length=text.length},insertData:function(offset,text){this.replaceData(offset,0,text)},appendChild:function(newChild){throw new Error(ExceptionMessage[HIERARCHY_REQUEST_ERR])},deleteData:function(offset,count){this.replaceData(offset,count,"")},replaceData:function(offset,count,text){var start=this.data.substring(0,offset),end=this.data.substring(offset+count);text=start+text+end,this.nodeValue=this.data=text,this.length=text.length}},_extends(CharacterData,Node),Text.prototype={nodeName:"#text",nodeType:TEXT_NODE,splitText:function(offset){var text=this.data,newText=text.substring(offset);text=text.substring(0,offset),this.data=this.nodeValue=text,this.length=text.length;var newNode=this.ownerDocument.createTextNode(newText);return this.parentNode&&this.parentNode.insertBefore(newNode,this.nextSibling),newNode}},_extends(Text,CharacterData),Comment.prototype={nodeName:"#comment",nodeType:COMMENT_NODE},_extends(Comment,CharacterData),CDATASection.prototype={nodeName:"#cdata-section",nodeType:CDATA_SECTION_NODE},_extends(CDATASection,CharacterData),DocumentType.prototype.nodeType=DOCUMENT_TYPE_NODE,_extends(DocumentType,Node),Notation.prototype.nodeType=NOTATION_NODE,_extends(Notation,Node),Entity.prototype.nodeType=ENTITY_NODE,_extends(Entity,Node),EntityReference.prototype.nodeType=ENTITY_REFERENCE_NODE,_extends(EntityReference,Node),DocumentFragment.prototype.nodeName="#document-fragment",DocumentFragment.prototype.nodeType=DOCUMENT_FRAGMENT_NODE,_extends(DocumentFragment,Node),ProcessingInstruction.prototype.nodeType=PROCESSING_INSTRUCTION_NODE,_extends(ProcessingInstruction,Node),XMLSerializer.prototype.serializeToString=function(node,isHtml,nodeFilter){return nodeSerializeToString.call(node,isHtml,nodeFilter)},Node.prototype.toString=nodeSerializeToString;try{Object.defineProperty&&(Object.defineProperty(LiveNodeList.prototype,"length",{get:function(){return _updateLiveList(this),this.$$length}}),Object.defineProperty(Node.prototype,"textContent",{get:function(){return getTextContent(this)},set:function(data){switch(this.nodeType){case ELEMENT_NODE:case DOCUMENT_FRAGMENT_NODE:for(;this.firstChild;)this.removeChild(this.firstChild);(data||String(data))&&this.appendChild(this.ownerDocument.createTextNode(data));break;default:this.data=data,this.value=data,this.nodeValue=data}}}),__set__=function(object,key,value){object["$$"+key]=value})}catch(e){}exports.DocumentType=DocumentType,exports.DOMException=DOMException,exports.DOMImplementation=DOMImplementation,exports.Element=Element,exports.Node=Node,exports.NodeList=NodeList,exports.XMLSerializer=XMLSerializer},{"./conventions":41}],44:[function(require,module,exports){var freeze=require("./conventions").freeze;exports.XML_ENTITIES=freeze({amp:"&",apos:"'",gt:">",lt:"<",quot:'"'}),exports.HTML_ENTITIES=freeze({lt:"<",gt:">",amp:"&",quot:'"',apos:"'",Agrave:"À",Aacute:"Á",Acirc:"Â",Atilde:"Ã",Auml:"Ä",Aring:"Å",AElig:"Æ",Ccedil:"Ç",Egrave:"È",Eacute:"É",Ecirc:"Ê",Euml:"Ë",Igrave:"Ì",Iacute:"Í",Icirc:"Î",Iuml:"Ï",ETH:"Ð",Ntilde:"Ñ",Ograve:"Ò",Oacute:"Ó",Ocirc:"Ô",Otilde:"Õ",Ouml:"Ö",Oslash:"Ø",Ugrave:"Ù",Uacute:"Ú",Ucirc:"Û",Uuml:"Ü",Yacute:"Ý",THORN:"Þ",szlig:"ß",agrave:"à",aacute:"á",acirc:"â",atilde:"ã",auml:"ä",aring:"å",aelig:"æ",ccedil:"ç",egrave:"è",eacute:"é",ecirc:"ê",euml:"ë",igrave:"ì",iacute:"í",icirc:"î",iuml:"ï",eth:"ð",ntilde:"ñ",ograve:"ò",oacute:"ó",ocirc:"ô",otilde:"õ",ouml:"ö",oslash:"ø",ugrave:"ù",uacute:"ú",ucirc:"û",uuml:"ü",yacute:"ý",thorn:"þ",yuml:"ÿ",nbsp:" ",iexcl:"¡",cent:"¢",pound:"£",curren:"¤",yen:"¥",brvbar:"¦",sect:"§",uml:"¨",copy:"©",ordf:"ª",laquo:"«",not:"¬",shy:"­­",reg:"®",macr:"¯",deg:"°",plusmn:"±",sup2:"²",sup3:"³",acute:"´",micro:"µ",para:"¶",middot:"·",cedil:"¸",sup1:"¹",ordm:"º",raquo:"»",frac14:"¼",frac12:"½",frac34:"¾",iquest:"¿",times:"×",divide:"÷",forall:"∀",part:"∂",exist:"∃",empty:"∅",nabla:"∇",isin:"∈",notin:"∉",ni:"∋",prod:"∏",sum:"∑",minus:"−",lowast:"∗",radic:"√",prop:"∝",infin:"∞",ang:"∠",and:"∧",or:"∨",cap:"∩",cup:"∪","int":"∫",there4:"∴",sim:"∼",cong:"≅",asymp:"≈",ne:"≠",equiv:"≡",le:"≤",ge:"≥",sub:"⊂",sup:"⊃",nsub:"⊄",sube:"⊆",supe:"⊇",oplus:"⊕",otimes:"⊗",perp:"⊥",sdot:"⋅",Alpha:"Α",Beta:"Β",Gamma:"Γ",Delta:"Δ",Epsilon:"Ε",Zeta:"Ζ",Eta:"Η",Theta:"Θ",Iota:"Ι",Kappa:"Κ",Lambda:"Λ",Mu:"Μ",Nu:"Ν",Xi:"Ξ",Omicron:"Ο",Pi:"Π",Rho:"Ρ",Sigma:"Σ",Tau:"Τ",Upsilon:"Υ",Phi:"Φ",Chi:"Χ",Psi:"Ψ",Omega:"Ω",alpha:"α",beta:"β",gamma:"γ",delta:"δ",epsilon:"ε",zeta:"ζ",eta:"η",theta:"θ",iota:"ι",kappa:"κ",lambda:"λ",mu:"μ",nu:"ν",xi:"ξ",omicron:"ο",pi:"π",rho:"ρ",sigmaf:"ς",sigma:"σ",tau:"τ",upsilon:"υ",phi:"φ",chi:"χ",psi:"ψ",omega:"ω",thetasym:"ϑ",upsih:"ϒ",piv:"ϖ",OElig:"Œ",oelig:"œ",Scaron:"Š",scaron:"š",Yuml:"Ÿ",fnof:"ƒ", -circ:"ˆ",tilde:"˜",ensp:" ",emsp:" ",thinsp:" ",zwnj:"‌",zwj:"‍",lrm:"‎",rlm:"‏",ndash:"–",mdash:"—",lsquo:"‘",rsquo:"’",sbquo:"‚",ldquo:"“",rdquo:"”",bdquo:"„",dagger:"†",Dagger:"‡",bull:"•",hellip:"…",permil:"‰",prime:"′",Prime:"″",lsaquo:"‹",rsaquo:"›",oline:"‾",euro:"€",trade:"™",larr:"←",uarr:"↑",rarr:"→",darr:"↓",harr:"↔",crarr:"↵",lceil:"⌈",rceil:"⌉",lfloor:"⌊",rfloor:"⌋",loz:"◊",spades:"♠",clubs:"♣",hearts:"♥",diams:"♦"}),exports.entityMap=exports.HTML_ENTITIES},{"./conventions":41}],45:[function(require,module,exports){var dom=require("./dom");exports.DOMImplementation=dom.DOMImplementation,exports.XMLSerializer=dom.XMLSerializer,exports.DOMParser=require("./dom-parser").DOMParser},{"./dom":43,"./dom-parser":42}],46:[function(require,module,exports){function ParseError(message,locator){this.message=message,this.locator=locator,Error.captureStackTrace&&Error.captureStackTrace(this,ParseError)}function XMLReader(){}function parse(source,defaultNSMapCopy,entityMap,domBuilder,errorHandler){function fixedFromCharCode(code){if(code>65535){code-=65536;var surrogate1=55296+(code>>10),surrogate2=56320+(1023&code);return String.fromCharCode(surrogate1,surrogate2)}return String.fromCharCode(code)}function entityReplacer(a){var k=a.slice(1,-1);return Object.hasOwnProperty.call(entityMap,k)?entityMap[k]:"#"===k.charAt(0)?fixedFromCharCode(parseInt(k.substr(1).replace("x","0x"))):(errorHandler.error("entity not found:"+a),a)}function appendText(end){if(end>start){var xt=source.substring(start,end).replace(/&#?\w+;/g,entityReplacer);locator&&position(start),domBuilder.characters(xt,0,end-start),start=end}}function position(p,m){for(;p>=lineEnd&&(m=linePattern.exec(source));)lineStart=m.index,lineEnd=lineStart+m[0].length,locator.lineNumber++;locator.columnNumber=p-lineStart+1}for(var lineStart=0,lineEnd=0,linePattern=/.*(?:\r\n?|\n)|.*$/g,locator=domBuilder.locator,parseStack=[{currentNSMap:defaultNSMapCopy}],closeMap={},start=0;;){try{var tagStart=source.indexOf("<",start);if(0>tagStart){if(!source.substr(start).match(/^\s*$/)){var doc=domBuilder.doc,text=doc.createTextNode(source.substr(start));doc.appendChild(text),domBuilder.currentElement=text}return}switch(tagStart>start&&appendText(tagStart),source.charAt(tagStart+1)){case"/":var end=source.indexOf(">",tagStart+3),tagName=source.substring(tagStart+2,end).replace(/[ \t\n\r]+$/g,""),config=parseStack.pop();0>end?(tagName=source.substring(tagStart+2).replace(/[\s<].*/,""),errorHandler.error("end tag name: "+tagName+" is not complete:"+config.tagName),end=tagStart+1+tagName.length):tagName.match(/\si;i++){var a=el[i];position(a.offset),a.locator=copyLocator(locator,{})}domBuilder.locator=locator2,appendElement(el,domBuilder,currentNSMap)&&parseStack.push(el),domBuilder.locator=locator}else appendElement(el,domBuilder,currentNSMap)&&parseStack.push(el);NAMESPACE.isHTML(el.uri)&&!el.closed?end=parseHtmlSpecialContent(source,end,el.tagName,entityReplacer,domBuilder):end++}}catch(e){if(e instanceof ParseError)throw e;errorHandler.error("element parse error: "+e),end=-1}end>start?start=end:appendText(Math.max(tagStart,start)+1)}}function copyLocator(f,t){return t.lineNumber=f.lineNumber,t.columnNumber=f.columnNumber,t}function parseElementStartPart(source,start,el,currentNSMap,entityReplacer,errorHandler){function addAttribute(qname,value,startIndex){el.attributeNames.hasOwnProperty(qname)&&errorHandler.fatalError("Attribute "+qname+" redefined"),el.addValue(qname,value.replace(/[\t\n\r]/g," ").replace(/&#?\w+;/g,entityReplacer),startIndex)}for(var attrName,value,p=++start,s=S_TAG;;){var c=source.charAt(p);switch(c){case"=":if(s===S_ATTR)attrName=source.slice(start,p),s=S_EQ;else{if(s!==S_ATTR_SPACE)throw new Error("attribute equal must after attrName");s=S_EQ}break;case"'":case'"':if(s===S_EQ||s===S_ATTR){if(s===S_ATTR&&(errorHandler.warning('attribute value must after "="'),attrName=source.slice(start,p)),start=p+1,p=source.indexOf(c,start),!(p>0))throw new Error("attribute value no end '"+c+"' match");value=source.slice(start,p),addAttribute(attrName,value,start-1),s=S_ATTR_END}else{if(s!=S_ATTR_NOQUOT_VALUE)throw new Error('attribute value must after "="');value=source.slice(start,p),addAttribute(attrName,value,start),errorHandler.warning('attribute "'+attrName+'" missed start quot('+c+")!!"),start=p+1,s=S_ATTR_END}break;case"/":switch(s){case S_TAG:el.setTagName(source.slice(start,p));case S_ATTR_END:case S_TAG_SPACE:case S_TAG_CLOSE:s=S_TAG_CLOSE,el.closed=!0;case S_ATTR_NOQUOT_VALUE:case S_ATTR:case S_ATTR_SPACE:break;default:throw new Error("attribute invalid close char('/')")}break;case"":return errorHandler.error("unexpected end of input"),s==S_TAG&&el.setTagName(source.slice(start,p)),p;case">":switch(s){case S_TAG:el.setTagName(source.slice(start,p));case S_ATTR_END:case S_TAG_SPACE:case S_TAG_CLOSE:break;case S_ATTR_NOQUOT_VALUE:case S_ATTR:value=source.slice(start,p),"/"===value.slice(-1)&&(el.closed=!0,value=value.slice(0,-1));case S_ATTR_SPACE:s===S_ATTR_SPACE&&(value=attrName),s==S_ATTR_NOQUOT_VALUE?(errorHandler.warning('attribute "'+value+'" missed quot(")!'),addAttribute(attrName,value,start)):(NAMESPACE.isHTML(currentNSMap[""])&&value.match(/^(?:disabled|checked|selected)$/i)||errorHandler.warning('attribute "'+value+'" missed value!! "'+value+'" instead!!'),addAttribute(value,value,start));break;case S_EQ:throw new Error("attribute value missed!!")}return p;case"€":c=" ";default:if(" ">=c)switch(s){case S_TAG:el.setTagName(source.slice(start,p)),s=S_TAG_SPACE;break;case S_ATTR:attrName=source.slice(start,p),s=S_ATTR_SPACE;break;case S_ATTR_NOQUOT_VALUE:var value=source.slice(start,p);errorHandler.warning('attribute "'+value+'" missed quot(")!!'),addAttribute(attrName,value,start);case S_ATTR_END:s=S_TAG_SPACE}else switch(s){case S_ATTR_SPACE:el.tagName;NAMESPACE.isHTML(currentNSMap[""])&&attrName.match(/^(?:disabled|checked|selected)$/i)||errorHandler.warning('attribute "'+attrName+'" missed value!! "'+attrName+'" instead2!!'),addAttribute(attrName,attrName,start),start=p,s=S_ATTR;break;case S_ATTR_END:errorHandler.warning('attribute space is required"'+attrName+'"!!');case S_TAG_SPACE:s=S_ATTR,start=p;break;case S_EQ:s=S_ATTR_NOQUOT_VALUE,start=p;break;case S_TAG_CLOSE:throw new Error("elements closed character '/' and '>' must be connected to")}}p++}}function appendElement(el,domBuilder,currentNSMap){for(var tagName=el.tagName,localNSMap=null,i=el.length;i--;){var a=el[i],qName=a.qName,value=a.value,nsp=qName.indexOf(":");if(nsp>0)var prefix=a.prefix=qName.slice(0,nsp),localName=qName.slice(nsp+1),nsPrefix="xmlns"===prefix&&localName;else localName=qName,prefix=null,nsPrefix="xmlns"===qName&&"";a.localName=localName,nsPrefix!==!1&&(null==localNSMap&&(localNSMap={},_copy(currentNSMap,currentNSMap={})),currentNSMap[nsPrefix]=localNSMap[nsPrefix]=value,a.uri=NAMESPACE.XMLNS,domBuilder.startPrefixMapping(nsPrefix,value))}for(var i=el.length;i--;){a=el[i];var prefix=a.prefix;prefix&&("xml"===prefix&&(a.uri=NAMESPACE.XML),"xmlns"!==prefix&&(a.uri=currentNSMap[prefix||""]))}var nsp=tagName.indexOf(":");nsp>0?(prefix=el.prefix=tagName.slice(0,nsp),localName=el.localName=tagName.slice(nsp+1)):(prefix=null,localName=el.localName=tagName);var ns=el.uri=currentNSMap[prefix||""];if(domBuilder.startElement(ns,localName,tagName,el),!el.closed)return el.currentNSMap=currentNSMap,el.localNSMap=localNSMap,!0;if(domBuilder.endElement(ns,localName,tagName),localNSMap)for(prefix in localNSMap)Object.prototype.hasOwnProperty.call(localNSMap,prefix)&&domBuilder.endPrefixMapping(prefix)}function parseHtmlSpecialContent(source,elStartEnd,tagName,entityReplacer,domBuilder){if(/^(?:script|textarea)$/i.test(tagName)){var elEndStart=source.indexOf("",elStartEnd),text=source.substring(elStartEnd+1,elEndStart);if(/[&<]/.test(text))return/^script$/i.test(tagName)?(domBuilder.characters(text,0,text.length),elEndStart):(text=text.replace(/&#?\w+;/g,entityReplacer),domBuilder.characters(text,0,text.length),elEndStart)}return elStartEnd+1}function fixSelfClosed(source,elStartEnd,tagName,closeMap){var pos=closeMap[tagName];return null==pos&&(pos=source.lastIndexOf(""),elStartEnd>pos&&(pos=source.lastIndexOf("pos}function _copy(source,target){for(var n in source)Object.prototype.hasOwnProperty.call(source,n)&&(target[n]=source[n])}function parseDCC(source,start,domBuilder,errorHandler){var next=source.charAt(start+2);switch(next){case"-":if("-"===source.charAt(start+3)){var end=source.indexOf("-->",start+4);return end>start?(domBuilder.comment(source,start+4,end-start-4),end+3):(errorHandler.error("Unclosed comment"),-1)}return-1;default:if("CDATA["==source.substr(start+3,6)){var end=source.indexOf("]]>",start+9);return domBuilder.startCDATA(),domBuilder.characters(source,start+9,end-start-9),domBuilder.endCDATA(),end+3}var matchs=split(source,start),len=matchs.length;if(len>1&&/!doctype/i.test(matchs[0][0])){var name=matchs[1][0],pubid=!1,sysid=!1;len>3&&(/^public$/i.test(matchs[2][0])?(pubid=matchs[3][0],sysid=len>4&&matchs[4][0]):/^system$/i.test(matchs[2][0])&&(sysid=matchs[3][0]));var lastMatch=matchs[len-1];return domBuilder.startDTD(name,pubid,sysid),domBuilder.endDTD(),lastMatch.index+lastMatch[0].length}}return-1}function parseInstruction(source,start,domBuilder){var end=source.indexOf("?>",start);if(end){var match=source.substring(start,end).match(/^<\?(\S*)\s*([\s\S]*?)\s*$/);if(match){match[0].length;return domBuilder.processingInstruction(match[1],match[2]),end+2}return-1}return-1}function ElementAttributes(){this.attributeNames={}}function split(source,start){var match,buf=[],reg=/'[^']+'|"[^"]+"|[^\s<>\/=]+=?|(\/?\s*>|<)/g;for(reg.lastIndex=start,reg.exec(source);match=reg.exec(source);)if(buf.push(match),match[1])return buf}var NAMESPACE=require("./conventions").NAMESPACE,nameStartChar=/[A-Z_a-z\xC0-\xD6\xD8-\xF6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]/,nameChar=new RegExp("[\\-\\.0-9"+nameStartChar.source.slice(1,-1)+"\\u00B7\\u0300-\\u036F\\u203F-\\u2040]"),tagNamePattern=new RegExp("^"+nameStartChar.source+nameChar.source+"*(?::"+nameStartChar.source+nameChar.source+"*)?$"),S_TAG=0,S_ATTR=1,S_ATTR_SPACE=2,S_EQ=3,S_ATTR_NOQUOT_VALUE=4,S_ATTR_END=5,S_TAG_SPACE=6,S_TAG_CLOSE=7;ParseError.prototype=new Error,ParseError.prototype.name=ParseError.name,XMLReader.prototype={parse:function(source,defaultNSMap,entityMap){var domBuilder=this.domBuilder;domBuilder.startDocument(),_copy(defaultNSMap,defaultNSMap={}),parse(source,defaultNSMap,entityMap,domBuilder,this.errorHandler),domBuilder.endDocument()}},ElementAttributes.prototype={setTagName:function(tagName){if(!tagNamePattern.test(tagName))throw new Error("invalid tagName:"+tagName);this.tagName=tagName},addValue:function(qName,value,offset){if(!tagNamePattern.test(qName))throw new Error("invalid attribute:"+qName);this.attributeNames[qName]=this.length,this[this.length++]={qName:qName,value:value,offset:offset}},length:0,getLocalName:function(i){return this[i].localName},getLocator:function(i){return this[i].locator},getQName:function(i){return this[i].qName},getURI:function(i){return this[i].uri},getValue:function(i){return this[i].value}},exports.XMLReader=XMLReader,exports.ParseError=ParseError},{"./conventions":41}],47:[function(require,module,exports){"use strict";function getLens(b64){var len=b64.length;if(len%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var validLen=b64.indexOf("=");-1===validLen&&(validLen=len);var placeHoldersLen=validLen===len?0:4-validLen%4;return[validLen,placeHoldersLen]}function byteLength(b64){var lens=getLens(b64),validLen=lens[0],placeHoldersLen=lens[1];return 3*(validLen+placeHoldersLen)/4-placeHoldersLen}function _byteLength(b64,validLen,placeHoldersLen){return 3*(validLen+placeHoldersLen)/4-placeHoldersLen}function toByteArray(b64){var tmp,i,lens=getLens(b64),validLen=lens[0],placeHoldersLen=lens[1],arr=new Arr(_byteLength(b64,validLen,placeHoldersLen)),curByte=0,len=placeHoldersLen>0?validLen-4:validLen;for(i=0;len>i;i+=4)tmp=revLookup[b64.charCodeAt(i)]<<18|revLookup[b64.charCodeAt(i+1)]<<12|revLookup[b64.charCodeAt(i+2)]<<6|revLookup[b64.charCodeAt(i+3)],arr[curByte++]=tmp>>16&255,arr[curByte++]=tmp>>8&255,arr[curByte++]=255&tmp;return 2===placeHoldersLen&&(tmp=revLookup[b64.charCodeAt(i)]<<2|revLookup[b64.charCodeAt(i+1)]>>4,arr[curByte++]=255&tmp),1===placeHoldersLen&&(tmp=revLookup[b64.charCodeAt(i)]<<10|revLookup[b64.charCodeAt(i+1)]<<4|revLookup[b64.charCodeAt(i+2)]>>2,arr[curByte++]=tmp>>8&255,arr[curByte++]=255&tmp),arr}function tripletToBase64(num){return lookup[num>>18&63]+lookup[num>>12&63]+lookup[num>>6&63]+lookup[63&num]}function encodeChunk(uint8,start,end){for(var tmp,output=[],i=start;end>i;i+=3)tmp=(uint8[i]<<16&16711680)+(uint8[i+1]<<8&65280)+(255&uint8[i+2]),output.push(tripletToBase64(tmp));return output.join("")}function fromByteArray(uint8){for(var tmp,len=uint8.length,extraBytes=len%3,parts=[],maxChunkLength=16383,i=0,len2=len-extraBytes;len2>i;i+=maxChunkLength)parts.push(encodeChunk(uint8,i,i+maxChunkLength>len2?len2:i+maxChunkLength));return 1===extraBytes?(tmp=uint8[len-1],parts.push(lookup[tmp>>2]+lookup[tmp<<4&63]+"==")):2===extraBytes&&(tmp=(uint8[len-2]<<8)+uint8[len-1],parts.push(lookup[tmp>>10]+lookup[tmp>>4&63]+lookup[tmp<<2&63]+"=")),parts.join("")}exports.byteLength=byteLength,exports.toByteArray=toByteArray,exports.fromByteArray=fromByteArray;for(var lookup=[],revLookup=[],Arr="undefined"!=typeof Uint8Array?Uint8Array:Array,code="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",i=0,len=code.length;len>i;++i)lookup[i]=code[i],revLookup[code.charCodeAt(i)]=i;revLookup["-".charCodeAt(0)]=62,revLookup["_".charCodeAt(0)]=63},{}],48:[function(require,module,exports){"use strict";module.exports=function(Promise){function any(promises){var ret=new SomePromiseArray(promises),promise=ret.promise();return ret.setHowMany(1),ret.setUnwrap(),ret.init(),promise}var SomePromiseArray=Promise._SomePromiseArray;Promise.any=function(promises){return any(promises)},Promise.prototype.any=function(){return any(this)}}},{}],49:[function(require,module,exports){(function(process){"use strict";function Async(){this._customScheduler=!1,this._isTickUsed=!1,this._lateQueue=new Queue(16),this._normalQueue=new Queue(16),this._haveDrainedQueues=!1,this._trampolineEnabled=!0;var self=this;this.drainQueues=function(){self._drainQueues()},this._schedule=schedule}function AsyncInvokeLater(fn,receiver,arg){this._lateQueue.push(fn,receiver,arg),this._queueTick()}function AsyncInvoke(fn,receiver,arg){this._normalQueue.push(fn,receiver,arg),this._queueTick()}function AsyncSettlePromises(promise){this._normalQueue._pushOne(promise),this._queueTick()}var firstLineError;try{throw new Error}catch(e){firstLineError=e}var schedule=require("./schedule"),Queue=require("./queue"),util=require("./util");Async.prototype.setScheduler=function(fn){var prev=this._schedule;return this._schedule=fn,this._customScheduler=!0,prev},Async.prototype.hasCustomScheduler=function(){return this._customScheduler},Async.prototype.enableTrampoline=function(){this._trampolineEnabled=!0},Async.prototype.disableTrampolineIfNecessary=function(){util.hasDevTools&&(this._trampolineEnabled=!1)},Async.prototype.haveItemsQueued=function(){return this._isTickUsed||this._haveDrainedQueues},Async.prototype.fatalError=function(e,isNode){isNode?(process.stderr.write("Fatal "+(e instanceof Error?e.stack:e)+"\n"),process.exit(2)):this.throwLater(e)},Async.prototype.throwLater=function(fn,arg){if(1===arguments.length&&(arg=fn,fn=function(){throw arg}),"undefined"!=typeof setTimeout)setTimeout(function(){fn(arg)},0);else try{this._schedule(function(){fn(arg)})}catch(e){throw new Error("No async scheduler available\n\n See http://goo.gl/MqrFmX\n")}},util.hasDevTools?(Async.prototype.invokeLater=function(fn,receiver,arg){this._trampolineEnabled?AsyncInvokeLater.call(this,fn,receiver,arg):this._schedule(function(){setTimeout(function(){fn.call(receiver,arg)},100)})},Async.prototype.invoke=function(fn,receiver,arg){this._trampolineEnabled?AsyncInvoke.call(this,fn,receiver,arg):this._schedule(function(){fn.call(receiver,arg)})},Async.prototype.settlePromises=function(promise){this._trampolineEnabled?AsyncSettlePromises.call(this,promise):this._schedule(function(){promise._settlePromises()})}):(Async.prototype.invokeLater=AsyncInvokeLater,Async.prototype.invoke=AsyncInvoke,Async.prototype.settlePromises=AsyncSettlePromises),Async.prototype._drainQueue=function(queue){for(;queue.length()>0;){var fn=queue.shift();if("function"==typeof fn){var receiver=queue.shift(),arg=queue.shift();fn.call(receiver,arg)}else fn._settlePromises()}},Async.prototype._drainQueues=function(){this._drainQueue(this._normalQueue),this._reset(),this._haveDrainedQueues=!0,this._drainQueue(this._lateQueue)},Async.prototype._queueTick=function(){this._isTickUsed||(this._isTickUsed=!0,this._schedule(this.drainQueues))},Async.prototype._reset=function(){this._isTickUsed=!1},module.exports=Async,module.exports.firstLineError=firstLineError}).call(this,require("_process"))},{"./queue":72,"./schedule":75,"./util":82,_process:101}],50:[function(require,module,exports){"use strict";module.exports=function(Promise,INTERNAL,tryConvertToPromise,debug){var calledBind=!1,rejectThis=function(_,e){this._reject(e)},targetRejected=function(e,context){context.promiseRejectionQueued=!0,context.bindingPromise._then(rejectThis,rejectThis,null,this,e)},bindingResolved=function(thisArg,context){0===(50397184&this._bitField)&&this._resolveCallback(context.target)},bindingRejected=function(e,context){context.promiseRejectionQueued||this._reject(e)};Promise.prototype.bind=function(thisArg){calledBind||(calledBind=!0,Promise.prototype._propagateFrom=debug.propagateFromFunction(),Promise.prototype._boundValue=debug.boundValueFunction());var maybePromise=tryConvertToPromise(thisArg),ret=new Promise(INTERNAL);ret._propagateFrom(this,1);var target=this._target();if(ret._setBoundTo(maybePromise),maybePromise instanceof Promise){var context={promiseRejectionQueued:!1,promise:ret,target:target,bindingPromise:maybePromise};target._then(INTERNAL,targetRejected,void 0,ret,context),maybePromise._then(bindingResolved,bindingRejected,void 0,ret,context),ret._setOnCancel(maybePromise)}else ret._resolveCallback(target);return ret},Promise.prototype._setBoundTo=function(obj){void 0!==obj?(this._bitField=2097152|this._bitField,this._boundTo=obj):this._bitField=-2097153&this._bitField},Promise.prototype._isBound=function(){return 2097152===(2097152&this._bitField)},Promise.bind=function(thisArg,value){return Promise.resolve(value).bind(thisArg)}}},{}],51:[function(require,module,exports){"use strict";var cr=Object.create;if(cr){var callerCache=cr(null),getterCache=cr(null);callerCache[" size"]=getterCache[" size"]=0}module.exports=function(Promise){function ensureMethod(obj,methodName){var fn;if(null!=obj&&(fn=obj[methodName]),"function"!=typeof fn){var message="Object "+util.classString(obj)+" has no method '"+util.toString(methodName)+"'";throw new Promise.TypeError(message)}return fn}function caller(obj){var methodName=this.pop(),fn=ensureMethod(obj,methodName);return fn.apply(obj,this)}function namedGetter(obj){return obj[this]}function indexedGetter(obj){var index=+this;return 0>index&&(index=Math.max(0,index+obj.length)),obj[index]}var getMethodCaller,getGetter,util=require("./util"),canEvaluate=util.canEvaluate,isIdentifier=util.isIdentifier,makeMethodCaller=function(methodName){return new Function("ensureMethod"," \n return function(obj) { \n 'use strict' \n var len = this.length; \n ensureMethod(obj, 'methodName'); \n switch(len) { \n case 1: return obj.methodName(this[0]); \n case 2: return obj.methodName(this[0], this[1]); \n case 3: return obj.methodName(this[0], this[1], this[2]); \n case 0: return obj.methodName(); \n default: \n return obj.methodName.apply(obj, this); \n } \n }; \n ".replace(/methodName/g,methodName))(ensureMethod)},makeGetter=function(propertyName){return new Function("obj"," \n 'use strict'; \n return obj.propertyName; \n ".replace("propertyName",propertyName))},getCompiled=function(name,compiler,cache){var ret=cache[name];if("function"!=typeof ret){if(!isIdentifier(name))return null;if(ret=compiler(name),cache[name]=ret,cache[" size"]++,cache[" size"]>512){for(var keys=Object.keys(cache),i=0;256>i;++i)delete cache[keys[i]];cache[" size"]=keys.length-256}}return ret};getMethodCaller=function(name){return getCompiled(name,makeMethodCaller,callerCache)},getGetter=function(name){return getCompiled(name,makeGetter,getterCache)},Promise.prototype.call=function(methodName){for(var $_len=arguments.length,args=new Array(Math.max($_len-1,0)),$_i=1;$_len>$_i;++$_i)args[$_i-1]=arguments[$_i];if(canEvaluate){var maybeCaller=getMethodCaller(methodName);if(null!==maybeCaller)return this._then(maybeCaller,void 0,void 0,args,void 0)}return args.push(methodName),this._then(caller,void 0,void 0,args,void 0)},Promise.prototype.get=function(propertyName){var getter,isIndex="number"==typeof propertyName;if(isIndex)getter=indexedGetter;else if(canEvaluate){var maybeGetter=getGetter(propertyName);getter=null!==maybeGetter?maybeGetter:namedGetter}else getter=namedGetter;return this._then(getter,void 0,void 0,propertyName,void 0)}}},{"./util":82}],52:[function(require,module,exports){"use strict";module.exports=function(Promise,PromiseArray,apiRejection,debug){var util=require("./util"),tryCatch=util.tryCatch,errorObj=util.errorObj,async=Promise._async;Promise.prototype["break"]=Promise.prototype.cancel=function(){if(!debug.cancellation())return this._warn("cancellation is disabled");for(var promise=this,child=promise;promise._isCancellable();){if(!promise._cancelBy(child)){child._isFollowing()?child._followee().cancel():child._cancelBranched();break}var parent=promise._cancellationParent;if(null==parent||!parent._isCancellable()){promise._isFollowing()?promise._followee().cancel():promise._cancelBranched();break}promise._isFollowing()&&promise._followee().cancel(),promise._setWillBeCancelled(),child=promise,promise=parent}},Promise.prototype._branchHasCancelled=function(){this._branchesRemainingToCancel--},Promise.prototype._enoughBranchesHaveCancelled=function(){return void 0===this._branchesRemainingToCancel||this._branchesRemainingToCancel<=0},Promise.prototype._cancelBy=function(canceller){return canceller===this?(this._branchesRemainingToCancel=0,this._invokeOnCancel(),!0):(this._branchHasCancelled(),this._enoughBranchesHaveCancelled()?(this._invokeOnCancel(),!0):!1)},Promise.prototype._cancelBranched=function(){this._enoughBranchesHaveCancelled()&&this._cancel()},Promise.prototype._cancel=function(){this._isCancellable()&&(this._setCancelled(),async.invoke(this._cancelPromises,this,void 0))},Promise.prototype._cancelPromises=function(){this._length()>0&&this._settlePromises()},Promise.prototype._unsetOnCancel=function(){this._onCancelField=void 0},Promise.prototype._isCancellable=function(){return this.isPending()&&!this._isCancelled()},Promise.prototype.isCancellable=function(){return this.isPending()&&!this.isCancelled()},Promise.prototype._doInvokeOnCancel=function(onCancelCallback,internalOnly){if(util.isArray(onCancelCallback))for(var i=0;i=0?contextStack[lastIndex]:void 0}var longStackTraces=!1,contextStack=[];return Promise.prototype._promiseCreated=function(){},Promise.prototype._pushContext=function(){},Promise.prototype._popContext=function(){return null},Promise._peekContext=Promise.prototype._peekContext=function(){},Context.prototype._pushContext=function(){void 0!==this._trace&&(this._trace._promiseCreated=null,contextStack.push(this._trace))},Context.prototype._popContext=function(){if(void 0!==this._trace){var trace=contextStack.pop(),ret=trace._promiseCreated;return trace._promiseCreated=null,ret}return null},Context.CapturedTrace=null,Context.create=createContext,Context.deactivateLongStackTraces=function(){},Context.activateLongStackTraces=function(){var Promise_pushContext=Promise.prototype._pushContext,Promise_popContext=Promise.prototype._popContext,Promise_PeekContext=Promise._peekContext,Promise_peekContext=Promise.prototype._peekContext,Promise_promiseCreated=Promise.prototype._promiseCreated;Context.deactivateLongStackTraces=function(){Promise.prototype._pushContext=Promise_pushContext,Promise.prototype._popContext=Promise_popContext,Promise._peekContext=Promise_PeekContext,Promise.prototype._peekContext=Promise_peekContext,Promise.prototype._promiseCreated=Promise_promiseCreated,longStackTraces=!1},longStackTraces=!0,Promise.prototype._pushContext=Context.prototype._pushContext,Promise.prototype._popContext=Context.prototype._popContext,Promise._peekContext=Promise.prototype._peekContext=peekContext,Promise.prototype._promiseCreated=function(){var ctx=this._peekContext();ctx&&null==ctx._promiseCreated&&(ctx._promiseCreated=this)}},Context}},{}],55:[function(require,module,exports){(function(process){"use strict";module.exports=function(Promise,Context){function generatePromiseLifecycleEventObject(name,promise){return{promise:promise}}function defaultFireEvent(){return!1}function cancellationExecute(executor,resolve,reject){var promise=this;try{executor(resolve,reject,function(onCancel){if("function"!=typeof onCancel)throw new TypeError("onCancel must be a function, got: "+util.toString(onCancel));promise._attachCancellationCallback(onCancel)})}catch(e){return e}}function cancellationAttachCancellationCallback(onCancel){if(!this._isCancellable())return this;var previousOnCancel=this._onCancel();void 0!==previousOnCancel?util.isArray(previousOnCancel)?previousOnCancel.push(onCancel):this._setOnCancel([previousOnCancel,onCancel]):this._setOnCancel(onCancel)}function cancellationOnCancel(){return this._onCancelField}function cancellationSetOnCancel(onCancel){this._onCancelField=onCancel}function cancellationClearCancellationData(){this._cancellationParent=void 0,this._onCancelField=void 0}function cancellationPropagateFrom(parent,flags){if(0!==(1&flags)){this._cancellationParent=parent;var branchesRemainingToCancel=parent._branchesRemainingToCancel;void 0===branchesRemainingToCancel&&(branchesRemainingToCancel=0),parent._branchesRemainingToCancel=branchesRemainingToCancel+1}0!==(2&flags)&&parent._isBound()&&this._setBoundTo(parent._boundTo)}function bindingPropagateFrom(parent,flags){0!==(2&flags)&&parent._isBound()&&this._setBoundTo(parent._boundTo)}function boundValueFunction(){var ret=this._boundTo;return void 0!==ret&&ret instanceof Promise?ret.isFulfilled()?ret.value():void 0:ret}function longStackTracesCaptureStackTrace(){this._trace=new CapturedTrace(this._peekContext())}function longStackTracesAttachExtraTrace(error,ignoreSelf){if(canAttachTrace(error)){var trace=this._trace;if(void 0!==trace&&ignoreSelf&&(trace=trace._parent),void 0!==trace)trace.attachExtraTrace(error);else if(!error.__stackCleaned__){var parsed=parseStackAndMessage(error);util.notEnumerableProp(error,"stack",parsed.message+"\n"+parsed.stack.join("\n")),util.notEnumerableProp(error,"__stackCleaned__",!0)}}}function checkForgottenReturns(returnValue,promiseCreated,name,promise,parent){if(void 0===returnValue&&null!==promiseCreated&&wForgottenReturn){if(void 0!==parent&&parent._returnedNonUndefined())return;if(0===(65535&promise._bitField))return;name&&(name+=" ");var handlerLine="",creatorLine="";if(promiseCreated._trace){for(var traceLines=promiseCreated._trace.stack.split("\n"),stack=cleanStack(traceLines),i=stack.length-1;i>=0;--i){var line=stack[i];if(!nodeFramePattern.test(line)){var lineMatches=line.match(parseLinePattern);lineMatches&&(handlerLine="at "+lineMatches[1]+":"+lineMatches[2]+":"+lineMatches[3]+" ");break}}if(stack.length>0)for(var firstUserLine=stack[0],i=0;i0&&(creatorLine="\n"+traceLines[i-1]);break}}var msg="a promise was created in a "+name+"handler "+handlerLine+"but was not returned from it, see http://goo.gl/rRqMUw"+creatorLine;promise._warn(msg,!0,promiseCreated)}}function deprecated(name,replacement){var message=name+" is deprecated and will be removed in a future version.";return replacement&&(message+=" Use "+replacement+" instead."),warn(message)}function warn(message,shouldUseOwnTrace,promise){if(config.warnings){var ctx,warning=new Warning(message);if(shouldUseOwnTrace)promise._attachExtraTrace(warning);else if(config.longStackTraces&&(ctx=Promise._peekContext()))ctx.attachExtraTrace(warning);else{var parsed=parseStackAndMessage(warning);warning.stack=parsed.message+"\n"+parsed.stack.join("\n")}activeFireEvent("warning",warning)||formatAndLogError(warning,"",!0)}}function reconstructStack(message,stacks){for(var i=0;i=0;--j)if(prev[j]===currentLastLine){commonRootMeetPoint=j;break}for(var j=commonRootMeetPoint;j>=0;--j){var line=prev[j];if(current[currentLastIndex]!==line)break;current.pop(),currentLastIndex--}current=prev}}function cleanStack(stack){for(var ret=[],i=0;i0&&"SyntaxError"!=error.name&&(stack=stack.slice(i)),stack}function parseStackAndMessage(error){var stack=error.stack,message=error.toString();return stack="string"==typeof stack&&stack.length>0?stackFramesAsArray(error):[" (No stack trace)"],{message:message,stack:"SyntaxError"==error.name?stack:cleanStack(stack)}}function formatAndLogError(error,title,isSoft){if("undefined"!=typeof console){var message;if(util.isObject(error)){var stack=error.stack;message=title+formatStack(stack,error)}else message=title+String(error);"function"==typeof printWarning?printWarning(message,isSoft):("function"==typeof console.log||"object"==typeof console.log)&&console.log(message)}}function fireRejectionEvent(name,localHandler,reason,promise){var localEventFired=!1;try{"function"==typeof localHandler&&(localEventFired=!0,"rejectionHandled"===name?localHandler(promise):localHandler(reason,promise))}catch(e){async.throwLater(e)}"unhandledRejection"===name?activeFireEvent(name,reason,promise)||localEventFired||formatAndLogError(reason,"Unhandled rejection "):activeFireEvent(name,promise)}function formatNonError(obj){var str;if("function"==typeof obj)str="[function "+(obj.name||"anonymous")+"]";else{str=obj&&"function"==typeof obj.toString?obj.toString():util.toString(obj);var ruselessToString=/\[object [a-zA-Z0-9$_]+\]/;if(ruselessToString.test(str))try{var newStr=JSON.stringify(obj);str=newStr}catch(e){}0===str.length&&(str="(empty array)")}return"(<"+snip(str)+">, no stack trace)"}function snip(str){var maxChars=41;return str.lengthfirstIndex||0>lastIndex||!firstFileName||!lastFileName||firstFileName!==lastFileName||firstIndex>=lastIndex||(shouldIgnore=function(line){if(bluebirdFramePattern.test(line))return!0;var info=parseLineInfo(line);return info&&info.fileName===firstFileName&&firstIndex<=info.line&&info.line<=lastIndex?!0:!1})}}function CapturedTrace(parent){this._parent=parent,this._promisesCreated=0;var length=this._length=1+(void 0===parent?0:parent._length);captureStackTrace(this,CapturedTrace),length>32&&this.uncycle()}var unhandledRejectionHandled,possiblyUnhandledRejection,printWarning,getDomain=Promise._getDomain,async=Promise._async,Warning=require("./errors").Warning,util=require("./util"),canAttachTrace=util.canAttachTrace,bluebirdFramePattern=/[\\\/]bluebird[\\\/]js[\\\/](release|debug|instrumented)/,nodeFramePattern=/\((?:timers\.js):\d+:\d+\)/,parseLinePattern=/[\/<\(](.+?):(\d+):(\d+)\)?\s*$/,stackFramePattern=null,formatStack=null,indentStackFrames=!1,debugging=!(0==util.env("BLUEBIRD_DEBUG")||!util.env("BLUEBIRD_DEBUG")&&"development"!==util.env("NODE_ENV")),warnings=!(0==util.env("BLUEBIRD_WARNINGS")||!debugging&&!util.env("BLUEBIRD_WARNINGS")),longStackTraces=!(0==util.env("BLUEBIRD_LONG_STACK_TRACES")||!debugging&&!util.env("BLUEBIRD_LONG_STACK_TRACES")),wForgottenReturn=0!=util.env("BLUEBIRD_W_FORGOTTEN_RETURN")&&(warnings||!!util.env("BLUEBIRD_W_FORGOTTEN_RETURN"));Promise.prototype.suppressUnhandledRejections=function(){var target=this._target();target._bitField=-1048577&target._bitField|524288},Promise.prototype._ensurePossibleRejectionHandled=function(){0===(524288&this._bitField)&&(this._setRejectionIsUnhandled(),async.invokeLater(this._notifyUnhandledRejection,this,void 0))},Promise.prototype._notifyUnhandledRejectionIsHandled=function(){fireRejectionEvent("rejectionHandled",unhandledRejectionHandled,void 0,this)},Promise.prototype._setReturnedNonUndefined=function(){this._bitField=268435456|this._bitField},Promise.prototype._returnedNonUndefined=function(){return 0!==(268435456&this._bitField)},Promise.prototype._notifyUnhandledRejection=function(){if(this._isRejectionUnhandled()){var reason=this._settledValue();this._setUnhandledRejectionIsNotified(),fireRejectionEvent("unhandledRejection",possiblyUnhandledRejection,reason,this)}},Promise.prototype._setUnhandledRejectionIsNotified=function(){this._bitField=262144|this._bitField},Promise.prototype._unsetUnhandledRejectionIsNotified=function(){this._bitField=-262145&this._bitField},Promise.prototype._isUnhandledRejectionNotified=function(){return(262144&this._bitField)>0},Promise.prototype._setRejectionIsUnhandled=function(){this._bitField=1048576|this._bitField},Promise.prototype._unsetRejectionIsUnhandled=function(){this._bitField=-1048577&this._bitField,this._isUnhandledRejectionNotified()&&(this._unsetUnhandledRejectionIsNotified(),this._notifyUnhandledRejectionIsHandled())},Promise.prototype._isRejectionUnhandled=function(){return(1048576&this._bitField)>0},Promise.prototype._warn=function(message,shouldUseOwnTrace,promise){return warn(message,shouldUseOwnTrace,promise||this)},Promise.onPossiblyUnhandledRejection=function(fn){var domain=getDomain();possiblyUnhandledRejection="function"==typeof fn?null===domain?fn:util.domainBind(domain,fn):void 0},Promise.onUnhandledRejectionHandled=function(fn){var domain=getDomain();unhandledRejectionHandled="function"==typeof fn?null===domain?fn:util.domainBind(domain,fn):void 0};var disableLongStackTraces=function(){};Promise.longStackTraces=function(){if(async.haveItemsQueued()&&!config.longStackTraces)throw new Error("cannot enable long stack traces after promises have been created\n\n See http://goo.gl/MqrFmX\n");if(!config.longStackTraces&&longStackTracesIsSupported()){var Promise_captureStackTrace=Promise.prototype._captureStackTrace,Promise_attachExtraTrace=Promise.prototype._attachExtraTrace;config.longStackTraces=!0,disableLongStackTraces=function(){if(async.haveItemsQueued()&&!config.longStackTraces)throw new Error("cannot enable long stack traces after promises have been created\n\n See http://goo.gl/MqrFmX\n");Promise.prototype._captureStackTrace=Promise_captureStackTrace,Promise.prototype._attachExtraTrace=Promise_attachExtraTrace,Context.deactivateLongStackTraces(),async.enableTrampoline(),config.longStackTraces=!1},Promise.prototype._captureStackTrace=longStackTracesCaptureStackTrace,Promise.prototype._attachExtraTrace=longStackTracesAttachExtraTrace,Context.activateLongStackTraces(),async.disableTrampolineIfNecessary()}},Promise.hasLongStackTraces=function(){return config.longStackTraces&&longStackTracesIsSupported()};var fireDomEvent=function(){try{if("function"==typeof CustomEvent){var event=new CustomEvent("CustomEvent");return util.global.dispatchEvent(event),function(name,event){var domEvent=new CustomEvent(name.toLowerCase(),{detail:event,cancelable:!0});return!util.global.dispatchEvent(domEvent)}}if("function"==typeof Event){var event=new Event("CustomEvent");return util.global.dispatchEvent(event),function(name,event){var domEvent=new Event(name.toLowerCase(),{cancelable:!0});return domEvent.detail=event,!util.global.dispatchEvent(domEvent)}}var event=document.createEvent("CustomEvent");return event.initCustomEvent("testingtheevent",!1,!0,{}),util.global.dispatchEvent(event),function(name,event){var domEvent=document.createEvent("CustomEvent");return domEvent.initCustomEvent(name.toLowerCase(),!1,!0,event),!util.global.dispatchEvent(domEvent)}}catch(e){}return function(){return!1}}(),fireGlobalEvent=function(){return util.isNode?function(){return process.emit.apply(process,arguments)}:util.global?function(name){var methodName="on"+name.toLowerCase(),method=util.global[methodName];return method?(method.apply(util.global,[].slice.call(arguments,1)),!0):!1}:function(){return!1}}(),eventToObjectGenerator={promiseCreated:generatePromiseLifecycleEventObject,promiseFulfilled:generatePromiseLifecycleEventObject,promiseRejected:generatePromiseLifecycleEventObject,promiseResolved:generatePromiseLifecycleEventObject,promiseCancelled:generatePromiseLifecycleEventObject,promiseChained:function(name,promise,child){return{promise:promise,child:child}},warning:function(name,warning){return{warning:warning}},unhandledRejection:function(name,reason,promise){return{reason:reason,promise:promise}},rejectionHandled:generatePromiseLifecycleEventObject},activeFireEvent=function(name){var globalEventFired=!1;try{globalEventFired=fireGlobalEvent.apply(null,arguments)}catch(e){async.throwLater(e),globalEventFired=!0}var domEventFired=!1;try{domEventFired=fireDomEvent(name,eventToObjectGenerator[name].apply(null,arguments))}catch(e){async.throwLater(e),domEventFired=!0}return domEventFired||globalEventFired};Promise.config=function(opts){if(opts=Object(opts),"longStackTraces"in opts&&(opts.longStackTraces?Promise.longStackTraces():!opts.longStackTraces&&Promise.hasLongStackTraces()&&disableLongStackTraces()),"warnings"in opts){var warningsOption=opts.warnings;config.warnings=!!warningsOption,wForgottenReturn=config.warnings,util.isObject(warningsOption)&&"wForgottenReturn"in warningsOption&&(wForgottenReturn=!!warningsOption.wForgottenReturn)}if("cancellation"in opts&&opts.cancellation&&!config.cancellation){if(async.haveItemsQueued())throw new Error("cannot enable cancellation after promises are in use");Promise.prototype._clearCancellationData=cancellationClearCancellationData,Promise.prototype._propagateFrom=cancellationPropagateFrom,Promise.prototype._onCancel=cancellationOnCancel,Promise.prototype._setOnCancel=cancellationSetOnCancel,Promise.prototype._attachCancellationCallback=cancellationAttachCancellationCallback,Promise.prototype._execute=cancellationExecute,propagateFromFunction=cancellationPropagateFrom,config.cancellation=!0}return"monitoring"in opts&&(opts.monitoring&&!config.monitoring?(config.monitoring=!0,Promise.prototype._fireEvent=activeFireEvent):!opts.monitoring&&config.monitoring&&(config.monitoring=!1,Promise.prototype._fireEvent=defaultFireEvent)),Promise},Promise.prototype._fireEvent=defaultFireEvent,Promise.prototype._execute=function(executor,resolve,reject){try{executor(resolve,reject)}catch(e){return e}},Promise.prototype._onCancel=function(){},Promise.prototype._setOnCancel=function(handler){},Promise.prototype._attachCancellationCallback=function(onCancel){},Promise.prototype._captureStackTrace=function(){},Promise.prototype._attachExtraTrace=function(){},Promise.prototype._clearCancellationData=function(){},Promise.prototype._propagateFrom=function(parent,flags){};var propagateFromFunction=bindingPropagateFrom,shouldIgnore=function(){return!1},parseLineInfoRegex=/[\/<\(]([^:\/]+):(\d+):(?:\d+)\)?\s*$/;util.inherits(CapturedTrace,Error),Context.CapturedTrace=CapturedTrace,CapturedTrace.prototype.uncycle=function(){var length=this._length;if(!(2>length)){for(var nodes=[],stackToIndex={},i=0,node=this;void 0!==node;++i)nodes.push(node),node=node._parent;length=this._length=i;for(var i=length-1;i>=0;--i){var stack=nodes[i].stack;void 0===stackToIndex[stack]&&(stackToIndex[stack]=i)}for(var i=0;length>i;++i){var currentStack=nodes[i].stack,index=stackToIndex[currentStack];if(void 0!==index&&index!==i){index>0&&(nodes[index-1]._parent=void 0,nodes[index-1]._length=1),nodes[i]._parent=void 0,nodes[i]._length=1;var cycleEdgeNode=i>0?nodes[i-1]:this;length-1>index?(cycleEdgeNode._parent=nodes[index+1],cycleEdgeNode._parent.uncycle(),cycleEdgeNode._length=cycleEdgeNode._parent._length+1):(cycleEdgeNode._parent=void 0,cycleEdgeNode._length=1);for(var currentChildLength=cycleEdgeNode._length+1,j=i-2;j>=0;--j)nodes[j]._length=currentChildLength,currentChildLength++;return}}}},CapturedTrace.prototype.attachExtraTrace=function(error){if(!error.__stackCleaned__){this.uncycle();for(var parsed=parseStackAndMessage(error),message=parsed.message,stacks=[parsed.stack],trace=this;void 0!==trace;)stacks.push(cleanStack(trace.stack.split("\n"))),trace=trace._parent;removeCommonRoots(stacks),removeDuplicateOrEmptyJumps(stacks),util.notEnumerableProp(error,"stack",reconstructStack(message,stacks)),util.notEnumerableProp(error,"__stackCleaned__",!0)}};var captureStackTrace=function(){var v8stackFramePattern=/^\s*at\s*/,v8stackFormatter=function(stack,error){return"string"==typeof stack?stack:void 0!==error.name&&void 0!==error.message?error.toString():formatNonError(error)};if("number"==typeof Error.stackTraceLimit&&"function"==typeof Error.captureStackTrace){Error.stackTraceLimit+=6,stackFramePattern=v8stackFramePattern,formatStack=v8stackFormatter;var captureStackTrace=Error.captureStackTrace;return shouldIgnore=function(line){return bluebirdFramePattern.test(line)},function(receiver,ignoreUntil){Error.stackTraceLimit+=6,captureStackTrace(receiver,ignoreUntil),Error.stackTraceLimit-=6}}var err=new Error;if("string"==typeof err.stack&&err.stack.split("\n")[0].indexOf("stackDetection@")>=0)return stackFramePattern=/@/,formatStack=v8stackFormatter,indentStackFrames=!0,function(o){o.stack=(new Error).stack};var hasStackAfterThrow;try{throw new Error}catch(e){hasStackAfterThrow="stack"in e}return"stack"in err||!hasStackAfterThrow||"number"!=typeof Error.stackTraceLimit?(formatStack=function(stack,error){return"string"==typeof stack?stack:"object"!=typeof error&&"function"!=typeof error||void 0===error.name||void 0===error.message?formatNonError(error):error.toString()},null):(stackFramePattern=v8stackFramePattern,formatStack=v8stackFormatter,function(o){Error.stackTraceLimit+=6;try{throw new Error}catch(e){o.stack=e.stack}Error.stackTraceLimit-=6})}([]);"undefined"!=typeof console&&"undefined"!=typeof console.warn&&(printWarning=function(message){console.warn(message)},util.isNode&&process.stderr.isTTY?printWarning=function(message,isSoft){var color=isSoft?"":"";console.warn(color+message+"\n")}:util.isNode||"string"!=typeof(new Error).stack||(printWarning=function(message,isSoft){console.warn("%c"+message,isSoft?"color: darkorange":"color: red")}));var config={warnings:warnings,longStackTraces:!1,cancellation:!1,monitoring:!1};return longStackTraces&&Promise.longStackTraces(),{longStackTraces:function(){return config.longStackTraces},warnings:function(){return config.warnings},cancellation:function(){return config.cancellation},monitoring:function(){return config.monitoring},propagateFromFunction:function(){return propagateFromFunction},boundValueFunction:function(){return boundValueFunction},checkForgottenReturns:checkForgottenReturns,setBounds:setBounds,warn:warn,deprecated:deprecated,CapturedTrace:CapturedTrace,fireDomEvent:fireDomEvent,fireGlobalEvent:fireGlobalEvent}}}).call(this,require("_process"))},{"./errors":58,"./util":82,_process:101}],56:[function(require,module,exports){"use strict";module.exports=function(Promise){function returner(){return this.value}function thrower(){throw this.reason}Promise.prototype["return"]=Promise.prototype.thenReturn=function(value){return value instanceof Promise&&value.suppressUnhandledRejections(),this._then(returner,void 0,void 0,{value:value},void 0)},Promise.prototype["throw"]=Promise.prototype.thenThrow=function(reason){return this._then(thrower,void 0,void 0,{reason:reason},void 0)},Promise.prototype.catchThrow=function(reason){if(arguments.length<=1)return this._then(void 0,thrower,void 0,{reason:reason},void 0);var _reason=arguments[1],handler=function(){throw _reason};return this.caught(reason,handler)},Promise.prototype.catchReturn=function(value){if(arguments.length<=1)return value instanceof Promise&&value.suppressUnhandledRejections(),this._then(void 0,returner,void 0,{value:value},void 0);var _value=arguments[1];_value instanceof Promise&&_value.suppressUnhandledRejections();var handler=function(){return _value};return this.caught(value,handler)}}},{}],57:[function(require,module,exports){"use strict";module.exports=function(Promise,INTERNAL){function promiseAllThis(){return PromiseAll(this)}function PromiseMapSeries(promises,fn){return PromiseReduce(promises,fn,INTERNAL,INTERNAL)}var PromiseReduce=Promise.reduce,PromiseAll=Promise.all;Promise.prototype.each=function(fn){return PromiseReduce(this,fn,INTERNAL,0)._then(promiseAllThis,void 0,void 0,this,void 0)},Promise.prototype.mapSeries=function(fn){return PromiseReduce(this,fn,INTERNAL,INTERNAL)},Promise.each=function(promises,fn){return PromiseReduce(promises,fn,INTERNAL,0)._then(promiseAllThis,void 0,void 0,promises,void 0)},Promise.mapSeries=PromiseMapSeries}},{}],58:[function(require,module,exports){"use strict";function subError(nameProperty,defaultMessage){function SubError(message){return this instanceof SubError?(notEnumerableProp(this,"message","string"==typeof message?message:defaultMessage),notEnumerableProp(this,"name",nameProperty),void(Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):Error.call(this))):new SubError(message)}return inherits(SubError,Error),SubError}function OperationalError(message){return this instanceof OperationalError?(notEnumerableProp(this,"name","OperationalError"),notEnumerableProp(this,"message",message),this.cause=message,this.isOperational=!0,void(message instanceof Error?(notEnumerableProp(this,"message",message.message),notEnumerableProp(this,"stack",message.stack)):Error.captureStackTrace&&Error.captureStackTrace(this,this.constructor))):new OperationalError(message)}var _TypeError,_RangeError,es5=require("./es5"),Objectfreeze=es5.freeze,util=require("./util"),inherits=util.inherits,notEnumerableProp=util.notEnumerableProp,Warning=subError("Warning","warning"),CancellationError=subError("CancellationError","cancellation error"),TimeoutError=subError("TimeoutError","timeout error"),AggregateError=subError("AggregateError","aggregate error");try{_TypeError=TypeError,_RangeError=RangeError}catch(e){_TypeError=subError("TypeError","type error"),_RangeError=subError("RangeError","range error")}for(var methods="join pop push shift unshift slice filter forEach some every map indexOf lastIndexOf reduce reduceRight sort reverse".split(" "),i=0;i1?ctx.cancelPromise._reject(reason):ctx.cancelPromise._cancel(),ctx.cancelPromise=null,!0):!1}function succeed(){return finallyHandler.call(this,this.promise._target()._settledValue())}function fail(reason){return checkCancel(this,reason)?void 0:(errorObj.e=reason,errorObj)}function finallyHandler(reasonOrValue){var promise=this.promise,handler=this.handler;if(!this.called){this.called=!0;var ret=this.isFinallyHandler()?handler.call(promise._boundValue()):handler.call(promise._boundValue(),reasonOrValue);if(void 0!==ret){promise._setReturnedNonUndefined();var maybePromise=tryConvertToPromise(ret,promise);if(maybePromise instanceof Promise){if(null!=this.cancelPromise){if(maybePromise._isCancelled()){var reason=new CancellationError("late cancellation observer");return promise._attachExtraTrace(reason),errorObj.e=reason,errorObj}maybePromise.isPending()&&maybePromise._attachCancellationCallback(new FinallyHandlerCancelReaction(this))}return maybePromise._then(succeed,fail,void 0,this,void 0)}}}return promise.isRejected()?(checkCancel(this),errorObj.e=reasonOrValue,errorObj):(checkCancel(this),reasonOrValue)}var util=require("./util"),CancellationError=Promise.CancellationError,errorObj=util.errorObj;return PassThroughHandlerContext.prototype.isFinallyHandler=function(){return 0===this.type},FinallyHandlerCancelReaction.prototype._resultCancelled=function(){checkCancel(this.finallyHandler)},Promise.prototype._passThrough=function(handler,type,success,fail){return"function"!=typeof handler?this.then():this._then(success,fail,void 0,new PassThroughHandlerContext(this,type,handler),void 0)},Promise.prototype.lastly=Promise.prototype["finally"]=function(handler){return this._passThrough(handler,0,finallyHandler,finallyHandler)},Promise.prototype.tap=function(handler){return this._passThrough(handler,1,finallyHandler)},PassThroughHandlerContext}},{"./util":82}],62:[function(require,module,exports){"use strict";module.exports=function(Promise,apiRejection,INTERNAL,tryConvertToPromise,Proxyable,debug){function promiseFromYieldHandler(value,yieldHandlers,traceParent){for(var i=0;ii;++i)holderClasses.push(generateHolderClass(i+1)),thenCallbacks.push(thenCallback(i+1)),promiseSetters.push(promiseSetter(i+1));reject=function(reason){this._reject(reason)}}Promise.join=function(){var fn,last=arguments.length-1;if(last>0&&"function"==typeof arguments[last]&&(fn=arguments[last],8>=last&&canEvaluate)){var ret=new Promise(INTERNAL);ret._captureStackTrace();for(var HolderClass=holderClasses[last-1],holder=new HolderClass(fn),callbacks=thenCallbacks,i=0;last>i;++i){var maybePromise=tryConvertToPromise(arguments[i],ret);if(maybePromise instanceof Promise){maybePromise=maybePromise._target();var bitField=maybePromise._bitField;0===(50397184&bitField)?(maybePromise._then(callbacks[i],reject,void 0,ret,holder),promiseSetters[i](maybePromise,holder),holder.asyncNeeded=!1):0!==(33554432&bitField)?callbacks[i].call(ret,maybePromise._value(),holder):0!==(16777216&bitField)?ret._reject(maybePromise._reason()):ret._cancel()}else callbacks[i].call(ret,maybePromise,holder)}if(!ret._isFateSealed()){if(holder.asyncNeeded){var domain=getDomain();null!==domain&&(holder.fn=util.domainBind(domain,holder.fn))}ret._setAsyncGuaranteed(),ret._setOnCancel(holder)}return ret}for(var $_len=arguments.length,args=new Array($_len),$_i=0;$_len>$_i;++$_i)args[$_i]=arguments[$_i];fn&&args.pop();var ret=new PromiseArray(args).promise();return void 0!==fn?ret.spread(fn):ret}}},{"./util":82}],64:[function(require,module,exports){"use strict";module.exports=function(Promise,PromiseArray,apiRejection,tryConvertToPromise,INTERNAL,debug){function MappingPromiseArray(promises,fn,limit,_filter){this.constructor$(promises),this._promise._captureStackTrace();var domain=getDomain();this._callback=null===domain?fn:util.domainBind(domain,fn),this._preservedValues=_filter===INTERNAL?new Array(this.length()):null,this._limit=limit,this._inFlight=0,this._queue=[],async.invoke(this._asyncInit,this,void 0)}function map(promises,fn,options,_filter){if("function"!=typeof fn)return apiRejection("expecting a function but got "+util.classString(fn));var limit=0;if(void 0!==options){if("object"!=typeof options||null===options)return Promise.reject(new TypeError("options argument must be an object but it is "+util.classString(options)));if("number"!=typeof options.concurrency)return Promise.reject(new TypeError("'concurrency' must be a number but it is "+util.classString(options.concurrency)));limit=options.concurrency}return limit="number"==typeof limit&&isFinite(limit)&&limit>=1?limit:0,new MappingPromiseArray(promises,fn,limit,_filter).promise()}var getDomain=Promise._getDomain,util=require("./util"),tryCatch=util.tryCatch,errorObj=util.errorObj,async=Promise._async;util.inherits(MappingPromiseArray,PromiseArray),MappingPromiseArray.prototype._asyncInit=function(){this._init$(void 0,-2)},MappingPromiseArray.prototype._init=function(){},MappingPromiseArray.prototype._promiseFulfilled=function(value,index){var values=this._values,length=this.length(),preservedValues=this._preservedValues,limit=this._limit;if(0>index){if(index=-1*index-1,values[index]=value,limit>=1&&(this._inFlight--,this._drainQueue(),this._isResolved()))return!0}else{if(limit>=1&&this._inFlight>=limit)return values[index]=value,this._queue.push(index),!1;null!==preservedValues&&(preservedValues[index]=value);var promise=this._promise,callback=this._callback,receiver=promise._boundValue();promise._pushContext();var ret=tryCatch(callback).call(receiver,value,index,length),promiseCreated=promise._popContext();if(debug.checkForgottenReturns(ret,promiseCreated,null!==preservedValues?"Promise.filter":"Promise.map",promise),ret===errorObj)return this._reject(ret.e),!0;var maybePromise=tryConvertToPromise(ret,this._promise);if(maybePromise instanceof Promise){maybePromise=maybePromise._target();var bitField=maybePromise._bitField;if(0===(50397184&bitField))return limit>=1&&this._inFlight++,values[index]=maybePromise,maybePromise._proxy(this,-1*(index+1)),!1;if(0===(33554432&bitField))return 0!==(16777216&bitField)?(this._reject(maybePromise._reason()),!0):(this._cancel(),!0);ret=maybePromise._value()}values[index]=ret}var totalResolved=++this._totalResolved;return totalResolved>=length?(null!==preservedValues?this._filter(values,preservedValues):this._resolve(values),!0):!1},MappingPromiseArray.prototype._drainQueue=function(){for(var queue=this._queue,limit=this._limit,values=this._values;queue.length>0&&this._inFlighti;++i)booleans[i]&&(ret[j++]=values[i]);ret.length=j,this._resolve(ret)},MappingPromiseArray.prototype.preservedValues=function(){return this._preservedValues},Promise.prototype.map=function(fn,options){return map(this,fn,options,null)},Promise.map=function(promises,fn,options,_filter){return map(promises,fn,options,_filter)}}},{"./util":82}],65:[function(require,module,exports){"use strict";module.exports=function(Promise,INTERNAL,tryConvertToPromise,apiRejection,debug){var util=require("./util"),tryCatch=util.tryCatch;Promise.method=function(fn){if("function"!=typeof fn)throw new Promise.TypeError("expecting a function but got "+util.classString(fn));return function(){var ret=new Promise(INTERNAL);ret._captureStackTrace(),ret._pushContext();var value=tryCatch(fn).apply(this,arguments),promiseCreated=ret._popContext();return debug.checkForgottenReturns(value,promiseCreated,"Promise.method",ret),ret._resolveFromSyncValue(value),ret}},Promise.attempt=Promise["try"]=function(fn){if("function"!=typeof fn)return apiRejection("expecting a function but got "+util.classString(fn));var ret=new Promise(INTERNAL);ret._captureStackTrace(),ret._pushContext();var value;if(arguments.length>1){debug.deprecated("calling Promise.try with more than 1 argument");var arg=arguments[1],ctx=arguments[2];value=util.isArray(arg)?tryCatch(fn).apply(ctx,arg):tryCatch(fn).call(ctx,arg)}else value=tryCatch(fn)();var promiseCreated=ret._popContext();return debug.checkForgottenReturns(value,promiseCreated,"Promise.try",ret),ret._resolveFromSyncValue(value),ret},Promise.prototype._resolveFromSyncValue=function(value){value===util.errorObj?this._rejectCallback(value.e,!1):this._resolveCallback(value,!0)}}},{"./util":82}],66:[function(require,module,exports){"use strict";function isUntypedError(obj){return obj instanceof Error&&es5.getPrototypeOf(obj)===Error.prototype}function wrapAsOperationalError(obj){var ret;if(isUntypedError(obj)){ret=new OperationalError(obj),ret.name=obj.name,ret.message=obj.message,ret.stack=obj.stack;for(var keys=es5.keys(obj),i=0;i$_i;++$_i)args[$_i-1]=arguments[$_i];promise._fulfill(args)}else promise._fulfill(value);promise=null}}}var util=require("./util"),maybeWrapAsError=util.maybeWrapAsError,errors=require("./errors"),OperationalError=errors.OperationalError,es5=require("./es5"),rErrorKey=/^(?:name|message|stack|cause)$/;module.exports=nodebackForPromise},{"./errors":58,"./es5":59,"./util":82}],67:[function(require,module,exports){"use strict";module.exports=function(Promise){function spreadAdapter(val,nodeback){var promise=this;if(!util.isArray(val))return successAdapter.call(promise,val,nodeback);var ret=tryCatch(nodeback).apply(promise._boundValue(),[null].concat(val));ret===errorObj&&async.throwLater(ret.e)}function successAdapter(val,nodeback){var promise=this,receiver=promise._boundValue(),ret=void 0===val?tryCatch(nodeback).call(receiver,null):tryCatch(nodeback).call(receiver,null,val);ret===errorObj&&async.throwLater(ret.e)}function errorAdapter(reason,nodeback){var promise=this;if(!reason){var newReason=new Error(reason+"");newReason.cause=reason,reason=newReason}var ret=tryCatch(nodeback).call(promise._boundValue(),reason);ret===errorObj&&async.throwLater(ret.e)}var util=require("./util"),async=Promise._async,tryCatch=util.tryCatch,errorObj=util.errorObj;Promise.prototype.asCallback=Promise.prototype.nodeify=function(nodeback,options){if("function"==typeof nodeback){var adapter=successAdapter;void 0!==options&&Object(options).spread&&(adapter=spreadAdapter),this._then(adapter,errorAdapter,void 0,this,nodeback)}return this}}},{"./util":82}],68:[function(require,module,exports){(function(process){"use strict";module.exports=function(){function Proxyable(){}function check(self,executor){if("function"!=typeof executor)throw new TypeError("expecting a function but got "+util.classString(executor));if(self.constructor!==Promise)throw new TypeError("the promise constructor cannot be invoked directly\n\n See http://goo.gl/MqrFmX\n")}function Promise(executor){this._bitField=0,this._fulfillmentHandler0=void 0,this._rejectionHandler0=void 0,this._promise0=void 0,this._receiver0=void 0,executor!==INTERNAL&&(check(this,executor),this._resolveFromExecutor(executor)),this._promiseCreated(),this._fireEvent("promiseCreated",this)}function deferResolve(v){this.promise._resolveCallback(v)}function deferReject(v){this.promise._rejectCallback(v,!1)}function fillTypes(value){var p=new Promise(INTERNAL);p._fulfillmentHandler0=value,p._rejectionHandler0=value,p._promise0=value,p._receiver0=value}var getDomain,makeSelfResolutionError=function(){return new TypeError("circular promise resolution chain\n\n See http://goo.gl/MqrFmX\n")},reflectHandler=function(){return new Promise.PromiseInspection(this._target())},apiRejection=function(msg){return Promise.reject(new TypeError(msg))},UNDEFINED_BINDING={},util=require("./util");getDomain=util.isNode?function(){var ret=process.domain;return void 0===ret&&(ret=null),ret}:function(){return null},util.notEnumerableProp(Promise,"_getDomain",getDomain);var es5=require("./es5"),Async=require("./async"),async=new Async;es5.defineProperty(Promise,"_async",{value:async});var errors=require("./errors"),TypeError=Promise.TypeError=errors.TypeError;Promise.RangeError=errors.RangeError;var CancellationError=Promise.CancellationError=errors.CancellationError;Promise.TimeoutError=errors.TimeoutError,Promise.OperationalError=errors.OperationalError,Promise.RejectionError=errors.OperationalError,Promise.AggregateError=errors.AggregateError;var INTERNAL=function(){},APPLY={},NEXT_FILTER={},tryConvertToPromise=require("./thenables")(Promise,INTERNAL),PromiseArray=require("./promise_array")(Promise,INTERNAL,tryConvertToPromise,apiRejection,Proxyable),Context=require("./context")(Promise),createContext=Context.create,debug=require("./debuggability")(Promise,Context),PassThroughHandlerContext=(debug.CapturedTrace,require("./finally")(Promise,tryConvertToPromise)),catchFilter=require("./catch_filter")(NEXT_FILTER),nodebackForPromise=require("./nodeback"),errorObj=util.errorObj,tryCatch=util.tryCatch;return Promise.prototype.toString=function(){return"[object Promise]"},Promise.prototype.caught=Promise.prototype["catch"]=function(fn){var len=arguments.length;if(len>1){var i,catchInstances=new Array(len-1),j=0;for(i=0;len-1>i;++i){var item=arguments[i];if(!util.isObject(item))return apiRejection("expecting an object but got A catch statement predicate "+util.classString(item));catchInstances[j++]=item}return catchInstances.length=j,fn=arguments[i],this.then(void 0,catchFilter(catchInstances,fn,this))}return this.then(void 0,fn)},Promise.prototype.reflect=function(){return this._then(reflectHandler,reflectHandler,void 0,this,void 0)},Promise.prototype.then=function(didFulfill,didReject){if(debug.warnings()&&arguments.length>0&&"function"!=typeof didFulfill&&"function"!=typeof didReject){var msg=".then() only accepts functions but was passed: "+util.classString(didFulfill);arguments.length>1&&(msg+=", "+util.classString(didReject)),this._warn(msg)}return this._then(didFulfill,didReject,void 0,void 0,void 0)},Promise.prototype.done=function(didFulfill,didReject){var promise=this._then(didFulfill,didReject,void 0,void 0,void 0);promise._setIsFinal()},Promise.prototype.spread=function(fn){return"function"!=typeof fn?apiRejection("expecting a function but got "+util.classString(fn)):this.all()._then(fn,void 0,void 0,APPLY,void 0)},Promise.prototype.toJSON=function(){var ret={isFulfilled:!1,isRejected:!1,fulfillmentValue:void 0,rejectionReason:void 0};return this.isFulfilled()?(ret.fulfillmentValue=this.value(),ret.isFulfilled=!0):this.isRejected()&&(ret.rejectionReason=this.reason(),ret.isRejected=!0),ret},Promise.prototype.all=function(){return arguments.length>0&&this._warn(".all() was passed arguments but it does not take any"),new PromiseArray(this).promise()},Promise.prototype.error=function(fn){return this.caught(util.originatesFromRejection,fn)},Promise.getNewLibraryCopy=module.exports,Promise.is=function(val){return val instanceof Promise},Promise.fromNode=Promise.fromCallback=function(fn){var ret=new Promise(INTERNAL);ret._captureStackTrace();var multiArgs=arguments.length>1?!!Object(arguments[1]).multiArgs:!1,result=tryCatch(fn)(nodebackForPromise(ret,multiArgs));return result===errorObj&&ret._rejectCallback(result.e,!0),ret._isFateSealed()||ret._setAsyncGuaranteed(),ret},Promise.all=function(promises){return new PromiseArray(promises).promise()},Promise.cast=function(obj){var ret=tryConvertToPromise(obj);return ret instanceof Promise||(ret=new Promise(INTERNAL),ret._captureStackTrace(),ret._setFulfilled(),ret._rejectionHandler0=obj),ret},Promise.resolve=Promise.fulfilled=Promise.cast,Promise.reject=Promise.rejected=function(reason){var ret=new Promise(INTERNAL);return ret._captureStackTrace(),ret._rejectCallback(reason,!0),ret},Promise.setScheduler=function(fn){if("function"!=typeof fn)throw new TypeError("expecting a function but got "+util.classString(fn));return async.setScheduler(fn)},Promise.prototype._then=function(didFulfill,didReject,_,receiver,internalData){var haveInternalData=void 0!==internalData,promise=haveInternalData?internalData:new Promise(INTERNAL),target=this._target(),bitField=target._bitField;haveInternalData||(promise._propagateFrom(this,3),promise._captureStackTrace(),void 0===receiver&&0!==(2097152&this._bitField)&&(receiver=0!==(50397184&bitField)?this._boundValue():target===this?void 0:this._boundTo),this._fireEvent("promiseChained",this,promise));var domain=getDomain();if(0!==(50397184&bitField)){var handler,value,settler=target._settlePromiseCtx;0!==(33554432&bitField)?(value=target._rejectionHandler0,handler=didFulfill):0!==(16777216&bitField)?(value=target._fulfillmentHandler0,handler=didReject,target._unsetRejectionIsUnhandled()):(settler=target._settlePromiseLateCancellationObserver,value=new CancellationError("late cancellation observer"),target._attachExtraTrace(value),handler=didReject),async.invoke(settler,target,{handler:null===domain?handler:"function"==typeof handler&&util.domainBind(domain,handler),promise:promise,receiver:receiver,value:value})}else target._addCallbacks(didFulfill,didReject,promise,receiver,domain);return promise},Promise.prototype._length=function(){return 65535&this._bitField},Promise.prototype._isFateSealed=function(){return 0!==(117506048&this._bitField)},Promise.prototype._isFollowing=function(){return 67108864===(67108864&this._bitField)},Promise.prototype._setLength=function(len){this._bitField=-65536&this._bitField|65535&len},Promise.prototype._setFulfilled=function(){this._bitField=33554432|this._bitField,this._fireEvent("promiseFulfilled",this)},Promise.prototype._setRejected=function(){this._bitField=16777216|this._bitField,this._fireEvent("promiseRejected",this)},Promise.prototype._setFollowing=function(){this._bitField=67108864|this._bitField,this._fireEvent("promiseResolved",this)},Promise.prototype._setIsFinal=function(){this._bitField=4194304|this._bitField},Promise.prototype._isFinal=function(){return(4194304&this._bitField)>0},Promise.prototype._unsetCancelled=function(){this._bitField=-65537&this._bitField},Promise.prototype._setCancelled=function(){this._bitField=65536|this._bitField,this._fireEvent("promiseCancelled",this)},Promise.prototype._setWillBeCancelled=function(){this._bitField=8388608|this._bitField},Promise.prototype._setAsyncGuaranteed=function(){async.hasCustomScheduler()||(this._bitField=134217728|this._bitField)},Promise.prototype._receiverAt=function(index){var ret=0===index?this._receiver0:this[4*index-4+3];return ret===UNDEFINED_BINDING?void 0:void 0===ret&&this._isBound()?this._boundValue():ret},Promise.prototype._promiseAt=function(index){return this[4*index-4+2]},Promise.prototype._fulfillmentHandlerAt=function(index){return this[4*index-4+0]},Promise.prototype._rejectionHandlerAt=function(index){return this[4*index-4+1]},Promise.prototype._boundValue=function(){},Promise.prototype._migrateCallback0=function(follower){var fulfill=(follower._bitField,follower._fulfillmentHandler0),reject=follower._rejectionHandler0,promise=follower._promise0,receiver=follower._receiverAt(0);void 0===receiver&&(receiver=UNDEFINED_BINDING),this._addCallbacks(fulfill,reject,promise,receiver,null)},Promise.prototype._migrateCallbackAt=function(follower,index){var fulfill=follower._fulfillmentHandlerAt(index),reject=follower._rejectionHandlerAt(index),promise=follower._promiseAt(index),receiver=follower._receiverAt(index);void 0===receiver&&(receiver=UNDEFINED_BINDING),this._addCallbacks(fulfill,reject,promise,receiver,null)},Promise.prototype._addCallbacks=function(fulfill,reject,promise,receiver,domain){var index=this._length();if(index>=65531&&(index=0,this._setLength(0)),0===index)this._promise0=promise,this._receiver0=receiver,"function"==typeof fulfill&&(this._fulfillmentHandler0=null===domain?fulfill:util.domainBind(domain,fulfill)),"function"==typeof reject&&(this._rejectionHandler0=null===domain?reject:util.domainBind(domain,reject));else{var base=4*index-4;this[base+2]=promise,this[base+3]=receiver,"function"==typeof fulfill&&(this[base+0]=null===domain?fulfill:util.domainBind(domain,fulfill)),"function"==typeof reject&&(this[base+1]=null===domain?reject:util.domainBind(domain,reject))}return this._setLength(index+1),index},Promise.prototype._proxy=function(proxyable,arg){this._addCallbacks(void 0,void 0,arg,proxyable,null)},Promise.prototype._resolveCallback=function(value,shouldBind){if(0===(117506048&this._bitField)){if(value===this)return this._rejectCallback(makeSelfResolutionError(),!1);var maybePromise=tryConvertToPromise(value,this);if(!(maybePromise instanceof Promise))return this._fulfill(value);shouldBind&&this._propagateFrom(maybePromise,2);var promise=maybePromise._target();if(promise===this)return void this._reject(makeSelfResolutionError());var bitField=promise._bitField;if(0===(50397184&bitField)){var len=this._length();len>0&&promise._migrateCallback0(this);for(var i=1;len>i;++i)promise._migrateCallbackAt(this,i);this._setFollowing(),this._setLength(0),this._setFollowee(promise)}else if(0!==(33554432&bitField))this._fulfill(promise._value());else if(0!==(16777216&bitField))this._reject(promise._reason());else{var reason=new CancellationError("late cancellation observer");promise._attachExtraTrace(reason),this._reject(reason)}}},Promise.prototype._rejectCallback=function(reason,synchronous,ignoreNonErrorWarnings){var trace=util.ensureErrorObject(reason),hasStack=trace===reason;if(!hasStack&&!ignoreNonErrorWarnings&&debug.warnings()){var message="a promise was rejected with a non-error: "+util.classString(reason);this._warn(message,!0)}this._attachExtraTrace(trace,synchronous?hasStack:!1),this._reject(reason)},Promise.prototype._resolveFromExecutor=function(executor){var promise=this;this._captureStackTrace(),this._pushContext();var synchronous=!0,r=this._execute(executor,function(value){promise._resolveCallback(value)},function(reason){promise._rejectCallback(reason,synchronous)});synchronous=!1,this._popContext(),void 0!==r&&promise._rejectCallback(r,!0)},Promise.prototype._settlePromiseFromHandler=function(handler,receiver,value,promise){var bitField=promise._bitField;if(0===(65536&bitField)){promise._pushContext();var x;receiver===APPLY?value&&"number"==typeof value.length?x=tryCatch(handler).apply(this._boundValue(),value):(x=errorObj,x.e=new TypeError("cannot .spread() a non-array: "+util.classString(value))):x=tryCatch(handler).call(receiver,value);var promiseCreated=promise._popContext();bitField=promise._bitField,0===(65536&bitField)&&(x===NEXT_FILTER?promise._reject(value):x===errorObj?promise._rejectCallback(x.e,!1):(debug.checkForgottenReturns(x,promiseCreated,"",promise,this),promise._resolveCallback(x)))}},Promise.prototype._target=function(){for(var ret=this;ret._isFollowing();)ret=ret._followee();return ret},Promise.prototype._followee=function(){return this._rejectionHandler0},Promise.prototype._setFollowee=function(promise){this._rejectionHandler0=promise},Promise.prototype._settlePromise=function(promise,handler,receiver,value){var isPromise=promise instanceof Promise,bitField=this._bitField,asyncGuaranteed=0!==(134217728&bitField);0!==(65536&bitField)?(isPromise&&promise._invokeInternalOnCancel(),receiver instanceof PassThroughHandlerContext&&receiver.isFinallyHandler()?(receiver.cancelPromise=promise,tryCatch(handler).call(receiver,value)===errorObj&&promise._reject(errorObj.e)):handler===reflectHandler?promise._fulfill(reflectHandler.call(receiver)):receiver instanceof Proxyable?receiver._promiseCancelled(promise):isPromise||promise instanceof PromiseArray?promise._cancel():receiver.cancel()):"function"==typeof handler?isPromise?(asyncGuaranteed&&promise._setAsyncGuaranteed(),this._settlePromiseFromHandler(handler,receiver,value,promise)):handler.call(receiver,value,promise):receiver instanceof Proxyable?receiver._isResolved()||(0!==(33554432&bitField)?receiver._promiseFulfilled(value,promise):receiver._promiseRejected(value,promise)):isPromise&&(asyncGuaranteed&&promise._setAsyncGuaranteed(),0!==(33554432&bitField)?promise._fulfill(value):promise._reject(value))},Promise.prototype._settlePromiseLateCancellationObserver=function(ctx){var handler=ctx.handler,promise=ctx.promise,receiver=ctx.receiver,value=ctx.value;"function"==typeof handler?promise instanceof Promise?this._settlePromiseFromHandler(handler,receiver,value,promise):handler.call(receiver,value,promise):promise instanceof Promise&&promise._reject(value)},Promise.prototype._settlePromiseCtx=function(ctx){this._settlePromise(ctx.promise,ctx.handler,ctx.receiver,ctx.value)},Promise.prototype._settlePromise0=function(handler,value,bitField){var promise=this._promise0,receiver=this._receiverAt(0);this._promise0=void 0,this._receiver0=void 0,this._settlePromise(promise,handler,receiver,value)},Promise.prototype._clearCallbackDataAtIndex=function(index){var base=4*index-4;this[base+2]=this[base+3]=this[base+0]=this[base+1]=void 0},Promise.prototype._fulfill=function(value){var bitField=this._bitField;if(!((117506048&bitField)>>>16)){if(value===this){var err=makeSelfResolutionError();return this._attachExtraTrace(err),this._reject(err)}this._setFulfilled(),this._rejectionHandler0=value,(65535&bitField)>0&&(0!==(134217728&bitField)?this._settlePromises():async.settlePromises(this))}},Promise.prototype._reject=function(reason){var bitField=this._bitField;if(!((117506048&bitField)>>>16))return this._setRejected(),this._fulfillmentHandler0=reason,this._isFinal()?async.fatalError(reason,util.isNode):void((65535&bitField)>0?async.settlePromises(this):this._ensurePossibleRejectionHandled())},Promise.prototype._fulfillPromises=function(len,value){for(var i=1;len>i;i++){var handler=this._fulfillmentHandlerAt(i),promise=this._promiseAt(i),receiver=this._receiverAt(i);this._clearCallbackDataAtIndex(i),this._settlePromise(promise,handler,receiver,value)}},Promise.prototype._rejectPromises=function(len,reason){for(var i=1;len>i;i++){var handler=this._rejectionHandlerAt(i),promise=this._promiseAt(i),receiver=this._receiverAt(i);this._clearCallbackDataAtIndex(i),this._settlePromise(promise,handler,receiver,reason)}},Promise.prototype._settlePromises=function(){var bitField=this._bitField,len=65535&bitField;if(len>0){if(0!==(16842752&bitField)){var reason=this._fulfillmentHandler0;this._settlePromise0(this._rejectionHandler0,reason,bitField),this._rejectPromises(len,reason)}else{var value=this._rejectionHandler0;this._settlePromise0(this._fulfillmentHandler0,value,bitField),this._fulfillPromises(len,value)}this._setLength(0)}this._clearCancellationData()},Promise.prototype._settledValue=function(){var bitField=this._bitField;return 0!==(33554432&bitField)?this._rejectionHandler0:0!==(16777216&bitField)?this._fulfillmentHandler0:void 0},Promise.defer=Promise.pending=function(){debug.deprecated("Promise.defer","new Promise");var promise=new Promise(INTERNAL);return{promise:promise,resolve:deferResolve,reject:deferReject}},util.notEnumerableProp(Promise,"_makeSelfResolutionError",makeSelfResolutionError),require("./method")(Promise,INTERNAL,tryConvertToPromise,apiRejection,debug),require("./bind")(Promise,INTERNAL,tryConvertToPromise,debug),require("./cancel")(Promise,PromiseArray,apiRejection,debug),require("./direct_resolve")(Promise),require("./synchronous_inspection")(Promise),require("./join")(Promise,PromiseArray,tryConvertToPromise,INTERNAL,async,getDomain),Promise.Promise=Promise,Promise.version="3.4.7",require("./map.js")(Promise,PromiseArray,apiRejection,tryConvertToPromise,INTERNAL,debug),require("./call_get.js")(Promise),require("./using.js")(Promise,apiRejection,tryConvertToPromise,createContext,INTERNAL,debug),require("./timers.js")(Promise,INTERNAL,debug),require("./generators.js")(Promise,apiRejection,INTERNAL,tryConvertToPromise,Proxyable,debug),require("./nodeify.js")(Promise),require("./promisify.js")(Promise,INTERNAL),require("./props.js")(Promise,PromiseArray,tryConvertToPromise,apiRejection),require("./race.js")(Promise,INTERNAL,tryConvertToPromise,apiRejection),require("./reduce.js")(Promise,PromiseArray,apiRejection,tryConvertToPromise,INTERNAL,debug),require("./settle.js")(Promise,PromiseArray,debug),require("./some.js")(Promise,PromiseArray,apiRejection),require("./filter.js")(Promise,INTERNAL),require("./each.js")(Promise,INTERNAL),require("./any.js")(Promise),util.toFastProperties(Promise),util.toFastProperties(Promise.prototype),fillTypes({a:1}),fillTypes({b:2}),fillTypes({c:3}),fillTypes(1),fillTypes(function(){}),fillTypes(void 0),fillTypes(!1),fillTypes(new Promise(INTERNAL)),debug.setBounds(Async.firstLineError,util.lastLineError),Promise}}).call(this,require("_process"))},{"./any.js":48,"./async":49,"./bind":50,"./call_get.js":51,"./cancel":52,"./catch_filter":53,"./context":54,"./debuggability":55,"./direct_resolve":56,"./each.js":57,"./errors":58,"./es5":59,"./filter.js":60,"./finally":61,"./generators.js":62,"./join":63,"./map.js":64,"./method":65,"./nodeback":66,"./nodeify.js":67,"./promise_array":69,"./promisify.js":70,"./props.js":71,"./race.js":73,"./reduce.js":74,"./settle.js":76,"./some.js":77,"./synchronous_inspection":78,"./thenables":79,"./timers.js":80,"./using.js":81,"./util":82,_process:101}],69:[function(require,module,exports){"use strict";module.exports=function(Promise,INTERNAL,tryConvertToPromise,apiRejection,Proxyable){function toResolutionValue(val){switch(val){case-2:return[];case-3:return{}}}function PromiseArray(values){ -var promise=this._promise=new Promise(INTERNAL);values instanceof Promise&&promise._propagateFrom(values,3),promise._setOnCancel(this),this._values=values,this._length=0,this._totalResolved=0,this._init(void 0,-2)}var util=require("./util");util.isArray;return util.inherits(PromiseArray,Proxyable),PromiseArray.prototype.length=function(){return this._length},PromiseArray.prototype.promise=function(){return this._promise},PromiseArray.prototype._init=function init(_,resolveValueIfEmpty){var values=tryConvertToPromise(this._values,this._promise);if(values instanceof Promise){values=values._target();var bitField=values._bitField;if(this._values=values,0===(50397184&bitField))return this._promise._setAsyncGuaranteed(),values._then(init,this._reject,void 0,this,resolveValueIfEmpty);if(0===(33554432&bitField))return 0!==(16777216&bitField)?this._reject(values._reason()):this._cancel();values=values._value()}if(values=util.asArray(values),null===values){var err=apiRejection("expecting an array or an iterable object but got "+util.classString(values)).reason();return void this._promise._rejectCallback(err,!1)}return 0===values.length?void(-5===resolveValueIfEmpty?this._resolveEmptyArray():this._resolve(toResolutionValue(resolveValueIfEmpty))):void this._iterate(values)},PromiseArray.prototype._iterate=function(values){var len=this.getActualLength(values.length);this._length=len,this._values=this.shouldCopyValues()?new Array(len):this._values;for(var result=this._promise,isResolved=!1,bitField=null,i=0;len>i;++i){var maybePromise=tryConvertToPromise(values[i],result);maybePromise instanceof Promise?(maybePromise=maybePromise._target(),bitField=maybePromise._bitField):bitField=null,isResolved?null!==bitField&&maybePromise.suppressUnhandledRejections():null!==bitField?0===(50397184&bitField)?(maybePromise._proxy(this,i),this._values[i]=maybePromise):isResolved=0!==(33554432&bitField)?this._promiseFulfilled(maybePromise._value(),i):0!==(16777216&bitField)?this._promiseRejected(maybePromise._reason(),i):this._promiseCancelled(i):isResolved=this._promiseFulfilled(maybePromise,i)}isResolved||result._setAsyncGuaranteed()},PromiseArray.prototype._isResolved=function(){return null===this._values},PromiseArray.prototype._resolve=function(value){this._values=null,this._promise._fulfill(value)},PromiseArray.prototype._cancel=function(){!this._isResolved()&&this._promise._isCancellable()&&(this._values=null,this._promise._cancel())},PromiseArray.prototype._reject=function(reason){this._values=null,this._promise._rejectCallback(reason,!1)},PromiseArray.prototype._promiseFulfilled=function(value,index){this._values[index]=value;var totalResolved=++this._totalResolved;return totalResolved>=this._length?(this._resolve(this._values),!0):!1},PromiseArray.prototype._promiseCancelled=function(){return this._cancel(),!0},PromiseArray.prototype._promiseRejected=function(reason){return this._totalResolved++,this._reject(reason),!0},PromiseArray.prototype._resultCancelled=function(){if(!this._isResolved()){var values=this._values;if(this._cancel(),values instanceof Promise)values.cancel();else for(var i=0;ii;i+=2){var key=methods[i],fn=methods[i+1],promisifiedKey=key+suffix;if(promisifier===makeNodePromisified)obj[promisifiedKey]=makeNodePromisified(key,THIS,key,fn,suffix,multiArgs);else{var promisified=promisifier(fn,function(){return makeNodePromisified(key,THIS,key,fn,suffix,multiArgs)});util.notEnumerableProp(promisified,"__isPromisified__",!0),obj[promisifiedKey]=promisified}}return util.toFastProperties(obj),obj}function promisify(callback,receiver,multiArgs){return makeNodePromisified(callback,receiver,void 0,callback,null,multiArgs)}var makeNodePromisifiedEval,THIS={},util=require("./util"),nodebackForPromise=require("./nodeback"),withAppended=util.withAppended,maybeWrapAsError=util.maybeWrapAsError,canEvaluate=util.canEvaluate,TypeError=require("./errors").TypeError,defaultSuffix="Async",defaultPromisified={__isPromisified__:!0},noCopyProps=["arity","length","name","arguments","caller","callee","prototype","__isPromisified__"],noCopyPropsPattern=new RegExp("^(?:"+noCopyProps.join("|")+")$"),defaultFilter=function(name){return util.isIdentifier(name)&&"_"!==name.charAt(0)&&"constructor"!==name},escapeIdentRegex=function(str){return str.replace(/([$])/,"\\$")},switchCaseArgumentOrder=function(likelyArgumentCount){for(var ret=[likelyArgumentCount],min=Math.max(0,likelyArgumentCount-1-3),i=likelyArgumentCount-1;i>=min;--i)ret.push(i);for(var i=likelyArgumentCount+1;3>=i;++i)ret.push(i);return ret},argumentSequence=function(argumentCount){return util.filledRange(argumentCount,"_arg","")},parameterDeclaration=function(parameterCount){return util.filledRange(Math.max(parameterCount,3),"_arg","")},parameterCount=function(fn){return"number"==typeof fn.length?Math.max(Math.min(fn.length,1024),0):0};makeNodePromisifiedEval=function(callback,receiver,originalName,fn,_,multiArgs){function generateCallForArgumentCount(count){var ret,args=argumentSequence(count).join(", "),comma=count>0?", ":"";return ret=shouldProxyThis?"ret = callback.call(this, {{args}}, nodeback); break;\n":void 0===receiver?"ret = callback({{args}}, nodeback); break;\n":"ret = callback.call(receiver, {{args}}, nodeback); break;\n",ret.replace("{{args}}",args).replace(", ",comma)}function generateArgumentSwitchCase(){for(var ret="",i=0;ii;++i){var key=keys[i];entries[i]=obj[key],entries[i+len]=key}}this.constructor$(entries),this._isMap=isMap,this._init$(void 0,-3)}function props(promises){var ret,castValue=tryConvertToPromise(promises);return isObject(castValue)?(ret=castValue instanceof Promise?castValue._then(Promise.props,void 0,void 0,void 0,void 0):new PropertiesPromiseArray(castValue).promise(),castValue instanceof Promise&&ret._propagateFrom(castValue,2),ret):apiRejection("cannot await properties of a non-object\n\n See http://goo.gl/MqrFmX\n")}var Es6Map,util=require("./util"),isObject=util.isObject,es5=require("./es5");"function"==typeof Map&&(Es6Map=Map);var mapToEntries=function(){function extractEntry(value,key){this[index]=value,this[index+size]=key,index++}var index=0,size=0;return function(map){size=map.size,index=0;var ret=new Array(2*map.size);return map.forEach(extractEntry,ret),ret}}(),entriesToMap=function(entries){for(var ret=new Es6Map,length=entries.length/2|0,i=0;length>i;++i){var key=entries[length+i],value=entries[i];ret.set(key,value)}return ret};util.inherits(PropertiesPromiseArray,PromiseArray),PropertiesPromiseArray.prototype._init=function(){},PropertiesPromiseArray.prototype._promiseFulfilled=function(value,index){this._values[index]=value;var totalResolved=++this._totalResolved;if(totalResolved>=this._length){var val;if(this._isMap)val=entriesToMap(this._values);else{val={};for(var keyOffset=this.length(),i=0,len=this.length();len>i;++i)val[this._values[i+keyOffset]]=this._values[i]}return this._resolve(val),!0}return!1},PropertiesPromiseArray.prototype.shouldCopyValues=function(){return!1},PropertiesPromiseArray.prototype.getActualLength=function(len){return len>>1},Promise.prototype.props=function(){return props(this)},Promise.props=function(promises){return props(promises)}}},{"./es5":59,"./util":82}],72:[function(require,module,exports){"use strict";function arrayMove(src,srcIndex,dst,dstIndex,len){for(var j=0;len>j;++j)dst[j+dstIndex]=src[j+srcIndex],src[j+srcIndex]=void 0}function Queue(capacity){this._capacity=capacity,this._length=0,this._front=0}Queue.prototype._willBeOverCapacity=function(size){return this._capacityi;++i){var val=promises[i];(void 0!==val||i in promises)&&Promise.cast(val)._then(fulfill,reject,void 0,ret,null)}return ret}var util=require("./util"),raceLater=function(promise){return promise.then(function(array){return race(array,promise)})};Promise.race=function(promises){return race(promises,void 0)},Promise.prototype.race=function(){return race(this,void 0)}}},{"./util":82}],74:[function(require,module,exports){"use strict";module.exports=function(Promise,PromiseArray,apiRejection,tryConvertToPromise,INTERNAL,debug){function ReductionPromiseArray(promises,fn,initialValue,_each){this.constructor$(promises);var domain=getDomain();this._fn=null===domain?fn:util.domainBind(domain,fn),void 0!==initialValue&&(initialValue=Promise.resolve(initialValue),initialValue._attachCancellationCallback(this)),this._initialValue=initialValue,this._currentCancellable=null,_each===INTERNAL?this._eachValues=Array(this._length):0===_each?this._eachValues=null:this._eachValues=void 0,this._promise._captureStackTrace(),this._init$(void 0,-5)}function completed(valueOrReason,array){this.isFulfilled()?array._resolve(valueOrReason):array._reject(valueOrReason)}function reduce(promises,fn,initialValue,_each){if("function"!=typeof fn)return apiRejection("expecting a function but got "+util.classString(fn));var array=new ReductionPromiseArray(promises,fn,initialValue,_each);return array.promise()}function gotAccum(accum){this.accum=accum,this.array._gotAccum(accum);var value=tryConvertToPromise(this.value,this.array._promise);return value instanceof Promise?(this.array._currentCancellable=value,value._then(gotValue,void 0,void 0,this,void 0)):gotValue.call(this,value)}function gotValue(value){var array=this.array,promise=array._promise,fn=tryCatch(array._fn);promise._pushContext();var ret;ret=void 0!==array._eachValues?fn.call(promise._boundValue(),value,this.index,this.length):fn.call(promise._boundValue(),this.accum,value,this.index,this.length),ret instanceof Promise&&(array._currentCancellable=ret);var promiseCreated=promise._popContext();return debug.checkForgottenReturns(ret,promiseCreated,void 0!==array._eachValues?"Promise.each":"Promise.reduce",promise),ret}var getDomain=Promise._getDomain,util=require("./util"),tryCatch=util.tryCatch;util.inherits(ReductionPromiseArray,PromiseArray),ReductionPromiseArray.prototype._gotAccum=function(accum){void 0!==this._eachValues&&null!==this._eachValues&&accum!==INTERNAL&&this._eachValues.push(accum)},ReductionPromiseArray.prototype._eachComplete=function(value){return null!==this._eachValues&&this._eachValues.push(value),this._eachValues},ReductionPromiseArray.prototype._init=function(){},ReductionPromiseArray.prototype._resolveEmptyArray=function(){this._resolve(void 0!==this._eachValues?this._eachValues:this._initialValue)},ReductionPromiseArray.prototype.shouldCopyValues=function(){return!1},ReductionPromiseArray.prototype._resolve=function(value){this._promise._resolveCallback(value),this._values=null},ReductionPromiseArray.prototype._resultCancelled=function(sender){return sender===this._initialValue?this._cancel():void(this._isResolved()||(this._resultCancelled$(),this._currentCancellable instanceof Promise&&this._currentCancellable.cancel(),this._initialValue instanceof Promise&&this._initialValue.cancel()))},ReductionPromiseArray.prototype._iterate=function(values){this._values=values;var value,i,length=values.length;if(void 0!==this._initialValue?(value=this._initialValue,i=0):(value=Promise.resolve(values[0]),i=1),this._currentCancellable=value,!value.isRejected())for(;length>i;++i){var ctx={accum:null,value:values[i],index:i,length:length,array:this};value=value._then(gotAccum,void 0,void 0,ctx,void 0)}void 0!==this._eachValues&&(value=value._then(this._eachComplete,void 0,void 0,this,void 0)),value._then(completed,completed,void 0,value,this)},Promise.prototype.reduce=function(fn,initialValue){return reduce(this,fn,initialValue,null)},Promise.reduce=function(promises,fn,initialValue,_each){return reduce(promises,fn,initialValue,_each)}}},{"./util":82}],75:[function(require,module,exports){(function(process,global){"use strict";var schedule,util=require("./util"),noAsyncScheduler=function(){throw new Error("No async scheduler available\n\n See http://goo.gl/MqrFmX\n")},NativePromise=util.getNativePromise();if(util.isNode&&"undefined"==typeof MutationObserver){var GlobalSetImmediate=global.setImmediate,ProcessNextTick=process.nextTick;schedule=util.isRecentNode?function(fn){GlobalSetImmediate.call(global,fn)}:function(fn){ProcessNextTick.call(process,fn)}}else if("function"==typeof NativePromise&&"function"==typeof NativePromise.resolve){var nativePromise=NativePromise.resolve();schedule=function(fn){nativePromise.then(fn)}}else schedule="undefined"==typeof MutationObserver||"undefined"!=typeof window&&window.navigator&&(window.navigator.standalone||window.cordova)?"undefined"!=typeof setImmediate?function(fn){setImmediate(fn)}:"undefined"!=typeof setTimeout?function(fn){setTimeout(fn,0)}:noAsyncScheduler:function(){var div=document.createElement("div"),opts={attributes:!0},toggleScheduled=!1,div2=document.createElement("div"),o2=new MutationObserver(function(){div.classList.toggle("foo"),toggleScheduled=!1});o2.observe(div2,opts);var scheduleToggle=function(){toggleScheduled||(toggleScheduled=!0,div2.classList.toggle("foo"))};return function(fn){var o=new MutationObserver(function(){o.disconnect(),fn()});o.observe(div,opts),scheduleToggle()}}();module.exports=schedule}).call(this,require("_process"),"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{"./util":82,_process:101}],76:[function(require,module,exports){"use strict";module.exports=function(Promise,PromiseArray,debug){function SettledPromiseArray(values){this.constructor$(values)}var PromiseInspection=Promise.PromiseInspection,util=require("./util");util.inherits(SettledPromiseArray,PromiseArray),SettledPromiseArray.prototype._promiseResolved=function(index,inspection){this._values[index]=inspection;var totalResolved=++this._totalResolved;return totalResolved>=this._length?(this._resolve(this._values),!0):!1},SettledPromiseArray.prototype._promiseFulfilled=function(value,index){var ret=new PromiseInspection;return ret._bitField=33554432,ret._settledValueField=value,this._promiseResolved(index,ret)},SettledPromiseArray.prototype._promiseRejected=function(reason,index){var ret=new PromiseInspection;return ret._bitField=16777216,ret._settledValueField=reason,this._promiseResolved(index,ret)},Promise.settle=function(promises){return debug.deprecated(".settle()",".reflect()"),new SettledPromiseArray(promises).promise()},Promise.prototype.settle=function(){return Promise.settle(this)}}},{"./util":82}],77:[function(require,module,exports){"use strict";module.exports=function(Promise,PromiseArray,apiRejection){function SomePromiseArray(values){this.constructor$(values),this._howMany=0,this._unwrap=!1,this._initialized=!1}function some(promises,howMany){if((0|howMany)!==howMany||0>howMany)return apiRejection("expecting a positive integer\n\n See http://goo.gl/MqrFmX\n");var ret=new SomePromiseArray(promises),promise=ret.promise();return ret.setHowMany(howMany),ret.init(),promise}var util=require("./util"),RangeError=require("./errors").RangeError,AggregateError=require("./errors").AggregateError,isArray=util.isArray,CANCELLATION={};util.inherits(SomePromiseArray,PromiseArray),SomePromiseArray.prototype._init=function(){if(this._initialized){if(0===this._howMany)return void this._resolve([]);this._init$(void 0,-5);var isArrayResolved=isArray(this._values);!this._isResolved()&&isArrayResolved&&this._howMany>this._canPossiblyFulfill()&&this._reject(this._getRangeError(this.length()))}},SomePromiseArray.prototype.init=function(){this._initialized=!0,this._init()},SomePromiseArray.prototype.setUnwrap=function(){this._unwrap=!0},SomePromiseArray.prototype.howMany=function(){return this._howMany},SomePromiseArray.prototype.setHowMany=function(count){this._howMany=count},SomePromiseArray.prototype._promiseFulfilled=function(value){return this._addFulfilled(value),this._fulfilled()===this.howMany()?(this._values.length=this.howMany(),1===this.howMany()&&this._unwrap?this._resolve(this._values[0]):this._resolve(this._values),!0):!1},SomePromiseArray.prototype._promiseRejected=function(reason){return this._addRejected(reason),this._checkOutcome()},SomePromiseArray.prototype._promiseCancelled=function(){return this._values instanceof Promise||null==this._values?this._cancel():(this._addRejected(CANCELLATION),this._checkOutcome())},SomePromiseArray.prototype._checkOutcome=function(){if(this.howMany()>this._canPossiblyFulfill()){for(var e=new AggregateError,i=this.length();i0?this._reject(e):this._cancel(),!0}return!1},SomePromiseArray.prototype._fulfilled=function(){return this._totalResolved},SomePromiseArray.prototype._rejected=function(){return this._values.length-this.length()},SomePromiseArray.prototype._addRejected=function(reason){this._values.push(reason)},SomePromiseArray.prototype._addFulfilled=function(value){this._values[this._totalResolved++]=value},SomePromiseArray.prototype._canPossiblyFulfill=function(){return this.length()-this._rejected()},SomePromiseArray.prototype._getRangeError=function(count){var message="Input array must contain at least "+this._howMany+" items but contains only "+count+" items";return new RangeError(message)},SomePromiseArray.prototype._resolveEmptyArray=function(){this._reject(this._getRangeError(0))},Promise.some=function(promises,howMany){return some(promises,howMany)},Promise.prototype.some=function(howMany){return some(this,howMany)},Promise._SomePromiseArray=SomePromiseArray}},{"./errors":58,"./util":82}],78:[function(require,module,exports){"use strict";module.exports=function(Promise){function PromiseInspection(promise){void 0!==promise?(promise=promise._target(),this._bitField=promise._bitField,this._settledValueField=promise._isFateSealed()?promise._settledValue():void 0):(this._bitField=0,this._settledValueField=void 0)}PromiseInspection.prototype._settledValue=function(){return this._settledValueField};var value=PromiseInspection.prototype.value=function(){if(!this.isFulfilled())throw new TypeError("cannot get fulfillment value of a non-fulfilled promise\n\n See http://goo.gl/MqrFmX\n");return this._settledValue()},reason=PromiseInspection.prototype.error=PromiseInspection.prototype.reason=function(){if(!this.isRejected())throw new TypeError("cannot get rejection reason of a non-rejected promise\n\n See http://goo.gl/MqrFmX\n");return this._settledValue()},isFulfilled=PromiseInspection.prototype.isFulfilled=function(){return 0!==(33554432&this._bitField)},isRejected=PromiseInspection.prototype.isRejected=function(){return 0!==(16777216&this._bitField)},isPending=PromiseInspection.prototype.isPending=function(){return 0===(50397184&this._bitField)},isResolved=PromiseInspection.prototype.isResolved=function(){return 0!==(50331648&this._bitField)};PromiseInspection.prototype.isCancelled=function(){return 0!==(8454144&this._bitField)},Promise.prototype.__isCancelled=function(){return 65536===(65536&this._bitField)},Promise.prototype._isCancelled=function(){return this._target().__isCancelled()},Promise.prototype.isCancelled=function(){return 0!==(8454144&this._target()._bitField)},Promise.prototype.isPending=function(){return isPending.call(this._target())},Promise.prototype.isRejected=function(){return isRejected.call(this._target())},Promise.prototype.isFulfilled=function(){return isFulfilled.call(this._target())},Promise.prototype.isResolved=function(){return isResolved.call(this._target())},Promise.prototype.value=function(){return value.call(this._target())},Promise.prototype.reason=function(){var target=this._target();return target._unsetRejectionIsUnhandled(),reason.call(target)},Promise.prototype._value=function(){return this._settledValue()},Promise.prototype._reason=function(){return this._unsetRejectionIsUnhandled(),this._settledValue()},Promise.PromiseInspection=PromiseInspection}},{}],79:[function(require,module,exports){"use strict";module.exports=function(Promise,INTERNAL){function tryConvertToPromise(obj,context){if(isObject(obj)){if(obj instanceof Promise)return obj;var then=getThen(obj);if(then===errorObj){context&&context._pushContext();var ret=Promise.reject(then.e);return context&&context._popContext(),ret}if("function"==typeof then){if(isAnyBluebirdPromise(obj)){var ret=new Promise(INTERNAL);return obj._then(ret._fulfill,ret._reject,void 0,ret,null),ret}return doThenable(obj,then,context)}}return obj}function doGetThen(obj){return obj.then}function getThen(obj){try{return doGetThen(obj)}catch(e){return errorObj.e=e,errorObj}}function isAnyBluebirdPromise(obj){try{return hasProp.call(obj,"_promise0")}catch(e){return!1}}function doThenable(x,then,context){function resolve(value){promise&&(promise._resolveCallback(value),promise=null)}function reject(reason){promise&&(promise._rejectCallback(reason,synchronous,!0),promise=null)}var promise=new Promise(INTERNAL),ret=promise;context&&context._pushContext(),promise._captureStackTrace(),context&&context._popContext();var synchronous=!0,result=util.tryCatch(then).call(x,resolve,reject);return synchronous=!1,promise&&result===errorObj&&(promise._rejectCallback(result.e,!0,!0),promise=null),ret}var util=require("./util"),errorObj=util.errorObj,isObject=util.isObject,hasProp={}.hasOwnProperty;return tryConvertToPromise}},{"./util":82}],80:[function(require,module,exports){"use strict";module.exports=function(Promise,INTERNAL,debug){function HandleWrapper(handle){this.handle=handle}function successClear(value){return clearTimeout(this.handle),value}function failureClear(reason){throw clearTimeout(this.handle),reason}var util=require("./util"),TimeoutError=Promise.TimeoutError;HandleWrapper.prototype._resultCancelled=function(){clearTimeout(this.handle)};var afterValue=function(value){return delay(+this).thenReturn(value)},delay=Promise.delay=function(ms,value){var ret,handle;return void 0!==value?(ret=Promise.resolve(value)._then(afterValue,null,null,ms,void 0),debug.cancellation()&&value instanceof Promise&&ret._setOnCancel(value)):(ret=new Promise(INTERNAL),handle=setTimeout(function(){ret._fulfill()},+ms),debug.cancellation()&&ret._setOnCancel(new HandleWrapper(handle)),ret._captureStackTrace()),ret._setAsyncGuaranteed(),ret};Promise.prototype.delay=function(ms){return delay(ms,this)};var afterTimeout=function(promise,message,parent){var err;err="string"!=typeof message?message instanceof Error?message:new TimeoutError("operation timed out"):new TimeoutError(message),util.markAsOriginatingFromRejection(err),promise._attachExtraTrace(err),promise._reject(err),null!=parent&&parent.cancel()};Promise.prototype.timeout=function(ms,message){ms=+ms;var ret,parent,handleWrapper=new HandleWrapper(setTimeout(function(){ret.isPending()&&afterTimeout(ret,message,parent)},ms));return debug.cancellation()?(parent=this.then(),ret=parent._then(successClear,failureClear,void 0,handleWrapper,void 0),ret._setOnCancel(handleWrapper)):ret=this._then(successClear,failureClear,void 0,handleWrapper,void 0), -ret}}},{"./util":82}],81:[function(require,module,exports){"use strict";module.exports=function(Promise,apiRejection,tryConvertToPromise,createContext,INTERNAL,debug){function thrower(e){setTimeout(function(){throw e},0)}function castPreservingDisposable(thenable){var maybePromise=tryConvertToPromise(thenable);return maybePromise!==thenable&&"function"==typeof thenable._isDisposable&&"function"==typeof thenable._getDisposer&&thenable._isDisposable()&&maybePromise._setDisposable(thenable._getDisposer()),maybePromise}function dispose(resources,inspection){function iterator(){if(i>=len)return ret._fulfill();var maybePromise=castPreservingDisposable(resources[i++]);if(maybePromise instanceof Promise&&maybePromise._isDisposable()){try{maybePromise=tryConvertToPromise(maybePromise._getDisposer().tryDispose(inspection),resources.promise)}catch(e){return thrower(e)}if(maybePromise instanceof Promise)return maybePromise._then(iterator,thrower,null,null,null)}iterator()}var i=0,len=resources.length,ret=new Promise(INTERNAL);return iterator(),ret}function Disposer(data,promise,context){this._data=data,this._promise=promise,this._context=context}function FunctionDisposer(fn,promise,context){this.constructor$(fn,promise,context)}function maybeUnwrapDisposer(value){return Disposer.isDisposer(value)?(this.resources[this.index]._setDisposable(value),value.promise()):value}function ResourceList(length){this.length=length,this.promise=null,this[length-1]=null}var util=require("./util"),TypeError=require("./errors").TypeError,inherits=require("./util").inherits,errorObj=util.errorObj,tryCatch=util.tryCatch,NULL={};Disposer.prototype.data=function(){return this._data},Disposer.prototype.promise=function(){return this._promise},Disposer.prototype.resource=function(){return this.promise().isFulfilled()?this.promise().value():NULL},Disposer.prototype.tryDispose=function(inspection){var resource=this.resource(),context=this._context;void 0!==context&&context._pushContext();var ret=resource!==NULL?this.doDispose(resource,inspection):null;return void 0!==context&&context._popContext(),this._promise._unsetDisposable(),this._data=null,ret},Disposer.isDisposer=function(d){return null!=d&&"function"==typeof d.resource&&"function"==typeof d.tryDispose},inherits(FunctionDisposer,Disposer),FunctionDisposer.prototype.doDispose=function(resource,inspection){var fn=this.data();return fn.call(resource,resource,inspection)},ResourceList.prototype._resultCancelled=function(){for(var len=this.length,i=0;len>i;++i){var item=this[i];item instanceof Promise&&item.cancel()}},Promise.using=function(){var len=arguments.length;if(2>len)return apiRejection("you must pass at least 2 arguments to Promise.using");var fn=arguments[len-1];if("function"!=typeof fn)return apiRejection("expecting a function but got "+util.classString(fn));var input,spreadArgs=!0;2===len&&Array.isArray(arguments[0])?(input=arguments[0],len=input.length,spreadArgs=!1):(input=arguments,len--);for(var resources=new ResourceList(len),i=0;len>i;++i){var resource=input[i];if(Disposer.isDisposer(resource)){var disposer=resource;resource=resource.promise(),resource._setDisposable(disposer)}else{var maybePromise=tryConvertToPromise(resource);maybePromise instanceof Promise&&(resource=maybePromise._then(maybeUnwrapDisposer,null,null,{resources:resources,index:i},void 0))}resources[i]=resource}for(var reflectedResources=new Array(resources.length),i=0;i0},Promise.prototype._getDisposer=function(){return this._disposer},Promise.prototype._unsetDisposable=function(){this._bitField=-131073&this._bitField,this._disposer=void 0},Promise.prototype.disposer=function(fn){if("function"==typeof fn)return new FunctionDisposer(fn,this,createContext());throw new TypeError}}},{"./errors":58,"./util":82}],82:[function(require,module,exports){(function(process,global){"use strict";function tryCatcher(){try{var target=tryCatchTarget;return tryCatchTarget=null,target.apply(this,arguments)}catch(e){return errorObj.e=e,errorObj}}function tryCatch(fn){return tryCatchTarget=fn,tryCatcher}function isPrimitive(val){return null==val||val===!0||val===!1||"string"==typeof val||"number"==typeof val}function isObject(value){return"function"==typeof value||"object"==typeof value&&null!==value}function maybeWrapAsError(maybeError){return isPrimitive(maybeError)?new Error(safeToString(maybeError)):maybeError}function withAppended(target,appendee){var i,len=target.length,ret=new Array(len+1);for(i=0;len>i;++i)ret[i]=target[i];return ret[i]=appendee,ret}function getDataPropertyOrDefault(obj,key,defaultValue){if(!es5.isES5)return{}.hasOwnProperty.call(obj,key)?obj[key]:void 0;var desc=Object.getOwnPropertyDescriptor(obj,key);return null!=desc?null==desc.get&&null==desc.set?desc.value:defaultValue:void 0}function notEnumerableProp(obj,name,value){if(isPrimitive(obj))return obj;var descriptor={value:value,configurable:!0,enumerable:!1,writable:!0};return es5.defineProperty(obj,name,descriptor),obj}function thrower(r){throw r}function isClass(fn){try{if("function"==typeof fn){var keys=es5.names(fn.prototype),hasMethods=es5.isES5&&keys.length>1,hasMethodsOtherThanConstructor=keys.length>0&&!(1===keys.length&&"constructor"===keys[0]),hasThisAssignmentAndStaticMethods=thisAssignmentPattern.test(fn+"")&&es5.names(fn).length>0;if(hasMethods||hasMethodsOtherThanConstructor||hasThisAssignmentAndStaticMethods)return!0}return!1}catch(e){return!1}}function toFastProperties(obj){function FakeConstructor(){}FakeConstructor.prototype=obj;for(var l=8;l--;)new FakeConstructor;return obj}function isIdentifier(str){return rident.test(str)}function filledRange(count,prefix,suffix){for(var ret=new Array(count),i=0;count>i;++i)ret[i]=prefix+i+suffix;return ret}function safeToString(obj){try{return obj+""}catch(e){return"[no string representation]"}}function isError(obj){return null!==obj&&"object"==typeof obj&&"string"==typeof obj.message&&"string"==typeof obj.name}function markAsOriginatingFromRejection(e){try{notEnumerableProp(e,"isOperational",!0)}catch(ignore){}}function originatesFromRejection(e){return null==e?!1:e instanceof Error.__BluebirdErrorTypes__.OperationalError||e.isOperational===!0}function canAttachTrace(obj){return isError(obj)&&es5.propertyIsWritable(obj,"stack")}function classString(obj){return{}.toString.call(obj)}function copyDescriptors(from,to,filter){for(var keys=es5.names(from),i=0;i10||version[0]>0}(),ret.isNode&&ret.toFastProperties(process);try{throw new Error}catch(e){ret.lastLineError=e}module.exports=ret}).call(this,require("_process"),"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{"./es5":59,_process:101}],83:[function(require,module,exports){(function(global){"use strict";function typedArraySupport(){try{var arr=new Uint8Array(1);return arr.__proto__={__proto__:Uint8Array.prototype,foo:function(){return 42}},42===arr.foo()&&"function"==typeof arr.subarray&&0===arr.subarray(1,1).byteLength}catch(e){return!1}}function kMaxLength(){return Buffer.TYPED_ARRAY_SUPPORT?2147483647:1073741823}function createBuffer(that,length){if(kMaxLength()size)throw new RangeError('"size" argument must not be negative')}function alloc(that,size,fill,encoding){return assertSize(size),0>=size?createBuffer(that,size):void 0!==fill?"string"==typeof encoding?createBuffer(that,size).fill(fill,encoding):createBuffer(that,size).fill(fill):createBuffer(that,size)}function allocUnsafe(that,size){if(assertSize(size),that=createBuffer(that,0>size?0:0|checked(size)),!Buffer.TYPED_ARRAY_SUPPORT)for(var i=0;size>i;++i)that[i]=0;return that}function fromString(that,string,encoding){if(("string"!=typeof encoding||""===encoding)&&(encoding="utf8"),!Buffer.isEncoding(encoding))throw new TypeError('"encoding" must be a valid string encoding');var length=0|byteLength(string,encoding);that=createBuffer(that,length);var actual=that.write(string,encoding);return actual!==length&&(that=that.slice(0,actual)),that}function fromArrayLike(that,array){var length=array.length<0?0:0|checked(array.length);that=createBuffer(that,length);for(var i=0;length>i;i+=1)that[i]=255&array[i];return that}function fromArrayBuffer(that,array,byteOffset,length){if(array.byteLength,0>byteOffset||array.byteLength=kMaxLength())throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+kMaxLength().toString(16)+" bytes");return 0|length}function SlowBuffer(length){return+length!=length&&(length=0),Buffer.alloc(+length)}function byteLength(string,encoding){if(Buffer.isBuffer(string))return string.length;if("undefined"!=typeof ArrayBuffer&&"function"==typeof ArrayBuffer.isView&&(ArrayBuffer.isView(string)||string instanceof ArrayBuffer))return string.byteLength;"string"!=typeof string&&(string=""+string);var len=string.length;if(0===len)return 0;for(var loweredCase=!1;;)switch(encoding){case"ascii":case"latin1":case"binary":return len;case"utf8":case"utf-8":case void 0:return utf8ToBytes(string).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*len;case"hex":return len>>>1;case"base64":return base64ToBytes(string).length;default:if(loweredCase)return utf8ToBytes(string).length;encoding=(""+encoding).toLowerCase(),loweredCase=!0}}function slowToString(encoding,start,end){var loweredCase=!1;if((void 0===start||0>start)&&(start=0),start>this.length)return"";if((void 0===end||end>this.length)&&(end=this.length),0>=end)return"";if(end>>>=0,start>>>=0,start>=end)return"";for(encoding||(encoding="utf8");;)switch(encoding){case"hex":return hexSlice(this,start,end);case"utf8":case"utf-8":return utf8Slice(this,start,end);case"ascii":return asciiSlice(this,start,end);case"latin1":case"binary":return latin1Slice(this,start,end);case"base64":return base64Slice(this,start,end);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return utf16leSlice(this,start,end);default:if(loweredCase)throw new TypeError("Unknown encoding: "+encoding);encoding=(encoding+"").toLowerCase(),loweredCase=!0}}function swap(b,n,m){var i=b[n];b[n]=b[m],b[m]=i}function bidirectionalIndexOf(buffer,val,byteOffset,encoding,dir){if(0===buffer.length)return-1;if("string"==typeof byteOffset?(encoding=byteOffset,byteOffset=0):byteOffset>2147483647?byteOffset=2147483647:-2147483648>byteOffset&&(byteOffset=-2147483648),byteOffset=+byteOffset,isNaN(byteOffset)&&(byteOffset=dir?0:buffer.length-1),0>byteOffset&&(byteOffset=buffer.length+byteOffset),byteOffset>=buffer.length){if(dir)return-1;byteOffset=buffer.length-1}else if(0>byteOffset){if(!dir)return-1;byteOffset=0}if("string"==typeof val&&(val=Buffer.from(val,encoding)),Buffer.isBuffer(val))return 0===val.length?-1:arrayIndexOf(buffer,val,byteOffset,encoding,dir);if("number"==typeof val)return val=255&val,Buffer.TYPED_ARRAY_SUPPORT&&"function"==typeof Uint8Array.prototype.indexOf?dir?Uint8Array.prototype.indexOf.call(buffer,val,byteOffset):Uint8Array.prototype.lastIndexOf.call(buffer,val,byteOffset):arrayIndexOf(buffer,[val],byteOffset,encoding,dir);throw new TypeError("val must be string, number or Buffer")}function arrayIndexOf(arr,val,byteOffset,encoding,dir){function read(buf,i){return 1===indexSize?buf[i]:buf.readUInt16BE(i*indexSize)}var indexSize=1,arrLength=arr.length,valLength=val.length;if(void 0!==encoding&&(encoding=String(encoding).toLowerCase(),"ucs2"===encoding||"ucs-2"===encoding||"utf16le"===encoding||"utf-16le"===encoding)){if(arr.length<2||val.length<2)return-1;indexSize=2,arrLength/=2,valLength/=2,byteOffset/=2}var i;if(dir){var foundIndex=-1;for(i=byteOffset;arrLength>i;i++)if(read(arr,i)===read(val,-1===foundIndex?0:i-foundIndex)){if(-1===foundIndex&&(foundIndex=i),i-foundIndex+1===valLength)return foundIndex*indexSize}else-1!==foundIndex&&(i-=i-foundIndex),foundIndex=-1}else for(byteOffset+valLength>arrLength&&(byteOffset=arrLength-valLength),i=byteOffset;i>=0;i--){for(var found=!0,j=0;valLength>j;j++)if(read(arr,i+j)!==read(val,j)){found=!1;break}if(found)return i}return-1}function hexWrite(buf,string,offset,length){offset=Number(offset)||0;var remaining=buf.length-offset;length?(length=Number(length),length>remaining&&(length=remaining)):length=remaining;var strLen=string.length;if(strLen%2!==0)throw new TypeError("Invalid hex string");length>strLen/2&&(length=strLen/2);for(var i=0;length>i;++i){var parsed=parseInt(string.substr(2*i,2),16);if(isNaN(parsed))return i;buf[offset+i]=parsed}return i}function utf8Write(buf,string,offset,length){return blitBuffer(utf8ToBytes(string,buf.length-offset),buf,offset,length)}function asciiWrite(buf,string,offset,length){return blitBuffer(asciiToBytes(string),buf,offset,length)}function latin1Write(buf,string,offset,length){return asciiWrite(buf,string,offset,length)}function base64Write(buf,string,offset,length){return blitBuffer(base64ToBytes(string),buf,offset,length)}function ucs2Write(buf,string,offset,length){return blitBuffer(utf16leToBytes(string,buf.length-offset),buf,offset,length)}function base64Slice(buf,start,end){return 0===start&&end===buf.length?base64.fromByteArray(buf):base64.fromByteArray(buf.slice(start,end))}function utf8Slice(buf,start,end){end=Math.min(buf.length,end);for(var res=[],i=start;end>i;){var firstByte=buf[i],codePoint=null,bytesPerSequence=firstByte>239?4:firstByte>223?3:firstByte>191?2:1;if(end>=i+bytesPerSequence){var secondByte,thirdByte,fourthByte,tempCodePoint;switch(bytesPerSequence){case 1:128>firstByte&&(codePoint=firstByte);break;case 2:secondByte=buf[i+1],128===(192&secondByte)&&(tempCodePoint=(31&firstByte)<<6|63&secondByte,tempCodePoint>127&&(codePoint=tempCodePoint));break;case 3:secondByte=buf[i+1],thirdByte=buf[i+2],128===(192&secondByte)&&128===(192&thirdByte)&&(tempCodePoint=(15&firstByte)<<12|(63&secondByte)<<6|63&thirdByte,tempCodePoint>2047&&(55296>tempCodePoint||tempCodePoint>57343)&&(codePoint=tempCodePoint));break;case 4:secondByte=buf[i+1],thirdByte=buf[i+2],fourthByte=buf[i+3],128===(192&secondByte)&&128===(192&thirdByte)&&128===(192&fourthByte)&&(tempCodePoint=(15&firstByte)<<18|(63&secondByte)<<12|(63&thirdByte)<<6|63&fourthByte,tempCodePoint>65535&&1114112>tempCodePoint&&(codePoint=tempCodePoint))}}null===codePoint?(codePoint=65533,bytesPerSequence=1):codePoint>65535&&(codePoint-=65536,res.push(codePoint>>>10&1023|55296),codePoint=56320|1023&codePoint),res.push(codePoint),i+=bytesPerSequence}return decodeCodePointsArray(res)}function decodeCodePointsArray(codePoints){var len=codePoints.length;if(MAX_ARGUMENTS_LENGTH>=len)return String.fromCharCode.apply(String,codePoints);for(var res="",i=0;len>i;)res+=String.fromCharCode.apply(String,codePoints.slice(i,i+=MAX_ARGUMENTS_LENGTH));return res}function asciiSlice(buf,start,end){var ret="";end=Math.min(buf.length,end);for(var i=start;end>i;++i)ret+=String.fromCharCode(127&buf[i]);return ret}function latin1Slice(buf,start,end){var ret="";end=Math.min(buf.length,end);for(var i=start;end>i;++i)ret+=String.fromCharCode(buf[i]);return ret}function hexSlice(buf,start,end){var len=buf.length;(!start||0>start)&&(start=0),(!end||0>end||end>len)&&(end=len);for(var out="",i=start;end>i;++i)out+=toHex(buf[i]);return out}function utf16leSlice(buf,start,end){for(var bytes=buf.slice(start,end),res="",i=0;ioffset)throw new RangeError("offset is not uint");if(offset+ext>length)throw new RangeError("Trying to access beyond buffer length")}function checkInt(buf,value,offset,ext,max,min){if(!Buffer.isBuffer(buf))throw new TypeError('"buffer" argument must be a Buffer instance');if(value>max||min>value)throw new RangeError('"value" argument is out of bounds');if(offset+ext>buf.length)throw new RangeError("Index out of range")}function objectWriteUInt16(buf,value,offset,littleEndian){0>value&&(value=65535+value+1);for(var i=0,j=Math.min(buf.length-offset,2);j>i;++i)buf[offset+i]=(value&255<<8*(littleEndian?i:1-i))>>>8*(littleEndian?i:1-i)}function objectWriteUInt32(buf,value,offset,littleEndian){0>value&&(value=4294967295+value+1);for(var i=0,j=Math.min(buf.length-offset,4);j>i;++i)buf[offset+i]=value>>>8*(littleEndian?i:3-i)&255}function checkIEEE754(buf,value,offset,ext,max,min){if(offset+ext>buf.length)throw new RangeError("Index out of range");if(0>offset)throw new RangeError("Index out of range")}function writeFloat(buf,value,offset,littleEndian,noAssert){return noAssert||checkIEEE754(buf,value,offset,4,3.4028234663852886e38,-3.4028234663852886e38),ieee754.write(buf,value,offset,littleEndian,23,4),offset+4}function writeDouble(buf,value,offset,littleEndian,noAssert){return noAssert||checkIEEE754(buf,value,offset,8,1.7976931348623157e308,-1.7976931348623157e308),ieee754.write(buf,value,offset,littleEndian,52,8),offset+8}function base64clean(str){if(str=stringtrim(str).replace(INVALID_BASE64_RE,""),str.length<2)return"";for(;str.length%4!==0;)str+="=";return str}function stringtrim(str){return str.trim?str.trim():str.replace(/^\s+|\s+$/g,"")}function toHex(n){return 16>n?"0"+n.toString(16):n.toString(16)}function utf8ToBytes(string,units){units=units||1/0;for(var codePoint,length=string.length,leadSurrogate=null,bytes=[],i=0;length>i;++i){if(codePoint=string.charCodeAt(i),codePoint>55295&&57344>codePoint){if(!leadSurrogate){if(codePoint>56319){(units-=3)>-1&&bytes.push(239,191,189);continue}if(i+1===length){(units-=3)>-1&&bytes.push(239,191,189);continue}leadSurrogate=codePoint;continue}if(56320>codePoint){(units-=3)>-1&&bytes.push(239,191,189),leadSurrogate=codePoint;continue}codePoint=(leadSurrogate-55296<<10|codePoint-56320)+65536}else leadSurrogate&&(units-=3)>-1&&bytes.push(239,191,189);if(leadSurrogate=null,128>codePoint){if((units-=1)<0)break;bytes.push(codePoint)}else if(2048>codePoint){if((units-=2)<0)break;bytes.push(codePoint>>6|192,63&codePoint|128)}else if(65536>codePoint){if((units-=3)<0)break;bytes.push(codePoint>>12|224,codePoint>>6&63|128,63&codePoint|128)}else{if(!(1114112>codePoint))throw new Error("Invalid code point");if((units-=4)<0)break;bytes.push(codePoint>>18|240,codePoint>>12&63|128,codePoint>>6&63|128,63&codePoint|128)}}return bytes}function asciiToBytes(str){for(var byteArray=[],i=0;i>8,lo=c%256,byteArray.push(lo),byteArray.push(hi);return byteArray}function base64ToBytes(str){return base64.toByteArray(base64clean(str))}function blitBuffer(src,dst,offset,length){for(var i=0;length>i&&!(i+offset>=dst.length||i>=src.length);++i)dst[i+offset]=src[i];return i}function isnan(val){return val!==val}var base64=require("base64-js"),ieee754=require("ieee754"),isArray=require("isarray");exports.Buffer=Buffer,exports.SlowBuffer=SlowBuffer,exports.INSPECT_MAX_BYTES=50,Buffer.TYPED_ARRAY_SUPPORT=void 0!==global.TYPED_ARRAY_SUPPORT?global.TYPED_ARRAY_SUPPORT:typedArraySupport(),exports.kMaxLength=kMaxLength(),Buffer.poolSize=8192,Buffer._augment=function(arr){return arr.__proto__=Buffer.prototype,arr},Buffer.from=function(value,encodingOrOffset,length){return from(null,value,encodingOrOffset,length)},Buffer.TYPED_ARRAY_SUPPORT&&(Buffer.prototype.__proto__=Uint8Array.prototype,Buffer.__proto__=Uint8Array,"undefined"!=typeof Symbol&&Symbol.species&&Buffer[Symbol.species]===Buffer&&Object.defineProperty(Buffer,Symbol.species,{value:null,configurable:!0})),Buffer.alloc=function(size,fill,encoding){return alloc(null,size,fill,encoding)},Buffer.allocUnsafe=function(size){return allocUnsafe(null,size)},Buffer.allocUnsafeSlow=function(size){return allocUnsafe(null,size)},Buffer.isBuffer=function(b){return!(null==b||!b._isBuffer)},Buffer.compare=function(a,b){if(!Buffer.isBuffer(a)||!Buffer.isBuffer(b))throw new TypeError("Arguments must be Buffers");if(a===b)return 0;for(var x=a.length,y=b.length,i=0,len=Math.min(x,y);len>i;++i)if(a[i]!==b[i]){x=a[i],y=b[i];break}return y>x?-1:x>y?1:0},Buffer.isEncoding=function(encoding){switch(String(encoding).toLowerCase()){case"hex":case"utf8":case"utf-8":case"ascii":case"latin1":case"binary":case"base64":case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return!0;default:return!1}},Buffer.concat=function(list,length){if(!isArray(list))throw new TypeError('"list" argument must be an Array of Buffers');if(0===list.length)return Buffer.alloc(0);var i;if(void 0===length)for(length=0,i=0;ii;i+=2)swap(this,i,i+1);return this},Buffer.prototype.swap32=function(){var len=this.length;if(len%4!==0)throw new RangeError("Buffer size must be a multiple of 32-bits");for(var i=0;len>i;i+=4)swap(this,i,i+3),swap(this,i+1,i+2);return this},Buffer.prototype.swap64=function(){var len=this.length;if(len%8!==0)throw new RangeError("Buffer size must be a multiple of 64-bits");for(var i=0;len>i;i+=8)swap(this,i,i+7),swap(this,i+1,i+6),swap(this,i+2,i+5),swap(this,i+3,i+4);return this},Buffer.prototype.toString=function(){var length=0|this.length;return 0===length?"":0===arguments.length?utf8Slice(this,0,length):slowToString.apply(this,arguments)},Buffer.prototype.equals=function(b){if(!Buffer.isBuffer(b))throw new TypeError("Argument must be a Buffer");return this===b?!0:0===Buffer.compare(this,b)},Buffer.prototype.inspect=function(){var str="",max=exports.INSPECT_MAX_BYTES;return this.length>0&&(str=this.toString("hex",0,max).match(/.{2}/g).join(" "),this.length>max&&(str+=" ... ")),""},Buffer.prototype.compare=function(target,start,end,thisStart,thisEnd){if(!Buffer.isBuffer(target))throw new TypeError("Argument must be a Buffer");if(void 0===start&&(start=0),void 0===end&&(end=target?target.length:0),void 0===thisStart&&(thisStart=0),void 0===thisEnd&&(thisEnd=this.length),0>start||end>target.length||0>thisStart||thisEnd>this.length)throw new RangeError("out of range index");if(thisStart>=thisEnd&&start>=end)return 0;if(thisStart>=thisEnd)return-1;if(start>=end)return 1;if(start>>>=0,end>>>=0,thisStart>>>=0,thisEnd>>>=0,this===target)return 0;for(var x=thisEnd-thisStart,y=end-start,len=Math.min(x,y),thisCopy=this.slice(thisStart,thisEnd),targetCopy=target.slice(start,end),i=0;len>i;++i)if(thisCopy[i]!==targetCopy[i]){x=thisCopy[i],y=targetCopy[i];break}return y>x?-1:x>y?1:0},Buffer.prototype.includes=function(val,byteOffset,encoding){return-1!==this.indexOf(val,byteOffset,encoding)},Buffer.prototype.indexOf=function(val,byteOffset,encoding){return bidirectionalIndexOf(this,val,byteOffset,encoding,!0)},Buffer.prototype.lastIndexOf=function(val,byteOffset,encoding){return bidirectionalIndexOf(this,val,byteOffset,encoding,!1)},Buffer.prototype.write=function(string,offset,length,encoding){if(void 0===offset)encoding="utf8",length=this.length,offset=0;else if(void 0===length&&"string"==typeof offset)encoding=offset,length=this.length,offset=0;else{if(!isFinite(offset))throw new Error("Buffer.write(string, encoding, offset[, length]) is no longer supported");offset=0|offset,isFinite(length)?(length=0|length,void 0===encoding&&(encoding="utf8")):(encoding=length,length=void 0)}var remaining=this.length-offset;if((void 0===length||length>remaining)&&(length=remaining),string.length>0&&(0>length||0>offset)||offset>this.length)throw new RangeError("Attempt to write outside buffer bounds");encoding||(encoding="utf8");for(var loweredCase=!1;;)switch(encoding){case"hex":return hexWrite(this,string,offset,length);case"utf8":case"utf-8":return utf8Write(this,string,offset,length);case"ascii":return asciiWrite(this,string,offset,length);case"latin1":case"binary":return latin1Write(this,string,offset,length);case"base64":return base64Write(this,string,offset,length);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return ucs2Write(this,string,offset,length);default:if(loweredCase)throw new TypeError("Unknown encoding: "+encoding);encoding=(""+encoding).toLowerCase(),loweredCase=!0}},Buffer.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};var MAX_ARGUMENTS_LENGTH=4096;Buffer.prototype.slice=function(start,end){var len=this.length;start=~~start,end=void 0===end?len:~~end,0>start?(start+=len,0>start&&(start=0)):start>len&&(start=len),0>end?(end+=len,0>end&&(end=0)):end>len&&(end=len),start>end&&(end=start);var newBuf;if(Buffer.TYPED_ARRAY_SUPPORT)newBuf=this.subarray(start,end),newBuf.__proto__=Buffer.prototype;else{var sliceLen=end-start;newBuf=new Buffer(sliceLen,void 0);for(var i=0;sliceLen>i;++i)newBuf[i]=this[i+start]; -}return newBuf},Buffer.prototype.readUIntLE=function(offset,byteLength,noAssert){offset=0|offset,byteLength=0|byteLength,noAssert||checkOffset(offset,byteLength,this.length);for(var val=this[offset],mul=1,i=0;++i0&&(mul*=256);)val+=this[offset+--byteLength]*mul;return val},Buffer.prototype.readUInt8=function(offset,noAssert){return noAssert||checkOffset(offset,1,this.length),this[offset]},Buffer.prototype.readUInt16LE=function(offset,noAssert){return noAssert||checkOffset(offset,2,this.length),this[offset]|this[offset+1]<<8},Buffer.prototype.readUInt16BE=function(offset,noAssert){return noAssert||checkOffset(offset,2,this.length),this[offset]<<8|this[offset+1]},Buffer.prototype.readUInt32LE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),(this[offset]|this[offset+1]<<8|this[offset+2]<<16)+16777216*this[offset+3]},Buffer.prototype.readUInt32BE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),16777216*this[offset]+(this[offset+1]<<16|this[offset+2]<<8|this[offset+3])},Buffer.prototype.readIntLE=function(offset,byteLength,noAssert){offset=0|offset,byteLength=0|byteLength,noAssert||checkOffset(offset,byteLength,this.length);for(var val=this[offset],mul=1,i=0;++i=mul&&(val-=Math.pow(2,8*byteLength)),val},Buffer.prototype.readIntBE=function(offset,byteLength,noAssert){offset=0|offset,byteLength=0|byteLength,noAssert||checkOffset(offset,byteLength,this.length);for(var i=byteLength,mul=1,val=this[offset+--i];i>0&&(mul*=256);)val+=this[offset+--i]*mul;return mul*=128,val>=mul&&(val-=Math.pow(2,8*byteLength)),val},Buffer.prototype.readInt8=function(offset,noAssert){return noAssert||checkOffset(offset,1,this.length),128&this[offset]?-1*(255-this[offset]+1):this[offset]},Buffer.prototype.readInt16LE=function(offset,noAssert){noAssert||checkOffset(offset,2,this.length);var val=this[offset]|this[offset+1]<<8;return 32768&val?4294901760|val:val},Buffer.prototype.readInt16BE=function(offset,noAssert){noAssert||checkOffset(offset,2,this.length);var val=this[offset+1]|this[offset]<<8;return 32768&val?4294901760|val:val},Buffer.prototype.readInt32LE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),this[offset]|this[offset+1]<<8|this[offset+2]<<16|this[offset+3]<<24},Buffer.prototype.readInt32BE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),this[offset]<<24|this[offset+1]<<16|this[offset+2]<<8|this[offset+3]},Buffer.prototype.readFloatLE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),ieee754.read(this,offset,!0,23,4)},Buffer.prototype.readFloatBE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),ieee754.read(this,offset,!1,23,4)},Buffer.prototype.readDoubleLE=function(offset,noAssert){return noAssert||checkOffset(offset,8,this.length),ieee754.read(this,offset,!0,52,8)},Buffer.prototype.readDoubleBE=function(offset,noAssert){return noAssert||checkOffset(offset,8,this.length),ieee754.read(this,offset,!1,52,8)},Buffer.prototype.writeUIntLE=function(value,offset,byteLength,noAssert){if(value=+value,offset=0|offset,byteLength=0|byteLength,!noAssert){var maxBytes=Math.pow(2,8*byteLength)-1;checkInt(this,value,offset,byteLength,maxBytes,0)}var mul=1,i=0;for(this[offset]=255&value;++i=0&&(mul*=256);)this[offset+i]=value/mul&255;return offset+byteLength},Buffer.prototype.writeUInt8=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,1,255,0),Buffer.TYPED_ARRAY_SUPPORT||(value=Math.floor(value)),this[offset]=255&value,offset+1},Buffer.prototype.writeUInt16LE=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,2,65535,0),Buffer.TYPED_ARRAY_SUPPORT?(this[offset]=255&value,this[offset+1]=value>>>8):objectWriteUInt16(this,value,offset,!0),offset+2},Buffer.prototype.writeUInt16BE=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,2,65535,0),Buffer.TYPED_ARRAY_SUPPORT?(this[offset]=value>>>8,this[offset+1]=255&value):objectWriteUInt16(this,value,offset,!1),offset+2},Buffer.prototype.writeUInt32LE=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,4,4294967295,0),Buffer.TYPED_ARRAY_SUPPORT?(this[offset+3]=value>>>24,this[offset+2]=value>>>16,this[offset+1]=value>>>8,this[offset]=255&value):objectWriteUInt32(this,value,offset,!0),offset+4},Buffer.prototype.writeUInt32BE=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,4,4294967295,0),Buffer.TYPED_ARRAY_SUPPORT?(this[offset]=value>>>24,this[offset+1]=value>>>16,this[offset+2]=value>>>8,this[offset+3]=255&value):objectWriteUInt32(this,value,offset,!1),offset+4},Buffer.prototype.writeIntLE=function(value,offset,byteLength,noAssert){if(value=+value,offset=0|offset,!noAssert){var limit=Math.pow(2,8*byteLength-1);checkInt(this,value,offset,byteLength,limit-1,-limit)}var i=0,mul=1,sub=0;for(this[offset]=255&value;++ivalue&&0===sub&&0!==this[offset+i-1]&&(sub=1),this[offset+i]=(value/mul>>0)-sub&255;return offset+byteLength},Buffer.prototype.writeIntBE=function(value,offset,byteLength,noAssert){if(value=+value,offset=0|offset,!noAssert){var limit=Math.pow(2,8*byteLength-1);checkInt(this,value,offset,byteLength,limit-1,-limit)}var i=byteLength-1,mul=1,sub=0;for(this[offset+i]=255&value;--i>=0&&(mul*=256);)0>value&&0===sub&&0!==this[offset+i+1]&&(sub=1),this[offset+i]=(value/mul>>0)-sub&255;return offset+byteLength},Buffer.prototype.writeInt8=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,1,127,-128),Buffer.TYPED_ARRAY_SUPPORT||(value=Math.floor(value)),0>value&&(value=255+value+1),this[offset]=255&value,offset+1},Buffer.prototype.writeInt16LE=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,2,32767,-32768),Buffer.TYPED_ARRAY_SUPPORT?(this[offset]=255&value,this[offset+1]=value>>>8):objectWriteUInt16(this,value,offset,!0),offset+2},Buffer.prototype.writeInt16BE=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,2,32767,-32768),Buffer.TYPED_ARRAY_SUPPORT?(this[offset]=value>>>8,this[offset+1]=255&value):objectWriteUInt16(this,value,offset,!1),offset+2},Buffer.prototype.writeInt32LE=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,4,2147483647,-2147483648),Buffer.TYPED_ARRAY_SUPPORT?(this[offset]=255&value,this[offset+1]=value>>>8,this[offset+2]=value>>>16,this[offset+3]=value>>>24):objectWriteUInt32(this,value,offset,!0),offset+4},Buffer.prototype.writeInt32BE=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,4,2147483647,-2147483648),0>value&&(value=4294967295+value+1),Buffer.TYPED_ARRAY_SUPPORT?(this[offset]=value>>>24,this[offset+1]=value>>>16,this[offset+2]=value>>>8,this[offset+3]=255&value):objectWriteUInt32(this,value,offset,!1),offset+4},Buffer.prototype.writeFloatLE=function(value,offset,noAssert){return writeFloat(this,value,offset,!0,noAssert)},Buffer.prototype.writeFloatBE=function(value,offset,noAssert){return writeFloat(this,value,offset,!1,noAssert)},Buffer.prototype.writeDoubleLE=function(value,offset,noAssert){return writeDouble(this,value,offset,!0,noAssert)},Buffer.prototype.writeDoubleBE=function(value,offset,noAssert){return writeDouble(this,value,offset,!1,noAssert)},Buffer.prototype.copy=function(target,targetStart,start,end){if(start||(start=0),end||0===end||(end=this.length),targetStart>=target.length&&(targetStart=target.length),targetStart||(targetStart=0),end>0&&start>end&&(end=start),end===start)return 0;if(0===target.length||0===this.length)return 0;if(0>targetStart)throw new RangeError("targetStart out of bounds");if(0>start||start>=this.length)throw new RangeError("sourceStart out of bounds");if(0>end)throw new RangeError("sourceEnd out of bounds");end>this.length&&(end=this.length),target.length-targetStartstart&&end>targetStart)for(i=len-1;i>=0;--i)target[i+targetStart]=this[i+start];else if(1e3>len||!Buffer.TYPED_ARRAY_SUPPORT)for(i=0;len>i;++i)target[i+targetStart]=this[i+start];else Uint8Array.prototype.set.call(target,this.subarray(start,start+len),targetStart);return len},Buffer.prototype.fill=function(val,start,end,encoding){if("string"==typeof val){if("string"==typeof start?(encoding=start,start=0,end=this.length):"string"==typeof end&&(encoding=end,end=this.length),1===val.length){var code=val.charCodeAt(0);256>code&&(val=code)}if(void 0!==encoding&&"string"!=typeof encoding)throw new TypeError("encoding must be a string");if("string"==typeof encoding&&!Buffer.isEncoding(encoding))throw new TypeError("Unknown encoding: "+encoding)}else"number"==typeof val&&(val=255&val);if(0>start||this.length=end)return this;start>>>=0,end=void 0===end?this.length:end>>>0,val||(val=0);var i;if("number"==typeof val)for(i=start;end>i;++i)this[i]=val;else{var bytes=Buffer.isBuffer(val)?val:utf8ToBytes(new Buffer(val,encoding).toString()),len=bytes.length;for(i=0;end-start>i;++i)this[i+start]=bytes[i%len]}return this};var INVALID_BASE64_RE=/[^+\/0-9A-Za-z-_]/g}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{"base64-js":47,ieee754:86,isarray:87}],84:[function(require,module,exports){"use strict";Object.defineProperty(exports,"__esModule",{value:!0});var dingbats=[{"Typeface name":"Symbol","Dingbat dec":"32","Dingbat hex":"20","Unicode dec":"32","Unicode hex":"20"},{"Typeface name":"Symbol","Dingbat dec":"33","Dingbat hex":"21","Unicode dec":"33","Unicode hex":"21"},{"Typeface name":"Symbol","Dingbat dec":"34","Dingbat hex":"22","Unicode dec":"8704","Unicode hex":"2200"},{"Typeface name":"Symbol","Dingbat dec":"35","Dingbat hex":"23","Unicode dec":"35","Unicode hex":"23"},{"Typeface name":"Symbol","Dingbat dec":"36","Dingbat hex":"24","Unicode dec":"8707","Unicode hex":"2203"},{"Typeface name":"Symbol","Dingbat dec":"37","Dingbat hex":"25","Unicode dec":"37","Unicode hex":"25"},{"Typeface name":"Symbol","Dingbat dec":"38","Dingbat hex":"26","Unicode dec":"38","Unicode hex":"26"},{"Typeface name":"Symbol","Dingbat dec":"39","Dingbat hex":"27","Unicode dec":"8717","Unicode hex":"220D"},{"Typeface name":"Symbol","Dingbat dec":"40","Dingbat hex":"28","Unicode dec":"40","Unicode hex":"28"},{"Typeface name":"Symbol","Dingbat dec":"41","Dingbat hex":"29","Unicode dec":"41","Unicode hex":"29"},{"Typeface name":"Symbol","Dingbat dec":"42","Dingbat hex":"2A","Unicode dec":"42","Unicode hex":"2A"},{"Typeface name":"Symbol","Dingbat dec":"43","Dingbat hex":"2B","Unicode dec":"43","Unicode hex":"2B"},{"Typeface name":"Symbol","Dingbat dec":"44","Dingbat hex":"2C","Unicode dec":"44","Unicode hex":"2C"},{"Typeface name":"Symbol","Dingbat dec":"45","Dingbat hex":"2D","Unicode dec":"8722","Unicode hex":"2212"},{"Typeface name":"Symbol","Dingbat dec":"46","Dingbat hex":"2E","Unicode dec":"46","Unicode hex":"2E"},{"Typeface name":"Symbol","Dingbat dec":"47","Dingbat hex":"2F","Unicode dec":"47","Unicode hex":"2F"},{"Typeface name":"Symbol","Dingbat dec":"48","Dingbat hex":"30","Unicode dec":"48","Unicode hex":"30"},{"Typeface name":"Symbol","Dingbat dec":"49","Dingbat hex":"31","Unicode dec":"49","Unicode hex":"31"},{"Typeface name":"Symbol","Dingbat dec":"50","Dingbat hex":"32","Unicode dec":"50","Unicode hex":"32"},{"Typeface name":"Symbol","Dingbat dec":"51","Dingbat hex":"33","Unicode dec":"51","Unicode hex":"33"},{"Typeface name":"Symbol","Dingbat dec":"52","Dingbat hex":"34","Unicode dec":"52","Unicode hex":"34"},{"Typeface name":"Symbol","Dingbat dec":"53","Dingbat hex":"35","Unicode dec":"53","Unicode hex":"35"},{"Typeface name":"Symbol","Dingbat dec":"54","Dingbat hex":"36","Unicode dec":"54","Unicode hex":"36"},{"Typeface name":"Symbol","Dingbat dec":"55","Dingbat hex":"37","Unicode dec":"55","Unicode hex":"37"},{"Typeface name":"Symbol","Dingbat dec":"56","Dingbat hex":"38","Unicode dec":"56","Unicode hex":"38"},{"Typeface name":"Symbol","Dingbat dec":"57","Dingbat hex":"39","Unicode dec":"57","Unicode hex":"39"},{"Typeface name":"Symbol","Dingbat dec":"58","Dingbat hex":"3A","Unicode dec":"58","Unicode hex":"3A"},{"Typeface name":"Symbol","Dingbat dec":"59","Dingbat hex":"3B","Unicode dec":"59","Unicode hex":"3B"},{"Typeface name":"Symbol","Dingbat dec":"60","Dingbat hex":"3C","Unicode dec":"60","Unicode hex":"3C"},{"Typeface name":"Symbol","Dingbat dec":"61","Dingbat hex":"3D","Unicode dec":"61","Unicode hex":"3D"},{"Typeface name":"Symbol","Dingbat dec":"62","Dingbat hex":"3E","Unicode dec":"62","Unicode hex":"3E"},{"Typeface name":"Symbol","Dingbat dec":"63","Dingbat hex":"3F","Unicode dec":"63","Unicode hex":"3F"},{"Typeface name":"Symbol","Dingbat dec":"64","Dingbat hex":"40","Unicode dec":"8773","Unicode hex":"2245"},{"Typeface name":"Symbol","Dingbat dec":"65","Dingbat hex":"41","Unicode dec":"913","Unicode hex":"391"},{"Typeface name":"Symbol","Dingbat dec":"66","Dingbat hex":"42","Unicode dec":"914","Unicode hex":"392"},{"Typeface name":"Symbol","Dingbat dec":"67","Dingbat hex":"43","Unicode dec":"935","Unicode hex":"3A7"},{"Typeface name":"Symbol","Dingbat dec":"68","Dingbat hex":"44","Unicode dec":"916","Unicode hex":"394"},{"Typeface name":"Symbol","Dingbat dec":"69","Dingbat hex":"45","Unicode dec":"917","Unicode hex":"395"},{"Typeface name":"Symbol","Dingbat dec":"70","Dingbat hex":"46","Unicode dec":"934","Unicode hex":"3A6"},{"Typeface name":"Symbol","Dingbat dec":"71","Dingbat hex":"47","Unicode dec":"915","Unicode hex":"393"},{"Typeface name":"Symbol","Dingbat dec":"72","Dingbat hex":"48","Unicode dec":"919","Unicode hex":"397"},{"Typeface name":"Symbol","Dingbat dec":"73","Dingbat hex":"49","Unicode dec":"921","Unicode hex":"399"},{"Typeface name":"Symbol","Dingbat dec":"74","Dingbat hex":"4A","Unicode dec":"977","Unicode hex":"3D1"},{"Typeface name":"Symbol","Dingbat dec":"75","Dingbat hex":"4B","Unicode dec":"922","Unicode hex":"39A"},{"Typeface name":"Symbol","Dingbat dec":"76","Dingbat hex":"4C","Unicode dec":"923","Unicode hex":"39B"},{"Typeface name":"Symbol","Dingbat dec":"77","Dingbat hex":"4D","Unicode dec":"924","Unicode hex":"39C"},{"Typeface name":"Symbol","Dingbat dec":"78","Dingbat hex":"4E","Unicode dec":"925","Unicode hex":"39D"},{"Typeface name":"Symbol","Dingbat dec":"79","Dingbat hex":"4F","Unicode dec":"927","Unicode hex":"39F"},{"Typeface name":"Symbol","Dingbat dec":"80","Dingbat hex":"50","Unicode dec":"928","Unicode hex":"3A0"},{"Typeface name":"Symbol","Dingbat dec":"81","Dingbat hex":"51","Unicode dec":"920","Unicode hex":"398"},{"Typeface name":"Symbol","Dingbat dec":"82","Dingbat hex":"52","Unicode dec":"929","Unicode hex":"3A1"},{"Typeface name":"Symbol","Dingbat dec":"83","Dingbat hex":"53","Unicode dec":"931","Unicode hex":"3A3"},{"Typeface name":"Symbol","Dingbat dec":"84","Dingbat hex":"54","Unicode dec":"932","Unicode hex":"3A4"},{"Typeface name":"Symbol","Dingbat dec":"85","Dingbat hex":"55","Unicode dec":"933","Unicode hex":"3A5"},{"Typeface name":"Symbol","Dingbat dec":"86","Dingbat hex":"56","Unicode dec":"962","Unicode hex":"3C2"},{"Typeface name":"Symbol","Dingbat dec":"87","Dingbat hex":"57","Unicode dec":"937","Unicode hex":"3A9"},{"Typeface name":"Symbol","Dingbat dec":"88","Dingbat hex":"58","Unicode dec":"926","Unicode hex":"39E"},{"Typeface name":"Symbol","Dingbat dec":"89","Dingbat hex":"59","Unicode dec":"936","Unicode hex":"3A8"},{"Typeface name":"Symbol","Dingbat dec":"90","Dingbat hex":"5A","Unicode dec":"918","Unicode hex":"396"},{"Typeface name":"Symbol","Dingbat dec":"91","Dingbat hex":"5B","Unicode dec":"91","Unicode hex":"5B"},{"Typeface name":"Symbol","Dingbat dec":"92","Dingbat hex":"5C","Unicode dec":"8756","Unicode hex":"2234"},{"Typeface name":"Symbol","Dingbat dec":"93","Dingbat hex":"5D","Unicode dec":"93","Unicode hex":"5D"},{"Typeface name":"Symbol","Dingbat dec":"94","Dingbat hex":"5E","Unicode dec":"8869","Unicode hex":"22A5"},{"Typeface name":"Symbol","Dingbat dec":"95","Dingbat hex":"5F","Unicode dec":"95","Unicode hex":"5F"},{"Typeface name":"Symbol","Dingbat dec":"96","Dingbat hex":"60","Unicode dec":"8254","Unicode hex":"203E"},{"Typeface name":"Symbol","Dingbat dec":"97","Dingbat hex":"61","Unicode dec":"945","Unicode hex":"3B1"},{"Typeface name":"Symbol","Dingbat dec":"98","Dingbat hex":"62","Unicode dec":"946","Unicode hex":"3B2"},{"Typeface name":"Symbol","Dingbat dec":"99","Dingbat hex":"63","Unicode dec":"967","Unicode hex":"3C7"},{"Typeface name":"Symbol","Dingbat dec":"100","Dingbat hex":"64","Unicode dec":"948","Unicode hex":"3B4"},{"Typeface name":"Symbol","Dingbat dec":"101","Dingbat hex":"65","Unicode dec":"949","Unicode hex":"3B5"},{"Typeface name":"Symbol","Dingbat dec":"102","Dingbat hex":"66","Unicode dec":"966","Unicode hex":"3C6"},{"Typeface name":"Symbol","Dingbat dec":"103","Dingbat hex":"67","Unicode dec":"947","Unicode hex":"3B3"},{"Typeface name":"Symbol","Dingbat dec":"104","Dingbat hex":"68","Unicode dec":"951","Unicode hex":"3B7"},{"Typeface name":"Symbol","Dingbat dec":"105","Dingbat hex":"69","Unicode dec":"953","Unicode hex":"3B9"},{"Typeface name":"Symbol","Dingbat dec":"106","Dingbat hex":"6A","Unicode dec":"981","Unicode hex":"3D5"},{"Typeface name":"Symbol","Dingbat dec":"107","Dingbat hex":"6B","Unicode dec":"954","Unicode hex":"3BA"},{"Typeface name":"Symbol","Dingbat dec":"108","Dingbat hex":"6C","Unicode dec":"955","Unicode hex":"3BB"},{"Typeface name":"Symbol","Dingbat dec":"109","Dingbat hex":"6D","Unicode dec":"956","Unicode hex":"3BC"},{"Typeface name":"Symbol","Dingbat dec":"110","Dingbat hex":"6E","Unicode dec":"957","Unicode hex":"3BD"},{"Typeface name":"Symbol","Dingbat dec":"111","Dingbat hex":"6F","Unicode dec":"959","Unicode hex":"3BF"},{"Typeface name":"Symbol","Dingbat dec":"112","Dingbat hex":"70","Unicode dec":"960","Unicode hex":"3C0"},{"Typeface name":"Symbol","Dingbat dec":"113","Dingbat hex":"71","Unicode dec":"952","Unicode hex":"3B8"},{"Typeface name":"Symbol","Dingbat dec":"114","Dingbat hex":"72","Unicode dec":"961","Unicode hex":"3C1"},{"Typeface name":"Symbol","Dingbat dec":"115","Dingbat hex":"73","Unicode dec":"963","Unicode hex":"3C3"},{"Typeface name":"Symbol","Dingbat dec":"116","Dingbat hex":"74","Unicode dec":"964","Unicode hex":"3C4"},{"Typeface name":"Symbol","Dingbat dec":"117","Dingbat hex":"75","Unicode dec":"965","Unicode hex":"3C5"},{"Typeface name":"Symbol","Dingbat dec":"118","Dingbat hex":"76","Unicode dec":"982","Unicode hex":"3D6"},{"Typeface name":"Symbol","Dingbat dec":"119","Dingbat hex":"77","Unicode dec":"969","Unicode hex":"3C9"},{"Typeface name":"Symbol","Dingbat dec":"120","Dingbat hex":"78","Unicode dec":"958","Unicode hex":"3BE"},{"Typeface name":"Symbol","Dingbat dec":"121","Dingbat hex":"79","Unicode dec":"968","Unicode hex":"3C8"},{"Typeface name":"Symbol","Dingbat dec":"122","Dingbat hex":"7A","Unicode dec":"950","Unicode hex":"3B6"},{"Typeface name":"Symbol","Dingbat dec":"123","Dingbat hex":"7B","Unicode dec":"123","Unicode hex":"7B"},{"Typeface name":"Symbol","Dingbat dec":"124","Dingbat hex":"7C","Unicode dec":"124","Unicode hex":"7C"},{"Typeface name":"Symbol","Dingbat dec":"125","Dingbat hex":"7D","Unicode dec":"125","Unicode hex":"7D"},{"Typeface name":"Symbol","Dingbat dec":"126","Dingbat hex":"7E","Unicode dec":"126","Unicode hex":"7E"},{"Typeface name":"Symbol","Dingbat dec":"160","Dingbat hex":"A0","Unicode dec":"8364","Unicode hex":"20AC"},{"Typeface name":"Symbol","Dingbat dec":"161","Dingbat hex":"A1","Unicode dec":"978","Unicode hex":"3D2"},{"Typeface name":"Symbol","Dingbat dec":"162","Dingbat hex":"A2","Unicode dec":"8242","Unicode hex":"2032"},{"Typeface name":"Symbol","Dingbat dec":"163","Dingbat hex":"A3","Unicode dec":"8804","Unicode hex":"2264"},{"Typeface name":"Symbol","Dingbat dec":"164","Dingbat hex":"A4","Unicode dec":"8260","Unicode hex":"2044"},{"Typeface name":"Symbol","Dingbat dec":"165","Dingbat hex":"A5","Unicode dec":"8734","Unicode hex":"221E"},{"Typeface name":"Symbol","Dingbat dec":"166","Dingbat hex":"A6","Unicode dec":"402","Unicode hex":"192"},{"Typeface name":"Symbol","Dingbat dec":"167","Dingbat hex":"A7","Unicode dec":"9827","Unicode hex":"2663"},{"Typeface name":"Symbol","Dingbat dec":"168","Dingbat hex":"A8","Unicode dec":"9830","Unicode hex":"2666"},{"Typeface name":"Symbol","Dingbat dec":"169","Dingbat hex":"A9","Unicode dec":"9829","Unicode hex":"2665"},{"Typeface name":"Symbol","Dingbat dec":"170","Dingbat hex":"AA","Unicode dec":"9824","Unicode hex":"2660"},{"Typeface name":"Symbol","Dingbat dec":"171","Dingbat hex":"AB","Unicode dec":"8596","Unicode hex":"2194"},{"Typeface name":"Symbol","Dingbat dec":"172","Dingbat hex":"AC","Unicode dec":"8592","Unicode hex":"2190"},{"Typeface name":"Symbol","Dingbat dec":"173","Dingbat hex":"AD","Unicode dec":"8593","Unicode hex":"2191"},{"Typeface name":"Symbol","Dingbat dec":"174","Dingbat hex":"AE","Unicode dec":"8594","Unicode hex":"2192"},{"Typeface name":"Symbol","Dingbat dec":"175","Dingbat hex":"AF","Unicode dec":"8595","Unicode hex":"2193"},{"Typeface name":"Symbol","Dingbat dec":"176","Dingbat hex":"B0","Unicode dec":"176","Unicode hex":"B0"},{"Typeface name":"Symbol","Dingbat dec":"177","Dingbat hex":"B1","Unicode dec":"177","Unicode hex":"B1"},{"Typeface name":"Symbol","Dingbat dec":"178","Dingbat hex":"B2","Unicode dec":"8243","Unicode hex":"2033"},{"Typeface name":"Symbol","Dingbat dec":"179","Dingbat hex":"B3","Unicode dec":"8805","Unicode hex":"2265"},{"Typeface name":"Symbol","Dingbat dec":"180","Dingbat hex":"B4","Unicode dec":"215","Unicode hex":"D7"},{"Typeface name":"Symbol","Dingbat dec":"181","Dingbat hex":"B5","Unicode dec":"8733","Unicode hex":"221D"},{"Typeface name":"Symbol","Dingbat dec":"182","Dingbat hex":"B6","Unicode dec":"8706","Unicode hex":"2202"},{"Typeface name":"Symbol","Dingbat dec":"183","Dingbat hex":"B7","Unicode dec":"8226","Unicode hex":"2022"},{"Typeface name":"Symbol","Dingbat dec":"184","Dingbat hex":"B8","Unicode dec":"247","Unicode hex":"F7"},{"Typeface name":"Symbol","Dingbat dec":"185","Dingbat hex":"B9","Unicode dec":"8800","Unicode hex":"2260"},{"Typeface name":"Symbol","Dingbat dec":"186","Dingbat hex":"BA","Unicode dec":"8801","Unicode hex":"2261"},{"Typeface name":"Symbol","Dingbat dec":"187","Dingbat hex":"BB","Unicode dec":"8776","Unicode hex":"2248"},{"Typeface name":"Symbol","Dingbat dec":"188","Dingbat hex":"BC","Unicode dec":"8230","Unicode hex":"2026"},{"Typeface name":"Symbol","Dingbat dec":"189","Dingbat hex":"BD","Unicode dec":"9168","Unicode hex":"23D0"},{"Typeface name":"Symbol","Dingbat dec":"190","Dingbat hex":"BE","Unicode dec":"9135","Unicode hex":"23AF"},{"Typeface name":"Symbol","Dingbat dec":"191","Dingbat hex":"BF","Unicode dec":"8629","Unicode hex":"21B5"},{"Typeface name":"Symbol","Dingbat dec":"192","Dingbat hex":"C0","Unicode dec":"8501","Unicode hex":"2135"},{"Typeface name":"Symbol","Dingbat dec":"193","Dingbat hex":"C1","Unicode dec":"8465","Unicode hex":"2111"},{"Typeface name":"Symbol","Dingbat dec":"194","Dingbat hex":"C2","Unicode dec":"8476","Unicode hex":"211C"},{"Typeface name":"Symbol","Dingbat dec":"195","Dingbat hex":"C3","Unicode dec":"8472","Unicode hex":"2118"},{"Typeface name":"Symbol","Dingbat dec":"196","Dingbat hex":"C4","Unicode dec":"8855","Unicode hex":"2297"},{"Typeface name":"Symbol","Dingbat dec":"197","Dingbat hex":"C5","Unicode dec":"8853","Unicode hex":"2295"},{"Typeface name":"Symbol","Dingbat dec":"198","Dingbat hex":"C6","Unicode dec":"8709","Unicode hex":"2205"},{"Typeface name":"Symbol","Dingbat dec":"199","Dingbat hex":"C7","Unicode dec":"8745","Unicode hex":"2229"},{"Typeface name":"Symbol","Dingbat dec":"200","Dingbat hex":"C8","Unicode dec":"8746","Unicode hex":"222A"},{"Typeface name":"Symbol","Dingbat dec":"201","Dingbat hex":"C9","Unicode dec":"8835","Unicode hex":"2283"},{"Typeface name":"Symbol","Dingbat dec":"202","Dingbat hex":"CA","Unicode dec":"8839","Unicode hex":"2287"},{"Typeface name":"Symbol","Dingbat dec":"203","Dingbat hex":"CB","Unicode dec":"8836","Unicode hex":"2284"},{"Typeface name":"Symbol","Dingbat dec":"204","Dingbat hex":"CC","Unicode dec":"8834","Unicode hex":"2282"},{"Typeface name":"Symbol","Dingbat dec":"205","Dingbat hex":"CD","Unicode dec":"8838","Unicode hex":"2286"},{"Typeface name":"Symbol","Dingbat dec":"206","Dingbat hex":"CE","Unicode dec":"8712","Unicode hex":"2208"},{"Typeface name":"Symbol","Dingbat dec":"207","Dingbat hex":"CF","Unicode dec":"8713","Unicode hex":"2209"},{"Typeface name":"Symbol","Dingbat dec":"208","Dingbat hex":"D0","Unicode dec":"8736","Unicode hex":"2220"},{"Typeface name":"Symbol","Dingbat dec":"209","Dingbat hex":"D1","Unicode dec":"8711","Unicode hex":"2207"},{"Typeface name":"Symbol","Dingbat dec":"210","Dingbat hex":"D2","Unicode dec":"174","Unicode hex":"AE"},{"Typeface name":"Symbol","Dingbat dec":"211","Dingbat hex":"D3","Unicode dec":"169","Unicode hex":"A9"},{"Typeface name":"Symbol","Dingbat dec":"212","Dingbat hex":"D4","Unicode dec":"8482","Unicode hex":"2122"},{"Typeface name":"Symbol","Dingbat dec":"213","Dingbat hex":"D5","Unicode dec":"8719","Unicode hex":"220F"},{"Typeface name":"Symbol","Dingbat dec":"214","Dingbat hex":"D6","Unicode dec":"8730","Unicode hex":"221A"},{"Typeface name":"Symbol","Dingbat dec":"215","Dingbat hex":"D7","Unicode dec":"8901","Unicode hex":"22C5"},{"Typeface name":"Symbol","Dingbat dec":"216","Dingbat hex":"D8","Unicode dec":"172","Unicode hex":"AC"},{"Typeface name":"Symbol","Dingbat dec":"217","Dingbat hex":"D9","Unicode dec":"8743","Unicode hex":"2227"},{"Typeface name":"Symbol","Dingbat dec":"218","Dingbat hex":"DA","Unicode dec":"8744","Unicode hex":"2228"},{"Typeface name":"Symbol","Dingbat dec":"219","Dingbat hex":"DB","Unicode dec":"8660","Unicode hex":"21D4"},{"Typeface name":"Symbol","Dingbat dec":"220","Dingbat hex":"DC","Unicode dec":"8656","Unicode hex":"21D0"},{"Typeface name":"Symbol","Dingbat dec":"221","Dingbat hex":"DD","Unicode dec":"8657","Unicode hex":"21D1"},{"Typeface name":"Symbol","Dingbat dec":"222","Dingbat hex":"DE","Unicode dec":"8658","Unicode hex":"21D2"},{"Typeface name":"Symbol","Dingbat dec":"223","Dingbat hex":"DF","Unicode dec":"8659","Unicode hex":"21D3"},{"Typeface name":"Symbol","Dingbat dec":"224","Dingbat hex":"E0","Unicode dec":"9674","Unicode hex":"25CA"},{"Typeface name":"Symbol","Dingbat dec":"225","Dingbat hex":"E1","Unicode dec":"12296","Unicode hex":"3008"},{"Typeface name":"Symbol","Dingbat dec":"226","Dingbat hex":"E2","Unicode dec":"174","Unicode hex":"AE"},{"Typeface name":"Symbol","Dingbat dec":"227","Dingbat hex":"E3","Unicode dec":"169","Unicode hex":"A9"},{"Typeface name":"Symbol","Dingbat dec":"228","Dingbat hex":"E4","Unicode dec":"8482","Unicode hex":"2122"},{"Typeface name":"Symbol","Dingbat dec":"229","Dingbat hex":"E5","Unicode dec":"8721","Unicode hex":"2211"},{"Typeface name":"Symbol","Dingbat dec":"230","Dingbat hex":"E6","Unicode dec":"9115","Unicode hex":"239B"},{"Typeface name":"Symbol","Dingbat dec":"231","Dingbat hex":"E7","Unicode dec":"9116","Unicode hex":"239C"},{"Typeface name":"Symbol","Dingbat dec":"232","Dingbat hex":"E8","Unicode dec":"9117","Unicode hex":"239D"},{"Typeface name":"Symbol","Dingbat dec":"233","Dingbat hex":"E9","Unicode dec":"9121","Unicode hex":"23A1"},{"Typeface name":"Symbol","Dingbat dec":"234","Dingbat hex":"EA","Unicode dec":"9122","Unicode hex":"23A2"},{"Typeface name":"Symbol","Dingbat dec":"235","Dingbat hex":"EB","Unicode dec":"9123","Unicode hex":"23A3"},{"Typeface name":"Symbol","Dingbat dec":"236","Dingbat hex":"EC","Unicode dec":"9127","Unicode hex":"23A7"},{"Typeface name":"Symbol","Dingbat dec":"237","Dingbat hex":"ED","Unicode dec":"9128","Unicode hex":"23A8"},{"Typeface name":"Symbol","Dingbat dec":"238","Dingbat hex":"EE","Unicode dec":"9129","Unicode hex":"23A9"},{"Typeface name":"Symbol","Dingbat dec":"239","Dingbat hex":"EF","Unicode dec":"9130","Unicode hex":"23AA"},{"Typeface name":"Symbol","Dingbat dec":"240","Dingbat hex":"F0","Unicode dec":"63743","Unicode hex":"F8FF"},{"Typeface name":"Symbol","Dingbat dec":"241","Dingbat hex":"F1","Unicode dec":"12297","Unicode hex":"3009"},{"Typeface name":"Symbol","Dingbat dec":"242","Dingbat hex":"F2","Unicode dec":"8747","Unicode hex":"222B"},{"Typeface name":"Symbol","Dingbat dec":"243","Dingbat hex":"F3","Unicode dec":"8992","Unicode hex":"2320"},{"Typeface name":"Symbol","Dingbat dec":"244","Dingbat hex":"F4","Unicode dec":"9134","Unicode hex":"23AE"},{"Typeface name":"Symbol","Dingbat dec":"245","Dingbat hex":"F5","Unicode dec":"8993","Unicode hex":"2321"},{"Typeface name":"Symbol","Dingbat dec":"246","Dingbat hex":"F6","Unicode dec":"9118","Unicode hex":"239E"},{"Typeface name":"Symbol","Dingbat dec":"247","Dingbat hex":"F7","Unicode dec":"9119","Unicode hex":"239F"},{"Typeface name":"Symbol","Dingbat dec":"248","Dingbat hex":"F8","Unicode dec":"9120","Unicode hex":"23A0"},{"Typeface name":"Symbol","Dingbat dec":"249","Dingbat hex":"F9","Unicode dec":"9124","Unicode hex":"23A4"},{"Typeface name":"Symbol","Dingbat dec":"250","Dingbat hex":"FA","Unicode dec":"9125","Unicode hex":"23A5"},{"Typeface name":"Symbol","Dingbat dec":"251","Dingbat hex":"FB","Unicode dec":"9126","Unicode hex":"23A6"},{"Typeface name":"Symbol","Dingbat dec":"252","Dingbat hex":"FC","Unicode dec":"9131","Unicode hex":"23AB"},{"Typeface name":"Symbol","Dingbat dec":"253","Dingbat hex":"FD","Unicode dec":"9132","Unicode hex":"23AC"},{"Typeface name":"Symbol","Dingbat dec":"254","Dingbat hex":"FE","Unicode dec":"9133","Unicode hex":"23AD"},{"Typeface name":"Webdings","Dingbat dec":"32","Dingbat hex":"20","Unicode dec":"32","Unicode hex":"20"},{"Typeface name":"Webdings","Dingbat dec":"33","Dingbat hex":"21","Unicode dec":"128375","Unicode hex":"1F577"},{"Typeface name":"Webdings","Dingbat dec":"34","Dingbat hex":"22","Unicode dec":"128376","Unicode hex":"1F578"},{"Typeface name":"Webdings","Dingbat dec":"35","Dingbat hex":"23","Unicode dec":"128370","Unicode hex":"1F572"},{"Typeface name":"Webdings","Dingbat dec":"36","Dingbat hex":"24","Unicode dec":"128374","Unicode hex":"1F576"},{"Typeface name":"Webdings","Dingbat dec":"37","Dingbat hex":"25","Unicode dec":"127942","Unicode hex":"1F3C6"},{"Typeface name":"Webdings","Dingbat dec":"38","Dingbat hex":"26","Unicode dec":"127894","Unicode hex":"1F396"},{"Typeface name":"Webdings","Dingbat dec":"39","Dingbat hex":"27","Unicode dec":"128391","Unicode hex":"1F587"},{"Typeface name":"Webdings","Dingbat dec":"40","Dingbat hex":"28","Unicode dec":"128488","Unicode hex":"1F5E8"},{"Typeface name":"Webdings","Dingbat dec":"41","Dingbat hex":"29","Unicode dec":"128489","Unicode hex":"1F5E9"},{"Typeface name":"Webdings","Dingbat dec":"42","Dingbat hex":"2A","Unicode dec":"128496","Unicode hex":"1F5F0"},{"Typeface name":"Webdings","Dingbat dec":"43","Dingbat hex":"2B","Unicode dec":"128497","Unicode hex":"1F5F1"},{"Typeface name":"Webdings", -"Dingbat dec":"44","Dingbat hex":"2C","Unicode dec":"127798","Unicode hex":"1F336"},{"Typeface name":"Webdings","Dingbat dec":"45","Dingbat hex":"2D","Unicode dec":"127895","Unicode hex":"1F397"},{"Typeface name":"Webdings","Dingbat dec":"46","Dingbat hex":"2E","Unicode dec":"128638","Unicode hex":"1F67E"},{"Typeface name":"Webdings","Dingbat dec":"47","Dingbat hex":"2F","Unicode dec":"128636","Unicode hex":"1F67C"},{"Typeface name":"Webdings","Dingbat dec":"48","Dingbat hex":"30","Unicode dec":"128469","Unicode hex":"1F5D5"},{"Typeface name":"Webdings","Dingbat dec":"49","Dingbat hex":"31","Unicode dec":"128470","Unicode hex":"1F5D6"},{"Typeface name":"Webdings","Dingbat dec":"50","Dingbat hex":"32","Unicode dec":"128471","Unicode hex":"1F5D7"},{"Typeface name":"Webdings","Dingbat dec":"51","Dingbat hex":"33","Unicode dec":"9204","Unicode hex":"23F4"},{"Typeface name":"Webdings","Dingbat dec":"52","Dingbat hex":"34","Unicode dec":"9205","Unicode hex":"23F5"},{"Typeface name":"Webdings","Dingbat dec":"53","Dingbat hex":"35","Unicode dec":"9206","Unicode hex":"23F6"},{"Typeface name":"Webdings","Dingbat dec":"54","Dingbat hex":"36","Unicode dec":"9207","Unicode hex":"23F7"},{"Typeface name":"Webdings","Dingbat dec":"55","Dingbat hex":"37","Unicode dec":"9194","Unicode hex":"23EA"},{"Typeface name":"Webdings","Dingbat dec":"56","Dingbat hex":"38","Unicode dec":"9193","Unicode hex":"23E9"},{"Typeface name":"Webdings","Dingbat dec":"57","Dingbat hex":"39","Unicode dec":"9198","Unicode hex":"23EE"},{"Typeface name":"Webdings","Dingbat dec":"58","Dingbat hex":"3A","Unicode dec":"9197","Unicode hex":"23ED"},{"Typeface name":"Webdings","Dingbat dec":"59","Dingbat hex":"3B","Unicode dec":"9208","Unicode hex":"23F8"},{"Typeface name":"Webdings","Dingbat dec":"60","Dingbat hex":"3C","Unicode dec":"9209","Unicode hex":"23F9"},{"Typeface name":"Webdings","Dingbat dec":"61","Dingbat hex":"3D","Unicode dec":"9210","Unicode hex":"23FA"},{"Typeface name":"Webdings","Dingbat dec":"62","Dingbat hex":"3E","Unicode dec":"128474","Unicode hex":"1F5DA"},{"Typeface name":"Webdings","Dingbat dec":"63","Dingbat hex":"3F","Unicode dec":"128499","Unicode hex":"1F5F3"},{"Typeface name":"Webdings","Dingbat dec":"64","Dingbat hex":"40","Unicode dec":"128736","Unicode hex":"1F6E0"},{"Typeface name":"Webdings","Dingbat dec":"65","Dingbat hex":"41","Unicode dec":"127959","Unicode hex":"1F3D7"},{"Typeface name":"Webdings","Dingbat dec":"66","Dingbat hex":"42","Unicode dec":"127960","Unicode hex":"1F3D8"},{"Typeface name":"Webdings","Dingbat dec":"67","Dingbat hex":"43","Unicode dec":"127961","Unicode hex":"1F3D9"},{"Typeface name":"Webdings","Dingbat dec":"68","Dingbat hex":"44","Unicode dec":"127962","Unicode hex":"1F3DA"},{"Typeface name":"Webdings","Dingbat dec":"69","Dingbat hex":"45","Unicode dec":"127964","Unicode hex":"1F3DC"},{"Typeface name":"Webdings","Dingbat dec":"70","Dingbat hex":"46","Unicode dec":"127981","Unicode hex":"1F3ED"},{"Typeface name":"Webdings","Dingbat dec":"71","Dingbat hex":"47","Unicode dec":"127963","Unicode hex":"1F3DB"},{"Typeface name":"Webdings","Dingbat dec":"72","Dingbat hex":"48","Unicode dec":"127968","Unicode hex":"1F3E0"},{"Typeface name":"Webdings","Dingbat dec":"73","Dingbat hex":"49","Unicode dec":"127958","Unicode hex":"1F3D6"},{"Typeface name":"Webdings","Dingbat dec":"74","Dingbat hex":"4A","Unicode dec":"127965","Unicode hex":"1F3DD"},{"Typeface name":"Webdings","Dingbat dec":"75","Dingbat hex":"4B","Unicode dec":"128739","Unicode hex":"1F6E3"},{"Typeface name":"Webdings","Dingbat dec":"76","Dingbat hex":"4C","Unicode dec":"128269","Unicode hex":"1F50D"},{"Typeface name":"Webdings","Dingbat dec":"77","Dingbat hex":"4D","Unicode dec":"127956","Unicode hex":"1F3D4"},{"Typeface name":"Webdings","Dingbat dec":"78","Dingbat hex":"4E","Unicode dec":"128065","Unicode hex":"1F441"},{"Typeface name":"Webdings","Dingbat dec":"79","Dingbat hex":"4F","Unicode dec":"128066","Unicode hex":"1F442"},{"Typeface name":"Webdings","Dingbat dec":"80","Dingbat hex":"50","Unicode dec":"127966","Unicode hex":"1F3DE"},{"Typeface name":"Webdings","Dingbat dec":"81","Dingbat hex":"51","Unicode dec":"127957","Unicode hex":"1F3D5"},{"Typeface name":"Webdings","Dingbat dec":"82","Dingbat hex":"52","Unicode dec":"128740","Unicode hex":"1F6E4"},{"Typeface name":"Webdings","Dingbat dec":"83","Dingbat hex":"53","Unicode dec":"127967","Unicode hex":"1F3DF"},{"Typeface name":"Webdings","Dingbat dec":"84","Dingbat hex":"54","Unicode dec":"128755","Unicode hex":"1F6F3"},{"Typeface name":"Webdings","Dingbat dec":"85","Dingbat hex":"55","Unicode dec":"128364","Unicode hex":"1F56C"},{"Typeface name":"Webdings","Dingbat dec":"86","Dingbat hex":"56","Unicode dec":"128363","Unicode hex":"1F56B"},{"Typeface name":"Webdings","Dingbat dec":"87","Dingbat hex":"57","Unicode dec":"128360","Unicode hex":"1F568"},{"Typeface name":"Webdings","Dingbat dec":"88","Dingbat hex":"58","Unicode dec":"128264","Unicode hex":"1F508"},{"Typeface name":"Webdings","Dingbat dec":"89","Dingbat hex":"59","Unicode dec":"127892","Unicode hex":"1F394"},{"Typeface name":"Webdings","Dingbat dec":"90","Dingbat hex":"5A","Unicode dec":"127893","Unicode hex":"1F395"},{"Typeface name":"Webdings","Dingbat dec":"91","Dingbat hex":"5B","Unicode dec":"128492","Unicode hex":"1F5EC"},{"Typeface name":"Webdings","Dingbat dec":"92","Dingbat hex":"5C","Unicode dec":"128637","Unicode hex":"1F67D"},{"Typeface name":"Webdings","Dingbat dec":"93","Dingbat hex":"5D","Unicode dec":"128493","Unicode hex":"1F5ED"},{"Typeface name":"Webdings","Dingbat dec":"94","Dingbat hex":"5E","Unicode dec":"128490","Unicode hex":"1F5EA"},{"Typeface name":"Webdings","Dingbat dec":"95","Dingbat hex":"5F","Unicode dec":"128491","Unicode hex":"1F5EB"},{"Typeface name":"Webdings","Dingbat dec":"96","Dingbat hex":"60","Unicode dec":"11156","Unicode hex":"2B94"},{"Typeface name":"Webdings","Dingbat dec":"97","Dingbat hex":"61","Unicode dec":"10004","Unicode hex":"2714"},{"Typeface name":"Webdings","Dingbat dec":"98","Dingbat hex":"62","Unicode dec":"128690","Unicode hex":"1F6B2"},{"Typeface name":"Webdings","Dingbat dec":"99","Dingbat hex":"63","Unicode dec":"11036","Unicode hex":"2B1C"},{"Typeface name":"Webdings","Dingbat dec":"100","Dingbat hex":"64","Unicode dec":"128737","Unicode hex":"1F6E1"},{"Typeface name":"Webdings","Dingbat dec":"101","Dingbat hex":"65","Unicode dec":"128230","Unicode hex":"1F4E6"},{"Typeface name":"Webdings","Dingbat dec":"102","Dingbat hex":"66","Unicode dec":"128753","Unicode hex":"1F6F1"},{"Typeface name":"Webdings","Dingbat dec":"103","Dingbat hex":"67","Unicode dec":"11035","Unicode hex":"2B1B"},{"Typeface name":"Webdings","Dingbat dec":"104","Dingbat hex":"68","Unicode dec":"128657","Unicode hex":"1F691"},{"Typeface name":"Webdings","Dingbat dec":"105","Dingbat hex":"69","Unicode dec":"128712","Unicode hex":"1F6C8"},{"Typeface name":"Webdings","Dingbat dec":"106","Dingbat hex":"6A","Unicode dec":"128745","Unicode hex":"1F6E9"},{"Typeface name":"Webdings","Dingbat dec":"107","Dingbat hex":"6B","Unicode dec":"128752","Unicode hex":"1F6F0"},{"Typeface name":"Webdings","Dingbat dec":"108","Dingbat hex":"6C","Unicode dec":"128968","Unicode hex":"1F7C8"},{"Typeface name":"Webdings","Dingbat dec":"109","Dingbat hex":"6D","Unicode dec":"128372","Unicode hex":"1F574"},{"Typeface name":"Webdings","Dingbat dec":"110","Dingbat hex":"6E","Unicode dec":"11044","Unicode hex":"2B24"},{"Typeface name":"Webdings","Dingbat dec":"111","Dingbat hex":"6F","Unicode dec":"128741","Unicode hex":"1F6E5"},{"Typeface name":"Webdings","Dingbat dec":"112","Dingbat hex":"70","Unicode dec":"128660","Unicode hex":"1F694"},{"Typeface name":"Webdings","Dingbat dec":"113","Dingbat hex":"71","Unicode dec":"128472","Unicode hex":"1F5D8"},{"Typeface name":"Webdings","Dingbat dec":"114","Dingbat hex":"72","Unicode dec":"128473","Unicode hex":"1F5D9"},{"Typeface name":"Webdings","Dingbat dec":"115","Dingbat hex":"73","Unicode dec":"10067","Unicode hex":"2753"},{"Typeface name":"Webdings","Dingbat dec":"116","Dingbat hex":"74","Unicode dec":"128754","Unicode hex":"1F6F2"},{"Typeface name":"Webdings","Dingbat dec":"117","Dingbat hex":"75","Unicode dec":"128647","Unicode hex":"1F687"},{"Typeface name":"Webdings","Dingbat dec":"118","Dingbat hex":"76","Unicode dec":"128653","Unicode hex":"1F68D"},{"Typeface name":"Webdings","Dingbat dec":"119","Dingbat hex":"77","Unicode dec":"9971","Unicode hex":"26F3"},{"Typeface name":"Webdings","Dingbat dec":"120","Dingbat hex":"78","Unicode dec":"10680","Unicode hex":"29B8"},{"Typeface name":"Webdings","Dingbat dec":"121","Dingbat hex":"79","Unicode dec":"8854","Unicode hex":"2296"},{"Typeface name":"Webdings","Dingbat dec":"122","Dingbat hex":"7A","Unicode dec":"128685","Unicode hex":"1F6AD"},{"Typeface name":"Webdings","Dingbat dec":"123","Dingbat hex":"7B","Unicode dec":"128494","Unicode hex":"1F5EE"},{"Typeface name":"Webdings","Dingbat dec":"124","Dingbat hex":"7C","Unicode dec":"9168","Unicode hex":"23D0"},{"Typeface name":"Webdings","Dingbat dec":"125","Dingbat hex":"7D","Unicode dec":"128495","Unicode hex":"1F5EF"},{"Typeface name":"Webdings","Dingbat dec":"126","Dingbat hex":"7E","Unicode dec":"128498","Unicode hex":"1F5F2"},{"Typeface name":"Webdings","Dingbat dec":"128","Dingbat hex":"80","Unicode dec":"128697","Unicode hex":"1F6B9"},{"Typeface name":"Webdings","Dingbat dec":"129","Dingbat hex":"81","Unicode dec":"128698","Unicode hex":"1F6BA"},{"Typeface name":"Webdings","Dingbat dec":"130","Dingbat hex":"82","Unicode dec":"128713","Unicode hex":"1F6C9"},{"Typeface name":"Webdings","Dingbat dec":"131","Dingbat hex":"83","Unicode dec":"128714","Unicode hex":"1F6CA"},{"Typeface name":"Webdings","Dingbat dec":"132","Dingbat hex":"84","Unicode dec":"128700","Unicode hex":"1F6BC"},{"Typeface name":"Webdings","Dingbat dec":"133","Dingbat hex":"85","Unicode dec":"128125","Unicode hex":"1F47D"},{"Typeface name":"Webdings","Dingbat dec":"134","Dingbat hex":"86","Unicode dec":"127947","Unicode hex":"1F3CB"},{"Typeface name":"Webdings","Dingbat dec":"135","Dingbat hex":"87","Unicode dec":"9975","Unicode hex":"26F7"},{"Typeface name":"Webdings","Dingbat dec":"136","Dingbat hex":"88","Unicode dec":"127938","Unicode hex":"1F3C2"},{"Typeface name":"Webdings","Dingbat dec":"137","Dingbat hex":"89","Unicode dec":"127948","Unicode hex":"1F3CC"},{"Typeface name":"Webdings","Dingbat dec":"138","Dingbat hex":"8A","Unicode dec":"127946","Unicode hex":"1F3CA"},{"Typeface name":"Webdings","Dingbat dec":"139","Dingbat hex":"8B","Unicode dec":"127940","Unicode hex":"1F3C4"},{"Typeface name":"Webdings","Dingbat dec":"140","Dingbat hex":"8C","Unicode dec":"127949","Unicode hex":"1F3CD"},{"Typeface name":"Webdings","Dingbat dec":"141","Dingbat hex":"8D","Unicode dec":"127950","Unicode hex":"1F3CE"},{"Typeface name":"Webdings","Dingbat dec":"142","Dingbat hex":"8E","Unicode dec":"128664","Unicode hex":"1F698"},{"Typeface name":"Webdings","Dingbat dec":"143","Dingbat hex":"8F","Unicode dec":"128480","Unicode hex":"1F5E0"},{"Typeface name":"Webdings","Dingbat dec":"144","Dingbat hex":"90","Unicode dec":"128738","Unicode hex":"1F6E2"},{"Typeface name":"Webdings","Dingbat dec":"145","Dingbat hex":"91","Unicode dec":"128176","Unicode hex":"1F4B0"},{"Typeface name":"Webdings","Dingbat dec":"146","Dingbat hex":"92","Unicode dec":"127991","Unicode hex":"1F3F7"},{"Typeface name":"Webdings","Dingbat dec":"147","Dingbat hex":"93","Unicode dec":"128179","Unicode hex":"1F4B3"},{"Typeface name":"Webdings","Dingbat dec":"148","Dingbat hex":"94","Unicode dec":"128106","Unicode hex":"1F46A"},{"Typeface name":"Webdings","Dingbat dec":"149","Dingbat hex":"95","Unicode dec":"128481","Unicode hex":"1F5E1"},{"Typeface name":"Webdings","Dingbat dec":"150","Dingbat hex":"96","Unicode dec":"128482","Unicode hex":"1F5E2"},{"Typeface name":"Webdings","Dingbat dec":"151","Dingbat hex":"97","Unicode dec":"128483","Unicode hex":"1F5E3"},{"Typeface name":"Webdings","Dingbat dec":"152","Dingbat hex":"98","Unicode dec":"10031","Unicode hex":"272F"},{"Typeface name":"Webdings","Dingbat dec":"153","Dingbat hex":"99","Unicode dec":"128388","Unicode hex":"1F584"},{"Typeface name":"Webdings","Dingbat dec":"154","Dingbat hex":"9A","Unicode dec":"128389","Unicode hex":"1F585"},{"Typeface name":"Webdings","Dingbat dec":"155","Dingbat hex":"9B","Unicode dec":"128387","Unicode hex":"1F583"},{"Typeface name":"Webdings","Dingbat dec":"156","Dingbat hex":"9C","Unicode dec":"128390","Unicode hex":"1F586"},{"Typeface name":"Webdings","Dingbat dec":"157","Dingbat hex":"9D","Unicode dec":"128441","Unicode hex":"1F5B9"},{"Typeface name":"Webdings","Dingbat dec":"158","Dingbat hex":"9E","Unicode dec":"128442","Unicode hex":"1F5BA"},{"Typeface name":"Webdings","Dingbat dec":"159","Dingbat hex":"9F","Unicode dec":"128443","Unicode hex":"1F5BB"},{"Typeface name":"Webdings","Dingbat dec":"160","Dingbat hex":"A0","Unicode dec":"128373","Unicode hex":"1F575"},{"Typeface name":"Webdings","Dingbat dec":"161","Dingbat hex":"A1","Unicode dec":"128368","Unicode hex":"1F570"},{"Typeface name":"Webdings","Dingbat dec":"162","Dingbat hex":"A2","Unicode dec":"128445","Unicode hex":"1F5BD"},{"Typeface name":"Webdings","Dingbat dec":"163","Dingbat hex":"A3","Unicode dec":"128446","Unicode hex":"1F5BE"},{"Typeface name":"Webdings","Dingbat dec":"164","Dingbat hex":"A4","Unicode dec":"128203","Unicode hex":"1F4CB"},{"Typeface name":"Webdings","Dingbat dec":"165","Dingbat hex":"A5","Unicode dec":"128466","Unicode hex":"1F5D2"},{"Typeface name":"Webdings","Dingbat dec":"166","Dingbat hex":"A6","Unicode dec":"128467","Unicode hex":"1F5D3"},{"Typeface name":"Webdings","Dingbat dec":"167","Dingbat hex":"A7","Unicode dec":"128366","Unicode hex":"1F56E"},{"Typeface name":"Webdings","Dingbat dec":"168","Dingbat hex":"A8","Unicode dec":"128218","Unicode hex":"1F4DA"},{"Typeface name":"Webdings","Dingbat dec":"169","Dingbat hex":"A9","Unicode dec":"128478","Unicode hex":"1F5DE"},{"Typeface name":"Webdings","Dingbat dec":"170","Dingbat hex":"AA","Unicode dec":"128479","Unicode hex":"1F5DF"},{"Typeface name":"Webdings","Dingbat dec":"171","Dingbat hex":"AB","Unicode dec":"128451","Unicode hex":"1F5C3"},{"Typeface name":"Webdings","Dingbat dec":"172","Dingbat hex":"AC","Unicode dec":"128450","Unicode hex":"1F5C2"},{"Typeface name":"Webdings","Dingbat dec":"173","Dingbat hex":"AD","Unicode dec":"128444","Unicode hex":"1F5BC"},{"Typeface name":"Webdings","Dingbat dec":"174","Dingbat hex":"AE","Unicode dec":"127917","Unicode hex":"1F3AD"},{"Typeface name":"Webdings","Dingbat dec":"175","Dingbat hex":"AF","Unicode dec":"127900","Unicode hex":"1F39C"},{"Typeface name":"Webdings","Dingbat dec":"176","Dingbat hex":"B0","Unicode dec":"127896","Unicode hex":"1F398"},{"Typeface name":"Webdings","Dingbat dec":"177","Dingbat hex":"B1","Unicode dec":"127897","Unicode hex":"1F399"},{"Typeface name":"Webdings","Dingbat dec":"178","Dingbat hex":"B2","Unicode dec":"127911","Unicode hex":"1F3A7"},{"Typeface name":"Webdings","Dingbat dec":"179","Dingbat hex":"B3","Unicode dec":"128191","Unicode hex":"1F4BF"},{"Typeface name":"Webdings","Dingbat dec":"180","Dingbat hex":"B4","Unicode dec":"127902","Unicode hex":"1F39E"},{"Typeface name":"Webdings","Dingbat dec":"181","Dingbat hex":"B5","Unicode dec":"128247","Unicode hex":"1F4F7"},{"Typeface name":"Webdings","Dingbat dec":"182","Dingbat hex":"B6","Unicode dec":"127903","Unicode hex":"1F39F"},{"Typeface name":"Webdings","Dingbat dec":"183","Dingbat hex":"B7","Unicode dec":"127916","Unicode hex":"1F3AC"},{"Typeface name":"Webdings","Dingbat dec":"184","Dingbat hex":"B8","Unicode dec":"128253","Unicode hex":"1F4FD"},{"Typeface name":"Webdings","Dingbat dec":"185","Dingbat hex":"B9","Unicode dec":"128249","Unicode hex":"1F4F9"},{"Typeface name":"Webdings","Dingbat dec":"186","Dingbat hex":"BA","Unicode dec":"128254","Unicode hex":"1F4FE"},{"Typeface name":"Webdings","Dingbat dec":"187","Dingbat hex":"BB","Unicode dec":"128251","Unicode hex":"1F4FB"},{"Typeface name":"Webdings","Dingbat dec":"188","Dingbat hex":"BC","Unicode dec":"127898","Unicode hex":"1F39A"},{"Typeface name":"Webdings","Dingbat dec":"189","Dingbat hex":"BD","Unicode dec":"127899","Unicode hex":"1F39B"},{"Typeface name":"Webdings","Dingbat dec":"190","Dingbat hex":"BE","Unicode dec":"128250","Unicode hex":"1F4FA"},{"Typeface name":"Webdings","Dingbat dec":"191","Dingbat hex":"BF","Unicode dec":"128187","Unicode hex":"1F4BB"},{"Typeface name":"Webdings","Dingbat dec":"192","Dingbat hex":"C0","Unicode dec":"128421","Unicode hex":"1F5A5"},{"Typeface name":"Webdings","Dingbat dec":"193","Dingbat hex":"C1","Unicode dec":"128422","Unicode hex":"1F5A6"},{"Typeface name":"Webdings","Dingbat dec":"194","Dingbat hex":"C2","Unicode dec":"128423","Unicode hex":"1F5A7"},{"Typeface name":"Webdings","Dingbat dec":"195","Dingbat hex":"C3","Unicode dec":"128377","Unicode hex":"1F579"},{"Typeface name":"Webdings","Dingbat dec":"196","Dingbat hex":"C4","Unicode dec":"127918","Unicode hex":"1F3AE"},{"Typeface name":"Webdings","Dingbat dec":"197","Dingbat hex":"C5","Unicode dec":"128379","Unicode hex":"1F57B"},{"Typeface name":"Webdings","Dingbat dec":"198","Dingbat hex":"C6","Unicode dec":"128380","Unicode hex":"1F57C"},{"Typeface name":"Webdings","Dingbat dec":"199","Dingbat hex":"C7","Unicode dec":"128223","Unicode hex":"1F4DF"},{"Typeface name":"Webdings","Dingbat dec":"200","Dingbat hex":"C8","Unicode dec":"128385","Unicode hex":"1F581"},{"Typeface name":"Webdings","Dingbat dec":"201","Dingbat hex":"C9","Unicode dec":"128384","Unicode hex":"1F580"},{"Typeface name":"Webdings","Dingbat dec":"202","Dingbat hex":"CA","Unicode dec":"128424","Unicode hex":"1F5A8"},{"Typeface name":"Webdings","Dingbat dec":"203","Dingbat hex":"CB","Unicode dec":"128425","Unicode hex":"1F5A9"},{"Typeface name":"Webdings","Dingbat dec":"204","Dingbat hex":"CC","Unicode dec":"128447","Unicode hex":"1F5BF"},{"Typeface name":"Webdings","Dingbat dec":"205","Dingbat hex":"CD","Unicode dec":"128426","Unicode hex":"1F5AA"},{"Typeface name":"Webdings","Dingbat dec":"206","Dingbat hex":"CE","Unicode dec":"128476","Unicode hex":"1F5DC"},{"Typeface name":"Webdings","Dingbat dec":"207","Dingbat hex":"CF","Unicode dec":"128274","Unicode hex":"1F512"},{"Typeface name":"Webdings","Dingbat dec":"208","Dingbat hex":"D0","Unicode dec":"128275","Unicode hex":"1F513"},{"Typeface name":"Webdings","Dingbat dec":"209","Dingbat hex":"D1","Unicode dec":"128477","Unicode hex":"1F5DD"},{"Typeface name":"Webdings","Dingbat dec":"210","Dingbat hex":"D2","Unicode dec":"128229","Unicode hex":"1F4E5"},{"Typeface name":"Webdings","Dingbat dec":"211","Dingbat hex":"D3","Unicode dec":"128228","Unicode hex":"1F4E4"},{"Typeface name":"Webdings","Dingbat dec":"212","Dingbat hex":"D4","Unicode dec":"128371","Unicode hex":"1F573"},{"Typeface name":"Webdings","Dingbat dec":"213","Dingbat hex":"D5","Unicode dec":"127779","Unicode hex":"1F323"},{"Typeface name":"Webdings","Dingbat dec":"214","Dingbat hex":"D6","Unicode dec":"127780","Unicode hex":"1F324"},{"Typeface name":"Webdings","Dingbat dec":"215","Dingbat hex":"D7","Unicode dec":"127781","Unicode hex":"1F325"},{"Typeface name":"Webdings","Dingbat dec":"216","Dingbat hex":"D8","Unicode dec":"127782","Unicode hex":"1F326"},{"Typeface name":"Webdings","Dingbat dec":"217","Dingbat hex":"D9","Unicode dec":"9729","Unicode hex":"2601"},{"Typeface name":"Webdings","Dingbat dec":"218","Dingbat hex":"DA","Unicode dec":"127784","Unicode hex":"1F328"},{"Typeface name":"Webdings","Dingbat dec":"219","Dingbat hex":"DB","Unicode dec":"127783","Unicode hex":"1F327"},{"Typeface name":"Webdings","Dingbat dec":"220","Dingbat hex":"DC","Unicode dec":"127785","Unicode hex":"1F329"},{"Typeface name":"Webdings","Dingbat dec":"221","Dingbat hex":"DD","Unicode dec":"127786","Unicode hex":"1F32A"},{"Typeface name":"Webdings","Dingbat dec":"222","Dingbat hex":"DE","Unicode dec":"127788","Unicode hex":"1F32C"},{"Typeface name":"Webdings","Dingbat dec":"223","Dingbat hex":"DF","Unicode dec":"127787","Unicode hex":"1F32B"},{"Typeface name":"Webdings","Dingbat dec":"224","Dingbat hex":"E0","Unicode dec":"127772","Unicode hex":"1F31C"},{"Typeface name":"Webdings","Dingbat dec":"225","Dingbat hex":"E1","Unicode dec":"127777","Unicode hex":"1F321"},{"Typeface name":"Webdings","Dingbat dec":"226","Dingbat hex":"E2","Unicode dec":"128715","Unicode hex":"1F6CB"},{"Typeface name":"Webdings","Dingbat dec":"227","Dingbat hex":"E3","Unicode dec":"128719","Unicode hex":"1F6CF"},{"Typeface name":"Webdings","Dingbat dec":"228","Dingbat hex":"E4","Unicode dec":"127869","Unicode hex":"1F37D"},{"Typeface name":"Webdings","Dingbat dec":"229","Dingbat hex":"E5","Unicode dec":"127864","Unicode hex":"1F378"},{"Typeface name":"Webdings","Dingbat dec":"230","Dingbat hex":"E6","Unicode dec":"128718","Unicode hex":"1F6CE"},{"Typeface name":"Webdings","Dingbat dec":"231","Dingbat hex":"E7","Unicode dec":"128717","Unicode hex":"1F6CD"},{"Typeface name":"Webdings","Dingbat dec":"232","Dingbat hex":"E8","Unicode dec":"9413","Unicode hex":"24C5"},{"Typeface name":"Webdings","Dingbat dec":"233","Dingbat hex":"E9","Unicode dec":"9855","Unicode hex":"267F"},{"Typeface name":"Webdings","Dingbat dec":"234","Dingbat hex":"EA","Unicode dec":"128710","Unicode hex":"1F6C6"},{"Typeface name":"Webdings","Dingbat dec":"235","Dingbat hex":"EB","Unicode dec":"128392","Unicode hex":"1F588"},{"Typeface name":"Webdings","Dingbat dec":"236","Dingbat hex":"EC","Unicode dec":"127891","Unicode hex":"1F393"},{"Typeface name":"Webdings","Dingbat dec":"237","Dingbat hex":"ED","Unicode dec":"128484","Unicode hex":"1F5E4"},{"Typeface name":"Webdings","Dingbat dec":"238","Dingbat hex":"EE","Unicode dec":"128485","Unicode hex":"1F5E5"},{"Typeface name":"Webdings","Dingbat dec":"239","Dingbat hex":"EF","Unicode dec":"128486","Unicode hex":"1F5E6"},{"Typeface name":"Webdings","Dingbat dec":"240","Dingbat hex":"F0","Unicode dec":"128487","Unicode hex":"1F5E7"},{"Typeface name":"Webdings","Dingbat dec":"241","Dingbat hex":"F1","Unicode dec":"128746","Unicode hex":"1F6EA"},{"Typeface name":"Webdings","Dingbat dec":"242","Dingbat hex":"F2","Unicode dec":"128063","Unicode hex":"1F43F"},{"Typeface name":"Webdings","Dingbat dec":"243","Dingbat hex":"F3","Unicode dec":"128038","Unicode hex":"1F426"},{"Typeface name":"Webdings","Dingbat dec":"244","Dingbat hex":"F4","Unicode dec":"128031","Unicode hex":"1F41F"},{"Typeface name":"Webdings","Dingbat dec":"245","Dingbat hex":"F5","Unicode dec":"128021","Unicode hex":"1F415"},{"Typeface name":"Webdings","Dingbat dec":"246","Dingbat hex":"F6","Unicode dec":"128008","Unicode hex":"1F408"},{"Typeface name":"Webdings","Dingbat dec":"247","Dingbat hex":"F7","Unicode dec":"128620","Unicode hex":"1F66C"},{"Typeface name":"Webdings","Dingbat dec":"248","Dingbat hex":"F8","Unicode dec":"128622","Unicode hex":"1F66E"},{"Typeface name":"Webdings","Dingbat dec":"249","Dingbat hex":"F9","Unicode dec":"128621","Unicode hex":"1F66D"},{"Typeface name":"Webdings","Dingbat dec":"250","Dingbat hex":"FA","Unicode dec":"128623","Unicode hex":"1F66F"},{"Typeface name":"Webdings","Dingbat dec":"251","Dingbat hex":"FB","Unicode dec":"128506","Unicode hex":"1F5FA"},{"Typeface name":"Webdings","Dingbat dec":"252","Dingbat hex":"FC","Unicode dec":"127757","Unicode hex":"1F30D"},{"Typeface name":"Webdings","Dingbat dec":"253","Dingbat hex":"FD","Unicode dec":"127759","Unicode hex":"1F30F"},{"Typeface name":"Webdings","Dingbat dec":"254","Dingbat hex":"FE","Unicode dec":"127758","Unicode hex":"1F30E"},{"Typeface name":"Webdings","Dingbat dec":"255","Dingbat hex":"FF","Unicode dec":"128330","Unicode hex":"1F54A"},{"Typeface name":"Wingdings","Dingbat dec":"32","Dingbat hex":"20","Unicode dec":"32","Unicode hex":"20"},{"Typeface name":"Wingdings","Dingbat dec":"33","Dingbat hex":"21","Unicode dec":"128393","Unicode hex":"1F589"},{"Typeface name":"Wingdings","Dingbat dec":"34","Dingbat hex":"22","Unicode dec":"9986","Unicode hex":"2702"},{"Typeface name":"Wingdings","Dingbat dec":"35","Dingbat hex":"23","Unicode dec":"9985","Unicode hex":"2701"},{"Typeface name":"Wingdings","Dingbat dec":"36","Dingbat hex":"24","Unicode dec":"128083","Unicode hex":"1F453"},{"Typeface name":"Wingdings","Dingbat dec":"37","Dingbat hex":"25","Unicode dec":"128365","Unicode hex":"1F56D"},{"Typeface name":"Wingdings","Dingbat dec":"38","Dingbat hex":"26","Unicode dec":"128366","Unicode hex":"1F56E"},{"Typeface name":"Wingdings","Dingbat dec":"39","Dingbat hex":"27","Unicode dec":"128367","Unicode hex":"1F56F"},{"Typeface name":"Wingdings","Dingbat dec":"40","Dingbat hex":"28","Unicode dec":"128383","Unicode hex":"1F57F"},{"Typeface name":"Wingdings","Dingbat dec":"41","Dingbat hex":"29","Unicode dec":"9990","Unicode hex":"2706"},{"Typeface name":"Wingdings","Dingbat dec":"42","Dingbat hex":"2A","Unicode dec":"128386","Unicode hex":"1F582"},{"Typeface name":"Wingdings","Dingbat dec":"43","Dingbat hex":"2B","Unicode dec":"128387","Unicode hex":"1F583"},{"Typeface name":"Wingdings","Dingbat dec":"44","Dingbat hex":"2C","Unicode dec":"128234","Unicode hex":"1F4EA"},{"Typeface name":"Wingdings","Dingbat dec":"45","Dingbat hex":"2D","Unicode dec":"128235","Unicode hex":"1F4EB"},{"Typeface name":"Wingdings","Dingbat dec":"46","Dingbat hex":"2E","Unicode dec":"128236","Unicode hex":"1F4EC"},{"Typeface name":"Wingdings","Dingbat dec":"47","Dingbat hex":"2F","Unicode dec":"128237","Unicode hex":"1F4ED"},{"Typeface name":"Wingdings","Dingbat dec":"48","Dingbat hex":"30","Unicode dec":"128448","Unicode hex":"1F5C0"},{"Typeface name":"Wingdings","Dingbat dec":"49","Dingbat hex":"31","Unicode dec":"128449","Unicode hex":"1F5C1"},{"Typeface name":"Wingdings","Dingbat dec":"50","Dingbat hex":"32","Unicode dec":"128462","Unicode hex":"1F5CE"},{"Typeface name":"Wingdings","Dingbat dec":"51","Dingbat hex":"33","Unicode dec":"128463","Unicode hex":"1F5CF"},{"Typeface name":"Wingdings","Dingbat dec":"52","Dingbat hex":"34","Unicode dec":"128464","Unicode hex":"1F5D0"},{"Typeface name":"Wingdings","Dingbat dec":"53","Dingbat hex":"35","Unicode dec":"128452","Unicode hex":"1F5C4"},{"Typeface name":"Wingdings","Dingbat dec":"54","Dingbat hex":"36","Unicode dec":"8987","Unicode hex":"231B"},{"Typeface name":"Wingdings","Dingbat dec":"55","Dingbat hex":"37","Unicode dec":"128430","Unicode hex":"1F5AE"},{"Typeface name":"Wingdings","Dingbat dec":"56","Dingbat hex":"38","Unicode dec":"128432","Unicode hex":"1F5B0"},{"Typeface name":"Wingdings","Dingbat dec":"57","Dingbat hex":"39","Unicode dec":"128434","Unicode hex":"1F5B2"},{"Typeface name":"Wingdings","Dingbat dec":"58","Dingbat hex":"3A","Unicode dec":"128435","Unicode hex":"1F5B3"},{"Typeface name":"Wingdings","Dingbat dec":"59","Dingbat hex":"3B","Unicode dec":"128436","Unicode hex":"1F5B4"},{"Typeface name":"Wingdings","Dingbat dec":"60","Dingbat hex":"3C","Unicode dec":"128427","Unicode hex":"1F5AB"},{"Typeface name":"Wingdings","Dingbat dec":"61","Dingbat hex":"3D","Unicode dec":"128428","Unicode hex":"1F5AC"},{"Typeface name":"Wingdings","Dingbat dec":"62","Dingbat hex":"3E","Unicode dec":"9991","Unicode hex":"2707"},{"Typeface name":"Wingdings","Dingbat dec":"63","Dingbat hex":"3F","Unicode dec":"9997","Unicode hex":"270D"},{"Typeface name":"Wingdings","Dingbat dec":"64","Dingbat hex":"40","Unicode dec":"128398","Unicode hex":"1F58E"},{"Typeface name":"Wingdings","Dingbat dec":"65","Dingbat hex":"41","Unicode dec":"9996","Unicode hex":"270C"},{"Typeface name":"Wingdings","Dingbat dec":"66","Dingbat hex":"42","Unicode dec":"128399","Unicode hex":"1F58F"},{"Typeface name":"Wingdings","Dingbat dec":"67","Dingbat hex":"43","Unicode dec":"128077","Unicode hex":"1F44D"},{"Typeface name":"Wingdings","Dingbat dec":"68","Dingbat hex":"44","Unicode dec":"128078","Unicode hex":"1F44E"},{"Typeface name":"Wingdings","Dingbat dec":"69","Dingbat hex":"45","Unicode dec":"9756","Unicode hex":"261C"},{"Typeface name":"Wingdings","Dingbat dec":"70","Dingbat hex":"46","Unicode dec":"9758","Unicode hex":"261E"},{"Typeface name":"Wingdings","Dingbat dec":"71","Dingbat hex":"47","Unicode dec":"9757","Unicode hex":"261D"},{"Typeface name":"Wingdings","Dingbat dec":"72","Dingbat hex":"48","Unicode dec":"9759","Unicode hex":"261F"},{"Typeface name":"Wingdings","Dingbat dec":"73","Dingbat hex":"49","Unicode dec":"128400","Unicode hex":"1F590"},{"Typeface name":"Wingdings","Dingbat dec":"74","Dingbat hex":"4A","Unicode dec":"9786","Unicode hex":"263A"},{"Typeface name":"Wingdings","Dingbat dec":"75","Dingbat hex":"4B","Unicode dec":"128528","Unicode hex":"1F610"},{"Typeface name":"Wingdings","Dingbat dec":"76","Dingbat hex":"4C","Unicode dec":"9785","Unicode hex":"2639"},{"Typeface name":"Wingdings","Dingbat dec":"77","Dingbat hex":"4D","Unicode dec":"128163","Unicode hex":"1F4A3"},{"Typeface name":"Wingdings","Dingbat dec":"78","Dingbat hex":"4E","Unicode dec":"128369","Unicode hex":"1F571"},{"Typeface name":"Wingdings","Dingbat dec":"79","Dingbat hex":"4F","Unicode dec":"127987","Unicode hex":"1F3F3"},{"Typeface name":"Wingdings","Dingbat dec":"80","Dingbat hex":"50","Unicode dec":"127985","Unicode hex":"1F3F1"},{"Typeface name":"Wingdings","Dingbat dec":"81","Dingbat hex":"51","Unicode dec":"9992","Unicode hex":"2708"},{"Typeface name":"Wingdings","Dingbat dec":"82","Dingbat hex":"52","Unicode dec":"9788","Unicode hex":"263C"},{"Typeface name":"Wingdings","Dingbat dec":"83","Dingbat hex":"53","Unicode dec":"127778","Unicode hex":"1F322"},{"Typeface name":"Wingdings","Dingbat dec":"84","Dingbat hex":"54","Unicode dec":"10052","Unicode hex":"2744"},{"Typeface name":"Wingdings","Dingbat dec":"85","Dingbat hex":"55","Unicode dec":"128326","Unicode hex":"1F546"},{"Typeface name":"Wingdings","Dingbat dec":"86","Dingbat hex":"56","Unicode dec":"10014","Unicode hex":"271E"},{"Typeface name":"Wingdings","Dingbat dec":"87","Dingbat hex":"57","Unicode dec":"128328","Unicode hex":"1F548"},{"Typeface name":"Wingdings","Dingbat dec":"88","Dingbat hex":"58","Unicode dec":"10016","Unicode hex":"2720"},{"Typeface name":"Wingdings","Dingbat dec":"89","Dingbat hex":"59","Unicode dec":"10017","Unicode hex":"2721"},{"Typeface name":"Wingdings","Dingbat dec":"90","Dingbat hex":"5A","Unicode dec":"9770","Unicode hex":"262A"},{"Typeface name":"Wingdings","Dingbat dec":"91","Dingbat hex":"5B","Unicode dec":"9775","Unicode hex":"262F"},{"Typeface name":"Wingdings","Dingbat dec":"92","Dingbat hex":"5C","Unicode dec":"128329","Unicode hex":"1F549"},{"Typeface name":"Wingdings","Dingbat dec":"93","Dingbat hex":"5D","Unicode dec":"9784","Unicode hex":"2638"},{"Typeface name":"Wingdings","Dingbat dec":"94","Dingbat hex":"5E","Unicode dec":"9800","Unicode hex":"2648"},{"Typeface name":"Wingdings","Dingbat dec":"95","Dingbat hex":"5F","Unicode dec":"9801","Unicode hex":"2649"},{"Typeface name":"Wingdings","Dingbat dec":"96","Dingbat hex":"60","Unicode dec":"9802","Unicode hex":"264A"},{"Typeface name":"Wingdings","Dingbat dec":"97","Dingbat hex":"61","Unicode dec":"9803","Unicode hex":"264B"},{"Typeface name":"Wingdings","Dingbat dec":"98","Dingbat hex":"62","Unicode dec":"9804","Unicode hex":"264C"},{"Typeface name":"Wingdings","Dingbat dec":"99","Dingbat hex":"63","Unicode dec":"9805","Unicode hex":"264D"},{"Typeface name":"Wingdings","Dingbat dec":"100","Dingbat hex":"64","Unicode dec":"9806","Unicode hex":"264E"},{"Typeface name":"Wingdings","Dingbat dec":"101","Dingbat hex":"65","Unicode dec":"9807","Unicode hex":"264F"},{"Typeface name":"Wingdings","Dingbat dec":"102","Dingbat hex":"66","Unicode dec":"9808","Unicode hex":"2650"},{"Typeface name":"Wingdings","Dingbat dec":"103","Dingbat hex":"67","Unicode dec":"9809","Unicode hex":"2651"},{"Typeface name":"Wingdings","Dingbat dec":"104","Dingbat hex":"68","Unicode dec":"9810","Unicode hex":"2652"},{"Typeface name":"Wingdings","Dingbat dec":"105","Dingbat hex":"69","Unicode dec":"9811","Unicode hex":"2653"},{"Typeface name":"Wingdings","Dingbat dec":"106", -"Dingbat hex":"6A","Unicode dec":"128624","Unicode hex":"1F670"},{"Typeface name":"Wingdings","Dingbat dec":"107","Dingbat hex":"6B","Unicode dec":"128629","Unicode hex":"1F675"},{"Typeface name":"Wingdings","Dingbat dec":"108","Dingbat hex":"6C","Unicode dec":"9899","Unicode hex":"26AB"},{"Typeface name":"Wingdings","Dingbat dec":"109","Dingbat hex":"6D","Unicode dec":"128318","Unicode hex":"1F53E"},{"Typeface name":"Wingdings","Dingbat dec":"110","Dingbat hex":"6E","Unicode dec":"9724","Unicode hex":"25FC"},{"Typeface name":"Wingdings","Dingbat dec":"111","Dingbat hex":"6F","Unicode dec":"128911","Unicode hex":"1F78F"},{"Typeface name":"Wingdings","Dingbat dec":"112","Dingbat hex":"70","Unicode dec":"128912","Unicode hex":"1F790"},{"Typeface name":"Wingdings","Dingbat dec":"113","Dingbat hex":"71","Unicode dec":"10065","Unicode hex":"2751"},{"Typeface name":"Wingdings","Dingbat dec":"114","Dingbat hex":"72","Unicode dec":"10066","Unicode hex":"2752"},{"Typeface name":"Wingdings","Dingbat dec":"115","Dingbat hex":"73","Unicode dec":"128927","Unicode hex":"1F79F"},{"Typeface name":"Wingdings","Dingbat dec":"116","Dingbat hex":"74","Unicode dec":"10731","Unicode hex":"29EB"},{"Typeface name":"Wingdings","Dingbat dec":"117","Dingbat hex":"75","Unicode dec":"9670","Unicode hex":"25C6"},{"Typeface name":"Wingdings","Dingbat dec":"118","Dingbat hex":"76","Unicode dec":"10070","Unicode hex":"2756"},{"Typeface name":"Wingdings","Dingbat dec":"119","Dingbat hex":"77","Unicode dec":"11049","Unicode hex":"2B29"},{"Typeface name":"Wingdings","Dingbat dec":"120","Dingbat hex":"78","Unicode dec":"8999","Unicode hex":"2327"},{"Typeface name":"Wingdings","Dingbat dec":"121","Dingbat hex":"79","Unicode dec":"11193","Unicode hex":"2BB9"},{"Typeface name":"Wingdings","Dingbat dec":"122","Dingbat hex":"7A","Unicode dec":"8984","Unicode hex":"2318"},{"Typeface name":"Wingdings","Dingbat dec":"123","Dingbat hex":"7B","Unicode dec":"127989","Unicode hex":"1F3F5"},{"Typeface name":"Wingdings","Dingbat dec":"124","Dingbat hex":"7C","Unicode dec":"127990","Unicode hex":"1F3F6"},{"Typeface name":"Wingdings","Dingbat dec":"125","Dingbat hex":"7D","Unicode dec":"128630","Unicode hex":"1F676"},{"Typeface name":"Wingdings","Dingbat dec":"126","Dingbat hex":"7E","Unicode dec":"128631","Unicode hex":"1F677"},{"Typeface name":"Wingdings","Dingbat dec":"127","Dingbat hex":"7F","Unicode dec":"9647","Unicode hex":"25AF"},{"Typeface name":"Wingdings","Dingbat dec":"128","Dingbat hex":"80","Unicode dec":"127243","Unicode hex":"1F10B"},{"Typeface name":"Wingdings","Dingbat dec":"129","Dingbat hex":"81","Unicode dec":"10112","Unicode hex":"2780"},{"Typeface name":"Wingdings","Dingbat dec":"130","Dingbat hex":"82","Unicode dec":"10113","Unicode hex":"2781"},{"Typeface name":"Wingdings","Dingbat dec":"131","Dingbat hex":"83","Unicode dec":"10114","Unicode hex":"2782"},{"Typeface name":"Wingdings","Dingbat dec":"132","Dingbat hex":"84","Unicode dec":"10115","Unicode hex":"2783"},{"Typeface name":"Wingdings","Dingbat dec":"133","Dingbat hex":"85","Unicode dec":"10116","Unicode hex":"2784"},{"Typeface name":"Wingdings","Dingbat dec":"134","Dingbat hex":"86","Unicode dec":"10117","Unicode hex":"2785"},{"Typeface name":"Wingdings","Dingbat dec":"135","Dingbat hex":"87","Unicode dec":"10118","Unicode hex":"2786"},{"Typeface name":"Wingdings","Dingbat dec":"136","Dingbat hex":"88","Unicode dec":"10119","Unicode hex":"2787"},{"Typeface name":"Wingdings","Dingbat dec":"137","Dingbat hex":"89","Unicode dec":"10120","Unicode hex":"2788"},{"Typeface name":"Wingdings","Dingbat dec":"138","Dingbat hex":"8A","Unicode dec":"10121","Unicode hex":"2789"},{"Typeface name":"Wingdings","Dingbat dec":"139","Dingbat hex":"8B","Unicode dec":"127244","Unicode hex":"1F10C"},{"Typeface name":"Wingdings","Dingbat dec":"140","Dingbat hex":"8C","Unicode dec":"10122","Unicode hex":"278A"},{"Typeface name":"Wingdings","Dingbat dec":"141","Dingbat hex":"8D","Unicode dec":"10123","Unicode hex":"278B"},{"Typeface name":"Wingdings","Dingbat dec":"142","Dingbat hex":"8E","Unicode dec":"10124","Unicode hex":"278C"},{"Typeface name":"Wingdings","Dingbat dec":"143","Dingbat hex":"8F","Unicode dec":"10125","Unicode hex":"278D"},{"Typeface name":"Wingdings","Dingbat dec":"144","Dingbat hex":"90","Unicode dec":"10126","Unicode hex":"278E"},{"Typeface name":"Wingdings","Dingbat dec":"145","Dingbat hex":"91","Unicode dec":"10127","Unicode hex":"278F"},{"Typeface name":"Wingdings","Dingbat dec":"146","Dingbat hex":"92","Unicode dec":"10128","Unicode hex":"2790"},{"Typeface name":"Wingdings","Dingbat dec":"147","Dingbat hex":"93","Unicode dec":"10129","Unicode hex":"2791"},{"Typeface name":"Wingdings","Dingbat dec":"148","Dingbat hex":"94","Unicode dec":"10130","Unicode hex":"2792"},{"Typeface name":"Wingdings","Dingbat dec":"149","Dingbat hex":"95","Unicode dec":"10131","Unicode hex":"2793"},{"Typeface name":"Wingdings","Dingbat dec":"150","Dingbat hex":"96","Unicode dec":"128610","Unicode hex":"1F662"},{"Typeface name":"Wingdings","Dingbat dec":"151","Dingbat hex":"97","Unicode dec":"128608","Unicode hex":"1F660"},{"Typeface name":"Wingdings","Dingbat dec":"152","Dingbat hex":"98","Unicode dec":"128609","Unicode hex":"1F661"},{"Typeface name":"Wingdings","Dingbat dec":"153","Dingbat hex":"99","Unicode dec":"128611","Unicode hex":"1F663"},{"Typeface name":"Wingdings","Dingbat dec":"154","Dingbat hex":"9A","Unicode dec":"128606","Unicode hex":"1F65E"},{"Typeface name":"Wingdings","Dingbat dec":"155","Dingbat hex":"9B","Unicode dec":"128604","Unicode hex":"1F65C"},{"Typeface name":"Wingdings","Dingbat dec":"156","Dingbat hex":"9C","Unicode dec":"128605","Unicode hex":"1F65D"},{"Typeface name":"Wingdings","Dingbat dec":"157","Dingbat hex":"9D","Unicode dec":"128607","Unicode hex":"1F65F"},{"Typeface name":"Wingdings","Dingbat dec":"158","Dingbat hex":"9E","Unicode dec":"8729","Unicode hex":"2219"},{"Typeface name":"Wingdings","Dingbat dec":"159","Dingbat hex":"9F","Unicode dec":"8226","Unicode hex":"2022"},{"Typeface name":"Wingdings","Dingbat dec":"160","Dingbat hex":"A0","Unicode dec":"11037","Unicode hex":"2B1D"},{"Typeface name":"Wingdings","Dingbat dec":"161","Dingbat hex":"A1","Unicode dec":"11096","Unicode hex":"2B58"},{"Typeface name":"Wingdings","Dingbat dec":"162","Dingbat hex":"A2","Unicode dec":"128902","Unicode hex":"1F786"},{"Typeface name":"Wingdings","Dingbat dec":"163","Dingbat hex":"A3","Unicode dec":"128904","Unicode hex":"1F788"},{"Typeface name":"Wingdings","Dingbat dec":"164","Dingbat hex":"A4","Unicode dec":"128906","Unicode hex":"1F78A"},{"Typeface name":"Wingdings","Dingbat dec":"165","Dingbat hex":"A5","Unicode dec":"128907","Unicode hex":"1F78B"},{"Typeface name":"Wingdings","Dingbat dec":"166","Dingbat hex":"A6","Unicode dec":"128319","Unicode hex":"1F53F"},{"Typeface name":"Wingdings","Dingbat dec":"167","Dingbat hex":"A7","Unicode dec":"9642","Unicode hex":"25AA"},{"Typeface name":"Wingdings","Dingbat dec":"168","Dingbat hex":"A8","Unicode dec":"128910","Unicode hex":"1F78E"},{"Typeface name":"Wingdings","Dingbat dec":"169","Dingbat hex":"A9","Unicode dec":"128961","Unicode hex":"1F7C1"},{"Typeface name":"Wingdings","Dingbat dec":"170","Dingbat hex":"AA","Unicode dec":"128965","Unicode hex":"1F7C5"},{"Typeface name":"Wingdings","Dingbat dec":"171","Dingbat hex":"AB","Unicode dec":"9733","Unicode hex":"2605"},{"Typeface name":"Wingdings","Dingbat dec":"172","Dingbat hex":"AC","Unicode dec":"128971","Unicode hex":"1F7CB"},{"Typeface name":"Wingdings","Dingbat dec":"173","Dingbat hex":"AD","Unicode dec":"128975","Unicode hex":"1F7CF"},{"Typeface name":"Wingdings","Dingbat dec":"174","Dingbat hex":"AE","Unicode dec":"128979","Unicode hex":"1F7D3"},{"Typeface name":"Wingdings","Dingbat dec":"175","Dingbat hex":"AF","Unicode dec":"128977","Unicode hex":"1F7D1"},{"Typeface name":"Wingdings","Dingbat dec":"176","Dingbat hex":"B0","Unicode dec":"11216","Unicode hex":"2BD0"},{"Typeface name":"Wingdings","Dingbat dec":"177","Dingbat hex":"B1","Unicode dec":"8982","Unicode hex":"2316"},{"Typeface name":"Wingdings","Dingbat dec":"178","Dingbat hex":"B2","Unicode dec":"11214","Unicode hex":"2BCE"},{"Typeface name":"Wingdings","Dingbat dec":"179","Dingbat hex":"B3","Unicode dec":"11215","Unicode hex":"2BCF"},{"Typeface name":"Wingdings","Dingbat dec":"180","Dingbat hex":"B4","Unicode dec":"11217","Unicode hex":"2BD1"},{"Typeface name":"Wingdings","Dingbat dec":"181","Dingbat hex":"B5","Unicode dec":"10026","Unicode hex":"272A"},{"Typeface name":"Wingdings","Dingbat dec":"182","Dingbat hex":"B6","Unicode dec":"10032","Unicode hex":"2730"},{"Typeface name":"Wingdings","Dingbat dec":"183","Dingbat hex":"B7","Unicode dec":"128336","Unicode hex":"1F550"},{"Typeface name":"Wingdings","Dingbat dec":"184","Dingbat hex":"B8","Unicode dec":"128337","Unicode hex":"1F551"},{"Typeface name":"Wingdings","Dingbat dec":"185","Dingbat hex":"B9","Unicode dec":"128338","Unicode hex":"1F552"},{"Typeface name":"Wingdings","Dingbat dec":"186","Dingbat hex":"BA","Unicode dec":"128339","Unicode hex":"1F553"},{"Typeface name":"Wingdings","Dingbat dec":"187","Dingbat hex":"BB","Unicode dec":"128340","Unicode hex":"1F554"},{"Typeface name":"Wingdings","Dingbat dec":"188","Dingbat hex":"BC","Unicode dec":"128341","Unicode hex":"1F555"},{"Typeface name":"Wingdings","Dingbat dec":"189","Dingbat hex":"BD","Unicode dec":"128342","Unicode hex":"1F556"},{"Typeface name":"Wingdings","Dingbat dec":"190","Dingbat hex":"BE","Unicode dec":"128343","Unicode hex":"1F557"},{"Typeface name":"Wingdings","Dingbat dec":"191","Dingbat hex":"BF","Unicode dec":"128344","Unicode hex":"1F558"},{"Typeface name":"Wingdings","Dingbat dec":"192","Dingbat hex":"C0","Unicode dec":"128345","Unicode hex":"1F559"},{"Typeface name":"Wingdings","Dingbat dec":"193","Dingbat hex":"C1","Unicode dec":"128346","Unicode hex":"1F55A"},{"Typeface name":"Wingdings","Dingbat dec":"194","Dingbat hex":"C2","Unicode dec":"128347","Unicode hex":"1F55B"},{"Typeface name":"Wingdings","Dingbat dec":"195","Dingbat hex":"C3","Unicode dec":"11184","Unicode hex":"2BB0"},{"Typeface name":"Wingdings","Dingbat dec":"196","Dingbat hex":"C4","Unicode dec":"11185","Unicode hex":"2BB1"},{"Typeface name":"Wingdings","Dingbat dec":"197","Dingbat hex":"C5","Unicode dec":"11186","Unicode hex":"2BB2"},{"Typeface name":"Wingdings","Dingbat dec":"198","Dingbat hex":"C6","Unicode dec":"11187","Unicode hex":"2BB3"},{"Typeface name":"Wingdings","Dingbat dec":"199","Dingbat hex":"C7","Unicode dec":"11188","Unicode hex":"2BB4"},{"Typeface name":"Wingdings","Dingbat dec":"200","Dingbat hex":"C8","Unicode dec":"11189","Unicode hex":"2BB5"},{"Typeface name":"Wingdings","Dingbat dec":"201","Dingbat hex":"C9","Unicode dec":"11190","Unicode hex":"2BB6"},{"Typeface name":"Wingdings","Dingbat dec":"202","Dingbat hex":"CA","Unicode dec":"11191","Unicode hex":"2BB7"},{"Typeface name":"Wingdings","Dingbat dec":"203","Dingbat hex":"CB","Unicode dec":"128618","Unicode hex":"1F66A"},{"Typeface name":"Wingdings","Dingbat dec":"204","Dingbat hex":"CC","Unicode dec":"128619","Unicode hex":"1F66B"},{"Typeface name":"Wingdings","Dingbat dec":"205","Dingbat hex":"CD","Unicode dec":"128597","Unicode hex":"1F655"},{"Typeface name":"Wingdings","Dingbat dec":"206","Dingbat hex":"CE","Unicode dec":"128596","Unicode hex":"1F654"},{"Typeface name":"Wingdings","Dingbat dec":"207","Dingbat hex":"CF","Unicode dec":"128599","Unicode hex":"1F657"},{"Typeface name":"Wingdings","Dingbat dec":"208","Dingbat hex":"D0","Unicode dec":"128598","Unicode hex":"1F656"},{"Typeface name":"Wingdings","Dingbat dec":"209","Dingbat hex":"D1","Unicode dec":"128592","Unicode hex":"1F650"},{"Typeface name":"Wingdings","Dingbat dec":"210","Dingbat hex":"D2","Unicode dec":"128593","Unicode hex":"1F651"},{"Typeface name":"Wingdings","Dingbat dec":"211","Dingbat hex":"D3","Unicode dec":"128594","Unicode hex":"1F652"},{"Typeface name":"Wingdings","Dingbat dec":"212","Dingbat hex":"D4","Unicode dec":"128595","Unicode hex":"1F653"},{"Typeface name":"Wingdings","Dingbat dec":"213","Dingbat hex":"D5","Unicode dec":"9003","Unicode hex":"232B"},{"Typeface name":"Wingdings","Dingbat dec":"214","Dingbat hex":"D6","Unicode dec":"8998","Unicode hex":"2326"},{"Typeface name":"Wingdings","Dingbat dec":"215","Dingbat hex":"D7","Unicode dec":"11160","Unicode hex":"2B98"},{"Typeface name":"Wingdings","Dingbat dec":"216","Dingbat hex":"D8","Unicode dec":"11162","Unicode hex":"2B9A"},{"Typeface name":"Wingdings","Dingbat dec":"217","Dingbat hex":"D9","Unicode dec":"11161","Unicode hex":"2B99"},{"Typeface name":"Wingdings","Dingbat dec":"218","Dingbat hex":"DA","Unicode dec":"11163","Unicode hex":"2B9B"},{"Typeface name":"Wingdings","Dingbat dec":"219","Dingbat hex":"DB","Unicode dec":"11144","Unicode hex":"2B88"},{"Typeface name":"Wingdings","Dingbat dec":"220","Dingbat hex":"DC","Unicode dec":"11146","Unicode hex":"2B8A"},{"Typeface name":"Wingdings","Dingbat dec":"221","Dingbat hex":"DD","Unicode dec":"11145","Unicode hex":"2B89"},{"Typeface name":"Wingdings","Dingbat dec":"222","Dingbat hex":"DE","Unicode dec":"11147","Unicode hex":"2B8B"},{"Typeface name":"Wingdings","Dingbat dec":"223","Dingbat hex":"DF","Unicode dec":"129128","Unicode hex":"1F868"},{"Typeface name":"Wingdings","Dingbat dec":"224","Dingbat hex":"E0","Unicode dec":"129130","Unicode hex":"1F86A"},{"Typeface name":"Wingdings","Dingbat dec":"225","Dingbat hex":"E1","Unicode dec":"129129","Unicode hex":"1F869"},{"Typeface name":"Wingdings","Dingbat dec":"226","Dingbat hex":"E2","Unicode dec":"129131","Unicode hex":"1F86B"},{"Typeface name":"Wingdings","Dingbat dec":"227","Dingbat hex":"E3","Unicode dec":"129132","Unicode hex":"1F86C"},{"Typeface name":"Wingdings","Dingbat dec":"228","Dingbat hex":"E4","Unicode dec":"129133","Unicode hex":"1F86D"},{"Typeface name":"Wingdings","Dingbat dec":"229","Dingbat hex":"E5","Unicode dec":"129135","Unicode hex":"1F86F"},{"Typeface name":"Wingdings","Dingbat dec":"230","Dingbat hex":"E6","Unicode dec":"129134","Unicode hex":"1F86E"},{"Typeface name":"Wingdings","Dingbat dec":"231","Dingbat hex":"E7","Unicode dec":"129144","Unicode hex":"1F878"},{"Typeface name":"Wingdings","Dingbat dec":"232","Dingbat hex":"E8","Unicode dec":"129146","Unicode hex":"1F87A"},{"Typeface name":"Wingdings","Dingbat dec":"233","Dingbat hex":"E9","Unicode dec":"129145","Unicode hex":"1F879"},{"Typeface name":"Wingdings","Dingbat dec":"234","Dingbat hex":"EA","Unicode dec":"129147","Unicode hex":"1F87B"},{"Typeface name":"Wingdings","Dingbat dec":"235","Dingbat hex":"EB","Unicode dec":"129148","Unicode hex":"1F87C"},{"Typeface name":"Wingdings","Dingbat dec":"236","Dingbat hex":"EC","Unicode dec":"129149","Unicode hex":"1F87D"},{"Typeface name":"Wingdings","Dingbat dec":"237","Dingbat hex":"ED","Unicode dec":"129151","Unicode hex":"1F87F"},{"Typeface name":"Wingdings","Dingbat dec":"238","Dingbat hex":"EE","Unicode dec":"129150","Unicode hex":"1F87E"},{"Typeface name":"Wingdings","Dingbat dec":"239","Dingbat hex":"EF","Unicode dec":"8678","Unicode hex":"21E6"},{"Typeface name":"Wingdings","Dingbat dec":"240","Dingbat hex":"F0","Unicode dec":"8680","Unicode hex":"21E8"},{"Typeface name":"Wingdings","Dingbat dec":"241","Dingbat hex":"F1","Unicode dec":"8679","Unicode hex":"21E7"},{"Typeface name":"Wingdings","Dingbat dec":"242","Dingbat hex":"F2","Unicode dec":"8681","Unicode hex":"21E9"},{"Typeface name":"Wingdings","Dingbat dec":"243","Dingbat hex":"F3","Unicode dec":"11012","Unicode hex":"2B04"},{"Typeface name":"Wingdings","Dingbat dec":"244","Dingbat hex":"F4","Unicode dec":"8691","Unicode hex":"21F3"},{"Typeface name":"Wingdings","Dingbat dec":"245","Dingbat hex":"F5","Unicode dec":"11009","Unicode hex":"2B01"},{"Typeface name":"Wingdings","Dingbat dec":"246","Dingbat hex":"F6","Unicode dec":"11008","Unicode hex":"2B00"},{"Typeface name":"Wingdings","Dingbat dec":"247","Dingbat hex":"F7","Unicode dec":"11011","Unicode hex":"2B03"},{"Typeface name":"Wingdings","Dingbat dec":"248","Dingbat hex":"F8","Unicode dec":"11010","Unicode hex":"2B02"},{"Typeface name":"Wingdings","Dingbat dec":"249","Dingbat hex":"F9","Unicode dec":"129196","Unicode hex":"1F8AC"},{"Typeface name":"Wingdings","Dingbat dec":"250","Dingbat hex":"FA","Unicode dec":"129197","Unicode hex":"1F8AD"},{"Typeface name":"Wingdings","Dingbat dec":"251","Dingbat hex":"FB","Unicode dec":"128502","Unicode hex":"1F5F6"},{"Typeface name":"Wingdings","Dingbat dec":"252","Dingbat hex":"FC","Unicode dec":"10003","Unicode hex":"2713"},{"Typeface name":"Wingdings","Dingbat dec":"253","Dingbat hex":"FD","Unicode dec":"128503","Unicode hex":"1F5F7"},{"Typeface name":"Wingdings","Dingbat dec":"254","Dingbat hex":"FE","Unicode dec":"128505","Unicode hex":"1F5F9"},{"Typeface name":"Wingdings 2","Dingbat dec":"32","Dingbat hex":"20","Unicode dec":"32","Unicode hex":"20"},{"Typeface name":"Wingdings 2","Dingbat dec":"33","Dingbat hex":"21","Unicode dec":"128394","Unicode hex":"1F58A"},{"Typeface name":"Wingdings 2","Dingbat dec":"34","Dingbat hex":"22","Unicode dec":"128395","Unicode hex":"1F58B"},{"Typeface name":"Wingdings 2","Dingbat dec":"35","Dingbat hex":"23","Unicode dec":"128396","Unicode hex":"1F58C"},{"Typeface name":"Wingdings 2","Dingbat dec":"36","Dingbat hex":"24","Unicode dec":"128397","Unicode hex":"1F58D"},{"Typeface name":"Wingdings 2","Dingbat dec":"37","Dingbat hex":"25","Unicode dec":"9988","Unicode hex":"2704"},{"Typeface name":"Wingdings 2","Dingbat dec":"38","Dingbat hex":"26","Unicode dec":"9984","Unicode hex":"2700"},{"Typeface name":"Wingdings 2","Dingbat dec":"39","Dingbat hex":"27","Unicode dec":"128382","Unicode hex":"1F57E"},{"Typeface name":"Wingdings 2","Dingbat dec":"40","Dingbat hex":"28","Unicode dec":"128381","Unicode hex":"1F57D"},{"Typeface name":"Wingdings 2","Dingbat dec":"41","Dingbat hex":"29","Unicode dec":"128453","Unicode hex":"1F5C5"},{"Typeface name":"Wingdings 2","Dingbat dec":"42","Dingbat hex":"2A","Unicode dec":"128454","Unicode hex":"1F5C6"},{"Typeface name":"Wingdings 2","Dingbat dec":"43","Dingbat hex":"2B","Unicode dec":"128455","Unicode hex":"1F5C7"},{"Typeface name":"Wingdings 2","Dingbat dec":"44","Dingbat hex":"2C","Unicode dec":"128456","Unicode hex":"1F5C8"},{"Typeface name":"Wingdings 2","Dingbat dec":"45","Dingbat hex":"2D","Unicode dec":"128457","Unicode hex":"1F5C9"},{"Typeface name":"Wingdings 2","Dingbat dec":"46","Dingbat hex":"2E","Unicode dec":"128458","Unicode hex":"1F5CA"},{"Typeface name":"Wingdings 2","Dingbat dec":"47","Dingbat hex":"2F","Unicode dec":"128459","Unicode hex":"1F5CB"},{"Typeface name":"Wingdings 2","Dingbat dec":"48","Dingbat hex":"30","Unicode dec":"128460","Unicode hex":"1F5CC"},{"Typeface name":"Wingdings 2","Dingbat dec":"49","Dingbat hex":"31","Unicode dec":"128461","Unicode hex":"1F5CD"},{"Typeface name":"Wingdings 2","Dingbat dec":"50","Dingbat hex":"32","Unicode dec":"128203","Unicode hex":"1F4CB"},{"Typeface name":"Wingdings 2","Dingbat dec":"51","Dingbat hex":"33","Unicode dec":"128465","Unicode hex":"1F5D1"},{"Typeface name":"Wingdings 2","Dingbat dec":"52","Dingbat hex":"34","Unicode dec":"128468","Unicode hex":"1F5D4"},{"Typeface name":"Wingdings 2","Dingbat dec":"53","Dingbat hex":"35","Unicode dec":"128437","Unicode hex":"1F5B5"},{"Typeface name":"Wingdings 2","Dingbat dec":"54","Dingbat hex":"36","Unicode dec":"128438","Unicode hex":"1F5B6"},{"Typeface name":"Wingdings 2","Dingbat dec":"55","Dingbat hex":"37","Unicode dec":"128439","Unicode hex":"1F5B7"},{"Typeface name":"Wingdings 2","Dingbat dec":"56","Dingbat hex":"38","Unicode dec":"128440","Unicode hex":"1F5B8"},{"Typeface name":"Wingdings 2","Dingbat dec":"57","Dingbat hex":"39","Unicode dec":"128429","Unicode hex":"1F5AD"},{"Typeface name":"Wingdings 2","Dingbat dec":"58","Dingbat hex":"3A","Unicode dec":"128431","Unicode hex":"1F5AF"},{"Typeface name":"Wingdings 2","Dingbat dec":"59","Dingbat hex":"3B","Unicode dec":"128433","Unicode hex":"1F5B1"},{"Typeface name":"Wingdings 2","Dingbat dec":"60","Dingbat hex":"3C","Unicode dec":"128402","Unicode hex":"1F592"},{"Typeface name":"Wingdings 2","Dingbat dec":"61","Dingbat hex":"3D","Unicode dec":"128403","Unicode hex":"1F593"},{"Typeface name":"Wingdings 2","Dingbat dec":"62","Dingbat hex":"3E","Unicode dec":"128408","Unicode hex":"1F598"},{"Typeface name":"Wingdings 2","Dingbat dec":"63","Dingbat hex":"3F","Unicode dec":"128409","Unicode hex":"1F599"},{"Typeface name":"Wingdings 2","Dingbat dec":"64","Dingbat hex":"40","Unicode dec":"128410","Unicode hex":"1F59A"},{"Typeface name":"Wingdings 2","Dingbat dec":"65","Dingbat hex":"41","Unicode dec":"128411","Unicode hex":"1F59B"},{"Typeface name":"Wingdings 2","Dingbat dec":"66","Dingbat hex":"42","Unicode dec":"128072","Unicode hex":"1F448"},{"Typeface name":"Wingdings 2","Dingbat dec":"67","Dingbat hex":"43","Unicode dec":"128073","Unicode hex":"1F449"},{"Typeface name":"Wingdings 2","Dingbat dec":"68","Dingbat hex":"44","Unicode dec":"128412","Unicode hex":"1F59C"},{"Typeface name":"Wingdings 2","Dingbat dec":"69","Dingbat hex":"45","Unicode dec":"128413","Unicode hex":"1F59D"},{"Typeface name":"Wingdings 2","Dingbat dec":"70","Dingbat hex":"46","Unicode dec":"128414","Unicode hex":"1F59E"},{"Typeface name":"Wingdings 2","Dingbat dec":"71","Dingbat hex":"47","Unicode dec":"128415","Unicode hex":"1F59F"},{"Typeface name":"Wingdings 2","Dingbat dec":"72","Dingbat hex":"48","Unicode dec":"128416","Unicode hex":"1F5A0"},{"Typeface name":"Wingdings 2","Dingbat dec":"73","Dingbat hex":"49","Unicode dec":"128417","Unicode hex":"1F5A1"},{"Typeface name":"Wingdings 2","Dingbat dec":"74","Dingbat hex":"4A","Unicode dec":"128070","Unicode hex":"1F446"},{"Typeface name":"Wingdings 2","Dingbat dec":"75","Dingbat hex":"4B","Unicode dec":"128071","Unicode hex":"1F447"},{"Typeface name":"Wingdings 2","Dingbat dec":"76","Dingbat hex":"4C","Unicode dec":"128418","Unicode hex":"1F5A2"},{"Typeface name":"Wingdings 2","Dingbat dec":"77","Dingbat hex":"4D","Unicode dec":"128419","Unicode hex":"1F5A3"},{"Typeface name":"Wingdings 2","Dingbat dec":"78","Dingbat hex":"4E","Unicode dec":"128401","Unicode hex":"1F591"},{"Typeface name":"Wingdings 2","Dingbat dec":"79","Dingbat hex":"4F","Unicode dec":"128500","Unicode hex":"1F5F4"},{"Typeface name":"Wingdings 2","Dingbat dec":"80","Dingbat hex":"50","Unicode dec":"128504","Unicode hex":"1F5F8"},{"Typeface name":"Wingdings 2","Dingbat dec":"81","Dingbat hex":"51","Unicode dec":"128501","Unicode hex":"1F5F5"},{"Typeface name":"Wingdings 2","Dingbat dec":"82","Dingbat hex":"52","Unicode dec":"9745","Unicode hex":"2611"},{"Typeface name":"Wingdings 2","Dingbat dec":"83","Dingbat hex":"53","Unicode dec":"11197","Unicode hex":"2BBD"},{"Typeface name":"Wingdings 2","Dingbat dec":"84","Dingbat hex":"54","Unicode dec":"9746","Unicode hex":"2612"},{"Typeface name":"Wingdings 2","Dingbat dec":"85","Dingbat hex":"55","Unicode dec":"11198","Unicode hex":"2BBE"},{"Typeface name":"Wingdings 2","Dingbat dec":"86","Dingbat hex":"56","Unicode dec":"11199","Unicode hex":"2BBF"},{"Typeface name":"Wingdings 2","Dingbat dec":"87","Dingbat hex":"57","Unicode dec":"128711","Unicode hex":"1F6C7"},{"Typeface name":"Wingdings 2","Dingbat dec":"88","Dingbat hex":"58","Unicode dec":"10680","Unicode hex":"29B8"},{"Typeface name":"Wingdings 2","Dingbat dec":"89","Dingbat hex":"59","Unicode dec":"128625","Unicode hex":"1F671"},{"Typeface name":"Wingdings 2","Dingbat dec":"90","Dingbat hex":"5A","Unicode dec":"128628","Unicode hex":"1F674"},{"Typeface name":"Wingdings 2","Dingbat dec":"91","Dingbat hex":"5B","Unicode dec":"128626","Unicode hex":"1F672"},{"Typeface name":"Wingdings 2","Dingbat dec":"92","Dingbat hex":"5C","Unicode dec":"128627","Unicode hex":"1F673"},{"Typeface name":"Wingdings 2","Dingbat dec":"93","Dingbat hex":"5D","Unicode dec":"8253","Unicode hex":"203D"},{"Typeface name":"Wingdings 2","Dingbat dec":"94","Dingbat hex":"5E","Unicode dec":"128633","Unicode hex":"1F679"},{"Typeface name":"Wingdings 2","Dingbat dec":"95","Dingbat hex":"5F","Unicode dec":"128634","Unicode hex":"1F67A"},{"Typeface name":"Wingdings 2","Dingbat dec":"96","Dingbat hex":"60","Unicode dec":"128635","Unicode hex":"1F67B"},{"Typeface name":"Wingdings 2","Dingbat dec":"97","Dingbat hex":"61","Unicode dec":"128614","Unicode hex":"1F666"},{"Typeface name":"Wingdings 2","Dingbat dec":"98","Dingbat hex":"62","Unicode dec":"128612","Unicode hex":"1F664"},{"Typeface name":"Wingdings 2","Dingbat dec":"99","Dingbat hex":"63","Unicode dec":"128613","Unicode hex":"1F665"},{"Typeface name":"Wingdings 2","Dingbat dec":"100","Dingbat hex":"64","Unicode dec":"128615","Unicode hex":"1F667"},{"Typeface name":"Wingdings 2","Dingbat dec":"101","Dingbat hex":"65","Unicode dec":"128602","Unicode hex":"1F65A"},{"Typeface name":"Wingdings 2","Dingbat dec":"102","Dingbat hex":"66","Unicode dec":"128600","Unicode hex":"1F658"},{"Typeface name":"Wingdings 2","Dingbat dec":"103","Dingbat hex":"67","Unicode dec":"128601","Unicode hex":"1F659"},{"Typeface name":"Wingdings 2","Dingbat dec":"104","Dingbat hex":"68","Unicode dec":"128603","Unicode hex":"1F65B"},{"Typeface name":"Wingdings 2","Dingbat dec":"105","Dingbat hex":"69","Unicode dec":"9450","Unicode hex":"24EA"},{"Typeface name":"Wingdings 2","Dingbat dec":"106","Dingbat hex":"6A","Unicode dec":"9312","Unicode hex":"2460"},{"Typeface name":"Wingdings 2","Dingbat dec":"107","Dingbat hex":"6B","Unicode dec":"9313","Unicode hex":"2461"},{"Typeface name":"Wingdings 2","Dingbat dec":"108","Dingbat hex":"6C","Unicode dec":"9314","Unicode hex":"2462"},{"Typeface name":"Wingdings 2","Dingbat dec":"109","Dingbat hex":"6D","Unicode dec":"9315","Unicode hex":"2463"},{"Typeface name":"Wingdings 2","Dingbat dec":"110","Dingbat hex":"6E","Unicode dec":"9316","Unicode hex":"2464"},{"Typeface name":"Wingdings 2","Dingbat dec":"111","Dingbat hex":"6F","Unicode dec":"9317","Unicode hex":"2465"},{"Typeface name":"Wingdings 2","Dingbat dec":"112","Dingbat hex":"70","Unicode dec":"9318","Unicode hex":"2466"},{"Typeface name":"Wingdings 2","Dingbat dec":"113","Dingbat hex":"71","Unicode dec":"9319","Unicode hex":"2467"},{"Typeface name":"Wingdings 2","Dingbat dec":"114","Dingbat hex":"72","Unicode dec":"9320","Unicode hex":"2468"},{"Typeface name":"Wingdings 2","Dingbat dec":"115","Dingbat hex":"73","Unicode dec":"9321","Unicode hex":"2469"},{"Typeface name":"Wingdings 2","Dingbat dec":"116","Dingbat hex":"74","Unicode dec":"9471","Unicode hex":"24FF"},{"Typeface name":"Wingdings 2","Dingbat dec":"117","Dingbat hex":"75","Unicode dec":"10102","Unicode hex":"2776"},{"Typeface name":"Wingdings 2","Dingbat dec":"118","Dingbat hex":"76","Unicode dec":"10103","Unicode hex":"2777"},{"Typeface name":"Wingdings 2","Dingbat dec":"119","Dingbat hex":"77","Unicode dec":"10104","Unicode hex":"2778"},{"Typeface name":"Wingdings 2","Dingbat dec":"120","Dingbat hex":"78","Unicode dec":"10105","Unicode hex":"2779"},{"Typeface name":"Wingdings 2","Dingbat dec":"121","Dingbat hex":"79","Unicode dec":"10106","Unicode hex":"277A"},{"Typeface name":"Wingdings 2","Dingbat dec":"122","Dingbat hex":"7A","Unicode dec":"10107","Unicode hex":"277B"},{"Typeface name":"Wingdings 2","Dingbat dec":"123","Dingbat hex":"7B","Unicode dec":"10108","Unicode hex":"277C"},{"Typeface name":"Wingdings 2","Dingbat dec":"124","Dingbat hex":"7C","Unicode dec":"10109","Unicode hex":"277D"},{"Typeface name":"Wingdings 2","Dingbat dec":"125","Dingbat hex":"7D","Unicode dec":"10110","Unicode hex":"277E"},{"Typeface name":"Wingdings 2","Dingbat dec":"126","Dingbat hex":"7E","Unicode dec":"10111","Unicode hex":"277F"},{"Typeface name":"Wingdings 2","Dingbat dec":"128","Dingbat hex":"80","Unicode dec":"9737","Unicode hex":"2609"},{"Typeface name":"Wingdings 2","Dingbat dec":"129","Dingbat hex":"81","Unicode dec":"127765","Unicode hex":"1F315"},{"Typeface name":"Wingdings 2","Dingbat dec":"130","Dingbat hex":"82","Unicode dec":"9789","Unicode hex":"263D"},{"Typeface name":"Wingdings 2","Dingbat dec":"131","Dingbat hex":"83","Unicode dec":"9790","Unicode hex":"263E"},{"Typeface name":"Wingdings 2","Dingbat dec":"132","Dingbat hex":"84","Unicode dec":"11839","Unicode hex":"2E3F"},{"Typeface name":"Wingdings 2","Dingbat dec":"133","Dingbat hex":"85","Unicode dec":"10013","Unicode hex":"271D"},{"Typeface name":"Wingdings 2","Dingbat dec":"134","Dingbat hex":"86","Unicode dec":"128327","Unicode hex":"1F547"},{"Typeface name":"Wingdings 2","Dingbat dec":"135","Dingbat hex":"87","Unicode dec":"128348","Unicode hex":"1F55C"},{"Typeface name":"Wingdings 2","Dingbat dec":"136","Dingbat hex":"88","Unicode dec":"128349","Unicode hex":"1F55D"},{"Typeface name":"Wingdings 2","Dingbat dec":"137","Dingbat hex":"89","Unicode dec":"128350","Unicode hex":"1F55E"},{"Typeface name":"Wingdings 2","Dingbat dec":"138","Dingbat hex":"8A","Unicode dec":"128351","Unicode hex":"1F55F"},{"Typeface name":"Wingdings 2","Dingbat dec":"139","Dingbat hex":"8B","Unicode dec":"128352","Unicode hex":"1F560"},{"Typeface name":"Wingdings 2","Dingbat dec":"140","Dingbat hex":"8C","Unicode dec":"128353","Unicode hex":"1F561"},{"Typeface name":"Wingdings 2","Dingbat dec":"141","Dingbat hex":"8D","Unicode dec":"128354","Unicode hex":"1F562"},{"Typeface name":"Wingdings 2","Dingbat dec":"142","Dingbat hex":"8E","Unicode dec":"128355","Unicode hex":"1F563"},{"Typeface name":"Wingdings 2","Dingbat dec":"143","Dingbat hex":"8F","Unicode dec":"128356","Unicode hex":"1F564"},{"Typeface name":"Wingdings 2","Dingbat dec":"144","Dingbat hex":"90","Unicode dec":"128357","Unicode hex":"1F565"},{"Typeface name":"Wingdings 2","Dingbat dec":"145","Dingbat hex":"91","Unicode dec":"128358","Unicode hex":"1F566"},{"Typeface name":"Wingdings 2","Dingbat dec":"146","Dingbat hex":"92","Unicode dec":"128359","Unicode hex":"1F567"},{"Typeface name":"Wingdings 2","Dingbat dec":"147","Dingbat hex":"93","Unicode dec":"128616","Unicode hex":"1F668"},{"Typeface name":"Wingdings 2","Dingbat dec":"148","Dingbat hex":"94","Unicode dec":"128617","Unicode hex":"1F669"},{"Typeface name":"Wingdings 2","Dingbat dec":"149","Dingbat hex":"95","Unicode dec":"8901","Unicode hex":"22C5"},{"Typeface name":"Wingdings 2","Dingbat dec":"150","Dingbat hex":"96","Unicode dec":"128900","Unicode hex":"1F784"},{"Typeface name":"Wingdings 2","Dingbat dec":"151","Dingbat hex":"97","Unicode dec":"10625","Unicode hex":"2981"},{"Typeface name":"Wingdings 2","Dingbat dec":"152","Dingbat hex":"98","Unicode dec":"9679","Unicode hex":"25CF"},{"Typeface name":"Wingdings 2","Dingbat dec":"153","Dingbat hex":"99","Unicode dec":"9675","Unicode hex":"25CB"},{"Typeface name":"Wingdings 2","Dingbat dec":"154","Dingbat hex":"9A","Unicode dec":"128901","Unicode hex":"1F785"},{"Typeface name":"Wingdings 2","Dingbat dec":"155","Dingbat hex":"9B","Unicode dec":"128903","Unicode hex":"1F787"},{"Typeface name":"Wingdings 2","Dingbat dec":"156","Dingbat hex":"9C","Unicode dec":"128905","Unicode hex":"1F789"},{"Typeface name":"Wingdings 2","Dingbat dec":"157","Dingbat hex":"9D","Unicode dec":"8857","Unicode hex":"2299"},{"Typeface name":"Wingdings 2","Dingbat dec":"158","Dingbat hex":"9E","Unicode dec":"10687","Unicode hex":"29BF"},{"Typeface name":"Wingdings 2","Dingbat dec":"159","Dingbat hex":"9F","Unicode dec":"128908","Unicode hex":"1F78C"},{"Typeface name":"Wingdings 2","Dingbat dec":"160","Dingbat hex":"A0","Unicode dec":"128909","Unicode hex":"1F78D"},{"Typeface name":"Wingdings 2","Dingbat dec":"161","Dingbat hex":"A1","Unicode dec":"9726","Unicode hex":"25FE"},{"Typeface name":"Wingdings 2","Dingbat dec":"162","Dingbat hex":"A2","Unicode dec":"9632","Unicode hex":"25A0"},{"Typeface name":"Wingdings 2","Dingbat dec":"163","Dingbat hex":"A3","Unicode dec":"9633","Unicode hex":"25A1"},{"Typeface name":"Wingdings 2","Dingbat dec":"164","Dingbat hex":"A4","Unicode dec":"128913","Unicode hex":"1F791"},{"Typeface name":"Wingdings 2","Dingbat dec":"165","Dingbat hex":"A5","Unicode dec":"128914","Unicode hex":"1F792" -},{"Typeface name":"Wingdings 2","Dingbat dec":"166","Dingbat hex":"A6","Unicode dec":"128915","Unicode hex":"1F793"},{"Typeface name":"Wingdings 2","Dingbat dec":"167","Dingbat hex":"A7","Unicode dec":"128916","Unicode hex":"1F794"},{"Typeface name":"Wingdings 2","Dingbat dec":"168","Dingbat hex":"A8","Unicode dec":"9635","Unicode hex":"25A3"},{"Typeface name":"Wingdings 2","Dingbat dec":"169","Dingbat hex":"A9","Unicode dec":"128917","Unicode hex":"1F795"},{"Typeface name":"Wingdings 2","Dingbat dec":"170","Dingbat hex":"AA","Unicode dec":"128918","Unicode hex":"1F796"},{"Typeface name":"Wingdings 2","Dingbat dec":"171","Dingbat hex":"AB","Unicode dec":"128919","Unicode hex":"1F797"},{"Typeface name":"Wingdings 2","Dingbat dec":"172","Dingbat hex":"AC","Unicode dec":"128920","Unicode hex":"1F798"},{"Typeface name":"Wingdings 2","Dingbat dec":"173","Dingbat hex":"AD","Unicode dec":"11049","Unicode hex":"2B29"},{"Typeface name":"Wingdings 2","Dingbat dec":"174","Dingbat hex":"AE","Unicode dec":"11045","Unicode hex":"2B25"},{"Typeface name":"Wingdings 2","Dingbat dec":"175","Dingbat hex":"AF","Unicode dec":"9671","Unicode hex":"25C7"},{"Typeface name":"Wingdings 2","Dingbat dec":"176","Dingbat hex":"B0","Unicode dec":"128922","Unicode hex":"1F79A"},{"Typeface name":"Wingdings 2","Dingbat dec":"177","Dingbat hex":"B1","Unicode dec":"9672","Unicode hex":"25C8"},{"Typeface name":"Wingdings 2","Dingbat dec":"178","Dingbat hex":"B2","Unicode dec":"128923","Unicode hex":"1F79B"},{"Typeface name":"Wingdings 2","Dingbat dec":"179","Dingbat hex":"B3","Unicode dec":"128924","Unicode hex":"1F79C"},{"Typeface name":"Wingdings 2","Dingbat dec":"180","Dingbat hex":"B4","Unicode dec":"128925","Unicode hex":"1F79D"},{"Typeface name":"Wingdings 2","Dingbat dec":"181","Dingbat hex":"B5","Unicode dec":"128926","Unicode hex":"1F79E"},{"Typeface name":"Wingdings 2","Dingbat dec":"182","Dingbat hex":"B6","Unicode dec":"11050","Unicode hex":"2B2A"},{"Typeface name":"Wingdings 2","Dingbat dec":"183","Dingbat hex":"B7","Unicode dec":"11047","Unicode hex":"2B27"},{"Typeface name":"Wingdings 2","Dingbat dec":"184","Dingbat hex":"B8","Unicode dec":"9674","Unicode hex":"25CA"},{"Typeface name":"Wingdings 2","Dingbat dec":"185","Dingbat hex":"B9","Unicode dec":"128928","Unicode hex":"1F7A0"},{"Typeface name":"Wingdings 2","Dingbat dec":"186","Dingbat hex":"BA","Unicode dec":"9686","Unicode hex":"25D6"},{"Typeface name":"Wingdings 2","Dingbat dec":"187","Dingbat hex":"BB","Unicode dec":"9687","Unicode hex":"25D7"},{"Typeface name":"Wingdings 2","Dingbat dec":"188","Dingbat hex":"BC","Unicode dec":"11210","Unicode hex":"2BCA"},{"Typeface name":"Wingdings 2","Dingbat dec":"189","Dingbat hex":"BD","Unicode dec":"11211","Unicode hex":"2BCB"},{"Typeface name":"Wingdings 2","Dingbat dec":"190","Dingbat hex":"BE","Unicode dec":"11200","Unicode hex":"2BC0"},{"Typeface name":"Wingdings 2","Dingbat dec":"191","Dingbat hex":"BF","Unicode dec":"11201","Unicode hex":"2BC1"},{"Typeface name":"Wingdings 2","Dingbat dec":"192","Dingbat hex":"C0","Unicode dec":"11039","Unicode hex":"2B1F"},{"Typeface name":"Wingdings 2","Dingbat dec":"193","Dingbat hex":"C1","Unicode dec":"11202","Unicode hex":"2BC2"},{"Typeface name":"Wingdings 2","Dingbat dec":"194","Dingbat hex":"C2","Unicode dec":"11043","Unicode hex":"2B23"},{"Typeface name":"Wingdings 2","Dingbat dec":"195","Dingbat hex":"C3","Unicode dec":"11042","Unicode hex":"2B22"},{"Typeface name":"Wingdings 2","Dingbat dec":"196","Dingbat hex":"C4","Unicode dec":"11203","Unicode hex":"2BC3"},{"Typeface name":"Wingdings 2","Dingbat dec":"197","Dingbat hex":"C5","Unicode dec":"11204","Unicode hex":"2BC4"},{"Typeface name":"Wingdings 2","Dingbat dec":"198","Dingbat hex":"C6","Unicode dec":"128929","Unicode hex":"1F7A1"},{"Typeface name":"Wingdings 2","Dingbat dec":"199","Dingbat hex":"C7","Unicode dec":"128930","Unicode hex":"1F7A2"},{"Typeface name":"Wingdings 2","Dingbat dec":"200","Dingbat hex":"C8","Unicode dec":"128931","Unicode hex":"1F7A3"},{"Typeface name":"Wingdings 2","Dingbat dec":"201","Dingbat hex":"C9","Unicode dec":"128932","Unicode hex":"1F7A4"},{"Typeface name":"Wingdings 2","Dingbat dec":"202","Dingbat hex":"CA","Unicode dec":"128933","Unicode hex":"1F7A5"},{"Typeface name":"Wingdings 2","Dingbat dec":"203","Dingbat hex":"CB","Unicode dec":"128934","Unicode hex":"1F7A6"},{"Typeface name":"Wingdings 2","Dingbat dec":"204","Dingbat hex":"CC","Unicode dec":"128935","Unicode hex":"1F7A7"},{"Typeface name":"Wingdings 2","Dingbat dec":"205","Dingbat hex":"CD","Unicode dec":"128936","Unicode hex":"1F7A8"},{"Typeface name":"Wingdings 2","Dingbat dec":"206","Dingbat hex":"CE","Unicode dec":"128937","Unicode hex":"1F7A9"},{"Typeface name":"Wingdings 2","Dingbat dec":"207","Dingbat hex":"CF","Unicode dec":"128938","Unicode hex":"1F7AA"},{"Typeface name":"Wingdings 2","Dingbat dec":"208","Dingbat hex":"D0","Unicode dec":"128939","Unicode hex":"1F7AB"},{"Typeface name":"Wingdings 2","Dingbat dec":"209","Dingbat hex":"D1","Unicode dec":"128940","Unicode hex":"1F7AC"},{"Typeface name":"Wingdings 2","Dingbat dec":"210","Dingbat hex":"D2","Unicode dec":"128941","Unicode hex":"1F7AD"},{"Typeface name":"Wingdings 2","Dingbat dec":"211","Dingbat hex":"D3","Unicode dec":"128942","Unicode hex":"1F7AE"},{"Typeface name":"Wingdings 2","Dingbat dec":"212","Dingbat hex":"D4","Unicode dec":"128943","Unicode hex":"1F7AF"},{"Typeface name":"Wingdings 2","Dingbat dec":"213","Dingbat hex":"D5","Unicode dec":"128944","Unicode hex":"1F7B0"},{"Typeface name":"Wingdings 2","Dingbat dec":"214","Dingbat hex":"D6","Unicode dec":"128945","Unicode hex":"1F7B1"},{"Typeface name":"Wingdings 2","Dingbat dec":"215","Dingbat hex":"D7","Unicode dec":"128946","Unicode hex":"1F7B2"},{"Typeface name":"Wingdings 2","Dingbat dec":"216","Dingbat hex":"D8","Unicode dec":"128947","Unicode hex":"1F7B3"},{"Typeface name":"Wingdings 2","Dingbat dec":"217","Dingbat hex":"D9","Unicode dec":"128948","Unicode hex":"1F7B4"},{"Typeface name":"Wingdings 2","Dingbat dec":"218","Dingbat hex":"DA","Unicode dec":"128949","Unicode hex":"1F7B5"},{"Typeface name":"Wingdings 2","Dingbat dec":"219","Dingbat hex":"DB","Unicode dec":"128950","Unicode hex":"1F7B6"},{"Typeface name":"Wingdings 2","Dingbat dec":"220","Dingbat hex":"DC","Unicode dec":"128951","Unicode hex":"1F7B7"},{"Typeface name":"Wingdings 2","Dingbat dec":"221","Dingbat hex":"DD","Unicode dec":"128952","Unicode hex":"1F7B8"},{"Typeface name":"Wingdings 2","Dingbat dec":"222","Dingbat hex":"DE","Unicode dec":"128953","Unicode hex":"1F7B9"},{"Typeface name":"Wingdings 2","Dingbat dec":"223","Dingbat hex":"DF","Unicode dec":"128954","Unicode hex":"1F7BA"},{"Typeface name":"Wingdings 2","Dingbat dec":"224","Dingbat hex":"E0","Unicode dec":"128955","Unicode hex":"1F7BB"},{"Typeface name":"Wingdings 2","Dingbat dec":"225","Dingbat hex":"E1","Unicode dec":"128956","Unicode hex":"1F7BC"},{"Typeface name":"Wingdings 2","Dingbat dec":"226","Dingbat hex":"E2","Unicode dec":"128957","Unicode hex":"1F7BD"},{"Typeface name":"Wingdings 2","Dingbat dec":"227","Dingbat hex":"E3","Unicode dec":"128958","Unicode hex":"1F7BE"},{"Typeface name":"Wingdings 2","Dingbat dec":"228","Dingbat hex":"E4","Unicode dec":"128959","Unicode hex":"1F7BF"},{"Typeface name":"Wingdings 2","Dingbat dec":"229","Dingbat hex":"E5","Unicode dec":"128960","Unicode hex":"1F7C0"},{"Typeface name":"Wingdings 2","Dingbat dec":"230","Dingbat hex":"E6","Unicode dec":"128962","Unicode hex":"1F7C2"},{"Typeface name":"Wingdings 2","Dingbat dec":"231","Dingbat hex":"E7","Unicode dec":"128964","Unicode hex":"1F7C4"},{"Typeface name":"Wingdings 2","Dingbat dec":"232","Dingbat hex":"E8","Unicode dec":"128966","Unicode hex":"1F7C6"},{"Typeface name":"Wingdings 2","Dingbat dec":"233","Dingbat hex":"E9","Unicode dec":"128969","Unicode hex":"1F7C9"},{"Typeface name":"Wingdings 2","Dingbat dec":"234","Dingbat hex":"EA","Unicode dec":"128970","Unicode hex":"1F7CA"},{"Typeface name":"Wingdings 2","Dingbat dec":"235","Dingbat hex":"EB","Unicode dec":"10038","Unicode hex":"2736"},{"Typeface name":"Wingdings 2","Dingbat dec":"236","Dingbat hex":"EC","Unicode dec":"128972","Unicode hex":"1F7CC"},{"Typeface name":"Wingdings 2","Dingbat dec":"237","Dingbat hex":"ED","Unicode dec":"128974","Unicode hex":"1F7CE"},{"Typeface name":"Wingdings 2","Dingbat dec":"238","Dingbat hex":"EE","Unicode dec":"128976","Unicode hex":"1F7D0"},{"Typeface name":"Wingdings 2","Dingbat dec":"239","Dingbat hex":"EF","Unicode dec":"128978","Unicode hex":"1F7D2"},{"Typeface name":"Wingdings 2","Dingbat dec":"240","Dingbat hex":"F0","Unicode dec":"10041","Unicode hex":"2739"},{"Typeface name":"Wingdings 2","Dingbat dec":"241","Dingbat hex":"F1","Unicode dec":"128963","Unicode hex":"1F7C3"},{"Typeface name":"Wingdings 2","Dingbat dec":"242","Dingbat hex":"F2","Unicode dec":"128967","Unicode hex":"1F7C7"},{"Typeface name":"Wingdings 2","Dingbat dec":"243","Dingbat hex":"F3","Unicode dec":"10031","Unicode hex":"272F"},{"Typeface name":"Wingdings 2","Dingbat dec":"244","Dingbat hex":"F4","Unicode dec":"128973","Unicode hex":"1F7CD"},{"Typeface name":"Wingdings 2","Dingbat dec":"245","Dingbat hex":"F5","Unicode dec":"128980","Unicode hex":"1F7D4"},{"Typeface name":"Wingdings 2","Dingbat dec":"246","Dingbat hex":"F6","Unicode dec":"11212","Unicode hex":"2BCC"},{"Typeface name":"Wingdings 2","Dingbat dec":"247","Dingbat hex":"F7","Unicode dec":"11213","Unicode hex":"2BCD"},{"Typeface name":"Wingdings 2","Dingbat dec":"248","Dingbat hex":"F8","Unicode dec":"8251","Unicode hex":"203B"},{"Typeface name":"Wingdings 2","Dingbat dec":"249","Dingbat hex":"F9","Unicode dec":"8258","Unicode hex":"2042"},{"Typeface name":"Wingdings 3","Dingbat dec":"32","Dingbat hex":"20","Unicode dec":"32","Unicode hex":"20"},{"Typeface name":"Wingdings 3","Dingbat dec":"33","Dingbat hex":"21","Unicode dec":"11104","Unicode hex":"2B60"},{"Typeface name":"Wingdings 3","Dingbat dec":"34","Dingbat hex":"22","Unicode dec":"11106","Unicode hex":"2B62"},{"Typeface name":"Wingdings 3","Dingbat dec":"35","Dingbat hex":"23","Unicode dec":"11105","Unicode hex":"2B61"},{"Typeface name":"Wingdings 3","Dingbat dec":"36","Dingbat hex":"24","Unicode dec":"11107","Unicode hex":"2B63"},{"Typeface name":"Wingdings 3","Dingbat dec":"37","Dingbat hex":"25","Unicode dec":"11110","Unicode hex":"2B66"},{"Typeface name":"Wingdings 3","Dingbat dec":"38","Dingbat hex":"26","Unicode dec":"11111","Unicode hex":"2B67"},{"Typeface name":"Wingdings 3","Dingbat dec":"39","Dingbat hex":"27","Unicode dec":"11113","Unicode hex":"2B69"},{"Typeface name":"Wingdings 3","Dingbat dec":"40","Dingbat hex":"28","Unicode dec":"11112","Unicode hex":"2B68"},{"Typeface name":"Wingdings 3","Dingbat dec":"41","Dingbat hex":"29","Unicode dec":"11120","Unicode hex":"2B70"},{"Typeface name":"Wingdings 3","Dingbat dec":"42","Dingbat hex":"2A","Unicode dec":"11122","Unicode hex":"2B72"},{"Typeface name":"Wingdings 3","Dingbat dec":"43","Dingbat hex":"2B","Unicode dec":"11121","Unicode hex":"2B71"},{"Typeface name":"Wingdings 3","Dingbat dec":"44","Dingbat hex":"2C","Unicode dec":"11123","Unicode hex":"2B73"},{"Typeface name":"Wingdings 3","Dingbat dec":"45","Dingbat hex":"2D","Unicode dec":"11126","Unicode hex":"2B76"},{"Typeface name":"Wingdings 3","Dingbat dec":"46","Dingbat hex":"2E","Unicode dec":"11128","Unicode hex":"2B78"},{"Typeface name":"Wingdings 3","Dingbat dec":"47","Dingbat hex":"2F","Unicode dec":"11131","Unicode hex":"2B7B"},{"Typeface name":"Wingdings 3","Dingbat dec":"48","Dingbat hex":"30","Unicode dec":"11133","Unicode hex":"2B7D"},{"Typeface name":"Wingdings 3","Dingbat dec":"49","Dingbat hex":"31","Unicode dec":"11108","Unicode hex":"2B64"},{"Typeface name":"Wingdings 3","Dingbat dec":"50","Dingbat hex":"32","Unicode dec":"11109","Unicode hex":"2B65"},{"Typeface name":"Wingdings 3","Dingbat dec":"51","Dingbat hex":"33","Unicode dec":"11114","Unicode hex":"2B6A"},{"Typeface name":"Wingdings 3","Dingbat dec":"52","Dingbat hex":"34","Unicode dec":"11116","Unicode hex":"2B6C"},{"Typeface name":"Wingdings 3","Dingbat dec":"53","Dingbat hex":"35","Unicode dec":"11115","Unicode hex":"2B6B"},{"Typeface name":"Wingdings 3","Dingbat dec":"54","Dingbat hex":"36","Unicode dec":"11117","Unicode hex":"2B6D"},{"Typeface name":"Wingdings 3","Dingbat dec":"55","Dingbat hex":"37","Unicode dec":"11085","Unicode hex":"2B4D"},{"Typeface name":"Wingdings 3","Dingbat dec":"56","Dingbat hex":"38","Unicode dec":"11168","Unicode hex":"2BA0"},{"Typeface name":"Wingdings 3","Dingbat dec":"57","Dingbat hex":"39","Unicode dec":"11169","Unicode hex":"2BA1"},{"Typeface name":"Wingdings 3","Dingbat dec":"58","Dingbat hex":"3A","Unicode dec":"11170","Unicode hex":"2BA2"},{"Typeface name":"Wingdings 3","Dingbat dec":"59","Dingbat hex":"3B","Unicode dec":"11171","Unicode hex":"2BA3"},{"Typeface name":"Wingdings 3","Dingbat dec":"60","Dingbat hex":"3C","Unicode dec":"11172","Unicode hex":"2BA4"},{"Typeface name":"Wingdings 3","Dingbat dec":"61","Dingbat hex":"3D","Unicode dec":"11173","Unicode hex":"2BA5"},{"Typeface name":"Wingdings 3","Dingbat dec":"62","Dingbat hex":"3E","Unicode dec":"11174","Unicode hex":"2BA6"},{"Typeface name":"Wingdings 3","Dingbat dec":"63","Dingbat hex":"3F","Unicode dec":"11175","Unicode hex":"2BA7"},{"Typeface name":"Wingdings 3","Dingbat dec":"64","Dingbat hex":"40","Unicode dec":"11152","Unicode hex":"2B90"},{"Typeface name":"Wingdings 3","Dingbat dec":"65","Dingbat hex":"41","Unicode dec":"11153","Unicode hex":"2B91"},{"Typeface name":"Wingdings 3","Dingbat dec":"66","Dingbat hex":"42","Unicode dec":"11154","Unicode hex":"2B92"},{"Typeface name":"Wingdings 3","Dingbat dec":"67","Dingbat hex":"43","Unicode dec":"11155","Unicode hex":"2B93"},{"Typeface name":"Wingdings 3","Dingbat dec":"68","Dingbat hex":"44","Unicode dec":"11136","Unicode hex":"2B80"},{"Typeface name":"Wingdings 3","Dingbat dec":"69","Dingbat hex":"45","Unicode dec":"11139","Unicode hex":"2B83"},{"Typeface name":"Wingdings 3","Dingbat dec":"70","Dingbat hex":"46","Unicode dec":"11134","Unicode hex":"2B7E"},{"Typeface name":"Wingdings 3","Dingbat dec":"71","Dingbat hex":"47","Unicode dec":"11135","Unicode hex":"2B7F"},{"Typeface name":"Wingdings 3","Dingbat dec":"72","Dingbat hex":"48","Unicode dec":"11140","Unicode hex":"2B84"},{"Typeface name":"Wingdings 3","Dingbat dec":"73","Dingbat hex":"49","Unicode dec":"11142","Unicode hex":"2B86"},{"Typeface name":"Wingdings 3","Dingbat dec":"74","Dingbat hex":"4A","Unicode dec":"11141","Unicode hex":"2B85"},{"Typeface name":"Wingdings 3","Dingbat dec":"75","Dingbat hex":"4B","Unicode dec":"11143","Unicode hex":"2B87"},{"Typeface name":"Wingdings 3","Dingbat dec":"76","Dingbat hex":"4C","Unicode dec":"11151","Unicode hex":"2B8F"},{"Typeface name":"Wingdings 3","Dingbat dec":"77","Dingbat hex":"4D","Unicode dec":"11149","Unicode hex":"2B8D"},{"Typeface name":"Wingdings 3","Dingbat dec":"78","Dingbat hex":"4E","Unicode dec":"11150","Unicode hex":"2B8E"},{"Typeface name":"Wingdings 3","Dingbat dec":"79","Dingbat hex":"4F","Unicode dec":"11148","Unicode hex":"2B8C"},{"Typeface name":"Wingdings 3","Dingbat dec":"80","Dingbat hex":"50","Unicode dec":"11118","Unicode hex":"2B6E"},{"Typeface name":"Wingdings 3","Dingbat dec":"81","Dingbat hex":"51","Unicode dec":"11119","Unicode hex":"2B6F"},{"Typeface name":"Wingdings 3","Dingbat dec":"82","Dingbat hex":"52","Unicode dec":"9099","Unicode hex":"238B"},{"Typeface name":"Wingdings 3","Dingbat dec":"83","Dingbat hex":"53","Unicode dec":"8996","Unicode hex":"2324"},{"Typeface name":"Wingdings 3","Dingbat dec":"84","Dingbat hex":"54","Unicode dec":"8963","Unicode hex":"2303"},{"Typeface name":"Wingdings 3","Dingbat dec":"85","Dingbat hex":"55","Unicode dec":"8997","Unicode hex":"2325"},{"Typeface name":"Wingdings 3","Dingbat dec":"86","Dingbat hex":"56","Unicode dec":"9251","Unicode hex":"2423"},{"Typeface name":"Wingdings 3","Dingbat dec":"87","Dingbat hex":"57","Unicode dec":"9085","Unicode hex":"237D"},{"Typeface name":"Wingdings 3","Dingbat dec":"88","Dingbat hex":"58","Unicode dec":"8682","Unicode hex":"21EA"},{"Typeface name":"Wingdings 3","Dingbat dec":"89","Dingbat hex":"59","Unicode dec":"11192","Unicode hex":"2BB8"},{"Typeface name":"Wingdings 3","Dingbat dec":"90","Dingbat hex":"5A","Unicode dec":"129184","Unicode hex":"1F8A0"},{"Typeface name":"Wingdings 3","Dingbat dec":"91","Dingbat hex":"5B","Unicode dec":"129185","Unicode hex":"1F8A1"},{"Typeface name":"Wingdings 3","Dingbat dec":"92","Dingbat hex":"5C","Unicode dec":"129186","Unicode hex":"1F8A2"},{"Typeface name":"Wingdings 3","Dingbat dec":"93","Dingbat hex":"5D","Unicode dec":"129187","Unicode hex":"1F8A3"},{"Typeface name":"Wingdings 3","Dingbat dec":"94","Dingbat hex":"5E","Unicode dec":"129188","Unicode hex":"1F8A4"},{"Typeface name":"Wingdings 3","Dingbat dec":"95","Dingbat hex":"5F","Unicode dec":"129189","Unicode hex":"1F8A5"},{"Typeface name":"Wingdings 3","Dingbat dec":"96","Dingbat hex":"60","Unicode dec":"129190","Unicode hex":"1F8A6"},{"Typeface name":"Wingdings 3","Dingbat dec":"97","Dingbat hex":"61","Unicode dec":"129191","Unicode hex":"1F8A7"},{"Typeface name":"Wingdings 3","Dingbat dec":"98","Dingbat hex":"62","Unicode dec":"129192","Unicode hex":"1F8A8"},{"Typeface name":"Wingdings 3","Dingbat dec":"99","Dingbat hex":"63","Unicode dec":"129193","Unicode hex":"1F8A9"},{"Typeface name":"Wingdings 3","Dingbat dec":"100","Dingbat hex":"64","Unicode dec":"129194","Unicode hex":"1F8AA"},{"Typeface name":"Wingdings 3","Dingbat dec":"101","Dingbat hex":"65","Unicode dec":"129195","Unicode hex":"1F8AB"},{"Typeface name":"Wingdings 3","Dingbat dec":"102","Dingbat hex":"66","Unicode dec":"129104","Unicode hex":"1F850"},{"Typeface name":"Wingdings 3","Dingbat dec":"103","Dingbat hex":"67","Unicode dec":"129106","Unicode hex":"1F852"},{"Typeface name":"Wingdings 3","Dingbat dec":"104","Dingbat hex":"68","Unicode dec":"129105","Unicode hex":"1F851"},{"Typeface name":"Wingdings 3","Dingbat dec":"105","Dingbat hex":"69","Unicode dec":"129107","Unicode hex":"1F853"},{"Typeface name":"Wingdings 3","Dingbat dec":"106","Dingbat hex":"6A","Unicode dec":"129108","Unicode hex":"1F854"},{"Typeface name":"Wingdings 3","Dingbat dec":"107","Dingbat hex":"6B","Unicode dec":"129109","Unicode hex":"1F855"},{"Typeface name":"Wingdings 3","Dingbat dec":"108","Dingbat hex":"6C","Unicode dec":"129111","Unicode hex":"1F857"},{"Typeface name":"Wingdings 3","Dingbat dec":"109","Dingbat hex":"6D","Unicode dec":"129110","Unicode hex":"1F856"},{"Typeface name":"Wingdings 3","Dingbat dec":"110","Dingbat hex":"6E","Unicode dec":"129112","Unicode hex":"1F858"},{"Typeface name":"Wingdings 3","Dingbat dec":"111","Dingbat hex":"6F","Unicode dec":"129113","Unicode hex":"1F859"},{"Typeface name":"Wingdings 3","Dingbat dec":"112","Dingbat hex":"70","Unicode dec":"9650","Unicode hex":"25B2"},{"Typeface name":"Wingdings 3","Dingbat dec":"113","Dingbat hex":"71","Unicode dec":"9660","Unicode hex":"25BC"},{"Typeface name":"Wingdings 3","Dingbat dec":"114","Dingbat hex":"72","Unicode dec":"9651","Unicode hex":"25B3"},{"Typeface name":"Wingdings 3","Dingbat dec":"115","Dingbat hex":"73","Unicode dec":"9661","Unicode hex":"25BD"},{"Typeface name":"Wingdings 3","Dingbat dec":"116","Dingbat hex":"74","Unicode dec":"9664","Unicode hex":"25C0"},{"Typeface name":"Wingdings 3","Dingbat dec":"117","Dingbat hex":"75","Unicode dec":"9654","Unicode hex":"25B6"},{"Typeface name":"Wingdings 3","Dingbat dec":"118","Dingbat hex":"76","Unicode dec":"9665","Unicode hex":"25C1"},{"Typeface name":"Wingdings 3","Dingbat dec":"119","Dingbat hex":"77","Unicode dec":"9655","Unicode hex":"25B7"},{"Typeface name":"Wingdings 3","Dingbat dec":"120","Dingbat hex":"78","Unicode dec":"9699","Unicode hex":"25E3"},{"Typeface name":"Wingdings 3","Dingbat dec":"121","Dingbat hex":"79","Unicode dec":"9698","Unicode hex":"25E2"},{"Typeface name":"Wingdings 3","Dingbat dec":"122","Dingbat hex":"7A","Unicode dec":"9700","Unicode hex":"25E4"},{"Typeface name":"Wingdings 3","Dingbat dec":"123","Dingbat hex":"7B","Unicode dec":"9701","Unicode hex":"25E5"},{"Typeface name":"Wingdings 3","Dingbat dec":"124","Dingbat hex":"7C","Unicode dec":"128896","Unicode hex":"1F780"},{"Typeface name":"Wingdings 3","Dingbat dec":"125","Dingbat hex":"7D","Unicode dec":"128898","Unicode hex":"1F782"},{"Typeface name":"Wingdings 3","Dingbat dec":"126","Dingbat hex":"7E","Unicode dec":"128897","Unicode hex":"1F781"},{"Typeface name":"Wingdings 3","Dingbat dec":"128","Dingbat hex":"80","Unicode dec":"128899","Unicode hex":"1F783"},{"Typeface name":"Wingdings 3","Dingbat dec":"129","Dingbat hex":"81","Unicode dec":"11205","Unicode hex":"2BC5"},{"Typeface name":"Wingdings 3","Dingbat dec":"130","Dingbat hex":"82","Unicode dec":"11206","Unicode hex":"2BC6"},{"Typeface name":"Wingdings 3","Dingbat dec":"131","Dingbat hex":"83","Unicode dec":"11207","Unicode hex":"2BC7"},{"Typeface name":"Wingdings 3","Dingbat dec":"132","Dingbat hex":"84","Unicode dec":"11208","Unicode hex":"2BC8"},{"Typeface name":"Wingdings 3","Dingbat dec":"133","Dingbat hex":"85","Unicode dec":"11164","Unicode hex":"2B9C"},{"Typeface name":"Wingdings 3","Dingbat dec":"134","Dingbat hex":"86","Unicode dec":"11166","Unicode hex":"2B9E"},{"Typeface name":"Wingdings 3","Dingbat dec":"135","Dingbat hex":"87","Unicode dec":"11165","Unicode hex":"2B9D"},{"Typeface name":"Wingdings 3","Dingbat dec":"136","Dingbat hex":"88","Unicode dec":"11167","Unicode hex":"2B9F"},{"Typeface name":"Wingdings 3","Dingbat dec":"137","Dingbat hex":"89","Unicode dec":"129040","Unicode hex":"1F810"},{"Typeface name":"Wingdings 3","Dingbat dec":"138","Dingbat hex":"8A","Unicode dec":"129042","Unicode hex":"1F812"},{"Typeface name":"Wingdings 3","Dingbat dec":"139","Dingbat hex":"8B","Unicode dec":"129041","Unicode hex":"1F811"},{"Typeface name":"Wingdings 3","Dingbat dec":"140","Dingbat hex":"8C","Unicode dec":"129043","Unicode hex":"1F813"},{"Typeface name":"Wingdings 3","Dingbat dec":"141","Dingbat hex":"8D","Unicode dec":"129044","Unicode hex":"1F814"},{"Typeface name":"Wingdings 3","Dingbat dec":"142","Dingbat hex":"8E","Unicode dec":"129046","Unicode hex":"1F816"},{"Typeface name":"Wingdings 3","Dingbat dec":"143","Dingbat hex":"8F","Unicode dec":"129045","Unicode hex":"1F815"},{"Typeface name":"Wingdings 3","Dingbat dec":"144","Dingbat hex":"90","Unicode dec":"129047","Unicode hex":"1F817"},{"Typeface name":"Wingdings 3","Dingbat dec":"145","Dingbat hex":"91","Unicode dec":"129048","Unicode hex":"1F818"},{"Typeface name":"Wingdings 3","Dingbat dec":"146","Dingbat hex":"92","Unicode dec":"129050","Unicode hex":"1F81A"},{"Typeface name":"Wingdings 3","Dingbat dec":"147","Dingbat hex":"93","Unicode dec":"129049","Unicode hex":"1F819"},{"Typeface name":"Wingdings 3","Dingbat dec":"148","Dingbat hex":"94","Unicode dec":"129051","Unicode hex":"1F81B"},{"Typeface name":"Wingdings 3","Dingbat dec":"149","Dingbat hex":"95","Unicode dec":"129052","Unicode hex":"1F81C"},{"Typeface name":"Wingdings 3","Dingbat dec":"150","Dingbat hex":"96","Unicode dec":"129054","Unicode hex":"1F81E"},{"Typeface name":"Wingdings 3","Dingbat dec":"151","Dingbat hex":"97","Unicode dec":"129053","Unicode hex":"1F81D"},{"Typeface name":"Wingdings 3","Dingbat dec":"152","Dingbat hex":"98","Unicode dec":"129055","Unicode hex":"1F81F"},{"Typeface name":"Wingdings 3","Dingbat dec":"153","Dingbat hex":"99","Unicode dec":"129024","Unicode hex":"1F800"},{"Typeface name":"Wingdings 3","Dingbat dec":"154","Dingbat hex":"9A","Unicode dec":"129026","Unicode hex":"1F802"},{"Typeface name":"Wingdings 3","Dingbat dec":"155","Dingbat hex":"9B","Unicode dec":"129025","Unicode hex":"1F801"},{"Typeface name":"Wingdings 3","Dingbat dec":"156","Dingbat hex":"9C","Unicode dec":"129027","Unicode hex":"1F803"},{"Typeface name":"Wingdings 3","Dingbat dec":"157","Dingbat hex":"9D","Unicode dec":"129028","Unicode hex":"1F804"},{"Typeface name":"Wingdings 3","Dingbat dec":"158","Dingbat hex":"9E","Unicode dec":"129030","Unicode hex":"1F806"},{"Typeface name":"Wingdings 3","Dingbat dec":"159","Dingbat hex":"9F","Unicode dec":"129029","Unicode hex":"1F805"},{"Typeface name":"Wingdings 3","Dingbat dec":"160","Dingbat hex":"A0","Unicode dec":"129031","Unicode hex":"1F807"},{"Typeface name":"Wingdings 3","Dingbat dec":"161","Dingbat hex":"A1","Unicode dec":"129032","Unicode hex":"1F808"},{"Typeface name":"Wingdings 3","Dingbat dec":"162","Dingbat hex":"A2","Unicode dec":"129034","Unicode hex":"1F80A"},{"Typeface name":"Wingdings 3","Dingbat dec":"163","Dingbat hex":"A3","Unicode dec":"129033","Unicode hex":"1F809"},{"Typeface name":"Wingdings 3","Dingbat dec":"164","Dingbat hex":"A4","Unicode dec":"129035","Unicode hex":"1F80B"},{"Typeface name":"Wingdings 3","Dingbat dec":"165","Dingbat hex":"A5","Unicode dec":"129056","Unicode hex":"1F820"},{"Typeface name":"Wingdings 3","Dingbat dec":"166","Dingbat hex":"A6","Unicode dec":"129058","Unicode hex":"1F822"},{"Typeface name":"Wingdings 3","Dingbat dec":"167","Dingbat hex":"A7","Unicode dec":"129060","Unicode hex":"1F824"},{"Typeface name":"Wingdings 3","Dingbat dec":"168","Dingbat hex":"A8","Unicode dec":"129062","Unicode hex":"1F826"},{"Typeface name":"Wingdings 3","Dingbat dec":"169","Dingbat hex":"A9","Unicode dec":"129064","Unicode hex":"1F828"},{"Typeface name":"Wingdings 3","Dingbat dec":"170","Dingbat hex":"AA","Unicode dec":"129066","Unicode hex":"1F82A"},{"Typeface name":"Wingdings 3","Dingbat dec":"171","Dingbat hex":"AB","Unicode dec":"129068","Unicode hex":"1F82C"},{"Typeface name":"Wingdings 3","Dingbat dec":"172","Dingbat hex":"AC","Unicode dec":"129180","Unicode hex":"1F89C"},{"Typeface name":"Wingdings 3","Dingbat dec":"173","Dingbat hex":"AD","Unicode dec":"129181","Unicode hex":"1F89D"},{"Typeface name":"Wingdings 3","Dingbat dec":"174","Dingbat hex":"AE","Unicode dec":"129182","Unicode hex":"1F89E"},{"Typeface name":"Wingdings 3","Dingbat dec":"175","Dingbat hex":"AF","Unicode dec":"129183","Unicode hex":"1F89F"},{"Typeface name":"Wingdings 3","Dingbat dec":"176","Dingbat hex":"B0","Unicode dec":"129070","Unicode hex":"1F82E"},{"Typeface name":"Wingdings 3","Dingbat dec":"177","Dingbat hex":"B1","Unicode dec":"129072","Unicode hex":"1F830"},{"Typeface name":"Wingdings 3","Dingbat dec":"178","Dingbat hex":"B2","Unicode dec":"129074","Unicode hex":"1F832"},{"Typeface name":"Wingdings 3","Dingbat dec":"179","Dingbat hex":"B3","Unicode dec":"129076","Unicode hex":"1F834"},{"Typeface name":"Wingdings 3","Dingbat dec":"180","Dingbat hex":"B4","Unicode dec":"129078","Unicode hex":"1F836"},{"Typeface name":"Wingdings 3","Dingbat dec":"181","Dingbat hex":"B5","Unicode dec":"129080","Unicode hex":"1F838"},{"Typeface name":"Wingdings 3","Dingbat dec":"182","Dingbat hex":"B6","Unicode dec":"129082","Unicode hex":"1F83A"},{"Typeface name":"Wingdings 3","Dingbat dec":"183","Dingbat hex":"B7","Unicode dec":"129081","Unicode hex":"1F839"},{"Typeface name":"Wingdings 3","Dingbat dec":"184","Dingbat hex":"B8","Unicode dec":"129083","Unicode hex":"1F83B"},{"Typeface name":"Wingdings 3","Dingbat dec":"185","Dingbat hex":"B9","Unicode dec":"129176","Unicode hex":"1F898"},{"Typeface name":"Wingdings 3","Dingbat dec":"186","Dingbat hex":"BA","Unicode dec":"129178","Unicode hex":"1F89A"},{"Typeface name":"Wingdings 3","Dingbat dec":"187","Dingbat hex":"BB","Unicode dec":"129177","Unicode hex":"1F899"},{"Typeface name":"Wingdings 3","Dingbat dec":"188","Dingbat hex":"BC","Unicode dec":"129179","Unicode hex":"1F89B"},{"Typeface name":"Wingdings 3","Dingbat dec":"189","Dingbat hex":"BD","Unicode dec":"129084","Unicode hex":"1F83C"},{"Typeface name":"Wingdings 3","Dingbat dec":"190","Dingbat hex":"BE","Unicode dec":"129086","Unicode hex":"1F83E"},{"Typeface name":"Wingdings 3","Dingbat dec":"191","Dingbat hex":"BF","Unicode dec":"129085","Unicode hex":"1F83D"},{"Typeface name":"Wingdings 3","Dingbat dec":"192","Dingbat hex":"C0","Unicode dec":"129087","Unicode hex":"1F83F"},{"Typeface name":"Wingdings 3","Dingbat dec":"193","Dingbat hex":"C1","Unicode dec":"129088","Unicode hex":"1F840"},{"Typeface name":"Wingdings 3","Dingbat dec":"194","Dingbat hex":"C2","Unicode dec":"129090","Unicode hex":"1F842"},{"Typeface name":"Wingdings 3","Dingbat dec":"195","Dingbat hex":"C3","Unicode dec":"129089","Unicode hex":"1F841"},{"Typeface name":"Wingdings 3","Dingbat dec":"196","Dingbat hex":"C4","Unicode dec":"129091","Unicode hex":"1F843"},{"Typeface name":"Wingdings 3","Dingbat dec":"197","Dingbat hex":"C5","Unicode dec":"129092","Unicode hex":"1F844"},{"Typeface name":"Wingdings 3","Dingbat dec":"198","Dingbat hex":"C6","Unicode dec":"129094","Unicode hex":"1F846"},{"Typeface name":"Wingdings 3","Dingbat dec":"199","Dingbat hex":"C7","Unicode dec":"129093","Unicode hex":"1F845"},{"Typeface name":"Wingdings 3","Dingbat dec":"200","Dingbat hex":"C8","Unicode dec":"129095","Unicode hex":"1F847"},{"Typeface name":"Wingdings 3","Dingbat dec":"201","Dingbat hex":"C9","Unicode dec":"11176","Unicode hex":"2BA8"},{"Typeface name":"Wingdings 3","Dingbat dec":"202","Dingbat hex":"CA","Unicode dec":"11177","Unicode hex":"2BA9"},{"Typeface name":"Wingdings 3","Dingbat dec":"203","Dingbat hex":"CB","Unicode dec":"11178","Unicode hex":"2BAA"},{"Typeface name":"Wingdings 3","Dingbat dec":"204","Dingbat hex":"CC","Unicode dec":"11179","Unicode hex":"2BAB"},{"Typeface name":"Wingdings 3","Dingbat dec":"205","Dingbat hex":"CD","Unicode dec":"11180","Unicode hex":"2BAC"},{"Typeface name":"Wingdings 3","Dingbat dec":"206","Dingbat hex":"CE","Unicode dec":"11181","Unicode hex":"2BAD"},{"Typeface name":"Wingdings 3","Dingbat dec":"207","Dingbat hex":"CF","Unicode dec":"11182","Unicode hex":"2BAE"},{"Typeface name":"Wingdings 3","Dingbat dec":"208","Dingbat hex":"D0","Unicode dec":"11183","Unicode hex":"2BAF"},{"Typeface name":"Wingdings 3","Dingbat dec":"209","Dingbat hex":"D1","Unicode dec":"129120","Unicode hex":"1F860"},{"Typeface name":"Wingdings 3","Dingbat dec":"210","Dingbat hex":"D2","Unicode dec":"129122","Unicode hex":"1F862"},{"Typeface name":"Wingdings 3","Dingbat dec":"211","Dingbat hex":"D3","Unicode dec":"129121","Unicode hex":"1F861"},{"Typeface name":"Wingdings 3","Dingbat dec":"212","Dingbat hex":"D4","Unicode dec":"129123","Unicode hex":"1F863"},{"Typeface name":"Wingdings 3","Dingbat dec":"213","Dingbat hex":"D5","Unicode dec":"129124","Unicode hex":"1F864"},{"Typeface name":"Wingdings 3","Dingbat dec":"214","Dingbat hex":"D6","Unicode dec":"129125","Unicode hex":"1F865"},{"Typeface name":"Wingdings 3","Dingbat dec":"215","Dingbat hex":"D7","Unicode dec":"129127","Unicode hex":"1F867"},{"Typeface name":"Wingdings 3","Dingbat dec":"216","Dingbat hex":"D8","Unicode dec":"129126","Unicode hex":"1F866"},{"Typeface name":"Wingdings 3","Dingbat dec":"217","Dingbat hex":"D9","Unicode dec":"129136","Unicode hex":"1F870"},{"Typeface name":"Wingdings 3","Dingbat dec":"218","Dingbat hex":"DA","Unicode dec":"129138","Unicode hex":"1F872"},{"Typeface name":"Wingdings 3","Dingbat dec":"219","Dingbat hex":"DB","Unicode dec":"129137","Unicode hex":"1F871"},{"Typeface name":"Wingdings 3","Dingbat dec":"220","Dingbat hex":"DC","Unicode dec":"129139","Unicode hex":"1F873"},{"Typeface name":"Wingdings 3","Dingbat dec":"221","Dingbat hex":"DD","Unicode dec":"129140","Unicode hex":"1F874"},{"Typeface name":"Wingdings 3","Dingbat dec":"222","Dingbat hex":"DE","Unicode dec":"129141","Unicode hex":"1F875"},{"Typeface name":"Wingdings 3","Dingbat dec":"223","Dingbat hex":"DF","Unicode dec":"129143","Unicode hex":"1F877"},{"Typeface name":"Wingdings 3","Dingbat dec":"224","Dingbat hex":"E0","Unicode dec":"129142","Unicode hex":"1F876"},{"Typeface name":"Wingdings 3","Dingbat dec":"225","Dingbat hex":"E1","Unicode dec":"129152","Unicode hex":"1F880"},{"Typeface name":"Wingdings 3","Dingbat dec":"226","Dingbat hex":"E2","Unicode dec":"129154","Unicode hex":"1F882"},{"Typeface name":"Wingdings 3","Dingbat dec":"227","Dingbat hex":"E3","Unicode dec":"129153", -"Unicode hex":"1F881"},{"Typeface name":"Wingdings 3","Dingbat dec":"228","Dingbat hex":"E4","Unicode dec":"129155","Unicode hex":"1F883"},{"Typeface name":"Wingdings 3","Dingbat dec":"229","Dingbat hex":"E5","Unicode dec":"129156","Unicode hex":"1F884"},{"Typeface name":"Wingdings 3","Dingbat dec":"230","Dingbat hex":"E6","Unicode dec":"129157","Unicode hex":"1F885"},{"Typeface name":"Wingdings 3","Dingbat dec":"231","Dingbat hex":"E7","Unicode dec":"129159","Unicode hex":"1F887"},{"Typeface name":"Wingdings 3","Dingbat dec":"232","Dingbat hex":"E8","Unicode dec":"129158","Unicode hex":"1F886"},{"Typeface name":"Wingdings 3","Dingbat dec":"233","Dingbat hex":"E9","Unicode dec":"129168","Unicode hex":"1F890"},{"Typeface name":"Wingdings 3","Dingbat dec":"234","Dingbat hex":"EA","Unicode dec":"129170","Unicode hex":"1F892"},{"Typeface name":"Wingdings 3","Dingbat dec":"235","Dingbat hex":"EB","Unicode dec":"129169","Unicode hex":"1F891"},{"Typeface name":"Wingdings 3","Dingbat dec":"236","Dingbat hex":"EC","Unicode dec":"129171","Unicode hex":"1F893"},{"Typeface name":"Wingdings 3","Dingbat dec":"237","Dingbat hex":"ED","Unicode dec":"129172","Unicode hex":"1F894"},{"Typeface name":"Wingdings 3","Dingbat dec":"238","Dingbat hex":"EE","Unicode dec":"129174","Unicode hex":"1F896"},{"Typeface name":"Wingdings 3","Dingbat dec":"239","Dingbat hex":"EF","Unicode dec":"129173","Unicode hex":"1F895"},{"Typeface name":"Wingdings 3","Dingbat dec":"240","Dingbat hex":"F0","Unicode dec":"129175","Unicode hex":"1F897"}];exports["default"]=dingbats},{}],85:[function(require,module,exports){"use strict";function codePoint(typeface,codePoint){return dingbatsByCodePoint[typeface.toUpperCase()+"_"+codePoint]}function dec(typeface,dec){return codePoint(typeface,parseInt(dec,10))}function hex(typeface,hex){return codePoint(typeface,parseInt(hex,16))}function fromCodePointPolyfill(codePoint){if(65535>=codePoint)return String.fromCharCode(codePoint);var highSurrogate=Math.floor((codePoint-65536)/1024)+55296,lowSurrogate=(codePoint-65536)%1024+56320;return String.fromCharCode(highSurrogate,lowSurrogate)}var __importDefault=this&&this.__importDefault||function(mod){return mod&&mod.__esModule?mod:{"default":mod}};Object.defineProperty(exports,"__esModule",{value:!0}),exports.hex=exports.dec=exports.codePoint=void 0;for(var dingbats_1=__importDefault(require("./dingbats")),dingbatsByCodePoint={},fromCodePoint=String.fromCodePoint?String.fromCodePoint:fromCodePointPolyfill,_i=0,dingbats_2=dingbats_1["default"];_i>1,nBits=-7,i=isLE?nBytes-1:0,d=isLE?-1:1,s=buffer[offset+i];for(i+=d,e=s&(1<<-nBits)-1,s>>=-nBits,nBits+=eLen;nBits>0;e=256*e+buffer[offset+i],i+=d,nBits-=8);for(m=e&(1<<-nBits)-1,e>>=-nBits,nBits+=mLen;nBits>0;m=256*m+buffer[offset+i],i+=d,nBits-=8);if(0===e)e=1-eBias;else{if(e===eMax)return m?NaN:(s?-1:1)*(1/0);m+=Math.pow(2,mLen),e-=eBias}return(s?-1:1)*m*Math.pow(2,e-mLen)},exports.write=function(buffer,value,offset,isLE,mLen,nBytes){var e,m,c,eLen=8*nBytes-mLen-1,eMax=(1<>1,rt=23===mLen?Math.pow(2,-24)-Math.pow(2,-77):0,i=isLE?0:nBytes-1,d=isLE?1:-1,s=0>value||0===value&&0>1/value?1:0;for(value=Math.abs(value),isNaN(value)||value===1/0?(m=isNaN(value)?1:0,e=eMax):(e=Math.floor(Math.log(value)/Math.LN2),value*(c=Math.pow(2,-e))<1&&(e--,c*=2),value+=e+eBias>=1?rt/c:rt*Math.pow(2,1-eBias),value*c>=2&&(e++,c/=2),e+eBias>=eMax?(m=0,e=eMax):e+eBias>=1?(m=(value*c-1)*Math.pow(2,mLen),e+=eBias):(m=value*Math.pow(2,eBias-1)*Math.pow(2,mLen),e=0));mLen>=8;buffer[offset+i]=255&m,i+=d,m/=256,mLen-=8);for(e=e<0;buffer[offset+i]=255&e,i+=d,e/=256,eLen-=8);buffer[offset+i-d]|=128*s}},{}],87:[function(require,module,exports){var toString={}.toString;module.exports=Array.isArray||function(arr){return"[object Array]"==toString.call(arr)}},{}],88:[function(require,module,exports){(function(global,Buffer){!function(t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=t()}(function(){return function s(a,o,h){function u(r,t){if(!o[r]){if(!a[r]){var e="function"==typeof require&&require;if(!t&&e)return e(r,!0);if(l)return l(r,!0);var i=new Error("Cannot find module '"+r+"'");throw i.code="MODULE_NOT_FOUND",i}var n=o[r]={exports:{}};a[r][0].call(n.exports,function(t){var e=a[r][1][t];return u(e||t)},n,n.exports,s,a,o,h)}return o[r].exports}for(var l="function"==typeof require&&require,t=0;tu?t[u++]:0,l>u?t[u++]:0):(e=t.charCodeAt(u++),r=l>u?t.charCodeAt(u++):0,l>u?t.charCodeAt(u++):0),n=e>>2,s=(3&e)<<4|r>>4,a=f>1?(15&r)<<2|i>>6:64,o=f>2?63&i:64,h.push(p.charAt(n)+p.charAt(s)+p.charAt(a)+p.charAt(o));return h.join("")},r.decode=function(t){var e,r,i,n,s,a,o=0,h=0,u="data:";if(t.substr(0,u.length)===u)throw new Error("Invalid base64 input, it looks like a data url.");var l,f=3*(t=t.replace(/[^A-Za-z0-9\+\/\=]/g,"")).length/4;if(t.charAt(t.length-1)===p.charAt(64)&&f--,t.charAt(t.length-2)===p.charAt(64)&&f--,f%1!=0)throw new Error("Invalid base64 input, bad content length.");for(l=d.uint8array?new Uint8Array(0|f):new Array(0|f);o>4,r=(15&n)<<4|(s=p.indexOf(t.charAt(o++)))>>2,i=(3&s)<<6|(a=p.indexOf(t.charAt(o++))),l[h++]=e,64!==s&&(l[h++]=r),64!==a&&(l[h++]=i);return l}},{"./support":30,"./utils":32}],2:[function(t,e,r){"use strict";function o(t,e,r,i,n){this.compressedSize=t,this.uncompressedSize=e,this.crc32=r,this.compression=i,this.compressedContent=n}var i=t("./external"),n=t("./stream/DataWorker"),s=t("./stream/Crc32Probe"),a=t("./stream/DataLengthProbe");o.prototype={getContentWorker:function(){var t=new n(i.Promise.resolve(this.compressedContent)).pipe(this.compression.uncompressWorker()).pipe(new a("data_length")),e=this;return t.on("end",function(){if(this.streamInfo.data_length!==e.uncompressedSize)throw new Error("Bug : uncompressed data size mismatch")}),t},getCompressedWorker:function(){return new n(i.Promise.resolve(this.compressedContent)).withStreamInfo("compressedSize",this.compressedSize).withStreamInfo("uncompressedSize",this.uncompressedSize).withStreamInfo("crc32",this.crc32).withStreamInfo("compression",this.compression)}},o.createWorkerFrom=function(t,e,r){return t.pipe(new s).pipe(new a("uncompressedSize")).pipe(e.compressWorker(r)).pipe(new a("compressedSize")).withStreamInfo("compression",e)},e.exports=o},{"./external":6,"./stream/Crc32Probe":25,"./stream/DataLengthProbe":26,"./stream/DataWorker":27}],3:[function(t,e,r){"use strict";var i=t("./stream/GenericWorker");r.STORE={magic:"\x00\x00",compressWorker:function(t){return new i("STORE compression")},uncompressWorker:function(){return new i("STORE decompression")}},r.DEFLATE=t("./flate")},{"./flate":7,"./stream/GenericWorker":28}],4:[function(t,e,r){"use strict";var i=t("./utils"),o=function(){for(var t,e=[],r=0;256>r;r++){t=r;for(var i=0;8>i;i++)t=1&t?3988292384^t>>>1:t>>>1;e[r]=t}return e}();e.exports=function(t,e){return void 0!==t&&t.length?"string"!==i.getTypeOf(t)?function(t,e,r,i){var n=o,s=i+r;t^=-1;for(var a=i;s>a;a++)t=t>>>8^n[255&(t^e[a])];return-1^t}(0|e,t,t.length,0):function(t,e,r,i){var n=o,s=i+r;t^=-1;for(var a=i;s>a;a++)t=t>>>8^n[255&(t^e.charCodeAt(a))];return-1^t}(0|e,t,t.length,0):0}},{"./utils":32}],5:[function(t,e,r){"use strict";r.base64=!1,r.binary=!1,r.dir=!1,r.createFolders=!0,r.date=null,r.compression=null,r.compressionOptions=null,r.comment=null,r.unixPermissions=null,r.dosPermissions=null},{}],6:[function(t,e,r){"use strict";var i=null;i="undefined"!=typeof Promise?Promise:t("lie"),e.exports={Promise:i}},{lie:37}],7:[function(t,e,r){"use strict";function h(t,e){a.call(this,"FlateWorker/"+t),this._pako=null,this._pakoAction=t,this._pakoOptions=e,this.meta={}}var i="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array,n=t("pako"),s=t("./utils"),a=t("./stream/GenericWorker"),o=i?"uint8array":"array";r.magic="\b\x00",s.inherits(h,a),h.prototype.processChunk=function(t){this.meta=t.meta,null===this._pako&&this._createPako(),this._pako.push(s.transformTo(o,t.data),!1)},h.prototype.flush=function(){a.prototype.flush.call(this),null===this._pako&&this._createPako(),this._pako.push([],!0)},h.prototype.cleanUp=function(){a.prototype.cleanUp.call(this),this._pako=null},h.prototype._createPako=function(){this._pako=new n[this._pakoAction]({raw:!0,level:this._pakoOptions.level||-1});var e=this;this._pako.onData=function(t){e.push({data:t,meta:e.meta})}},r.compressWorker=function(t){return new h("Deflate",t)},r.uncompressWorker=function(){return new h("Inflate",{})}},{"./stream/GenericWorker":28,"./utils":32,pako:38}],8:[function(t,e,r){"use strict";function A(t,e){var r,i="";for(r=0;e>r;r++)i+=String.fromCharCode(255&t),t>>>=8;return i}function i(t,e,r,i,n,s){var a,o,h=t.file,u=t.compression,l=s!==O.utf8encode,f=I.transformTo("string",s(h.name)),d=I.transformTo("string",O.utf8encode(h.name)),c=h.comment,p=I.transformTo("string",s(c)),m=I.transformTo("string",O.utf8encode(c)),_=d.length!==h.name.length,g=m.length!==c.length,b="",v="",y="",w=h.dir,k=h.date,x={crc32:0,compressedSize:0,uncompressedSize:0};e&&!r||(x.crc32=t.crc32,x.compressedSize=t.compressedSize,x.uncompressedSize=t.uncompressedSize);var S=0;e&&(S|=8),l||!_&&!g||(S|=2048);var z=0,C=0;w&&(z|=16),"UNIX"===n?(C=798,z|=function(t,e){var r=t;return t||(r=e?16893:33204),(65535&r)<<16}(h.unixPermissions,w)):(C=20,z|=function(t){return 63&(t||0)}(h.dosPermissions)),a=k.getUTCHours(),a<<=6,a|=k.getUTCMinutes(),a<<=5,a|=k.getUTCSeconds()/2,o=k.getUTCFullYear()-1980,o<<=4,o|=k.getUTCMonth()+1,o<<=5,o|=k.getUTCDate(),_&&(v=A(1,1)+A(B(f),4)+d,b+="up"+A(v.length,2)+v),g&&(y=A(1,1)+A(B(p),4)+m,b+="uc"+A(y.length,2)+y);var E="";return E+="\n\x00",E+=A(S,2),E+=u.magic,E+=A(a,2),E+=A(o,2),E+=A(x.crc32,4),E+=A(x.compressedSize,4),E+=A(x.uncompressedSize,4),E+=A(f.length,2),E+=A(b.length,2),{fileRecord:R.LOCAL_FILE_HEADER+E+f+b,dirRecord:R.CENTRAL_FILE_HEADER+A(C,2)+E+A(p.length,2)+"\x00\x00\x00\x00"+A(z,4)+A(i,4)+f+b+p}}function s(t,e,r,i){n.call(this,"ZipFileWorker"),this.bytesWritten=0,this.zipComment=e,this.zipPlatform=r,this.encodeFileName=i,this.streamFiles=t,this.accumulate=!1,this.contentBuffer=[],this.dirRecords=[],this.currentSourceOffset=0,this.entriesCount=0,this.currentFile=null,this._sources=[]}var I=t("../utils"),n=t("../stream/GenericWorker"),O=t("../utf8"),B=t("../crc32"),R=t("../signature");I.inherits(s,n),s.prototype.push=function(t){var e=t.meta.percent||0,r=this.entriesCount,i=this._sources.length;this.accumulate?this.contentBuffer.push(t):(this.bytesWritten+=t.data.length,n.prototype.push.call(this,{data:t.data,meta:{currentFile:this.currentFile,percent:r?(e+100*(r-i-1))/r:100}}))},s.prototype.openedSource=function(t){this.currentSourceOffset=this.bytesWritten,this.currentFile=t.file.name;var e=this.streamFiles&&!t.file.dir;if(e){var r=i(t,e,!1,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);this.push({data:r.fileRecord,meta:{percent:0}})}else this.accumulate=!0},s.prototype.closedSource=function(t){this.accumulate=!1;var e=this.streamFiles&&!t.file.dir,r=i(t,e,!0,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);if(this.dirRecords.push(r.dirRecord),e)this.push({data:function(t){return R.DATA_DESCRIPTOR+A(t.crc32,4)+A(t.compressedSize,4)+A(t.uncompressedSize,4)}(t),meta:{percent:100}});else for(this.push({data:r.fileRecord,meta:{percent:0}});this.contentBuffer.length;)this.push(this.contentBuffer.shift());this.currentFile=null},s.prototype.flush=function(){for(var t=this.bytesWritten,e=0;e0?t.substring(0,e):""},g=function(t){return"/"!==t.slice(-1)&&(t+="/"),t},b=function(t,e){return e=void 0!==e?e:f.createFolders,t=g(t),this.files[t]||s.call(this,t,null,{dir:!0,createFolders:e}),this.files[t]},i={load:function(){throw new Error("This method has been removed in JSZip 3.0, please check the upgrade guide.")},forEach:function(t){var e,r,i;for(e in this.files)i=this.files[e],(r=e.slice(this.root.length,e.length))&&e.slice(0,this.root.length)===this.root&&t(r,i)},filter:function(r){var i=[];return this.forEach(function(t,e){r(t,e)&&i.push(e)}),i},file:function(t,e,r){if(1!==arguments.length)return t=this.root+t,s.call(this,t,e,r),this;if(h(t)){var i=t;return this.filter(function(t,e){return!e.dir&&i.test(t)})}var n=this.files[this.root+t];return n&&!n.dir?n:null},folder:function(r){if(!r)return this;if(h(r))return this.filter(function(t,e){return e.dir&&r.test(t)});var t=this.root+r,e=b.call(this,t),i=this.clone();return i.root=e.name,i},remove:function(r){r=this.root+r;var t=this.files[r];if(t||("/"!==r.slice(-1)&&(r+="/"),t=this.files[r]),t&&!t.dir)delete this.files[r];else for(var e=this.filter(function(t,e){return e.name.slice(0,r.length)===r}),i=0;i=0;--s)if(this.data[s]===e&&this.data[s+1]===r&&this.data[s+2]===i&&this.data[s+3]===n)return s-this.zero;return-1},n.prototype.readAndCheckSignature=function(t){var e=t.charCodeAt(0),r=t.charCodeAt(1),i=t.charCodeAt(2),n=t.charCodeAt(3),s=this.readData(4);return e===s[0]&&r===s[1]&&i===s[2]&&n===s[3]},n.prototype.readData=function(t){if(this.checkOffset(t),0===t)return[];var e=this.data.slice(this.zero+this.index,this.zero+this.index+t);return this.index+=t,e},e.exports=n},{"../utils":32,"./DataReader":18}],18:[function(t,e,r){"use strict";function n(t){this.data=t,this.length=t.length,this.index=0,this.zero=0}var i=t("../utils");n.prototype={checkOffset:function(t){this.checkIndex(this.index+t)},checkIndex:function(t){if(this.lengtht)throw new Error("End of data reached (data length = "+this.length+", asked index = "+t+"). Corrupted zip ?")},setIndex:function(t){this.checkIndex(t),this.index=t},skip:function(t){this.setIndex(this.index+t)},byteAt:function(t){},readInt:function(t){var e,r=0;for(this.checkOffset(t),e=this.index+t-1;e>=this.index;e--)r=(r<<8)+this.byteAt(e);return this.index+=t,r},readString:function(t){return i.transformTo("string",this.readData(t))},readData:function(t){},lastIndexOfSignature:function(t){},readAndCheckSignature:function(t){},readDate:function(){var t=this.readInt(4);return new Date(Date.UTC(1980+(t>>25&127),(t>>21&15)-1,t>>16&31,t>>11&31,t>>5&63,(31&t)<<1))}},e.exports=n},{"../utils":32}],19:[function(t,e,r){"use strict";function n(t){i.call(this,t)}var i=t("./Uint8ArrayReader");t("../utils").inherits(n,i),n.prototype.readData=function(t){this.checkOffset(t);var e=this.data.slice(this.zero+this.index,this.zero+this.index+t);return this.index+=t,e},e.exports=n},{"../utils":32,"./Uint8ArrayReader":21}],20:[function(t,e,r){"use strict";function n(t){i.call(this,t)}var i=t("./DataReader");t("../utils").inherits(n,i),n.prototype.byteAt=function(t){return this.data.charCodeAt(this.zero+t)},n.prototype.lastIndexOfSignature=function(t){return this.data.lastIndexOf(t)-this.zero},n.prototype.readAndCheckSignature=function(t){return t===this.readData(4)},n.prototype.readData=function(t){this.checkOffset(t);var e=this.data.slice(this.zero+this.index,this.zero+this.index+t);return this.index+=t,e},e.exports=n},{"../utils":32,"./DataReader":18}],21:[function(t,e,r){"use strict";function n(t){i.call(this,t)}var i=t("./ArrayReader");t("../utils").inherits(n,i),n.prototype.readData=function(t){if(this.checkOffset(t),0===t)return new Uint8Array(0);var e=this.data.subarray(this.zero+this.index,this.zero+this.index+t);return this.index+=t,e},e.exports=n},{"../utils":32,"./ArrayReader":17}],22:[function(t,e,r){"use strict";var i=t("../utils"),n=t("../support"),s=t("./ArrayReader"),a=t("./StringReader"),o=t("./NodeBufferReader"),h=t("./Uint8ArrayReader");e.exports=function(t){var e=i.getTypeOf(t);return i.checkSupport(e),"string"!==e||n.uint8array?"nodebuffer"===e?new o(t):n.uint8array?new h(i.transformTo("uint8array",t)):new s(i.transformTo("array",t)):new a(t)}},{"../support":30,"../utils":32,"./ArrayReader":17,"./NodeBufferReader":19,"./StringReader":20,"./Uint8ArrayReader":21}],23:[function(t,e,r){"use strict";r.LOCAL_FILE_HEADER="PK",r.CENTRAL_FILE_HEADER="PK",r.CENTRAL_DIRECTORY_END="PK",r.ZIP64_CENTRAL_DIRECTORY_LOCATOR="PK",r.ZIP64_CENTRAL_DIRECTORY_END="PK",r.DATA_DESCRIPTOR="PK\b"},{}],24:[function(t,e,r){"use strict";function s(t){i.call(this,"ConvertWorker to "+t),this.destType=t}var i=t("./GenericWorker"),n=t("../utils");n.inherits(s,i),s.prototype.processChunk=function(t){this.push({data:n.transformTo(this.destType,t.data),meta:t.meta})},e.exports=s},{"../utils":32,"./GenericWorker":28}],25:[function(t,e,r){"use strict";function s(){i.call(this,"Crc32Probe"),this.withStreamInfo("crc32",0)}var i=t("./GenericWorker"),n=t("../crc32");t("../utils").inherits(s,i),s.prototype.processChunk=function(t){this.streamInfo.crc32=n(t.data,this.streamInfo.crc32||0),this.push(t)},e.exports=s},{"../crc32":4,"../utils":32,"./GenericWorker":28}],26:[function(t,e,r){"use strict";function s(t){n.call(this,"DataLengthProbe for "+t),this.propName=t,this.withStreamInfo(t,0)}var i=t("../utils"),n=t("./GenericWorker");i.inherits(s,n),s.prototype.processChunk=function(t){if(t){var e=this.streamInfo[this.propName]||0;this.streamInfo[this.propName]=e+t.data.length}n.prototype.processChunk.call(this,t)},e.exports=s},{"../utils":32,"./GenericWorker":28}],27:[function(t,e,r){"use strict";function s(t){n.call(this,"DataWorker");var e=this;this.dataIsReady=!1,this.index=0,this.max=0,this.data=null,this.type="",this._tickScheduled=!1,t.then(function(t){e.dataIsReady=!0,e.data=t,e.max=t&&t.length||0,e.type=i.getTypeOf(t),e.isPaused||e._tickAndRepeat()},function(t){e.error(t)})}var i=t("../utils"),n=t("./GenericWorker");i.inherits(s,n),s.prototype.cleanUp=function(){n.prototype.cleanUp.call(this),this.data=null},s.prototype.resume=function(){return!!n.prototype.resume.call(this)&&(!this._tickScheduled&&this.dataIsReady&&(this._tickScheduled=!0,i.delay(this._tickAndRepeat,[],this)),!0)},s.prototype._tickAndRepeat=function(){this._tickScheduled=!1,this.isPaused||this.isFinished||(this._tick(),this.isFinished||(i.delay(this._tickAndRepeat,[],this),this._tickScheduled=!0))},s.prototype._tick=function(){if(this.isPaused||this.isFinished)return!1;var t=null,e=Math.min(this.max,this.index+16384);if(this.index>=this.max)return this.end();switch(this.type){case"string":t=this.data.substring(this.index,e);break;case"uint8array":t=this.data.subarray(this.index,e);break;case"array":case"nodebuffer":t=this.data.slice(this.index,e)}return this.index=e,this.push({data:t,meta:{percent:this.max?this.index/this.max*100:0}})},e.exports=s},{"../utils":32,"./GenericWorker":28}],28:[function(t,e,r){"use strict";function i(t){this.name=t||"default",this.streamInfo={},this.generatedError=null,this.extraStreamInfo={},this.isPaused=!0,this.isFinished=!1,this.isLocked=!1,this._listeners={data:[],end:[],error:[]},this.previous=null}i.prototype={push:function(t){this.emit("data",t)},end:function(){if(this.isFinished)return!1;this.flush();try{this.emit("end"),this.cleanUp(),this.isFinished=!0}catch(t){this.emit("error",t)}return!0},error:function(t){return!this.isFinished&&(this.isPaused?this.generatedError=t:(this.isFinished=!0,this.emit("error",t),this.previous&&this.previous.error(t),this.cleanUp()),!0)},on:function(t,e){return this._listeners[t].push(e),this},cleanUp:function(){this.streamInfo=this.generatedError=this.extraStreamInfo=null,this._listeners=[]},emit:function(t,e){if(this._listeners[t])for(var r=0;r "+t:t}},e.exports=i},{}],29:[function(t,e,r){"use strict";function l(t,o){return new a.Promise(function(e,r){var i=[],n=t._internalType,s=t._outputType,a=t._mimeType;t.on("data",function(t,e){i.push(t),o&&o(e)}).on("error",function(t){i=[],r(t)}).on("end",function(){try{var t=function(t,e,r){switch(t){case"blob":return h.newBlob(h.transformTo("arraybuffer",e),r);case"base64":return u.encode(e);default:return h.transformTo(t,e)}}(s,function(t,e){var r,i=0,n=null,s=0;for(r=0;rn;n++)u[n]=n>=252?6:n>=248?5:n>=240?4:n>=224?3:n>=192?2:1;u[254]=u[254]=1,s.utf8encode=function(t){return h.nodebuffer?r.newBufferFrom(t,"utf-8"):function(t){var e,r,i,n,s,a=t.length,o=0;for(n=0;a>n;n++)55296==(64512&(r=t.charCodeAt(n)))&&a>n+1&&56320==(64512&(i=t.charCodeAt(n+1)))&&(r=65536+(r-55296<<10)+(i-56320),n++),o+=128>r?1:2048>r?2:65536>r?3:4;for(e=h.uint8array?new Uint8Array(o):new Array(o),n=s=0;o>s;n++)55296==(64512&(r=t.charCodeAt(n)))&&a>n+1&&56320==(64512&(i=t.charCodeAt(n+1)))&&(r=65536+(r-55296<<10)+(i-56320),n++),128>r?e[s++]=r:(2048>r?e[s++]=192|r>>>6:(65536>r?e[s++]=224|r>>>12:(e[s++]=240|r>>>18,e[s++]=128|r>>>12&63),e[s++]=128|r>>>6&63),e[s++]=128|63&r);return e}(t)},s.utf8decode=function(t){return h.nodebuffer?o.transformTo("nodebuffer",t).toString("utf-8"):function(t){var e,r,i,n,s=t.length,a=new Array(2*s);for(e=r=0;s>e;)if((i=t[e++])<128)a[r++]=i;else if(4<(n=u[i]))a[r++]=65533,e+=n-1;else{for(i&=2===n?31:3===n?15:7;n>1&&s>e;)i=i<<6|63&t[e++],n--;n>1?a[r++]=65533:65536>i?a[r++]=i:(i-=65536,a[r++]=55296|i>>10&1023,a[r++]=56320|1023&i)}return a.length!==r&&(a.subarray?a=a.subarray(0,r):a.length=r),o.applyFromCharCode(a)}(t=o.transformTo(h.uint8array?"uint8array":"array",t))},o.inherits(a,i),a.prototype.processChunk=function(t){var e=o.transformTo(h.uint8array?"uint8array":"array",t.data);if(this.leftOver&&this.leftOver.length){if(h.uint8array){var r=e;(e=new Uint8Array(r.length+this.leftOver.length)).set(this.leftOver,0),e.set(r,this.leftOver.length)}else e=this.leftOver.concat(e);this.leftOver=null}var i=function(t,e){var r;for((e=e||t.length)>t.length&&(e=t.length),r=e-1;r>=0&&128==(192&t[r]);)r--;return 0>r?e:0===r?e:r+u[t[r]]>e?r:e}(e),n=e;i!==e.length&&(h.uint8array?(n=e.subarray(0,i),this.leftOver=e.subarray(i,e.length)):(n=e.slice(0,i),this.leftOver=e.slice(i,e.length))),this.push({data:s.utf8decode(n),meta:t.meta})},a.prototype.flush=function(){this.leftOver&&this.leftOver.length&&(this.push({data:s.utf8decode(this.leftOver),meta:{}}),this.leftOver=null)},s.Utf8DecodeWorker=a,o.inherits(l,i),l.prototype.processChunk=function(t){this.push({data:s.utf8encode(t.data),meta:t.meta})},s.Utf8EncodeWorker=l},{"./nodejsUtils":14,"./stream/GenericWorker":28,"./support":30,"./utils":32}],32:[function(t,e,a){"use strict";function n(t){return t}function l(t,e){for(var r=0;r1;)try{return s.stringifyByChunk(t,r,e)}catch(t){e=Math.floor(e/2)}return s.stringifyByChar(t)}function d(t,e){for(var r=0;r=s)return String.fromCharCode.apply(null,t);for(;s>n;)"array"===e||"nodebuffer"===e?i.push(String.fromCharCode.apply(null,t.slice(n,Math.min(n+r,s)))):i.push(String.fromCharCode.apply(null,t.subarray(n,Math.min(n+r,s)))),n+=r;return i.join("")},stringifyByChar:function(t){for(var e="",r=0;r0;)t=this.reader.readInt(2),e=this.reader.readInt(4),r=this.reader.readData(e),this.zip64ExtensibleData[t]={id:t,length:e,value:r}},readBlockZip64EndOfCentralLocator:function(){if(this.diskWithZip64CentralDirStart=this.reader.readInt(4),this.relativeOffsetEndOfZip64CentralDir=this.reader.readInt(8),this.disksCount=this.reader.readInt(4),1t)throw this.isSignature(0,s.LOCAL_FILE_HEADER)?new Error("Corrupted zip: can't find end of central directory"):new Error("Can't find end of central directory : is this a zip file ? If it is, see https://stuk.github.io/jszip/documentation/howto/read_zip.html");this.reader.setIndex(t);var e=t;if(this.checkSignature(s.CENTRAL_DIRECTORY_END),this.readBlockEndOfCentral(),this.diskNumber===n.MAX_VALUE_16BITS||this.diskWithCentralDirStart===n.MAX_VALUE_16BITS||this.centralDirRecordsOnThisDisk===n.MAX_VALUE_16BITS||this.centralDirRecords===n.MAX_VALUE_16BITS||this.centralDirSize===n.MAX_VALUE_32BITS||this.centralDirOffset===n.MAX_VALUE_32BITS){if(this.zip64=!0,(t=this.reader.lastIndexOfSignature(s.ZIP64_CENTRAL_DIRECTORY_LOCATOR))<0)throw new Error("Corrupted zip: can't find the ZIP64 end of central directory locator");if(this.reader.setIndex(t),this.checkSignature(s.ZIP64_CENTRAL_DIRECTORY_LOCATOR),this.readBlockZip64EndOfCentralLocator(),!this.isSignature(this.relativeOffsetEndOfZip64CentralDir,s.ZIP64_CENTRAL_DIRECTORY_END)&&(this.relativeOffsetEndOfZip64CentralDir=this.reader.lastIndexOfSignature(s.ZIP64_CENTRAL_DIRECTORY_END),this.relativeOffsetEndOfZip64CentralDir<0))throw new Error("Corrupted zip: can't find the ZIP64 end of central directory");this.reader.setIndex(this.relativeOffsetEndOfZip64CentralDir),this.checkSignature(s.ZIP64_CENTRAL_DIRECTORY_END),this.readBlockZip64EndOfCentral()}var r=this.centralDirOffset+this.centralDirSize;this.zip64&&(r+=20,r+=12+this.zip64EndOfCentralSize);var i=e-r;if(i>0)this.isSignature(e,s.CENTRAL_FILE_HEADER)||(this.reader.zero=i);else if(0>i)throw new Error("Corrupted zip: missing "+Math.abs(i)+" bytes.")},prepareReader:function(t){this.reader=i(t)},load:function(t){this.prepareReader(t),this.readEndOfCentral(),this.readCentralDir(),this.readLocalFiles()}},e.exports=h},{"./reader/readerFor":22,"./signature":23,"./support":30,"./utf8":31,"./utils":32,"./zipEntry":34}],34:[function(t,e,r){"use strict";function l(t,e){this.options=t,this.loadOptions=e}var i=t("./reader/readerFor"),s=t("./utils"),n=t("./compressedObject"),a=t("./crc32"),o=t("./utf8"),h=t("./compressions"),u=t("./support");l.prototype={isEncrypted:function(){return 1==(1&this.bitFlag)},useUTF8:function(){return 2048==(2048&this.bitFlag)},readLocalPart:function(t){var e,r;if(t.skip(22),this.fileNameLength=t.readInt(2),r=t.readInt(2),this.fileName=t.readData(this.fileNameLength),t.skip(r),-1===this.compressedSize||-1===this.uncompressedSize)throw new Error("Bug or corrupted zip : didn't get enough information from the central directory (compressedSize === -1 || uncompressedSize === -1)");if(null===(e=function(t){for(var e in h)if(h.hasOwnProperty(e)&&h[e].magic===t)return h[e];return null}(this.compressionMethod)))throw new Error("Corrupted zip : compression "+s.pretty(this.compressionMethod)+" unknown (inner file : "+s.transformTo("string",this.fileName)+")");this.decompressed=new n(this.compressedSize,this.uncompressedSize,this.crc32,e,t.readData(this.compressedSize))},readCentralPart:function(t){this.versionMadeBy=t.readInt(2),t.skip(2),this.bitFlag=t.readInt(2),this.compressionMethod=t.readString(2),this.date=t.readDate(),this.crc32=t.readInt(4),this.compressedSize=t.readInt(4),this.uncompressedSize=t.readInt(4);var e=t.readInt(2);if(this.extraFieldsLength=t.readInt(2),this.fileCommentLength=t.readInt(2),this.diskNumberStart=t.readInt(2),this.internalFileAttributes=t.readInt(2),this.externalFileAttributes=t.readInt(4),this.localHeaderOffset=t.readInt(4),this.isEncrypted())throw new Error("Encrypted zip are not supported");t.skip(e),this.readExtraFields(t),this.parseZIP64ExtraField(t),this.fileComment=t.readData(this.fileCommentLength)},processAttributes:function(){this.unixPermissions=null,this.dosPermissions=null;var t=this.versionMadeBy>>8;this.dir=!!(16&this.externalFileAttributes),0==t&&(this.dosPermissions=63&this.externalFileAttributes),3==t&&(this.unixPermissions=this.externalFileAttributes>>16&65535),this.dir||"/"!==this.fileNameStr.slice(-1)||(this.dir=!0)},parseZIP64ExtraField:function(t){if(this.extraFields[1]){var e=i(this.extraFields[1].value);this.uncompressedSize===s.MAX_VALUE_32BITS&&(this.uncompressedSize=e.readInt(8)),this.compressedSize===s.MAX_VALUE_32BITS&&(this.compressedSize=e.readInt(8)),this.localHeaderOffset===s.MAX_VALUE_32BITS&&(this.localHeaderOffset=e.readInt(8)),this.diskNumberStart===s.MAX_VALUE_32BITS&&(this.diskNumberStart=e.readInt(4))}},readExtraFields:function(t){var e,r,i,n=t.index+this.extraFieldsLength;for(this.extraFields||(this.extraFields={});t.index+4s;s++)t[n+s]=e[r+s]},flattenChunks:function(t){var e,r,i,n,s,a;for(e=i=0,r=t.length;r>e;e++)i+=t[e].length;for(a=new Uint8Array(i),e=n=0,r=t.length;r>e;e++)s=t[e],a.set(s,n),n+=s.length;return a}},s={arraySet:function(t,e,r,i,n){for(var s=0;i>s;s++)t[n+s]=e[r+s]},flattenChunks:function(t){return[].concat.apply([],t)}};r.setTyped=function(t){t?(r.Buf8=Uint8Array,r.Buf16=Uint16Array,r.Buf32=Int32Array,r.assign(r,n)):(r.Buf8=Array,r.Buf16=Array,r.Buf32=Array,r.assign(r,s))},r.setTyped(i)},{}],42:[function(t,e,r){"use strict";function l(t,e){if(65537>e&&(t.subarray&&s||!t.subarray&&n))return String.fromCharCode.apply(null,h.shrinkBuf(t,e));for(var r="",i=0;e>i;i++)r+=String.fromCharCode(t[i]);return r}var h=t("./common"),n=!0,s=!0;try{String.fromCharCode.apply(null,[0])}catch(t){n=!1}try{String.fromCharCode.apply(null,new Uint8Array(1))}catch(t){s=!1}for(var u=new h.Buf8(256),i=0;256>i;i++)u[i]=i>=252?6:i>=248?5:i>=240?4:i>=224?3:i>=192?2:1;u[254]=u[254]=1,r.string2buf=function(t){var e,r,i,n,s,a=t.length,o=0;for(n=0;a>n;n++)55296==(64512&(r=t.charCodeAt(n)))&&a>n+1&&56320==(64512&(i=t.charCodeAt(n+1)))&&(r=65536+(r-55296<<10)+(i-56320),n++),o+=128>r?1:2048>r?2:65536>r?3:4;for(e=new h.Buf8(o),n=s=0;o>s;n++)55296==(64512&(r=t.charCodeAt(n)))&&a>n+1&&56320==(64512&(i=t.charCodeAt(n+1)))&&(r=65536+(r-55296<<10)+(i-56320),n++),128>r?e[s++]=r:(2048>r?e[s++]=192|r>>>6:(65536>r?e[s++]=224|r>>>12:(e[s++]=240|r>>>18,e[s++]=128|r>>>12&63),e[s++]=128|r>>>6&63),e[s++]=128|63&r);return e},r.buf2binstring=function(t){return l(t,t.length)},r.binstring2buf=function(t){for(var e=new h.Buf8(t.length),r=0,i=e.length;i>r;r++)e[r]=t.charCodeAt(r);return e},r.buf2string=function(t,e){var r,i,n,s,a=e||t.length,o=new Array(2*a);for(r=i=0;a>r;)if((n=t[r++])<128)o[i++]=n;else if(4<(s=u[n]))o[i++]=65533,r+=s-1;else{for(n&=2===s?31:3===s?15:7;s>1&&a>r;)n=n<<6|63&t[r++],s--;s>1?o[i++]=65533:65536>n?o[i++]=n:(n-=65536,o[i++]=55296|n>>10&1023,o[i++]=56320|1023&n)}return l(o,i)},r.utf8border=function(t,e){var r;for((e=e||t.length)>t.length&&(e=t.length),r=e-1;r>=0&&128==(192&t[r]);)r--;return 0>r?e:0===r?e:r+u[t[r]]>e?r:e}},{"./common":41}],43:[function(t,e,r){"use strict";e.exports=function(t,e,r,i){for(var n=65535&t|0,s=t>>>16&65535|0,a=0;0!==r;){for(r-=a=r>2e3?2e3:r;s=s+(n=n+e[i++]|0)|0,--a;);n%=65521,s%=65521}return n|s<<16|0}},{}],44:[function(t,e,r){"use strict";e.exports={Z_NO_FLUSH:0,Z_PARTIAL_FLUSH:1,Z_SYNC_FLUSH:2,Z_FULL_FLUSH:3,Z_FINISH:4,Z_BLOCK:5,Z_TREES:6,Z_OK:0,Z_STREAM_END:1,Z_NEED_DICT:2,Z_ERRNO:-1,Z_STREAM_ERROR:-2,Z_DATA_ERROR:-3,Z_BUF_ERROR:-5,Z_NO_COMPRESSION:0,Z_BEST_SPEED:1,Z_BEST_COMPRESSION:9,Z_DEFAULT_COMPRESSION:-1,Z_FILTERED:1,Z_HUFFMAN_ONLY:2,Z_RLE:3,Z_FIXED:4,Z_DEFAULT_STRATEGY:0,Z_BINARY:0,Z_TEXT:1,Z_UNKNOWN:2,Z_DEFLATED:8}},{}],45:[function(t,e,r){"use strict";var o=function(){for(var t,e=[],r=0;256>r;r++){t=r;for(var i=0;8>i;i++)t=1&t?3988292384^t>>>1:t>>>1;e[r]=t}return e}();e.exports=function(t,e,r,i){var n=o,s=i+r;t^=-1;for(var a=i;s>a;a++)t=t>>>8^n[255&(t^e[a])];return-1^t}},{}],46:[function(t,e,r){"use strict";function R(t,e){return t.msg=i[e],e}function T(t){return(t<<1)-(t>4?9:0)}function D(t){for(var e=t.length;0<=--e;)t[e]=0}function F(t){var e=t.state,r=e.pending;r>t.avail_out&&(r=t.avail_out),0!==r&&(d.arraySet(t.output,e.pending_buf,e.pending_out,r,t.next_out),t.next_out+=r,e.pending_out+=r,t.total_out+=r,t.avail_out-=r,e.pending-=r,0===e.pending&&(e.pending_out=0))}function N(t,e){u._tr_flush_block(t,0<=t.block_start?t.block_start:-1,t.strstart-t.block_start,e),t.block_start=t.strstart,F(t.strm)}function U(t,e){t.pending_buf[t.pending++]=e}function P(t,e){ -t.pending_buf[t.pending++]=e>>>8&255,t.pending_buf[t.pending++]=255&e}function L(t,e){var r,i,n=t.max_chain_length,s=t.strstart,a=t.prev_length,o=t.nice_match,h=t.strstart>t.w_size-z?t.strstart-(t.w_size-z):0,u=t.window,l=t.w_mask,f=t.prev,d=t.strstart+S,c=u[s+a-1],p=u[s+a];t.prev_length>=t.good_match&&(n>>=2),o>t.lookahead&&(o=t.lookahead);do if(u[(r=e)+a]===p&&u[r+a-1]===c&&u[r]===u[s]&&u[++r]===u[s+1]){s+=2,r++;do;while(u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&d>s);if(i=S-(d-s),s=d-S,i>a){if(t.match_start=e,o<=(a=i))break;c=u[s+a-1],p=u[s+a]}}while((e=f[e&l])>h&&0!=--n);return a<=t.lookahead?a:t.lookahead}function j(t){var e,r,i,n,s,a,o,h,u,l,f=t.w_size;do{if(n=t.window_size-t.lookahead-t.strstart,t.strstart>=f+(f-z)){for(d.arraySet(t.window,t.window,f,f,0),t.match_start-=f,t.strstart-=f,t.block_start-=f,e=r=t.hash_size;i=t.head[--e],t.head[e]=i>=f?i-f:0,--r;);for(e=r=f;i=t.prev[--e],t.prev[e]=i>=f?i-f:0,--r;);n+=f}if(0===t.strm.avail_in)break;if(a=t.strm,o=t.window,h=t.strstart+t.lookahead,u=n,l=void 0,l=a.avail_in,l>u&&(l=u),r=0===l?0:(a.avail_in-=l,d.arraySet(o,a.input,a.next_in,l,h),1===a.state.wrap?a.adler=c(a.adler,o,l,h):2===a.state.wrap&&(a.adler=p(a.adler,o,l,h)),a.next_in+=l,a.total_in+=l,l),t.lookahead+=r,t.lookahead+t.insert>=x)for(s=t.strstart-t.insert,t.ins_h=t.window[s],t.ins_h=(t.ins_h<=x&&(t.ins_h=(t.ins_h<=x)if(i=u._tr_tally(t,t.strstart-t.match_start,t.match_length-x),t.lookahead-=t.match_length,t.match_length<=t.max_lazy_match&&t.lookahead>=x){for(t.match_length--;t.strstart++,t.ins_h=(t.ins_h<=x&&(t.ins_h=(t.ins_h<=x&&t.match_length<=t.prev_length){for(n=t.strstart+t.lookahead-x,i=u._tr_tally(t,t.strstart-1-t.prev_match,t.prev_length-x),t.lookahead-=t.prev_length-1,t.prev_length-=2;++t.strstart<=n&&(t.ins_h=(t.ins_h<i?(a=0,i=-i):i>15&&(a=2,i-=16),1>n||n>y||r!==v||8>i||i>15||0>e||e>9||0>s||s>b)return R(t,_);8===i&&(i=9);var o=new H;return(t.state=o).strm=t,o.wrap=a,o.gzhead=null,o.w_bits=i,o.w_size=1<t.pending_buf_size-5&&(r=t.pending_buf_size-5);;){if(t.lookahead<=1){if(j(t),0===t.lookahead&&e===l)return A;if(0===t.lookahead)break}t.strstart+=t.lookahead,t.lookahead=0;var i=t.block_start+r;if((0===t.strstart||t.strstart>=i)&&(t.lookahead=t.strstart-i,t.strstart=i,N(t,!1),0===t.strm.avail_out))return A;if(t.strstart-t.block_start>=t.w_size-z&&(N(t,!1),0===t.strm.avail_out))return A}return t.insert=0,e===f?(N(t,!0),0===t.strm.avail_out?O:B):(t.strstart>t.block_start&&(N(t,!1),t.strm.avail_out),A)}),new M(4,4,8,4,Z),new M(4,5,16,8,Z),new M(4,6,32,32,Z),new M(4,4,16,16,W),new M(8,16,32,32,W),new M(8,16,128,128,W),new M(8,32,128,256,W),new M(32,128,258,1024,W),new M(32,258,258,4096,W)],r.deflateInit=function(t,e){return Y(t,e,v,15,8,0)},r.deflateInit2=Y,r.deflateReset=K,r.deflateResetKeep=G,r.deflateSetHeader=function(t,e){return t&&t.state?2!==t.state.wrap?_:(t.state.gzhead=e,m):_},r.deflate=function(t,e){var r,i,n,s;if(!t||!t.state||e>5||0>e)return t?R(t,_):_;if(i=t.state,!t.output||!t.input&&0!==t.avail_in||666===i.status&&e!==f)return R(t,0===t.avail_out?-5:_);if(i.strm=t,r=i.last_flush,i.last_flush=e,i.status===C)if(2===i.wrap)t.adler=0,U(i,31),U(i,139),U(i,8),i.gzhead?(U(i,(i.gzhead.text?1:0)+(i.gzhead.hcrc?2:0)+(i.gzhead.extra?4:0)+(i.gzhead.name?8:0)+(i.gzhead.comment?16:0)),U(i,255&i.gzhead.time),U(i,i.gzhead.time>>8&255),U(i,i.gzhead.time>>16&255),U(i,i.gzhead.time>>24&255),U(i,9===i.level?2:2<=i.strategy||i.level<2?4:0),U(i,255&i.gzhead.os),i.gzhead.extra&&i.gzhead.extra.length&&(U(i,255&i.gzhead.extra.length),U(i,i.gzhead.extra.length>>8&255)),i.gzhead.hcrc&&(t.adler=p(t.adler,i.pending_buf,i.pending,0)),i.gzindex=0,i.status=69):(U(i,0),U(i,0),U(i,0),U(i,0),U(i,0),U(i,9===i.level?2:2<=i.strategy||i.level<2?4:0),U(i,3),i.status=E);else{var a=v+(i.w_bits-8<<4)<<8;a|=(2<=i.strategy||i.level<2?0:i.level<6?1:6===i.level?2:3)<<6,0!==i.strstart&&(a|=32),a+=31-a%31,i.status=E,P(i,a),0!==i.strstart&&(P(i,t.adler>>>16),P(i,65535&t.adler)),t.adler=1}if(69===i.status)if(i.gzhead.extra){for(n=i.pending;i.gzindex<(65535&i.gzhead.extra.length)&&(i.pending!==i.pending_buf_size||(i.gzhead.hcrc&&i.pending>n&&(t.adler=p(t.adler,i.pending_buf,i.pending-n,n)),F(t),n=i.pending,i.pending!==i.pending_buf_size));)U(i,255&i.gzhead.extra[i.gzindex]),i.gzindex++;i.gzhead.hcrc&&i.pending>n&&(t.adler=p(t.adler,i.pending_buf,i.pending-n,n)),i.gzindex===i.gzhead.extra.length&&(i.gzindex=0,i.status=73)}else i.status=73;if(73===i.status)if(i.gzhead.name){n=i.pending;do{if(i.pending===i.pending_buf_size&&(i.gzhead.hcrc&&i.pending>n&&(t.adler=p(t.adler,i.pending_buf,i.pending-n,n)),F(t),n=i.pending,i.pending===i.pending_buf_size)){s=1;break}s=i.gzindexn&&(t.adler=p(t.adler,i.pending_buf,i.pending-n,n)),0===s&&(i.gzindex=0,i.status=91)}else i.status=91;if(91===i.status)if(i.gzhead.comment){n=i.pending;do{if(i.pending===i.pending_buf_size&&(i.gzhead.hcrc&&i.pending>n&&(t.adler=p(t.adler,i.pending_buf,i.pending-n,n)),F(t),n=i.pending,i.pending===i.pending_buf_size)){s=1;break}s=i.gzindexn&&(t.adler=p(t.adler,i.pending_buf,i.pending-n,n)),0===s&&(i.status=103)}else i.status=103;if(103===i.status&&(i.gzhead.hcrc?(i.pending+2>i.pending_buf_size&&F(t),i.pending+2<=i.pending_buf_size&&(U(i,255&t.adler),U(i,t.adler>>8&255),t.adler=0,i.status=E)):i.status=E),0!==i.pending){if(F(t),0===t.avail_out)return i.last_flush=-1,m}else if(0===t.avail_in&&T(e)<=T(r)&&e!==f)return R(t,-5);if(666===i.status&&0!==t.avail_in)return R(t,-5);if(0!==t.avail_in||0!==i.lookahead||e!==l&&666!==i.status){var o=2===i.strategy?function(t,e){for(var r;;){if(0===t.lookahead&&(j(t),0===t.lookahead)){if(e===l)return A;break}if(t.match_length=0,r=u._tr_tally(t,0,t.window[t.strstart]),t.lookahead--,t.strstart++,r&&(N(t,!1),0===t.strm.avail_out))return A}return t.insert=0,e===f?(N(t,!0),0===t.strm.avail_out?O:B):t.last_lit&&(N(t,!1),0===t.strm.avail_out)?A:I}(i,e):3===i.strategy?function(t,e){for(var r,i,n,s,a=t.window;;){if(t.lookahead<=S){if(j(t),t.lookahead<=S&&e===l)return A;if(0===t.lookahead)break}if(t.match_length=0,t.lookahead>=x&&0n);t.match_length=S-(s-n),t.match_length>t.lookahead&&(t.match_length=t.lookahead)}if(t.match_length>=x?(r=u._tr_tally(t,1,t.match_length-x),t.lookahead-=t.match_length,t.strstart+=t.match_length,t.match_length=0):(r=u._tr_tally(t,0,t.window[t.strstart]),t.lookahead--,t.strstart++),r&&(N(t,!1),0===t.strm.avail_out))return A}return t.insert=0,e===f?(N(t,!0),0===t.strm.avail_out?O:B):t.last_lit&&(N(t,!1),0===t.strm.avail_out)?A:I}(i,e):h[i.level].func(i,e);if(o!==O&&o!==B||(i.status=666),o===A||o===O)return 0===t.avail_out&&(i.last_flush=-1),m;if(o===I&&(1===e?u._tr_align(i):5!==e&&(u._tr_stored_block(i,0,0,!1),3===e&&(D(i.head),0===i.lookahead&&(i.strstart=0,i.block_start=0,i.insert=0))),F(t),0===t.avail_out))return i.last_flush=-1,m}return e!==f?m:i.wrap<=0?1:(2===i.wrap?(U(i,255&t.adler),U(i,t.adler>>8&255),U(i,t.adler>>16&255),U(i,t.adler>>24&255),U(i,255&t.total_in),U(i,t.total_in>>8&255),U(i,t.total_in>>16&255),U(i,t.total_in>>24&255)):(P(i,t.adler>>>16),P(i,65535&t.adler)),F(t),0=r.w_size&&(0===s&&(D(r.head),r.strstart=0,r.block_start=0,r.insert=0),u=new d.Buf8(r.w_size),d.arraySet(u,e,l-r.w_size,r.w_size,0),e=u,l=r.w_size),a=t.avail_in,o=t.next_in,h=t.input,t.avail_in=l,t.next_in=0,t.input=e,j(r);r.lookahead>=x;){for(i=r.strstart,n=r.lookahead-(x-1);r.ins_h=(r.ins_h<p&&(c+=z[i++]<>>=y=v>>>24,p-=y,0===(y=v>>>16&255))C[s++]=65535&v;else{if(!(16&y)){if(0==(64&y)){v=m[(65535&v)+(c&(1<p&&(c+=z[i++]<>>=y,p-=y),15>p&&(c+=z[i++]<>>=y=v>>>24,p-=y,!(16&(y=v>>>16&255))){if(0==(64&y)){v=_[(65535&v)+(c&(1<>>=y,p-=y,(y=s-a)y){for(w-=y;C[s++]=d[x++],--y;);x=s-k,S=C}}else if(y>f){if(x+=u+f-y,(y-=f)f){for(w-=y=f;C[s++]=d[x++],--y;);x=s-k,S=C}}}else if(x+=f-y,w>y){for(w-=y;C[s++]=d[x++],--y;);x=s-k,S=C}for(;w>2;)C[s++]=S[x++],C[s++]=S[x++],C[s++]=S[x++],w-=3;w&&(C[s++]=S[x++],w>1&&(C[s++]=S[x++]))}else{for(x=s-k;C[s++]=C[x++],C[s++]=C[x++],C[s++]=C[x++],2<(w-=3););w&&(C[s++]=C[x++],w>1&&(C[s++]=C[x++]))}break}}break}}while(n>i&&o>s);i-=w=p>>3,c&=(1<<(p-=w<<3))-1,t.next_in=i,t.next_out=s,t.avail_in=n>i?n-i+5:5-(i-n),t.avail_out=o>s?o-s+257:257-(s-o),r.hold=c,r.bits=p}},{}],49:[function(t,e,r){"use strict";function L(t){return(t>>>24&255)+(t>>>8&65280)+((65280&t)<<8)+((255&t)<<24)}function s(){this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new I.Buf16(320),this.work=new I.Buf16(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}function a(t){var e;return t&&t.state?(e=t.state,t.total_in=t.total_out=e.total=0,t.msg="",e.wrap&&(t.adler=1&e.wrap),e.mode=P,e.last=0,e.havedict=0,e.dmax=32768,e.head=null,e.hold=0,e.bits=0,e.lencode=e.lendyn=new I.Buf32(i),e.distcode=e.distdyn=new I.Buf32(n),e.sane=1,e.back=-1,N):U}function o(t){var e;return t&&t.state?((e=t.state).wsize=0,e.whave=0,e.wnext=0,a(t)):U}function h(t,e){var r,i;return t&&t.state?(i=t.state,0>e?(r=0,e=-e):(r=1+(e>>4),48>e&&(e&=15)),e&&(8>e||e>15)?U:(null!==i.window&&i.wbits!==e&&(i.window=null),i.wrap=r,i.wbits=e,o(t))):U}function u(t,e){var r,i;return t?(i=new s,(t.state=i).window=null,(r=h(t,e))!==N&&(t.state=null),r):U}function j(t){if(d){var e;for(l=new I.Buf32(512),f=new I.Buf32(32),e=0;144>e;)t.lens[e++]=8;for(;256>e;)t.lens[e++]=9;for(;280>e;)t.lens[e++]=7;for(;288>e;)t.lens[e++]=8;for(T(D,t.lens,0,288,l,0,t.work,{bits:9}),e=0;32>e;)t.lens[e++]=5;T(F,t.lens,0,32,f,0,t.work,{bits:5}),d=!1}t.lencode=l,t.lenbits=9,t.distcode=f,t.distbits=5}function Z(t,e,r,i){var n,s=t.state;return null===s.window&&(s.wsize=1<=s.wsize?(I.arraySet(s.window,e,r-s.wsize,s.wsize,0),s.wnext=0,s.whave=s.wsize):(i<(n=s.wsize-s.wnext)&&(n=i),I.arraySet(s.window,e,r-i,n,s.wnext),(i-=n)?(I.arraySet(s.window,e,r-i,i,0),s.wnext=i,s.whave=s.wsize):(s.wnext+=n,s.wnext===s.wsize&&(s.wnext=0),s.whavel;){if(0===o)break t;o--,u+=i[s++]<>>8&255,r.check=B(r.check,E,2,0),l=u=0,r.mode=2;break}if(r.flags=0,r.head&&(r.head.done=!1),!(1&r.wrap)||(((255&u)<<8)+(u>>8))%31){t.msg="incorrect header check",r.mode=30;break}if(8!=(15&u)){t.msg="unknown compression method",r.mode=30;break}if(l-=4,k=8+(15&(u>>>=4)),0===r.wbits)r.wbits=k;else if(k>r.wbits){t.msg="invalid window size",r.mode=30;break}r.dmax=1<l;){if(0===o)break t;o--,u+=i[s++]<>8&1),512&r.flags&&(E[0]=255&u,E[1]=u>>>8&255,r.check=B(r.check,E,2,0)),l=u=0,r.mode=3;case 3:for(;32>l;){if(0===o)break t;o--,u+=i[s++]<>>8&255,E[2]=u>>>16&255,E[3]=u>>>24&255,r.check=B(r.check,E,4,0)),l=u=0,r.mode=4;case 4:for(;16>l;){if(0===o)break t;o--,u+=i[s++]<>8),512&r.flags&&(E[0]=255&u,E[1]=u>>>8&255,r.check=B(r.check,E,2,0)),l=u=0,r.mode=5;case 5:if(1024&r.flags){for(;16>l;){if(0===o)break t;o--,u+=i[s++]<>>8&255,r.check=B(r.check,E,2,0)),l=u=0}else r.head&&(r.head.extra=null);r.mode=6;case 6:if(1024&r.flags&&(o<(c=r.length)&&(c=o),c&&(r.head&&(k=r.head.extra_len-r.length,r.head.extra||(r.head.extra=new Array(r.head.extra_len)),I.arraySet(r.head.extra,i,s,c,k)),512&r.flags&&(r.check=B(r.check,i,c,s)),o-=c,s+=c,r.length-=c),r.length))break t;r.length=0,r.mode=7;case 7:if(2048&r.flags){if(0===o)break t;for(c=0;k=i[s+c++],r.head&&k&&r.length<65536&&(r.head.name+=String.fromCharCode(k)),k&&o>c;);if(512&r.flags&&(r.check=B(r.check,i,c,s)),o-=c,s+=c,k)break t}else r.head&&(r.head.name=null);r.length=0,r.mode=8;case 8:if(4096&r.flags){if(0===o)break t;for(c=0;k=i[s+c++],r.head&&k&&r.length<65536&&(r.head.comment+=String.fromCharCode(k)),k&&o>c;);if(512&r.flags&&(r.check=B(r.check,i,c,s)),o-=c,s+=c,k)break t}else r.head&&(r.head.comment=null);r.mode=9;case 9:if(512&r.flags){for(;16>l;){if(0===o)break t;o--,u+=i[s++]<>9&1,r.head.done=!0),t.adler=r.check=0,r.mode=12;break;case 10:for(;32>l;){if(0===o)break t;o--,u+=i[s++]<>>=7&l,l-=7&l,r.mode=27;break}for(;3>l;){if(0===o)break t;o--,u+=i[s++]<>>=1)){case 0:r.mode=14;break;case 1:if(j(r),r.mode=20,6!==e)break;u>>>=2,l-=2;break t;case 2:r.mode=17;break;case 3:t.msg="invalid block type",r.mode=30}u>>>=2,l-=2;break;case 14:for(u>>>=7&l,l-=7&l;32>l;){if(0===o)break t;o--,u+=i[s++]<>>16^65535)){t.msg="invalid stored block lengths",r.mode=30;break}if(r.length=65535&u,l=u=0,r.mode=15,6===e)break t;case 15:r.mode=16;case 16:if(c=r.length){if(c>o&&(c=o),c>h&&(c=h),0===c)break t;I.arraySet(n,i,s,c,a),o-=c,s+=c,h-=c,a+=c,r.length-=c;break}r.mode=12;break;case 17:for(;14>l;){if(0===o)break t;o--,u+=i[s++]<>>=5,l-=5,r.ndist=1+(31&u),u>>>=5,l-=5,r.ncode=4+(15&u),u>>>=4,l-=4,286l;){if(0===o)break t;o--,u+=i[s++]<>>=3,l-=3}for(;r.have<19;)r.lens[A[r.have++]]=0;if(r.lencode=r.lendyn,r.lenbits=7,S={bits:r.lenbits},x=T(0,r.lens,0,19,r.lencode,0,r.work,S),r.lenbits=S.bits,x){t.msg="invalid code lengths set",r.mode=30;break}r.have=0,r.mode=19;case 19:for(;r.have>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break t;o--,u+=i[s++]<b)u>>>=_,l-=_,r.lens[r.have++]=b;else{if(16===b){for(z=_+2;z>l;){if(0===o)break t;o--,u+=i[s++]<>>=_,l-=_,0===r.have){t.msg="invalid bit length repeat",r.mode=30;break}k=r.lens[r.have-1],c=3+(3&u),u>>>=2,l-=2}else if(17===b){for(z=_+3;z>l;){if(0===o)break t;o--,u+=i[s++]<>>=_)),u>>>=3,l-=3}else{for(z=_+7;z>l;){if(0===o)break t;o--,u+=i[s++]<>>=_)),u>>>=7,l-=7}if(r.have+c>r.nlen+r.ndist){t.msg="invalid bit length repeat",r.mode=30;break}for(;c--;)r.lens[r.have++]=k}}if(30===r.mode)break;if(0===r.lens[256]){t.msg="invalid code -- missing end-of-block",r.mode=30;break}if(r.lenbits=9,S={bits:r.lenbits},x=T(D,r.lens,0,r.nlen,r.lencode,0,r.work,S),r.lenbits=S.bits,x){t.msg="invalid literal/lengths set",r.mode=30;break}if(r.distbits=6,r.distcode=r.distdyn,S={bits:r.distbits},x=T(F,r.lens,r.nlen,r.ndist,r.distcode,0,r.work,S),r.distbits=S.bits,x){t.msg="invalid distances set",r.mode=30;break}if(r.mode=20,6===e)break t;case 20:r.mode=21;case 21:if(o>=6&&h>=258){t.next_out=a,t.avail_out=h,t.next_in=s,t.avail_in=o,r.hold=u,r.bits=l,R(t,d),a=t.next_out,n=t.output,h=t.avail_out,s=t.next_in,i=t.input,o=t.avail_in,u=r.hold,l=r.bits,12===r.mode&&(r.back=-1);break}for(r.back=0;g=(C=r.lencode[u&(1<>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break t;o--,u+=i[s++]<>v)])>>>16&255,b=65535&C,!(v+(_=C>>>24)<=l);){if(0===o)break t;o--,u+=i[s++]<>>=v,l-=v,r.back+=v}if(u>>>=_,l-=_,r.back+=_,r.length=b,0===g){r.mode=26;break}if(32&g){r.back=-1,r.mode=12;break}if(64&g){t.msg="invalid literal/length code",r.mode=30;break}r.extra=15&g,r.mode=22;case 22:if(r.extra){for(z=r.extra;z>l;){if(0===o)break t;o--,u+=i[s++]<>>=r.extra,l-=r.extra,r.back+=r.extra}r.was=r.length,r.mode=23;case 23:for(;g=(C=r.distcode[u&(1<>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break t;o--,u+=i[s++]<>v)])>>>16&255,b=65535&C,!(v+(_=C>>>24)<=l);){if(0===o)break t;o--,u+=i[s++]<>>=v,l-=v,r.back+=v}if(u>>>=_,l-=_,r.back+=_,64&g){t.msg="invalid distance code",r.mode=30;break}r.offset=b,r.extra=15&g,r.mode=24;case 24:if(r.extra){for(z=r.extra;z>l;){if(0===o)break t;o--,u+=i[s++]<>>=r.extra,l-=r.extra,r.back+=r.extra}if(r.offset>r.dmax){t.msg="invalid distance too far back",r.mode=30;break}r.mode=25;case 25:if(0===h)break t;if(c=d-h,r.offset>c){if((c=r.offset-c)>r.whave&&r.sane){t.msg="invalid distance too far back",r.mode=30;break}p=c>r.wnext?(c-=r.wnext,r.wsize-c):r.wnext-c,c>r.length&&(c=r.length),m=r.window}else m=n,p=a-r.offset,c=r.length;for(c>h&&(c=h),h-=c,r.length-=c;n[a++]=m[p++],--c;);0===r.length&&(r.mode=21);break;case 26:if(0===h)break t;n[a++]=r.length,h--,r.mode=21;break;case 27:if(r.wrap){for(;32>l;){if(0===o)break t;o--,u|=i[s++]<l;){if(0===o)break t;o--,u+=i[s++]<=b;b++)O[b]=0;for(v=0;i>v;v++)O[e[r+v]]++;for(k=g,w=15;w>=1&&0===O[w];w--);if(k>w&&(k=w),0===w)return n[s++]=20971520,n[s++]=20971520,o.bits=1,0;for(y=1;w>y&&0===O[y];y++);for(y>k&&(k=y),b=z=1;15>=b;b++)if(z<<=1,(z-=O[b])<0)return-1;if(z>0&&(0===t||1!==w))return-1;for(B[1]=0,b=1;15>b;b++)B[b+1]=B[b]+O[b];for(v=0;i>v;v++)0!==e[r+v]&&(a[B[e[r+v]]++]=v);if(c=0===t?(A=R=a,19):1===t?(A=F,I-=257,R=N,T-=257,256):(A=U,R=P,-1),b=y,d=s,S=v=E=0,l=-1,f=(C=1<<(x=k))-1,1===t&&C>852||2===t&&C>592)return 1;for(;;){for(p=b-S,_=a[v]c?(m=R[T+a[v]],A[I+a[v]]):(m=96,0),h=1<>S)+(u-=h)]=p<<24|m<<16|_|0,0!==u;);for(h=1<>=1;if(0!==h?(E&=h-1,E+=h):E=0,v++,0==--O[b]){if(b===w)break;b=e[r+a[v]]}if(b>k&&(E&f)!==l){for(0===S&&(S=k),d+=y,z=1<<(x=b-S);w>x+S&&!((z-=O[x+S])<=0);)x++,z<<=1;if(C+=1<852||2===t&&C>592)return 1;n[l=E&f]=k<<24|x<<16|d-s|0}}return 0!==E&&(n[d+E]=b-S<<24|64<<16|0),o.bits=k,0}},{"../utils/common":41}],51:[function(t,e,r){"use strict";e.exports={2:"need dictionary",1:"stream end",0:"","-1":"file error","-2":"stream error","-3":"data error","-4":"insufficient memory","-5":"buffer error","-6":"incompatible version"}},{}],52:[function(t,e,r){"use strict";function i(t){for(var e=t.length;0<=--e;)t[e]=0}function D(t,e,r,i,n){this.static_tree=t,this.extra_bits=e,this.extra_base=r,this.elems=i,this.max_length=n,this.has_stree=t&&t.length}function F(t,e){this.dyn_tree=t,this.max_code=0,this.stat_desc=e}function N(t){return 256>t?E[t]:E[256+(t>>>7)]}function U(t,e){t.pending_buf[t.pending++]=255&e,t.pending_buf[t.pending++]=e>>>8&255}function P(t,e,r){t.bi_valid>c-r?(t.bi_buf|=e<>c-t.bi_valid,t.bi_valid+=r-c):(t.bi_buf|=e<>>=1,r<<=1,0<--e;);return r>>>1}function Z(t,e,r){var i,n,s=new Array(g+1),a=0;for(i=1;g>=i;i++)s[i]=a=a+r[i-1]<<1;for(n=0;e>=n;n++){var o=t[2*n+1];0!==o&&(t[2*n]=j(s[o]++,o))}}function W(t){var e;for(e=0;l>e;e++)t.dyn_ltree[2*e]=0;for(e=0;f>e;e++)t.dyn_dtree[2*e]=0;for(e=0;d>e;e++)t.bl_tree[2*e]=0;t.dyn_ltree[2*m]=1,t.opt_len=t.static_len=0,t.last_lit=t.matches=0}function M(t){8r;r++)0!==s[2*r]?(t.heap[++t.heap_len]=u=r,t.depth[r]=0):s[2*r+1]=0;for(;t.heap_len<2;)s[2*(n=t.heap[++t.heap_len]=2>u?++u:0)]=1,t.depth[n]=0,t.opt_len--,o&&(t.static_len-=a[2*n+1]);for(e.max_code=u,r=t.heap_len>>1;r>=1;r--)G(t,s,r);for(n=h;r=t.heap[1],t.heap[1]=t.heap[t.heap_len--],G(t,s,1),i=t.heap[1],t.heap[--t.heap_max]=r,t.heap[--t.heap_max]=i,s[2*n]=s[2*r]+s[2*i],t.depth[n]=(t.depth[r]>=t.depth[i]?t.depth[r]:t.depth[i])+1,s[2*r+1]=s[2*i+1]=n,t.heap[1]=n++,G(t,s,1),2<=t.heap_len;);t.heap[--t.heap_max]=t.heap[1],function(t,e){var r,i,n,s,a,o,h=e.dyn_tree,u=e.max_code,l=e.stat_desc.static_tree,f=e.stat_desc.has_stree,d=e.stat_desc.extra_bits,c=e.stat_desc.extra_base,p=e.stat_desc.max_length,m=0;for(s=0;g>=s;s++)t.bl_count[s]=0;for(h[2*t.heap[t.heap_max]+1]=0,r=t.heap_max+1;_>r;r++)p<(s=h[2*h[2*(i=t.heap[r])+1]+1]+1)&&(s=p,m++),h[2*i+1]=s,i>u||(t.bl_count[s]++,a=0,i>=c&&(a=d[i-c]),o=h[2*i],t.opt_len+=o*(s+a),f&&(t.static_len+=o*(l[2*i+1]+a)));if(0!==m){do{for(s=p-1;0===t.bl_count[s];)s--;t.bl_count[s]--,t.bl_count[s+1]+=2,t.bl_count[p]--,m-=2}while(m>0);for(s=p;0!==s;s--)for(i=t.bl_count[s];0!==i;)u<(n=t.heap[--r])||(h[2*n+1]!==s&&(t.opt_len+=(s-h[2*n+1])*h[2*n],h[2*n+1]=s),i--)}}(t,e),Z(s,u,t.bl_count)}function X(t,e,r){var i,n,s=-1,a=e[1],o=0,h=7,u=4;for(0===a&&(h=138,u=3),e[2*(r+1)+1]=65535,i=0;r>=i;i++)n=a,a=e[2*(i+1)+1],++oo?t.bl_tree[2*n]+=o:0!==n?(n!==s&&t.bl_tree[2*n]++,t.bl_tree[2*b]++):10>=o?t.bl_tree[2*v]++:t.bl_tree[2*y]++, -s=n,u=(o=0)===a?(h=138,3):n===a?(h=6,3):(h=7,4))}function V(t,e,r){var i,n,s=-1,a=e[1],o=0,h=7,u=4;for(0===a&&(h=138,u=3),i=0;r>=i;i++)if(n=a,a=e[2*(i+1)+1],!(++oo)for(;L(t,n,t.bl_tree),0!=--o;);else 0!==n?(n!==s&&(L(t,n,t.bl_tree),o--),L(t,b,t.bl_tree),P(t,o-3,2)):10>=o?(L(t,v,t.bl_tree),P(t,o-3,3)):(L(t,y,t.bl_tree),P(t,o-11,7));s=n,u=(o=0)===a?(h=138,3):n===a?(h=6,3):(h=7,4)}}function J(t,e,r,i){P(t,(s<<1)+(i?1:0),3),function(t,e,r,i){M(t),i&&(U(t,r),U(t,~r)),n.arraySet(t.pending_buf,t.window,e,r,t.pending),t.pending+=r}(t,e,r,!0)}var n=t("../utils/common"),o=0,h=1,s=0,a=29,u=256,l=u+1+a,f=30,d=19,_=2*l+1,g=15,c=16,p=7,m=256,b=16,v=17,y=18,w=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0],k=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13],x=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7],S=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],z=new Array(2*(l+2));i(z);var C=new Array(2*f);i(C);var E=new Array(512);i(E);var A=new Array(256);i(A);var I=new Array(a);i(I);var O,B,R,T=new Array(f);i(T);var q=!1;r._tr_init=function(t){q||(function(){var t,e,r,i,n,s=new Array(g+1);for(i=r=0;a-1>i;i++)for(I[i]=r,t=0;t<1<i;i++)for(T[i]=n,t=0;t<1<>=7;f>i;i++)for(T[i]=n<<7,t=0;t<1<=e;e++)s[e]=0;for(t=0;143>=t;)z[2*t+1]=8,t++,s[8]++;for(;255>=t;)z[2*t+1]=9,t++,s[9]++;for(;279>=t;)z[2*t+1]=7,t++,s[7]++;for(;287>=t;)z[2*t+1]=8,t++,s[8]++;for(Z(z,l+1,s),t=0;f>t;t++)C[2*t+1]=5,C[2*t]=j(t,5);O=new D(z,w,u+1,l,g),B=new D(C,k,0,f,g),R=new D(new Array(0),x,0,d,p)}(),q=!0),t.l_desc=new F(t.dyn_ltree,O),t.d_desc=new F(t.dyn_dtree,B),t.bl_desc=new F(t.bl_tree,R),t.bi_buf=0,t.bi_valid=0,W(t)},r._tr_stored_block=J,r._tr_flush_block=function(t,e,r,i){var n,s,a=0;0=e;e++,r>>>=1)if(1&r&&0!==t.dyn_ltree[2*e])return o;if(0!==t.dyn_ltree[18]||0!==t.dyn_ltree[20]||0!==t.dyn_ltree[26])return h;for(e=32;u>e;e++)if(0!==t.dyn_ltree[2*e])return h;return o}(t)),Y(t,t.l_desc),Y(t,t.d_desc),a=function(t){var e;for(X(t,t.dyn_ltree,t.l_desc.max_code),X(t,t.dyn_dtree,t.d_desc.max_code),Y(t,t.bl_desc),e=d-1;e>=3&&0===t.bl_tree[2*S[e]+1];e--);return t.opt_len+=3*(e+1)+5+5+4,e}(t),n=t.opt_len+3+7>>>3,(s=t.static_len+3+7>>>3)<=n&&(n=s)):n=s=r+5,n>=r+4&&-1!==e?J(t,e,r,i):4===t.strategy||s===n?(P(t,2+(i?1:0),3),K(t,z,C)):(P(t,4+(i?1:0),3),function(t,e,r,i){var n;for(P(t,e-257,5),P(t,r-1,5),P(t,i-4,4),n=0;i>n;n++)P(t,t.bl_tree[2*S[n]+1],3);V(t,t.dyn_ltree,e-1),V(t,t.dyn_dtree,r-1)}(t,t.l_desc.max_code+1,t.d_desc.max_code+1,a+1),K(t,t.dyn_ltree,t.dyn_dtree)),W(t),i&&M(t)},r._tr_tally=function(t,e,r){return t.pending_buf[t.d_buf+2*t.last_lit]=e>>>8&255,t.pending_buf[t.d_buf+2*t.last_lit+1]=255&e,t.pending_buf[t.l_buf+t.last_lit]=255&r,t.last_lit++,0===e?t.dyn_ltree[2*r]++:(t.matches++,e--,t.dyn_ltree[2*(A[r]+u+1)]++,t.dyn_dtree[2*N(e)]++),t.last_lit===t.lit_bufsize-1},r._tr_align=function(t){P(t,2,3),L(t,m,z),function(t){16===t.bi_valid?(U(t,t.bi_buf),t.bi_buf=0,t.bi_valid=0):8<=t.bi_valid&&(t.pending_buf[t.pending++]=255&t.bi_buf,t.bi_buf>>=8,t.bi_valid-=8)}(t)}},{"../utils/common":41}],53:[function(t,e,r){"use strict";e.exports=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg="",this.state=null,this.data_type=2,this.adler=0}},{}],54:[function(t,e,r){"use strict";e.exports="function"==typeof setImmediate?setImmediate:function(){var t=[].slice.apply(arguments);t.splice(1,0,0),setTimeout.apply(null,t)}},{}]},{},[10])(10)})}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{},require("buffer").Buffer)},{buffer:83}],89:[function(require,module,exports){exports.Parser=require("./lib/parser").Parser,exports.rules=require("./lib/rules"),exports.errors=require("./lib/errors"),exports.results=require("./lib/parsing-results"),exports.StringSource=require("./lib/StringSource"),exports.Token=require("./lib/Token"),exports.bottomUp=require("./lib/bottom-up"),exports.RegexTokeniser=require("./lib/regex-tokeniser").RegexTokeniser,exports.rule=function(ruleBuilder){var rule;return function(input){return rule||(rule=ruleBuilder()),rule(input)}}},{"./lib/StringSource":90,"./lib/Token":91,"./lib/bottom-up":93,"./lib/errors":94,"./lib/parser":96,"./lib/parsing-results":97,"./lib/regex-tokeniser":98,"./lib/rules":99}],90:[function(require,module,exports){var util=require("util"),StringSourceRange=(module.exports=function(string,description){var self={asString:function(){return string},range:function(startIndex,endIndex){return new StringSourceRange(string,description,startIndex,endIndex)}};return self},function(string,description,startIndex,endIndex){this._string=string,this._description=description,this._startIndex=startIndex,this._endIndex=endIndex});StringSourceRange.prototype.to=function(otherRange){return new StringSourceRange(this._string,this._description,this._startIndex,otherRange._endIndex)},StringSourceRange.prototype.describe=function(){var position=this._position(),description=this._description?this._description+"\n":"";return util.format("%sLine number: %s\nCharacter number: %s",description,position.lineNumber,position.characterNumber)},StringSourceRange.prototype.lineNumber=function(){return this._position().lineNumber},StringSourceRange.prototype.characterNumber=function(){return this._position().characterNumber},StringSourceRange.prototype._position=function(){for(var self=this,index=0,nextNewLine=function(){return self._string.indexOf("\n",index)},lineNumber=1;-1!==nextNewLine()&&nextNewLine()startIndex){var value=result[1],token=new Token(rules[i].name,value,source.range(startIndex,endIndex));return{token:token,endIndex:endIndex}}}}var endIndex=startIndex+1,token=new Token("unrecognisedCharacter",string.substring(startIndex,endIndex),source.range(startIndex,endIndex));return{token:token,endIndex:endIndex}}function endToken(input,source){return new Token("end",null,source.range(input.length,input.length))}return rules=rules.map(function(rule){return{name:rule.name,regex:new RegExp(rule.regex.source,"g")}}),{tokenise:tokenise}}var Token=require("./Token"),StringSource=require("./StringSource");exports.RegexTokeniser=RegexTokeniser},{"./StringSource":90,"./Token":91}],99:[function(require,module,exports){function noOpRule(input){return results.success(null,input)}function describeTokenMismatch(input,expected){var error,token=input.head();return error=token?errors.error({expected:expected,actual:describeToken(token),location:token.source}):errors.error({expected:expected,actual:"end of tokens"}),results.failure([error],input)}var _=require("underscore"),options=require("option"),results=require("./parsing-results"),errors=require("./errors"),lazyIterators=require("./lazy-iterators");exports.token=function(tokenType,value){var matchValue=void 0!==value;return function(input){var token=input.head();if(!token||token.name!==tokenType||matchValue&&token.value!==value){var expected=describeToken({name:tokenType,value:value});return describeTokenMismatch(input,expected)}return results.success(token.value,input.tail(),token.source)}},exports.tokenOfType=function(tokenType){return exports.token(tokenType)},exports.firstOf=function(name,parsers){return _.isArray(parsers)||(parsers=Array.prototype.slice.call(arguments,1)),function(input){return lazyIterators.fromArray(parsers).map(function(parser){return parser(input)}).filter(function(result){return result.isSuccess()||result.isError()}).first()||describeTokenMismatch(input,name)}},exports.then=function(parser,func){return function(input){var result=parser(input);return result.map||console.log(result),result.map(func)}},exports.sequence=function(){function isCapturedRule(subRule){return subRule.isCaptured}var parsers=Array.prototype.slice.call(arguments,0),rule=function(input){var result=_.foldl(parsers,function(memo,parser){var result=memo.result,hasCut=memo.hasCut;if(!result.isSuccess())return{result:result,hasCut:hasCut};var subResult=parser(result.remaining());if(subResult.isCut())return{result:result,hasCut:!0};if(subResult.isSuccess()){var values;values=parser.isCaptured?result.value().withValue(parser,subResult.value()):result.value();var remaining=subResult.remaining(),source=input.to(remaining);return{result:results.success(values,remaining,source),hasCut:hasCut}}return hasCut?{result:results.error(subResult.errors(),subResult.remaining()),hasCut:hasCut}:{result:subResult,hasCut:hasCut}},{result:results.success(new SequenceValues,input),hasCut:!1}).result,source=input.to(result.remaining());return result.map(function(values){return values.withValue(exports.sequence.source,source)})};return rule.head=function(){var firstCapture=_.find(parsers,isCapturedRule);return exports.then(rule,exports.sequence.extract(firstCapture))},rule.map=function(func){return exports.then(rule,function(result){return func.apply(this,result.toArray())})},rule};var SequenceValues=function(values,valuesArray){this._values=values||{},this._valuesArray=valuesArray||[]};SequenceValues.prototype.withValue=function(rule,value){if(rule.captureName&&rule.captureName in this._values)throw new Error('Cannot add second value for capture "'+rule.captureName+'"');var newValues=_.clone(this._values);newValues[rule.captureName]=value;var newValuesArray=this._valuesArray.concat([value]);return new SequenceValues(newValues,newValuesArray)},SequenceValues.prototype.get=function(rule){if(rule.captureName in this._values)return this._values[rule.captureName];throw new Error('No value for capture "'+rule.captureName+'"')},SequenceValues.prototype.toArray=function(){return this._valuesArray},exports.sequence.capture=function(rule,name){var captureRule=function(){return rule.apply(this,arguments)};return captureRule.captureName=name,captureRule.isCaptured=!0,captureRule},exports.sequence.extract=function(rule){return function(result){return result.get(rule)}},exports.sequence.applyValues=function(func){var rules=Array.prototype.slice.call(arguments,1);return function(result){var values=rules.map(function(rule){return result.get(rule)});return func.apply(this,values)}},exports.sequence.source={captureName:"☃source☃"},exports.sequence.cut=function(){return function(input){return results.cut(input)}},exports.optional=function(rule){return function(input){var result=rule(input);return result.isSuccess()?result.map(options.some):result.isFailure()?results.success(options.none,input):result}},exports.zeroOrMoreWithSeparator=function(rule,separator){return repeatedWithSeparator(rule,separator,!1)},exports.oneOrMoreWithSeparator=function(rule,separator){return repeatedWithSeparator(rule,separator,!0)};var zeroOrMore=exports.zeroOrMore=function(rule){return function(input){for(var result,values=[];(result=rule(input))&&result.isSuccess();)input=result.remaining(),values.push(result.value());return result.isError()?result:results.success(values,input)}};exports.oneOrMore=function(rule){return exports.oneOrMoreWithSeparator(rule,noOpRule)};var repeatedWithSeparator=function(rule,separator,isOneOrMore){return function(input){var result=rule(input);if(result.isSuccess()){var mainRule=exports.sequence.capture(rule,"main"),remainingRule=zeroOrMore(exports.then(exports.sequence(separator,mainRule),exports.sequence.extract(mainRule))),remainingResult=remainingRule(result.remaining());return results.success([result.value()].concat(remainingResult.value()),remainingResult.remaining())}return isOneOrMore||result.isError()?result:results.success([],input)}};exports.leftAssociative=function(leftRule,rightRule,func){var rights;rights=func?[{func:func,rule:rightRule}]:rightRule,rights=rights.map(function(right){return exports.then(right.rule,function(rightValue){return function(leftValue,source){return right.func(leftValue,rightValue,source)}})});var repeatedRule=exports.firstOf.apply(null,["rules"].concat(rights));return function(input){var start=input,leftResult=leftRule(input);if(!leftResult.isSuccess())return leftResult;for(var repeatedResult=repeatedRule(leftResult.remaining());repeatedResult.isSuccess();){var remaining=repeatedResult.remaining(),source=start.to(repeatedResult.remaining()),right=repeatedResult.value();leftResult=results.success(right(leftResult.value(),source),remaining,source),repeatedResult=repeatedRule(leftResult.remaining())}return repeatedResult.isError()?repeatedResult:leftResult}},exports.leftAssociative.firstOf=function(){return Array.prototype.slice.call(arguments,0)},exports.nonConsuming=function(rule){return function(input){return rule(input).changeRemaining(input)}};var describeToken=function(token){return token.value?token.name+' "'+token.value+'"':token.name}},{"./errors":94,"./lazy-iterators":95,"./parsing-results":97,option:100,underscore:102}],100:[function(require,module,exports){function callOrReturn(value){return"function"==typeof value?value():value}exports.none=Object.create({value:function(){throw new Error("Called value on none")},isNone:function(){return!0},isSome:function(){return!1},map:function(){return exports.none},flatMap:function(){return exports.none},filter:function(){return exports.none},toArray:function(){return[]},orElse:callOrReturn,valueOrElse:callOrReturn}),exports.some=function(value){return new Some(value)};var Some=function(value){this._value=value};Some.prototype.value=function(){return this._value},Some.prototype.isNone=function(){return!1},Some.prototype.isSome=function(){return!0},Some.prototype.map=function(func){return new Some(func(this._value))},Some.prototype.flatMap=function(func){return func(this._value)},Some.prototype.filter=function(predicate){return predicate(this._value)?this:exports.none},Some.prototype.toArray=function(){return[this._value]},Some.prototype.orElse=function(value){return this},Some.prototype.valueOrElse=function(value){return this._value},exports.isOption=function(value){return value===exports.none||value instanceof Some},exports.fromNullable=function(value){return null==value?exports.none:new Some(value)}},{}],101:[function(require,module,exports){function defaultSetTimout(){throw new Error("setTimeout has not been defined")}function defaultClearTimeout(){throw new Error("clearTimeout has not been defined")}function runTimeout(fun){if(cachedSetTimeout===setTimeout)return setTimeout(fun,0);if((cachedSetTimeout===defaultSetTimout||!cachedSetTimeout)&&setTimeout)return cachedSetTimeout=setTimeout,setTimeout(fun,0);try{return cachedSetTimeout(fun,0)}catch(e){try{return cachedSetTimeout.call(null,fun,0)}catch(e){return cachedSetTimeout.call(this,fun,0)}}}function runClearTimeout(marker){if(cachedClearTimeout===clearTimeout)return clearTimeout(marker);if((cachedClearTimeout===defaultClearTimeout||!cachedClearTimeout)&&clearTimeout)return cachedClearTimeout=clearTimeout,clearTimeout(marker);try{return cachedClearTimeout(marker)}catch(e){try{return cachedClearTimeout.call(null,marker)}catch(e){return cachedClearTimeout.call(this,marker)}}}function cleanUpNextTick(){draining&¤tQueue&&(draining=!1,currentQueue.length?queue=currentQueue.concat(queue):queueIndex=-1,queue.length&&drainQueue())}function drainQueue(){if(!draining){var timeout=runTimeout(cleanUpNextTick);draining=!0;for(var len=queue.length;len;){for(currentQueue=queue,queue=[];++queueIndex1)for(var i=1;iindex;index++)rest[index]=arguments[index+startIndex];switch(startIndex){case 0:return func.call(this,rest);case 1:return func.call(this,arguments[0],rest);case 2:return func.call(this,arguments[0],arguments[1],rest)}var args=Array(startIndex+1);for(index=0;startIndex>index;index++)args[index]=arguments[index];return args[startIndex]=rest,func.apply(this,args)}}function isObject(obj){var type=typeof obj;return"function"===type||"object"===type&&!!obj}function isNull(obj){return null===obj}function isUndefined(obj){return void 0===obj}function isBoolean(obj){return obj===!0||obj===!1||"[object Boolean]"===toString.call(obj)}function isElement(obj){return!(!obj||1!==obj.nodeType)}function tagTester(name){var tag="[object "+name+"]";return function(obj){return toString.call(obj)===tag}}function ie10IsDataView(obj){return null!=obj&&isFunction$1(obj.getInt8)&&isArrayBuffer(obj.buffer)}function has$1(obj,key){return null!=obj&&hasOwnProperty.call(obj,key)}function isFinite$1(obj){return!isSymbol(obj)&&_isFinite(obj)&&!isNaN(parseFloat(obj))}function isNaN$1(obj){return isNumber(obj)&&_isNaN(obj)}function constant(value){return function(){return value}}function createSizePropertyCheck(getSizeProperty){return function(collection){var sizeProperty=getSizeProperty(collection);return"number"==typeof sizeProperty&&sizeProperty>=0&&MAX_ARRAY_INDEX>=sizeProperty}}function shallowProperty(key){return function(obj){return null==obj?void 0:obj[key]}}function isTypedArray(obj){return nativeIsView?nativeIsView(obj)&&!isDataView$1(obj):isBufferLike(obj)&&typedArrayPattern.test(toString.call(obj))}function emulatedSet(keys){for(var hash={},l=keys.length,i=0;l>i;++i)hash[keys[i]]=!0;return{contains:function(key){return hash[key]},push:function(key){return hash[key]=!0,keys.push(key)}}}function collectNonEnumProps(obj,keys){keys=emulatedSet(keys);var nonEnumIdx=nonEnumerableProps.length,constructor=obj.constructor,proto=isFunction$1(constructor)&&constructor.prototype||ObjProto,prop="constructor";for(has$1(obj,prop)&&!keys.contains(prop)&&keys.push(prop);nonEnumIdx--;)prop=nonEnumerableProps[nonEnumIdx],prop in obj&&obj[prop]!==proto[prop]&&!keys.contains(prop)&&keys.push(prop)}function keys(obj){if(!isObject(obj))return[];if(nativeKeys)return nativeKeys(obj);var keys=[];for(var key in obj)has$1(obj,key)&&keys.push(key);return hasEnumBug&&collectNonEnumProps(obj,keys),keys}function isEmpty(obj){if(null==obj)return!0;var length=getLength(obj);return"number"==typeof length&&(isArray(obj)||isString(obj)||isArguments$1(obj))?0===length:0===getLength(keys(obj))}function isMatch(object,attrs){var _keys=keys(attrs),length=_keys.length;if(null==object)return!length;for(var obj=Object(object),i=0;length>i;i++){var key=_keys[i];if(attrs[key]!==obj[key]||!(key in obj))return!1}return!0}function _$1(obj){return obj instanceof _$1?obj:this instanceof _$1?void(this._wrapped=obj):new _$1(obj)}function toBufferView(bufferSource){return new Uint8Array(bufferSource.buffer||bufferSource,bufferSource.byteOffset||0,getByteLength(bufferSource))}function eq(a,b,aStack,bStack){if(a===b)return 0!==a||1/a===1/b;if(null==a||null==b)return!1;if(a!==a)return b!==b;var type=typeof a;return"function"!==type&&"object"!==type&&"object"!=typeof b?!1:deepEq(a,b,aStack,bStack)}function deepEq(a,b,aStack,bStack){a instanceof _$1&&(a=a._wrapped),b instanceof _$1&&(b=b._wrapped);var className=toString.call(a);if(className!==toString.call(b))return!1;if(hasStringTagBug&&"[object Object]"==className&&isDataView$1(a)){if(!isDataView$1(b))return!1;className=tagDataView}switch(className){case"[object RegExp]":case"[object String]":return""+a==""+b;case"[object Number]":return+a!==+a?+b!==+b:0===+a?1/+a===1/b:+a===+b;case"[object Date]":case"[object Boolean]":return+a===+b;case"[object Symbol]":return SymbolProto.valueOf.call(a)===SymbolProto.valueOf.call(b);case"[object ArrayBuffer]":case tagDataView:return deepEq(toBufferView(a),toBufferView(b),aStack,bStack)}var areArrays="[object Array]"===className;if(!areArrays&&isTypedArray$1(a)){var byteLength=getByteLength(a);if(byteLength!==getByteLength(b))return!1;if(a.buffer===b.buffer&&a.byteOffset===b.byteOffset)return!0;areArrays=!0}if(!areArrays){if("object"!=typeof a||"object"!=typeof b)return!1;var aCtor=a.constructor,bCtor=b.constructor;if(aCtor!==bCtor&&!(isFunction$1(aCtor)&&aCtor instanceof aCtor&&isFunction$1(bCtor)&&bCtor instanceof bCtor)&&"constructor"in a&&"constructor"in b)return!1}aStack=aStack||[],bStack=bStack||[];for(var length=aStack.length;length--;)if(aStack[length]===a)return bStack[length]===b;if(aStack.push(a),bStack.push(b),areArrays){if(length=a.length,length!==b.length)return!1;for(;length--;)if(!eq(a[length],b[length],aStack,bStack))return!1}else{var key,_keys=keys(a);if(length=_keys.length,keys(b).length!==length)return!1;for(;length--;)if(key=_keys[length],!has$1(b,key)||!eq(a[key],b[key],aStack,bStack))return!1}return aStack.pop(),bStack.pop(),!0}function isEqual(a,b){return eq(a,b)}function allKeys(obj){if(!isObject(obj))return[];var keys=[];for(var key in obj)keys.push(key);return hasEnumBug&&collectNonEnumProps(obj,keys),keys}function ie11fingerprint(methods){var length=getLength(methods);return function(obj){if(null==obj)return!1;var keys=allKeys(obj);if(getLength(keys))return!1;for(var i=0;length>i;i++)if(!isFunction$1(obj[methods[i]]))return!1;return methods!==weakMapMethods||!isFunction$1(obj[forEachName])}}function values(obj){for(var _keys=keys(obj),length=_keys.length,values=Array(length),i=0;length>i;i++)values[i]=obj[_keys[i]];return values}function pairs(obj){for(var _keys=keys(obj),length=_keys.length,pairs=Array(length),i=0;length>i;i++)pairs[i]=[_keys[i],obj[_keys[i]]];return pairs}function invert(obj){for(var result={},_keys=keys(obj),i=0,length=_keys.length;length>i;i++)result[obj[_keys[i]]]=_keys[i];return result}function functions(obj){var names=[];for(var key in obj)isFunction$1(obj[key])&&names.push(key);return names.sort()}function createAssigner(keysFunc,defaults){return function(obj){var length=arguments.length;if(defaults&&(obj=Object(obj)),2>length||null==obj)return obj;for(var index=1;length>index;index++)for(var source=arguments[index],keys=keysFunc(source),l=keys.length,i=0;l>i;i++){var key=keys[i];defaults&&void 0!==obj[key]||(obj[key]=source[key])}return obj}}function ctor(){return function(){}}function baseCreate(prototype){if(!isObject(prototype))return{};if(nativeCreate)return nativeCreate(prototype);var Ctor=ctor();Ctor.prototype=prototype;var result=new Ctor;return Ctor.prototype=null,result}function create(prototype,props){var result=baseCreate(prototype);return props&&extendOwn(result,props),result}function clone(obj){return isObject(obj)?isArray(obj)?obj.slice():extend({},obj):obj}function tap(obj,interceptor){return interceptor(obj),obj}function toPath$1(path){return isArray(path)?path:[path]}function toPath(path){return _$1.toPath(path)}function deepGet(obj,path){ -for(var length=path.length,i=0;length>i;i++){if(null==obj)return void 0;obj=obj[path[i]]}return length?obj:void 0}function get(object,path,defaultValue){var value=deepGet(object,toPath(path));return isUndefined(value)?defaultValue:value}function has(obj,path){path=toPath(path);for(var length=path.length,i=0;length>i;i++){var key=path[i];if(!has$1(obj,key))return!1;obj=obj[key]}return!!length}function identity(value){return value}function matcher(attrs){return attrs=extendOwn({},attrs),function(obj){return isMatch(obj,attrs)}}function property(path){return path=toPath(path),function(obj){return deepGet(obj,path)}}function optimizeCb(func,context,argCount){if(void 0===context)return func;switch(null==argCount?3:argCount){case 1:return function(value){return func.call(context,value)};case 3:return function(value,index,collection){return func.call(context,value,index,collection)};case 4:return function(accumulator,value,index,collection){return func.call(context,accumulator,value,index,collection)}}return function(){return func.apply(context,arguments)}}function baseIteratee(value,context,argCount){return null==value?identity:isFunction$1(value)?optimizeCb(value,context,argCount):isObject(value)&&!isArray(value)?matcher(value):property(value)}function iteratee(value,context){return baseIteratee(value,context,1/0)}function cb(value,context,argCount){return _$1.iteratee!==iteratee?_$1.iteratee(value,context):baseIteratee(value,context,argCount)}function mapObject(obj,iteratee,context){iteratee=cb(iteratee,context);for(var _keys=keys(obj),length=_keys.length,results={},index=0;length>index;index++){var currentKey=_keys[index];results[currentKey]=iteratee(obj[currentKey],currentKey,obj)}return results}function noop(){}function propertyOf(obj){return null==obj?noop:function(path){return get(obj,path)}}function times(n,iteratee,context){var accum=Array(Math.max(0,n));iteratee=optimizeCb(iteratee,context,1);for(var i=0;n>i;i++)accum[i]=iteratee(i);return accum}function random(min,max){return null==max&&(max=min,min=0),min+Math.floor(Math.random()*(max-min+1))}function createEscaper(map){var escaper=function(match){return map[match]},source="(?:"+keys(map).join("|")+")",testRegexp=RegExp(source),replaceRegexp=RegExp(source,"g");return function(string){return string=null==string?"":""+string,testRegexp.test(string)?string.replace(replaceRegexp,escaper):string}}function escapeChar(match){return"\\"+escapes[match]}function template(text,settings,oldSettings){!settings&&oldSettings&&(settings=oldSettings),settings=defaults({},settings,_$1.templateSettings);var matcher=RegExp([(settings.escape||noMatch).source,(settings.interpolate||noMatch).source,(settings.evaluate||noMatch).source].join("|")+"|$","g"),index=0,source="__p+='";text.replace(matcher,function(match,escape,interpolate,evaluate,offset){return source+=text.slice(index,offset).replace(escapeRegExp,escapeChar),index=offset+match.length,escape?source+="'+\n((__t=("+escape+"))==null?'':_.escape(__t))+\n'":interpolate?source+="'+\n((__t=("+interpolate+"))==null?'':__t)+\n'":evaluate&&(source+="';\n"+evaluate+"\n__p+='"),match}),source+="';\n";var argument=settings.variable;if(argument){if(!bareIdentifier.test(argument))throw new Error("variable is not a bare identifier: "+argument)}else source="with(obj||{}){\n"+source+"}\n",argument="obj";source="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+source+"return __p;\n";var render;try{render=new Function(argument,"_",source)}catch(e){throw e.source=source,e}var template=function(data){return render.call(this,data,_$1)};return template.source="function("+argument+"){\n"+source+"}",template}function result(obj,path,fallback){path=toPath(path);var length=path.length;if(!length)return isFunction$1(fallback)?fallback.call(obj):fallback;for(var i=0;length>i;i++){var prop=null==obj?void 0:obj[path[i]];void 0===prop&&(prop=fallback,i=length),obj=isFunction$1(prop)?prop.call(obj):prop}return obj}function uniqueId(prefix){var id=++idCounter+"";return prefix?prefix+id:id}function chain(obj){var instance=_$1(obj);return instance._chain=!0,instance}function executeBound(sourceFunc,boundFunc,context,callingContext,args){if(!(callingContext instanceof boundFunc))return sourceFunc.apply(context,args);var self=baseCreate(sourceFunc.prototype),result=sourceFunc.apply(self,args);return isObject(result)?result:self}function flatten$1(input,depth,strict,output){if(output=output||[],depth||0===depth){if(0>=depth)return output.concat(input)}else depth=1/0;for(var idx=output.length,i=0,length=getLength(input);length>i;i++){var value=input[i];if(isArrayLike(value)&&(isArray(value)||isArguments$1(value)))if(depth>1)flatten$1(value,depth-1,strict,output),idx=output.length;else for(var j=0,len=value.length;len>j;)output[idx++]=value[j++];else strict||(output[idx++]=value)}return output}function memoize(func,hasher){var memoize=function(key){var cache=memoize.cache,address=""+(hasher?hasher.apply(this,arguments):key);return has$1(cache,address)||(cache[address]=func.apply(this,arguments)),cache[address]};return memoize.cache={},memoize}function throttle(func,wait,options){var timeout,context,args,result,previous=0;options||(options={});var later=function(){previous=options.leading===!1?0:now(),timeout=null,result=func.apply(context,args),timeout||(context=args=null)},throttled=function(){var _now=now();previous||options.leading!==!1||(previous=_now);var remaining=wait-(_now-previous);return context=this,args=arguments,0>=remaining||remaining>wait?(timeout&&(clearTimeout(timeout),timeout=null),previous=_now,result=func.apply(context,args),timeout||(context=args=null)):timeout||options.trailing===!1||(timeout=setTimeout(later,remaining)),result};return throttled.cancel=function(){clearTimeout(timeout),previous=0,timeout=context=args=null},throttled}function debounce(func,wait,immediate){var timeout,previous,args,result,context,later=function(){var passed=now()-previous;wait>passed?timeout=setTimeout(later,wait-passed):(timeout=null,immediate||(result=func.apply(context,args)),timeout||(args=context=null))},debounced=restArguments(function(_args){return context=this,args=_args,previous=now(),timeout||(timeout=setTimeout(later,wait),immediate&&(result=func.apply(context,args))),result});return debounced.cancel=function(){clearTimeout(timeout),timeout=args=context=null},debounced}function wrap(func,wrapper){return partial(wrapper,func)}function negate(predicate){return function(){return!predicate.apply(this,arguments)}}function compose(){var args=arguments,start=args.length-1;return function(){for(var i=start,result=args[start].apply(this,arguments);i--;)result=args[i].call(this,result);return result}}function after(times,func){return function(){return--times<1?func.apply(this,arguments):void 0}}function before(times,func){var memo;return function(){return--times>0&&(memo=func.apply(this,arguments)),1>=times&&(func=null),memo}}function findKey(obj,predicate,context){predicate=cb(predicate,context);for(var key,_keys=keys(obj),i=0,length=_keys.length;length>i;i++)if(key=_keys[i],predicate(obj[key],key,obj))return key}function createPredicateIndexFinder(dir){return function(array,predicate,context){predicate=cb(predicate,context);for(var length=getLength(array),index=dir>0?0:length-1;index>=0&&length>index;index+=dir)if(predicate(array[index],index,array))return index;return-1}}function sortedIndex(array,obj,iteratee,context){iteratee=cb(iteratee,context,1);for(var value=iteratee(obj),low=0,high=getLength(array);high>low;){var mid=Math.floor((low+high)/2);iteratee(array[mid])0?i=idx>=0?idx:Math.max(idx+length,i):length=idx>=0?Math.min(idx+1,length):idx+length+1;else if(sortedIndex&&idx&&length)return idx=sortedIndex(array,item),array[idx]===item?idx:-1;if(item!==item)return idx=predicateFind(slice.call(array,i,length),isNaN$1),idx>=0?idx+i:-1;for(idx=dir>0?i:length-1;idx>=0&&length>idx;idx+=dir)if(array[idx]===item)return idx;return-1}}function find(obj,predicate,context){var keyFinder=isArrayLike(obj)?findIndex:findKey,key=keyFinder(obj,predicate,context);return void 0!==key&&-1!==key?obj[key]:void 0}function findWhere(obj,attrs){return find(obj,matcher(attrs))}function each(obj,iteratee,context){iteratee=optimizeCb(iteratee,context);var i,length;if(isArrayLike(obj))for(i=0,length=obj.length;length>i;i++)iteratee(obj[i],i,obj);else{var _keys=keys(obj);for(i=0,length=_keys.length;length>i;i++)iteratee(obj[_keys[i]],_keys[i],obj)}return obj}function map(obj,iteratee,context){iteratee=cb(iteratee,context);for(var _keys=!isArrayLike(obj)&&keys(obj),length=(_keys||obj).length,results=Array(length),index=0;length>index;index++){var currentKey=_keys?_keys[index]:index;results[index]=iteratee(obj[currentKey],currentKey,obj)}return results}function createReduce(dir){var reducer=function(obj,iteratee,memo,initial){var _keys=!isArrayLike(obj)&&keys(obj),length=(_keys||obj).length,index=dir>0?0:length-1;for(initial||(memo=obj[_keys?_keys[index]:index],index+=dir);index>=0&&length>index;index+=dir){var currentKey=_keys?_keys[index]:index;memo=iteratee(memo,obj[currentKey],currentKey,obj)}return memo};return function(obj,iteratee,memo,context){var initial=arguments.length>=3;return reducer(obj,optimizeCb(iteratee,context,4),memo,initial)}}function filter(obj,predicate,context){var results=[];return predicate=cb(predicate,context),each(obj,function(value,index,list){predicate(value,index,list)&&results.push(value)}),results}function reject(obj,predicate,context){return filter(obj,negate(cb(predicate)),context)}function every(obj,predicate,context){predicate=cb(predicate,context);for(var _keys=!isArrayLike(obj)&&keys(obj),length=(_keys||obj).length,index=0;length>index;index++){var currentKey=_keys?_keys[index]:index;if(!predicate(obj[currentKey],currentKey,obj))return!1}return!0}function some(obj,predicate,context){predicate=cb(predicate,context);for(var _keys=!isArrayLike(obj)&&keys(obj),length=(_keys||obj).length,index=0;length>index;index++){var currentKey=_keys?_keys[index]:index;if(predicate(obj[currentKey],currentKey,obj))return!0}return!1}function contains(obj,item,fromIndex,guard){return isArrayLike(obj)||(obj=values(obj)),("number"!=typeof fromIndex||guard)&&(fromIndex=0),indexOf(obj,item,fromIndex)>=0}function pluck(obj,key){return map(obj,property(key))}function where(obj,attrs){return filter(obj,matcher(attrs))}function max(obj,iteratee,context){var value,computed,result=-(1/0),lastComputed=-(1/0);if(null==iteratee||"number"==typeof iteratee&&"object"!=typeof obj[0]&&null!=obj){obj=isArrayLike(obj)?obj:values(obj);for(var i=0,length=obj.length;length>i;i++)value=obj[i],null!=value&&value>result&&(result=value)}else iteratee=cb(iteratee,context),each(obj,function(v,index,list){computed=iteratee(v,index,list),(computed>lastComputed||computed===-(1/0)&&result===-(1/0))&&(result=v,lastComputed=computed)});return result}function min(obj,iteratee,context){var value,computed,result=1/0,lastComputed=1/0;if(null==iteratee||"number"==typeof iteratee&&"object"!=typeof obj[0]&&null!=obj){obj=isArrayLike(obj)?obj:values(obj);for(var i=0,length=obj.length;length>i;i++)value=obj[i],null!=value&&result>value&&(result=value)}else iteratee=cb(iteratee,context),each(obj,function(v,index,list){computed=iteratee(v,index,list),(lastComputed>computed||computed===1/0&&result===1/0)&&(result=v,lastComputed=computed)});return result}function sample(obj,n,guard){if(null==n||guard)return isArrayLike(obj)||(obj=values(obj)),obj[random(obj.length-1)];var sample=isArrayLike(obj)?clone(obj):values(obj),length=getLength(sample);n=Math.max(Math.min(n,length),0);for(var last=length-1,index=0;n>index;index++){var rand=random(index,last),temp=sample[index];sample[index]=sample[rand],sample[rand]=temp}return sample.slice(0,n)}function shuffle(obj){return sample(obj,1/0)}function sortBy(obj,iteratee,context){var index=0;return iteratee=cb(iteratee,context),pluck(map(obj,function(value,key,list){return{value:value,index:index++,criteria:iteratee(value,key,list)}}).sort(function(left,right){var a=left.criteria,b=right.criteria;if(a!==b){if(a>b||void 0===a)return 1;if(b>a||void 0===b)return-1}return left.index-right.index}),"value")}function group(behavior,partition){return function(obj,iteratee,context){var result=partition?[[],[]]:{};return iteratee=cb(iteratee,context),each(obj,function(value,index){var key=iteratee(value,index,obj);behavior(result,value,key)}),result}}function toArray(obj){return obj?isArray(obj)?slice.call(obj):isString(obj)?obj.match(reStrSymbol):isArrayLike(obj)?map(obj,identity):values(obj):[]}function size(obj){return null==obj?0:isArrayLike(obj)?obj.length:keys(obj).length}function keyInObj(value,key,obj){return key in obj}function initial(array,n,guard){return slice.call(array,0,Math.max(0,array.length-(null==n||guard?1:n)))}function first(array,n,guard){return null==array||array.length<1?null==n||guard?void 0:[]:null==n||guard?array[0]:initial(array,array.length-n)}function rest(array,n,guard){return slice.call(array,null==n||guard?1:n)}function last(array,n,guard){return null==array||array.length<1?null==n||guard?void 0:[]:null==n||guard?array[array.length-1]:rest(array,Math.max(0,array.length-n))}function compact(array){return filter(array,Boolean)}function flatten(array,depth){return flatten$1(array,depth,!1)}function uniq(array,isSorted,iteratee,context){isBoolean(isSorted)||(context=iteratee,iteratee=isSorted,isSorted=!1),null!=iteratee&&(iteratee=cb(iteratee,context));for(var result=[],seen=[],i=0,length=getLength(array);length>i;i++){var value=array[i],computed=iteratee?iteratee(value,i,array):value;isSorted&&!iteratee?(i&&seen===computed||result.push(value),seen=computed):iteratee?contains(seen,computed)||(seen.push(computed),result.push(value)):contains(result,value)||result.push(value)}return result}function intersection(array){for(var result=[],argsLength=arguments.length,i=0,length=getLength(array);length>i;i++){var item=array[i];if(!contains(result,item)){var j;for(j=1;argsLength>j&&contains(arguments[j],item);j++);j===argsLength&&result.push(item)}}return result}function unzip(array){for(var length=array&&max(array,getLength).length||0,result=Array(length),index=0;length>index;index++)result[index]=pluck(array,index);return result}function object(list,values){for(var result={},i=0,length=getLength(list);length>i;i++)values?result[list[i]]=values[i]:result[list[i][0]]=list[i][1];return result}function range(start,stop,step){null==stop&&(stop=start||0,start=0),step||(step=start>stop?-1:1);for(var length=Math.max(Math.ceil((stop-start)/step),0),range=Array(length),idx=0;length>idx;idx++,start+=step)range[idx]=start;return range}function chunk(array,count){if(null==count||1>count)return[];for(var result=[],i=0,length=array.length;length>i;)result.push(slice.call(array,i,i+=count));return result}function chainResult(instance,obj){return instance._chain?_$1(obj).chain():obj}function mixin(obj){return each(functions(obj),function(name){var func=_$1[name]=obj[name];_$1.prototype[name]=function(){var args=[this._wrapped];return push.apply(args,arguments),chainResult(this,func.apply(_$1,args))}}),_$1}var VERSION="1.13.1",root="object"==typeof self&&self.self===self&&self||"object"==typeof global&&global.global===global&&global||Function("return this")()||{},ArrayProto=Array.prototype,ObjProto=Object.prototype,SymbolProto="undefined"!=typeof Symbol?Symbol.prototype:null,push=ArrayProto.push,slice=ArrayProto.slice,toString=ObjProto.toString,hasOwnProperty=ObjProto.hasOwnProperty,supportsArrayBuffer="undefined"!=typeof ArrayBuffer,supportsDataView="undefined"!=typeof DataView,nativeIsArray=Array.isArray,nativeKeys=Object.keys,nativeCreate=Object.create,nativeIsView=supportsArrayBuffer&&ArrayBuffer.isView,_isNaN=isNaN,_isFinite=isFinite,hasEnumBug=!{toString:null}.propertyIsEnumerable("toString"),nonEnumerableProps=["valueOf","isPrototypeOf","toString","propertyIsEnumerable","hasOwnProperty","toLocaleString"],MAX_ARRAY_INDEX=Math.pow(2,53)-1,isString=tagTester("String"),isNumber=tagTester("Number"),isDate=tagTester("Date"),isRegExp=tagTester("RegExp"),isError=tagTester("Error"),isSymbol=tagTester("Symbol"),isArrayBuffer=tagTester("ArrayBuffer"),isFunction=tagTester("Function"),nodelist=root.document&&root.document.childNodes;"function"!=typeof/./&&"object"!=typeof Int8Array&&"function"!=typeof nodelist&&(isFunction=function(obj){return"function"==typeof obj||!1});var isFunction$1=isFunction,hasObjectTag=tagTester("Object"),hasStringTagBug=supportsDataView&&hasObjectTag(new DataView(new ArrayBuffer(8))),isIE11="undefined"!=typeof Map&&hasObjectTag(new Map),isDataView=tagTester("DataView"),isDataView$1=hasStringTagBug?ie10IsDataView:isDataView,isArray=nativeIsArray||tagTester("Array"),isArguments=tagTester("Arguments");!function(){isArguments(arguments)||(isArguments=function(obj){return has$1(obj,"callee")})}();var isArguments$1=isArguments,getByteLength=shallowProperty("byteLength"),isBufferLike=createSizePropertyCheck(getByteLength),typedArrayPattern=/\[object ((I|Ui)nt(8|16|32)|Float(32|64)|Uint8Clamped|Big(I|Ui)nt64)Array\]/,isTypedArray$1=supportsArrayBuffer?isTypedArray:constant(!1),getLength=shallowProperty("length");_$1.VERSION=VERSION,_$1.prototype.value=function(){return this._wrapped},_$1.prototype.valueOf=_$1.prototype.toJSON=_$1.prototype.value,_$1.prototype.toString=function(){return String(this._wrapped)};var tagDataView="[object DataView]",forEachName="forEach",hasName="has",commonInit=["clear","delete"],mapTail=["get",hasName,"set"],mapMethods=commonInit.concat(forEachName,mapTail),weakMapMethods=commonInit.concat(mapTail),setMethods=["add"].concat(commonInit,forEachName,hasName),isMap=isIE11?ie11fingerprint(mapMethods):tagTester("Map"),isWeakMap=isIE11?ie11fingerprint(weakMapMethods):tagTester("WeakMap"),isSet=isIE11?ie11fingerprint(setMethods):tagTester("Set"),isWeakSet=tagTester("WeakSet"),extend=createAssigner(allKeys),extendOwn=createAssigner(keys),defaults=createAssigner(allKeys,!0);_$1.toPath=toPath$1,_$1.iteratee=iteratee;var now=Date.now||function(){return(new Date).getTime()},escapeMap={"&":"&","<":"<",">":">",'"':""","'":"'","`":"`"},_escape=createEscaper(escapeMap),unescapeMap=invert(escapeMap),_unescape=createEscaper(unescapeMap),templateSettings=_$1.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g},noMatch=/(.)^/,escapes={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},escapeRegExp=/\\|'|\r|\n|\u2028|\u2029/g,bareIdentifier=/^\s*(\w|\$)+\s*$/,idCounter=0,partial=restArguments(function(func,boundArgs){var placeholder=partial.placeholder,bound=function(){for(var position=0,length=boundArgs.length,args=Array(length),i=0;length>i;i++)args[i]=boundArgs[i]===placeholder?arguments[position++]:boundArgs[i];for(;positionindex)throw new Error("bindAll must be passed function names");for(;index--;){var key=keys[index];obj[key]=bind(obj[key],obj)}return obj}),delay=restArguments(function(func,wait,args){return setTimeout(function(){return func.apply(null,args)},wait)}),defer=partial(delay,_$1,1),once=partial(before,2),findIndex=createPredicateIndexFinder(1),findLastIndex=createPredicateIndexFinder(-1),indexOf=createIndexFinder(1,findIndex,sortedIndex),lastIndexOf=createIndexFinder(-1,findLastIndex),reduce=createReduce(1),reduceRight=createReduce(-1),invoke=restArguments(function(obj,path,args){var contextPath,func;return isFunction$1(path)?func=path:(path=toPath(path),contextPath=path.slice(0,-1),path=path[path.length-1]),map(obj,function(context){var method=func;if(!method){if(contextPath&&contextPath.length&&(context=deepGet(context,contextPath)),null==context)return void 0;method=context[path]}return null==method?method:method.apply(context,args)})}),groupBy=group(function(result,value,key){has$1(result,key)?result[key].push(value):result[key]=[value]}),indexBy=group(function(result,value,key){result[key]=value}),countBy=group(function(result,value,key){has$1(result,key)?result[key]++:result[key]=1}),partition=group(function(result,value,pass){result[pass?0:1].push(value)},!0),reStrSymbol=/[^\ud800-\udfff]|[\ud800-\udbff][\udc00-\udfff]|[\ud800-\udfff]/g,pick=restArguments(function(obj,keys){var result={},iteratee=keys[0];if(null==obj)return result;isFunction$1(iteratee)?(keys.length>1&&(iteratee=optimizeCb(iteratee,keys[1])),keys=allKeys(obj)):(iteratee=keyInObj,keys=flatten$1(keys,!1,!1),obj=Object(obj));for(var i=0,length=keys.length;length>i;i++){var key=keys[i],value=obj[key];iteratee(value,key,obj)&&(result[key]=value)}return result}),omit=restArguments(function(obj,keys){var context,iteratee=keys[0];return isFunction$1(iteratee)?(iteratee=negate(iteratee),keys.length>1&&(context=keys[1])):(keys=map(flatten$1(keys,!1,!1),String),iteratee=function(value,key){return!contains(keys,key)}),pick(obj,iteratee,context)}),difference=restArguments(function(array,rest){return rest=flatten$1(rest,!0,!0),filter(array,function(value){return!contains(rest,value)})}),without=restArguments(function(array,otherArrays){return difference(array,otherArrays)}),union=restArguments(function(arrays){return uniq(flatten$1(arrays,!0,!0))}),zip=restArguments(unzip);each(["pop","push","reverse","shift","sort","splice","unshift"],function(name){var method=ArrayProto[name];_$1.prototype[name]=function(){var obj=this._wrapped;return null!=obj&&(method.apply(obj,arguments),"shift"!==name&&"splice"!==name||0!==obj.length||delete obj[0]),chainResult(this,obj)}}),each(["concat","join","slice"],function(name){var method=ArrayProto[name];_$1.prototype[name]=function(){var obj=this._wrapped;return null!=obj&&(obj=method.apply(obj,arguments)),chainResult(this,obj)}});var allExports={__proto__:null,VERSION:VERSION,restArguments:restArguments,isObject:isObject,isNull:isNull,isUndefined:isUndefined,isBoolean:isBoolean,isElement:isElement,isString:isString,isNumber:isNumber,isDate:isDate,isRegExp:isRegExp,isError:isError,isSymbol:isSymbol,isArrayBuffer:isArrayBuffer,isDataView:isDataView$1,isArray:isArray,isFunction:isFunction$1,isArguments:isArguments$1,isFinite:isFinite$1,isNaN:isNaN$1,isTypedArray:isTypedArray$1,isEmpty:isEmpty,isMatch:isMatch,isEqual:isEqual,isMap:isMap,isWeakMap:isWeakMap,isSet:isSet,isWeakSet:isWeakSet,keys:keys,allKeys:allKeys,values:values,pairs:pairs,invert:invert,functions:functions,methods:functions,extend:extend,extendOwn:extendOwn,assign:extendOwn,defaults:defaults,create:create,clone:clone,tap:tap,get:get,has:has,mapObject:mapObject,identity:identity,constant:constant,noop:noop,toPath:toPath$1,property:property,propertyOf:propertyOf,matcher:matcher,matches:matcher,times:times,random:random,now:now,escape:_escape,unescape:_unescape,templateSettings:templateSettings,template:template,result:result,uniqueId:uniqueId,chain:chain,iteratee:iteratee,partial:partial,bind:bind,bindAll:bindAll,memoize:memoize,delay:delay,defer:defer,throttle:throttle,debounce:debounce,wrap:wrap,negate:negate,compose:compose,after:after,before:before,once:once,findKey:findKey,findIndex:findIndex,findLastIndex:findLastIndex,sortedIndex:sortedIndex,indexOf:indexOf,lastIndexOf:lastIndexOf,find:find,detect:find,findWhere:findWhere,each:each,forEach:each,map:map,collect:map,reduce:reduce,foldl:reduce,inject:reduce,reduceRight:reduceRight,foldr:reduceRight,filter:filter,select:filter,reject:reject,every:every,all:every,some:some,any:some,contains:contains,includes:contains,include:contains,invoke:invoke,pluck:pluck,where:where,max:max,min:min,shuffle:shuffle,sample:sample,sortBy:sortBy,groupBy:groupBy,indexBy:indexBy,countBy:countBy,partition:partition,toArray:toArray,size:size,pick:pick,omit:omit,first:first,head:first,take:first,initial:initial,last:last,rest:rest,tail:rest,drop:rest,compact:compact,flatten:flatten,without:without,uniq:uniq,unique:uniq,union:union,intersection:intersection,difference:difference,unzip:unzip,transpose:unzip,zip:zip,object:object,range:range,chunk:chunk,mixin:mixin,"default":_$1},_=mixin(allExports);return _._=_,_})}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}],103:[function(require,module,exports){"function"==typeof Object.create?module.exports=function(ctor,superCtor){ctor.super_=superCtor,ctor.prototype=Object.create(superCtor.prototype,{constructor:{value:ctor,enumerable:!1,writable:!0,configurable:!0}})}:module.exports=function(ctor,superCtor){ctor.super_=superCtor;var TempCtor=function(){};TempCtor.prototype=superCtor.prototype,ctor.prototype=new TempCtor,ctor.prototype.constructor=ctor}},{}],104:[function(require,module,exports){module.exports=function(arg){return arg&&"object"==typeof arg&&"function"==typeof arg.copy&&"function"==typeof arg.fill&&"function"==typeof arg.readUInt8}},{}],105:[function(require,module,exports){(function(process,global){function inspect(obj,opts){var ctx={seen:[],stylize:stylizeNoColor};return arguments.length>=3&&(ctx.depth=arguments[2]),arguments.length>=4&&(ctx.colors=arguments[3]),isBoolean(opts)?ctx.showHidden=opts:opts&&exports._extend(ctx,opts),isUndefined(ctx.showHidden)&&(ctx.showHidden=!1),isUndefined(ctx.depth)&&(ctx.depth=2),isUndefined(ctx.colors)&&(ctx.colors=!1),isUndefined(ctx.customInspect)&&(ctx.customInspect=!0),ctx.colors&&(ctx.stylize=stylizeWithColor),formatValue(ctx,obj,ctx.depth)}function stylizeWithColor(str,styleType){var style=inspect.styles[styleType];return style?"["+inspect.colors[style][0]+"m"+str+"["+inspect.colors[style][1]+"m":str}function stylizeNoColor(str,styleType){return str}function arrayToHash(array){var hash={};return array.forEach(function(val,idx){hash[val]=!0}),hash}function formatValue(ctx,value,recurseTimes){if(ctx.customInspect&&value&&isFunction(value.inspect)&&value.inspect!==exports.inspect&&(!value.constructor||value.constructor.prototype!==value)){var ret=value.inspect(recurseTimes,ctx);return isString(ret)||(ret=formatValue(ctx,ret,recurseTimes)),ret}var primitive=formatPrimitive(ctx,value);if(primitive)return primitive;var keys=Object.keys(value),visibleKeys=arrayToHash(keys);if(ctx.showHidden&&(keys=Object.getOwnPropertyNames(value)),isError(value)&&(keys.indexOf("message")>=0||keys.indexOf("description")>=0))return formatError(value);if(0===keys.length){if(isFunction(value)){var name=value.name?": "+value.name:"";return ctx.stylize("[Function"+name+"]","special")}if(isRegExp(value))return ctx.stylize(RegExp.prototype.toString.call(value),"regexp");if(isDate(value))return ctx.stylize(Date.prototype.toString.call(value),"date");if(isError(value))return formatError(value)}var base="",array=!1,braces=["{","}"];if(isArray(value)&&(array=!0,braces=["[","]"]),isFunction(value)){var n=value.name?": "+value.name:"";base=" [Function"+n+"]"}if(isRegExp(value)&&(base=" "+RegExp.prototype.toString.call(value)),isDate(value)&&(base=" "+Date.prototype.toUTCString.call(value)),isError(value)&&(base=" "+formatError(value)),0===keys.length&&(!array||0==value.length))return braces[0]+base+braces[1];if(0>recurseTimes)return isRegExp(value)?ctx.stylize(RegExp.prototype.toString.call(value),"regexp"):ctx.stylize("[Object]","special");ctx.seen.push(value);var output;return output=array?formatArray(ctx,value,recurseTimes,visibleKeys,keys):keys.map(function(key){return formatProperty(ctx,value,recurseTimes,visibleKeys,key,array)}),ctx.seen.pop(),reduceToSingleString(output,base,braces)}function formatPrimitive(ctx,value){if(isUndefined(value))return ctx.stylize("undefined","undefined");if(isString(value)){var simple="'"+JSON.stringify(value).replace(/^"|"$/g,"").replace(/'/g,"\\'").replace(/\\"/g,'"')+"'";return ctx.stylize(simple,"string")}return isNumber(value)?ctx.stylize(""+value,"number"):isBoolean(value)?ctx.stylize(""+value,"boolean"):isNull(value)?ctx.stylize("null","null"):void 0}function formatError(value){return"["+Error.prototype.toString.call(value)+"]"}function formatArray(ctx,value,recurseTimes,visibleKeys,keys){for(var output=[],i=0,l=value.length;l>i;++i)hasOwnProperty(value,String(i))?output.push(formatProperty(ctx,value,recurseTimes,visibleKeys,String(i),!0)):output.push("");return keys.forEach(function(key){key.match(/^\d+$/)||output.push(formatProperty(ctx,value,recurseTimes,visibleKeys,key,!0))}),output}function formatProperty(ctx,value,recurseTimes,visibleKeys,key,array){var name,str,desc;if(desc=Object.getOwnPropertyDescriptor(value,key)||{value:value[key]},desc.get?str=desc.set?ctx.stylize("[Getter/Setter]","special"):ctx.stylize("[Getter]","special"):desc.set&&(str=ctx.stylize("[Setter]","special")),hasOwnProperty(visibleKeys,key)||(name="["+key+"]"),str||(ctx.seen.indexOf(desc.value)<0?(str=isNull(recurseTimes)?formatValue(ctx,desc.value,null):formatValue(ctx,desc.value,recurseTimes-1),str.indexOf("\n")>-1&&(str=array?str.split("\n").map(function(line){return" "+line}).join("\n").substr(2):"\n"+str.split("\n").map(function(line){return" "+line}).join("\n"))):str=ctx.stylize("[Circular]","special")),isUndefined(name)){if(array&&key.match(/^\d+$/))return str;name=JSON.stringify(""+key),name.match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)?(name=name.substr(1,name.length-2),name=ctx.stylize(name,"name")):(name=name.replace(/'/g,"\\'").replace(/\\"/g,'"').replace(/(^"|"$)/g,"'"),name=ctx.stylize(name,"string"))}return name+": "+str}function reduceToSingleString(output,base,braces){var numLinesEst=0,length=output.reduce(function(prev,cur){return numLinesEst++,cur.indexOf("\n")>=0&&numLinesEst++,prev+cur.replace(/\u001b\[\d\d?m/g,"").length+1},0);return length>60?braces[0]+(""===base?"":base+"\n ")+" "+output.join(",\n ")+" "+braces[1]:braces[0]+base+" "+output.join(", ")+" "+braces[1]}function isArray(ar){return Array.isArray(ar)}function isBoolean(arg){return"boolean"==typeof arg}function isNull(arg){return null===arg}function isNullOrUndefined(arg){return null==arg}function isNumber(arg){return"number"==typeof arg}function isString(arg){return"string"==typeof arg}function isSymbol(arg){return"symbol"==typeof arg}function isUndefined(arg){return void 0===arg}function isRegExp(re){return isObject(re)&&"[object RegExp]"===objectToString(re)}function isObject(arg){return"object"==typeof arg&&null!==arg}function isDate(d){return isObject(d)&&"[object Date]"===objectToString(d)}function isError(e){return isObject(e)&&("[object Error]"===objectToString(e)||e instanceof Error)}function isFunction(arg){return"function"==typeof arg}function isPrimitive(arg){return null===arg||"boolean"==typeof arg||"number"==typeof arg||"string"==typeof arg||"symbol"==typeof arg||"undefined"==typeof arg}function objectToString(o){return Object.prototype.toString.call(o)}function pad(n){return 10>n?"0"+n.toString(10):n.toString(10)}function timestamp(){var d=new Date,time=[pad(d.getHours()),pad(d.getMinutes()),pad(d.getSeconds())].join(":");return[d.getDate(),months[d.getMonth()],time].join(" ")}function hasOwnProperty(obj,prop){return Object.prototype.hasOwnProperty.call(obj,prop)}var formatRegExp=/%[sdj%]/g;exports.format=function(f){if(!isString(f)){for(var objects=[],i=0;i=len)return x;switch(x){case"%s":return String(args[i++]);case"%d":return Number(args[i++]);case"%j":try{return JSON.stringify(args[i++])}catch(_){return"[Circular]"}default:return x}}),x=args[i];len>i;x=args[++i])str+=isNull(x)||!isObject(x)?" "+x:" "+inspect(x);return str},exports.deprecate=function(fn,msg){function deprecated(){if(!warned){if(process.throwDeprecation)throw new Error(msg);process.traceDeprecation?console.trace(msg):console.error(msg),warned=!0}return fn.apply(this,arguments)}if(isUndefined(global.process))return function(){return exports.deprecate(fn,msg).apply(this,arguments)};if(process.noDeprecation===!0)return fn;var warned=!1;return deprecated};var debugEnviron,debugs={};exports.debuglog=function(set){if(isUndefined(debugEnviron)&&(debugEnviron=process.env.NODE_DEBUG||""),set=set.toUpperCase(),!debugs[set])if(new RegExp("\\b"+set+"\\b","i").test(debugEnviron)){var pid=process.pid;debugs[set]=function(){var msg=exports.format.apply(exports,arguments);console.error("%s %d: %s",set,pid,msg)}}else debugs[set]=function(){};return debugs[set]},exports.inspect=inspect,inspect.colors={bold:[1,22],italic:[3,23],underline:[4,24],inverse:[7,27],white:[37,39],grey:[90,39],black:[30,39],blue:[34,39],cyan:[36,39],green:[32,39],magenta:[35,39],red:[31,39],yellow:[33,39]},inspect.styles={special:"cyan",number:"yellow","boolean":"yellow",undefined:"grey","null":"bold",string:"green",date:"magenta",regexp:"red"},exports.isArray=isArray,exports.isBoolean=isBoolean,exports.isNull=isNull,exports.isNullOrUndefined=isNullOrUndefined,exports.isNumber=isNumber,exports.isString=isString,exports.isSymbol=isSymbol,exports.isUndefined=isUndefined,exports.isRegExp=isRegExp,exports.isObject=isObject,exports.isDate=isDate,exports.isError=isError,exports.isFunction=isFunction,exports.isPrimitive=isPrimitive,exports.isBuffer=require("./support/isBuffer");var months=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];exports.log=function(){console.log("%s - %s",timestamp(),exports.format.apply(exports,arguments))},exports.inherits=require("inherits"),exports._extend=function(origin,add){if(!add||!isObject(add))return origin;for(var keys=Object.keys(add),i=keys.length;i--;)origin[keys[i]]=add[keys[i]];return origin}}).call(this,require("_process"),"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{"./support/isBuffer":104,_process:101,inherits:103}],106:[function(require,module,exports){(function(){var assign,getValue,isArray,isEmpty,isFunction,isObject,isPlainObject,slice=[].slice,hasProp={}.hasOwnProperty;assign=function(){var i,key,len,source,sources,target;if(target=arguments[0],sources=2<=arguments.length?slice.call(arguments,1):[],isFunction(Object.assign))Object.assign.apply(null,arguments);else for(i=0,len=sources.length;len>i;i++)if(source=sources[i],null!=source)for(key in source)hasProp.call(source,key)&&(target[key]=source[key]);return target},isFunction=function(val){return!!val&&"[object Function]"===Object.prototype.toString.call(val)},isObject=function(val){var ref;return!!val&&("function"==(ref=typeof val)||"object"===ref)},isArray=function(val){return isFunction(Array.isArray)?Array.isArray(val):"[object Array]"===Object.prototype.toString.call(val)},isEmpty=function(val){var key;if(isArray(val))return!val.length;for(key in val)if(hasProp.call(val,key))return!1;return!0},isPlainObject=function(val){var ctor,proto;return isObject(val)&&(proto=Object.getPrototypeOf(val))&&(ctor=proto.constructor)&&"function"==typeof ctor&&ctor instanceof ctor&&Function.prototype.toString.call(ctor)===Function.prototype.toString.call(Object)},getValue=function(obj){return isFunction(obj.valueOf)?obj.valueOf():obj},module.exports.assign=assign,module.exports.isFunction=isFunction,module.exports.isObject=isObject,module.exports.isArray=isArray,module.exports.isEmpty=isEmpty,module.exports.isPlainObject=isPlainObject,module.exports.getValue=getValue}).call(this)},{}],107:[function(require,module,exports){(function(){var XMLAttribute;module.exports=XMLAttribute=function(){function XMLAttribute(parent,name,value){if(this.options=parent.options,this.stringify=parent.stringify,this.parent=parent,null==name)throw new Error("Missing attribute name. "+this.debugInfo(name));if(null==value)throw new Error("Missing attribute value. "+this.debugInfo(name));this.name=this.stringify.attName(name),this.value=this.stringify.attValue(value)}return XMLAttribute.prototype.clone=function(){return Object.create(this)},XMLAttribute.prototype.toString=function(options){return this.options.writer.set(options).attribute(this)},XMLAttribute.prototype.debugInfo=function(name){var ref,ref1;return name=name||this.name,null!=name||(null!=(ref=this.parent)?ref.name:void 0)?null==name?"parent: <"+this.parent.name+">":(null!=(ref1=this.parent)?ref1.name:void 0)?"attribute: {"+name+"}, parent: <"+this.parent.name+">":"attribute: {"+name+"}":""},XMLAttribute}()}).call(this)},{}],108:[function(require,module,exports){(function(){var XMLCData,XMLNode,extend=function(child,parent){function ctor(){this.constructor=child}for(var key in parent)hasProp.call(parent,key)&&(child[key]=parent[key]);return ctor.prototype=parent.prototype,child.prototype=new ctor,child.__super__=parent.prototype,child},hasProp={}.hasOwnProperty;XMLNode=require("./XMLNode"),module.exports=XMLCData=function(superClass){function XMLCData(parent,text){if(XMLCData.__super__.constructor.call(this,parent),null==text)throw new Error("Missing CDATA text. "+this.debugInfo());this.text=this.stringify.cdata(text)}return extend(XMLCData,superClass),XMLCData.prototype.clone=function(){return Object.create(this)},XMLCData.prototype.toString=function(options){return this.options.writer.set(options).cdata(this)},XMLCData}(XMLNode)}).call(this)},{"./XMLNode":119}],109:[function(require,module,exports){(function(){var XMLComment,XMLNode,extend=function(child,parent){function ctor(){this.constructor=child}for(var key in parent)hasProp.call(parent,key)&&(child[key]=parent[key]);return ctor.prototype=parent.prototype,child.prototype=new ctor,child.__super__=parent.prototype,child},hasProp={}.hasOwnProperty;XMLNode=require("./XMLNode"),module.exports=XMLComment=function(superClass){function XMLComment(parent,text){if(XMLComment.__super__.constructor.call(this,parent),null==text)throw new Error("Missing comment text. "+this.debugInfo());this.text=this.stringify.comment(text)}return extend(XMLComment,superClass),XMLComment.prototype.clone=function(){return Object.create(this)},XMLComment.prototype.toString=function(options){return this.options.writer.set(options).comment(this)},XMLComment}(XMLNode)}).call(this)},{"./XMLNode":119}],110:[function(require,module,exports){(function(){var XMLDTDAttList,XMLNode,extend=function(child,parent){function ctor(){this.constructor=child}for(var key in parent)hasProp.call(parent,key)&&(child[key]=parent[key]);return ctor.prototype=parent.prototype,child.prototype=new ctor,child.__super__=parent.prototype,child},hasProp={}.hasOwnProperty;XMLNode=require("./XMLNode"),module.exports=XMLDTDAttList=function(superClass){function XMLDTDAttList(parent,elementName,attributeName,attributeType,defaultValueType,defaultValue){if(XMLDTDAttList.__super__.constructor.call(this,parent),null==elementName)throw new Error("Missing DTD element name. "+this.debugInfo());if(null==attributeName)throw new Error("Missing DTD attribute name. "+this.debugInfo(elementName));if(!attributeType)throw new Error("Missing DTD attribute type. "+this.debugInfo(elementName));if(!defaultValueType)throw new Error("Missing DTD attribute default. "+this.debugInfo(elementName));if(0!==defaultValueType.indexOf("#")&&(defaultValueType="#"+defaultValueType),!defaultValueType.match(/^(#REQUIRED|#IMPLIED|#FIXED|#DEFAULT)$/))throw new Error("Invalid default value type; expected: #REQUIRED, #IMPLIED, #FIXED or #DEFAULT. "+this.debugInfo(elementName));if(defaultValue&&!defaultValueType.match(/^(#FIXED|#DEFAULT)$/))throw new Error("Default value only applies to #FIXED or #DEFAULT. "+this.debugInfo(elementName));this.elementName=this.stringify.eleName(elementName),this.attributeName=this.stringify.attName(attributeName),this.attributeType=this.stringify.dtdAttType(attributeType),this.defaultValue=this.stringify.dtdAttDefault(defaultValue),this.defaultValueType=defaultValueType}return extend(XMLDTDAttList,superClass),XMLDTDAttList.prototype.toString=function(options){return this.options.writer.set(options).dtdAttList(this)},XMLDTDAttList}(XMLNode)}).call(this)},{"./XMLNode":119}],111:[function(require,module,exports){(function(){var XMLDTDElement,XMLNode,extend=function(child,parent){function ctor(){this.constructor=child}for(var key in parent)hasProp.call(parent,key)&&(child[key]=parent[key]);return ctor.prototype=parent.prototype,child.prototype=new ctor,child.__super__=parent.prototype,child},hasProp={}.hasOwnProperty;XMLNode=require("./XMLNode"),module.exports=XMLDTDElement=function(superClass){function XMLDTDElement(parent,name,value){if(XMLDTDElement.__super__.constructor.call(this,parent),null==name)throw new Error("Missing DTD element name. "+this.debugInfo());value||(value="(#PCDATA)"),Array.isArray(value)&&(value="("+value.join(",")+")"),this.name=this.stringify.eleName(name),this.value=this.stringify.dtdElementValue(value)}return extend(XMLDTDElement,superClass),XMLDTDElement.prototype.toString=function(options){return this.options.writer.set(options).dtdElement(this)},XMLDTDElement}(XMLNode)}).call(this)},{"./XMLNode":119}],112:[function(require,module,exports){(function(){var XMLDTDEntity,XMLNode,isObject,extend=function(child,parent){function ctor(){this.constructor=child}for(var key in parent)hasProp.call(parent,key)&&(child[key]=parent[key]);return ctor.prototype=parent.prototype,child.prototype=new ctor,child.__super__=parent.prototype,child},hasProp={}.hasOwnProperty;isObject=require("./Utility").isObject,XMLNode=require("./XMLNode"),module.exports=XMLDTDEntity=function(superClass){function XMLDTDEntity(parent,pe,name,value){if(XMLDTDEntity.__super__.constructor.call(this,parent),null==name)throw new Error("Missing DTD entity name. "+this.debugInfo(name));if(null==value)throw new Error("Missing DTD entity value. "+this.debugInfo(name));if(this.pe=!!pe,this.name=this.stringify.eleName(name),isObject(value)){if(!value.pubID&&!value.sysID)throw new Error("Public and/or system identifiers are required for an external entity. "+this.debugInfo(name));if(value.pubID&&!value.sysID)throw new Error("System identifier is required for a public external entity. "+this.debugInfo(name));if(null!=value.pubID&&(this.pubID=this.stringify.dtdPubID(value.pubID)),null!=value.sysID&&(this.sysID=this.stringify.dtdSysID(value.sysID)),null!=value.nData&&(this.nData=this.stringify.dtdNData(value.nData)),this.pe&&this.nData)throw new Error("Notation declaration is not allowed in a parameter entity. "+this.debugInfo(name))}else this.value=this.stringify.dtdEntityValue(value)}return extend(XMLDTDEntity,superClass),XMLDTDEntity.prototype.toString=function(options){return this.options.writer.set(options).dtdEntity(this)},XMLDTDEntity}(XMLNode)}).call(this)},{"./Utility":106,"./XMLNode":119}],113:[function(require,module,exports){(function(){var XMLDTDNotation,XMLNode,extend=function(child,parent){function ctor(){this.constructor=child}for(var key in parent)hasProp.call(parent,key)&&(child[key]=parent[key]);return ctor.prototype=parent.prototype,child.prototype=new ctor,child.__super__=parent.prototype,child},hasProp={}.hasOwnProperty;XMLNode=require("./XMLNode"),module.exports=XMLDTDNotation=function(superClass){function XMLDTDNotation(parent,name,value){if(XMLDTDNotation.__super__.constructor.call(this,parent),null==name)throw new Error("Missing DTD notation name. "+this.debugInfo(name));if(!value.pubID&&!value.sysID)throw new Error("Public or system identifiers are required for an external entity. "+this.debugInfo(name));this.name=this.stringify.eleName(name),null!=value.pubID&&(this.pubID=this.stringify.dtdPubID(value.pubID)),null!=value.sysID&&(this.sysID=this.stringify.dtdSysID(value.sysID))}return extend(XMLDTDNotation,superClass),XMLDTDNotation.prototype.toString=function(options){return this.options.writer.set(options).dtdNotation(this)},XMLDTDNotation}(XMLNode)}).call(this)},{"./XMLNode":119}],114:[function(require,module,exports){(function(){var XMLDeclaration,XMLNode,isObject,extend=function(child,parent){function ctor(){this.constructor=child}for(var key in parent)hasProp.call(parent,key)&&(child[key]=parent[key]);return ctor.prototype=parent.prototype,child.prototype=new ctor,child.__super__=parent.prototype,child},hasProp={}.hasOwnProperty;isObject=require("./Utility").isObject,XMLNode=require("./XMLNode"),module.exports=XMLDeclaration=function(superClass){function XMLDeclaration(parent,version,encoding,standalone){var ref;XMLDeclaration.__super__.constructor.call(this,parent),isObject(version)&&(ref=version,version=ref.version,encoding=ref.encoding,standalone=ref.standalone),version||(version="1.0"),this.version=this.stringify.xmlVersion(version),null!=encoding&&(this.encoding=this.stringify.xmlEncoding(encoding)),null!=standalone&&(this.standalone=this.stringify.xmlStandalone(standalone))}return extend(XMLDeclaration,superClass),XMLDeclaration.prototype.toString=function(options){return this.options.writer.set(options).declaration(this)},XMLDeclaration}(XMLNode)}).call(this)},{"./Utility":106,"./XMLNode":119}],115:[function(require,module,exports){(function(){var XMLDTDAttList,XMLDTDElement,XMLDTDEntity,XMLDTDNotation,XMLDocType,XMLNode,isObject,extend=function(child,parent){function ctor(){this.constructor=child}for(var key in parent)hasProp.call(parent,key)&&(child[key]=parent[key]);return ctor.prototype=parent.prototype,child.prototype=new ctor,child.__super__=parent.prototype,child},hasProp={}.hasOwnProperty;isObject=require("./Utility").isObject,XMLNode=require("./XMLNode"),XMLDTDAttList=require("./XMLDTDAttList"),XMLDTDEntity=require("./XMLDTDEntity"),XMLDTDElement=require("./XMLDTDElement"),XMLDTDNotation=require("./XMLDTDNotation"),module.exports=XMLDocType=function(superClass){function XMLDocType(parent,pubID,sysID){var ref,ref1;XMLDocType.__super__.constructor.call(this,parent),this.name="!DOCTYPE",this.documentObject=parent,isObject(pubID)&&(ref=pubID,pubID=ref.pubID,sysID=ref.sysID),null==sysID&&(ref1=[pubID,sysID],sysID=ref1[0],pubID=ref1[1]),null!=pubID&&(this.pubID=this.stringify.dtdPubID(pubID)),null!=sysID&&(this.sysID=this.stringify.dtdSysID(sysID))}return extend(XMLDocType,superClass),XMLDocType.prototype.element=function(name,value){var child;return child=new XMLDTDElement(this,name,value),this.children.push(child),this},XMLDocType.prototype.attList=function(elementName,attributeName,attributeType,defaultValueType,defaultValue){var child;return child=new XMLDTDAttList(this,elementName,attributeName,attributeType,defaultValueType,defaultValue),this.children.push(child),this},XMLDocType.prototype.entity=function(name,value){var child;return child=new XMLDTDEntity(this,!1,name,value),this.children.push(child),this},XMLDocType.prototype.pEntity=function(name,value){var child;return child=new XMLDTDEntity(this,!0,name,value),this.children.push(child),this},XMLDocType.prototype.notation=function(name,value){var child;return child=new XMLDTDNotation(this,name,value),this.children.push(child),this},XMLDocType.prototype.toString=function(options){return this.options.writer.set(options).docType(this)},XMLDocType.prototype.ele=function(name,value){return this.element(name,value)},XMLDocType.prototype.att=function(elementName,attributeName,attributeType,defaultValueType,defaultValue){return this.attList(elementName,attributeName,attributeType,defaultValueType,defaultValue)},XMLDocType.prototype.ent=function(name,value){return this.entity(name,value)},XMLDocType.prototype.pent=function(name,value){return this.pEntity(name,value)},XMLDocType.prototype.not=function(name,value){return this.notation(name,value)},XMLDocType.prototype.up=function(){return this.root()||this.documentObject},XMLDocType}(XMLNode)}).call(this)},{"./Utility":106,"./XMLDTDAttList":110,"./XMLDTDElement":111,"./XMLDTDEntity":112,"./XMLDTDNotation":113,"./XMLNode":119}],116:[function(require,module,exports){(function(){var XMLDocument,XMLNode,XMLStringWriter,XMLStringifier,isPlainObject,extend=function(child,parent){function ctor(){this.constructor=child}for(var key in parent)hasProp.call(parent,key)&&(child[key]=parent[key]);return ctor.prototype=parent.prototype,child.prototype=new ctor,child.__super__=parent.prototype,child},hasProp={}.hasOwnProperty;isPlainObject=require("./Utility").isPlainObject,XMLNode=require("./XMLNode"),XMLStringifier=require("./XMLStringifier"),XMLStringWriter=require("./XMLStringWriter"),module.exports=XMLDocument=function(superClass){function XMLDocument(options){XMLDocument.__super__.constructor.call(this,null),this.name="?xml",options||(options={}),options.writer||(options.writer=new XMLStringWriter),this.options=options,this.stringify=new XMLStringifier(options),this.isDocument=!0}return extend(XMLDocument,superClass),XMLDocument.prototype.end=function(writer){var writerOptions;return writer?isPlainObject(writer)&&(writerOptions=writer,writer=this.options.writer.set(writerOptions)):writer=this.options.writer,writer.document(this)},XMLDocument.prototype.toString=function(options){return this.options.writer.set(options).document(this)},XMLDocument}(XMLNode)}).call(this)},{"./Utility":106,"./XMLNode":119,"./XMLStringWriter":123,"./XMLStringifier":124}],117:[function(require,module,exports){(function(){var XMLAttribute,XMLCData,XMLComment,XMLDTDAttList,XMLDTDElement,XMLDTDEntity,XMLDTDNotation,XMLDeclaration,XMLDocType,XMLDocumentCB,XMLElement,XMLProcessingInstruction,XMLRaw,XMLStringWriter,XMLStringifier,XMLText,getValue,isFunction,isObject,isPlainObject,ref,hasProp={}.hasOwnProperty;ref=require("./Utility"),isObject=ref.isObject,isFunction=ref.isFunction,isPlainObject=ref.isPlainObject,getValue=ref.getValue,XMLElement=require("./XMLElement"),XMLCData=require("./XMLCData"),XMLComment=require("./XMLComment"),XMLRaw=require("./XMLRaw"),XMLText=require("./XMLText"),XMLProcessingInstruction=require("./XMLProcessingInstruction"),XMLDeclaration=require("./XMLDeclaration"),XMLDocType=require("./XMLDocType"),XMLDTDAttList=require("./XMLDTDAttList"),XMLDTDEntity=require("./XMLDTDEntity"),XMLDTDElement=require("./XMLDTDElement"),XMLDTDNotation=require("./XMLDTDNotation"),XMLAttribute=require("./XMLAttribute"),XMLStringifier=require("./XMLStringifier"),XMLStringWriter=require("./XMLStringWriter"),module.exports=XMLDocumentCB=function(){function XMLDocumentCB(options,onData,onEnd){var writerOptions;this.name="?xml",options||(options={}),options.writer?isPlainObject(options.writer)&&(writerOptions=options.writer,options.writer=new XMLStringWriter(writerOptions)):options.writer=new XMLStringWriter(options),this.options=options,this.writer=options.writer,this.stringify=new XMLStringifier(options),this.onDataCallback=onData||function(){},this.onEndCallback=onEnd||function(){},this.currentNode=null,this.currentLevel=-1,this.openTags={},this.documentStarted=!1,this.documentCompleted=!1,this.root=null}return XMLDocumentCB.prototype.node=function(name,attributes,text){var ref1;if(null==name)throw new Error("Missing node name.");if(this.root&&-1===this.currentLevel)throw new Error("Document can only have one root node. "+this.debugInfo(name));return this.openCurrent(),name=getValue(name),null==attributes&&(attributes={}),attributes=getValue(attributes),isObject(attributes)||(ref1=[attributes,text],text=ref1[0],attributes=ref1[1]),this.currentNode=new XMLElement(this,name,attributes),this.currentNode.children=!1,this.currentLevel++,this.openTags[this.currentLevel]=this.currentNode,null!=text&&this.text(text),this},XMLDocumentCB.prototype.element=function(name,attributes,text){return this.currentNode&&this.currentNode instanceof XMLDocType?this.dtdElement.apply(this,arguments):this.node(name,attributes,text)},XMLDocumentCB.prototype.attribute=function(name,value){var attName,attValue;if(!this.currentNode||this.currentNode.children)throw new Error("att() can only be used immediately after an ele() call in callback mode. "+this.debugInfo(name));if(null!=name&&(name=getValue(name)),isObject(name))for(attName in name)hasProp.call(name,attName)&&(attValue=name[attName],this.attribute(attName,attValue));else isFunction(value)&&(value=value.apply()),this.options.skipNullAttributes&&null==value||(this.currentNode.attributes[name]=new XMLAttribute(this,name,value));return this},XMLDocumentCB.prototype.text=function(value){var node;return this.openCurrent(),node=new XMLText(this,value),this.onData(this.writer.text(node,this.currentLevel+1),this.currentLevel+1),this},XMLDocumentCB.prototype.cdata=function(value){var node;return this.openCurrent(),node=new XMLCData(this,value),this.onData(this.writer.cdata(node,this.currentLevel+1),this.currentLevel+1),this},XMLDocumentCB.prototype.comment=function(value){var node;return this.openCurrent(),node=new XMLComment(this,value),this.onData(this.writer.comment(node,this.currentLevel+1),this.currentLevel+1),this},XMLDocumentCB.prototype.raw=function(value){var node;return this.openCurrent(),node=new XMLRaw(this,value),this.onData(this.writer.raw(node,this.currentLevel+1),this.currentLevel+1),this},XMLDocumentCB.prototype.instruction=function(target,value){var i,insTarget,insValue,len,node;if(this.openCurrent(),null!=target&&(target=getValue(target)),null!=value&&(value=getValue(value)),Array.isArray(target))for(i=0,len=target.length;len>i;i++)insTarget=target[i],this.instruction(insTarget);else if(isObject(target))for(insTarget in target)hasProp.call(target,insTarget)&&(insValue=target[insTarget],this.instruction(insTarget,insValue));else isFunction(value)&&(value=value.apply()),node=new XMLProcessingInstruction(this,target,value),this.onData(this.writer.processingInstruction(node,this.currentLevel+1),this.currentLevel+1);return this},XMLDocumentCB.prototype.declaration=function(version,encoding,standalone){var node;if(this.openCurrent(),this.documentStarted)throw new Error("declaration() must be the first node.");return node=new XMLDeclaration(this,version,encoding,standalone),this.onData(this.writer.declaration(node,this.currentLevel+1),this.currentLevel+1),this},XMLDocumentCB.prototype.doctype=function(root,pubID,sysID){if(this.openCurrent(),null==root)throw new Error("Missing root node name.");if(this.root)throw new Error("dtd() must come before the root node.");return this.currentNode=new XMLDocType(this,pubID,sysID),this.currentNode.rootNodeName=root,this.currentNode.children=!1,this.currentLevel++,this.openTags[this.currentLevel]=this.currentNode,this},XMLDocumentCB.prototype.dtdElement=function(name,value){var node;return this.openCurrent(),node=new XMLDTDElement(this,name,value),this.onData(this.writer.dtdElement(node,this.currentLevel+1),this.currentLevel+1),this},XMLDocumentCB.prototype.attList=function(elementName,attributeName,attributeType,defaultValueType,defaultValue){var node;return this.openCurrent(),node=new XMLDTDAttList(this,elementName,attributeName,attributeType,defaultValueType,defaultValue),this.onData(this.writer.dtdAttList(node,this.currentLevel+1),this.currentLevel+1),this},XMLDocumentCB.prototype.entity=function(name,value){var node;return this.openCurrent(),node=new XMLDTDEntity(this,!1,name,value),this.onData(this.writer.dtdEntity(node,this.currentLevel+1),this.currentLevel+1),this},XMLDocumentCB.prototype.pEntity=function(name,value){var node;return this.openCurrent(),node=new XMLDTDEntity(this,!0,name,value),this.onData(this.writer.dtdEntity(node,this.currentLevel+1),this.currentLevel+1),this},XMLDocumentCB.prototype.notation=function(name,value){var node;return this.openCurrent(),node=new XMLDTDNotation(this,name,value),this.onData(this.writer.dtdNotation(node,this.currentLevel+1),this.currentLevel+1),this},XMLDocumentCB.prototype.up=function(){if(this.currentLevel<0)throw new Error("The document node has no parent.");return this.currentNode?(this.currentNode.children?this.closeNode(this.currentNode):this.openNode(this.currentNode),this.currentNode=null):this.closeNode(this.openTags[this.currentLevel]),delete this.openTags[this.currentLevel],this.currentLevel--,this},XMLDocumentCB.prototype.end=function(){for(;this.currentLevel>=0;)this.up();return this.onEnd()},XMLDocumentCB.prototype.openCurrent=function(){return this.currentNode?(this.currentNode.children=!0,this.openNode(this.currentNode)):void 0},XMLDocumentCB.prototype.openNode=function(node){return node.isOpen?void 0:(!this.root&&0===this.currentLevel&&node instanceof XMLElement&&(this.root=node),this.onData(this.writer.openNode(node,this.currentLevel),this.currentLevel),node.isOpen=!0)},XMLDocumentCB.prototype.closeNode=function(node){return node.isClosed?void 0:(this.onData(this.writer.closeNode(node,this.currentLevel),this.currentLevel),node.isClosed=!0)},XMLDocumentCB.prototype.onData=function(chunk,level){return this.documentStarted=!0,this.onDataCallback(chunk,level+1)},XMLDocumentCB.prototype.onEnd=function(){return this.documentCompleted=!0,this.onEndCallback()},XMLDocumentCB.prototype.debugInfo=function(name){return null==name?"":"node: <"+name+">"},XMLDocumentCB.prototype.ele=function(){return this.element.apply(this,arguments)},XMLDocumentCB.prototype.nod=function(name,attributes,text){return this.node(name,attributes,text)},XMLDocumentCB.prototype.txt=function(value){return this.text(value)},XMLDocumentCB.prototype.dat=function(value){return this.cdata(value)},XMLDocumentCB.prototype.com=function(value){return this.comment(value)},XMLDocumentCB.prototype.ins=function(target,value){return this.instruction(target,value)},XMLDocumentCB.prototype.dec=function(version,encoding,standalone){return this.declaration(version,encoding,standalone)},XMLDocumentCB.prototype.dtd=function(root,pubID,sysID){return this.doctype(root,pubID,sysID)},XMLDocumentCB.prototype.e=function(name,attributes,text){return this.element(name,attributes,text)},XMLDocumentCB.prototype.n=function(name,attributes,text){return this.node(name,attributes,text)},XMLDocumentCB.prototype.t=function(value){return this.text(value)},XMLDocumentCB.prototype.d=function(value){return this.cdata(value)},XMLDocumentCB.prototype.c=function(value){return this.comment(value)},XMLDocumentCB.prototype.r=function(value){return this.raw(value)},XMLDocumentCB.prototype.i=function(target,value){return this.instruction(target,value)},XMLDocumentCB.prototype.att=function(){return this.currentNode&&this.currentNode instanceof XMLDocType?this.attList.apply(this,arguments):this.attribute.apply(this,arguments)},XMLDocumentCB.prototype.a=function(){return this.currentNode&&this.currentNode instanceof XMLDocType?this.attList.apply(this,arguments):this.attribute.apply(this,arguments)},XMLDocumentCB.prototype.ent=function(name,value){return this.entity(name,value)},XMLDocumentCB.prototype.pent=function(name,value){return this.pEntity(name,value)},XMLDocumentCB.prototype.not=function(name,value){return this.notation(name,value)},XMLDocumentCB}()}).call(this)},{"./Utility":106,"./XMLAttribute":107,"./XMLCData":108,"./XMLComment":109,"./XMLDTDAttList":110,"./XMLDTDElement":111,"./XMLDTDEntity":112,"./XMLDTDNotation":113,"./XMLDeclaration":114,"./XMLDocType":115,"./XMLElement":118,"./XMLProcessingInstruction":120,"./XMLRaw":121,"./XMLStringWriter":123,"./XMLStringifier":124,"./XMLText":125}],118:[function(require,module,exports){(function(){var XMLAttribute,XMLElement,XMLNode,getValue,isFunction,isObject,ref,extend=function(child,parent){function ctor(){this.constructor=child}for(var key in parent)hasProp.call(parent,key)&&(child[key]=parent[key]);return ctor.prototype=parent.prototype,child.prototype=new ctor,child.__super__=parent.prototype,child},hasProp={}.hasOwnProperty;ref=require("./Utility"),isObject=ref.isObject,isFunction=ref.isFunction,getValue=ref.getValue,XMLNode=require("./XMLNode"),XMLAttribute=require("./XMLAttribute"),module.exports=XMLElement=function(superClass){function XMLElement(parent,name,attributes){if(XMLElement.__super__.constructor.call(this,parent),null==name)throw new Error("Missing element name. "+this.debugInfo());this.name=this.stringify.eleName(name),this.attributes={},null!=attributes&&this.attribute(attributes),parent.isDocument&&(this.isRoot=!0,this.documentObject=parent,parent.rootObject=this)}return extend(XMLElement,superClass),XMLElement.prototype.clone=function(){var att,attName,clonedSelf,ref1;clonedSelf=Object.create(this),clonedSelf.isRoot&&(clonedSelf.documentObject=null),clonedSelf.attributes={},ref1=this.attributes;for(attName in ref1)hasProp.call(ref1,attName)&&(att=ref1[attName],clonedSelf.attributes[attName]=att.clone());return clonedSelf.children=[],this.children.forEach(function(child){var clonedChild;return clonedChild=child.clone(),clonedChild.parent=clonedSelf,clonedSelf.children.push(clonedChild)}),clonedSelf},XMLElement.prototype.attribute=function(name,value){var attName,attValue;if(null!=name&&(name=getValue(name)),isObject(name))for(attName in name)hasProp.call(name,attName)&&(attValue=name[attName],this.attribute(attName,attValue));else isFunction(value)&&(value=value.apply()),this.options.skipNullAttributes&&null==value||(this.attributes[name]=new XMLAttribute(this,name,value));return this},XMLElement.prototype.removeAttribute=function(name){var attName,i,len;if(null==name)throw new Error("Missing attribute name. "+this.debugInfo());if(name=getValue(name),Array.isArray(name))for(i=0,len=name.length;len>i;i++)attName=name[i],delete this.attributes[attName];else delete this.attributes[name];return this},XMLElement.prototype.toString=function(options){return this.options.writer.set(options).element(this)},XMLElement.prototype.att=function(name,value){return this.attribute(name,value)},XMLElement.prototype.a=function(name,value){return this.attribute(name,value)},XMLElement}(XMLNode)}).call(this)},{"./Utility":106,"./XMLAttribute":107,"./XMLNode":119}],119:[function(require,module,exports){(function(){var XMLCData,XMLComment,XMLDeclaration,XMLDocType,XMLElement,XMLNode,XMLProcessingInstruction,XMLRaw,XMLText,getValue,isEmpty,isFunction,isObject,ref,hasProp={}.hasOwnProperty;ref=require("./Utility"),isObject=ref.isObject,isFunction=ref.isFunction,isEmpty=ref.isEmpty,getValue=ref.getValue,XMLElement=null,XMLCData=null,XMLComment=null,XMLDeclaration=null,XMLDocType=null,XMLRaw=null,XMLText=null,XMLProcessingInstruction=null,module.exports=XMLNode=function(){function XMLNode(parent){this.parent=parent,this.parent&&(this.options=this.parent.options,this.stringify=this.parent.stringify),this.children=[],XMLElement||(XMLElement=require("./XMLElement"),XMLCData=require("./XMLCData"),XMLComment=require("./XMLComment"),XMLDeclaration=require("./XMLDeclaration"),XMLDocType=require("./XMLDocType"),XMLRaw=require("./XMLRaw"),XMLText=require("./XMLText"),XMLProcessingInstruction=require("./XMLProcessingInstruction"))}return XMLNode.prototype.element=function(name,attributes,text){var childNode,item,j,k,key,lastChild,len,len1,ref1,val;if(lastChild=null,null==attributes&&(attributes={}),attributes=getValue(attributes),isObject(attributes)||(ref1=[attributes,text],text=ref1[0],attributes=ref1[1]),null!=name&&(name=getValue(name)),Array.isArray(name))for(j=0,len=name.length;len>j;j++)item=name[j],lastChild=this.element(item);else if(isFunction(name))lastChild=this.element(name.apply());else if(isObject(name)){for(key in name)if(hasProp.call(name,key))if(val=name[key],isFunction(val)&&(val=val.apply()),isObject(val)&&isEmpty(val)&&(val=null),!this.options.ignoreDecorators&&this.stringify.convertAttKey&&0===key.indexOf(this.stringify.convertAttKey))lastChild=this.attribute(key.substr(this.stringify.convertAttKey.length),val);else if(!this.options.separateArrayItems&&Array.isArray(val))for(k=0, -len1=val.length;len1>k;k++)item=val[k],childNode={},childNode[key]=item,lastChild=this.element(childNode);else isObject(val)?(lastChild=this.element(key),lastChild.element(val)):lastChild=this.element(key,val)}else lastChild=!this.options.ignoreDecorators&&this.stringify.convertTextKey&&0===name.indexOf(this.stringify.convertTextKey)?this.text(text):!this.options.ignoreDecorators&&this.stringify.convertCDataKey&&0===name.indexOf(this.stringify.convertCDataKey)?this.cdata(text):!this.options.ignoreDecorators&&this.stringify.convertCommentKey&&0===name.indexOf(this.stringify.convertCommentKey)?this.comment(text):!this.options.ignoreDecorators&&this.stringify.convertRawKey&&0===name.indexOf(this.stringify.convertRawKey)?this.raw(text):!this.options.ignoreDecorators&&this.stringify.convertPIKey&&0===name.indexOf(this.stringify.convertPIKey)?this.instruction(name.substr(this.stringify.convertPIKey.length),text):this.node(name,attributes,text);if(null==lastChild)throw new Error("Could not create any elements with: "+name+". "+this.debugInfo());return lastChild},XMLNode.prototype.insertBefore=function(name,attributes,text){var child,i,removed;if(this.isRoot)throw new Error("Cannot insert elements at root level. "+this.debugInfo(name));return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i),child=this.parent.element(name,attributes,text),Array.prototype.push.apply(this.parent.children,removed),child},XMLNode.prototype.insertAfter=function(name,attributes,text){var child,i,removed;if(this.isRoot)throw new Error("Cannot insert elements at root level. "+this.debugInfo(name));return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i+1),child=this.parent.element(name,attributes,text),Array.prototype.push.apply(this.parent.children,removed),child},XMLNode.prototype.remove=function(){var i,ref1;if(this.isRoot)throw new Error("Cannot remove the root element. "+this.debugInfo());return i=this.parent.children.indexOf(this),[].splice.apply(this.parent.children,[i,i-i+1].concat(ref1=[])),ref1,this.parent},XMLNode.prototype.node=function(name,attributes,text){var child,ref1;return null!=name&&(name=getValue(name)),attributes||(attributes={}),attributes=getValue(attributes),isObject(attributes)||(ref1=[attributes,text],text=ref1[0],attributes=ref1[1]),child=new XMLElement(this,name,attributes),null!=text&&child.text(text),this.children.push(child),child},XMLNode.prototype.text=function(value){var child;return child=new XMLText(this,value),this.children.push(child),this},XMLNode.prototype.cdata=function(value){var child;return child=new XMLCData(this,value),this.children.push(child),this},XMLNode.prototype.comment=function(value){var child;return child=new XMLComment(this,value),this.children.push(child),this},XMLNode.prototype.commentBefore=function(value){var child,i,removed;return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i),child=this.parent.comment(value),Array.prototype.push.apply(this.parent.children,removed),this},XMLNode.prototype.commentAfter=function(value){var child,i,removed;return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i+1),child=this.parent.comment(value),Array.prototype.push.apply(this.parent.children,removed),this},XMLNode.prototype.raw=function(value){var child;return child=new XMLRaw(this,value),this.children.push(child),this},XMLNode.prototype.instruction=function(target,value){var insTarget,insValue,instruction,j,len;if(null!=target&&(target=getValue(target)),null!=value&&(value=getValue(value)),Array.isArray(target))for(j=0,len=target.length;len>j;j++)insTarget=target[j],this.instruction(insTarget);else if(isObject(target))for(insTarget in target)hasProp.call(target,insTarget)&&(insValue=target[insTarget],this.instruction(insTarget,insValue));else isFunction(value)&&(value=value.apply()),instruction=new XMLProcessingInstruction(this,target,value),this.children.push(instruction);return this},XMLNode.prototype.instructionBefore=function(target,value){var child,i,removed;return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i),child=this.parent.instruction(target,value),Array.prototype.push.apply(this.parent.children,removed),this},XMLNode.prototype.instructionAfter=function(target,value){var child,i,removed;return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i+1),child=this.parent.instruction(target,value),Array.prototype.push.apply(this.parent.children,removed),this},XMLNode.prototype.declaration=function(version,encoding,standalone){var doc,xmldec;return doc=this.document(),xmldec=new XMLDeclaration(doc,version,encoding,standalone),doc.children[0]instanceof XMLDeclaration?doc.children[0]=xmldec:doc.children.unshift(xmldec),doc.root()||doc},XMLNode.prototype.doctype=function(pubID,sysID){var child,doc,doctype,i,j,k,len,len1,ref1,ref2;for(doc=this.document(),doctype=new XMLDocType(doc,pubID,sysID),ref1=doc.children,i=j=0,len=ref1.length;len>j;i=++j)if(child=ref1[i],child instanceof XMLDocType)return doc.children[i]=doctype,doctype;for(ref2=doc.children,i=k=0,len1=ref2.length;len1>k;i=++k)if(child=ref2[i],child.isRoot)return doc.children.splice(i,0,doctype),doctype;return doc.children.push(doctype),doctype},XMLNode.prototype.up=function(){if(this.isRoot)throw new Error("The root node has no parent. Use doc() if you need to get the document object.");return this.parent},XMLNode.prototype.root=function(){var node;for(node=this;node;){if(node.isDocument)return node.rootObject;if(node.isRoot)return node;node=node.parent}},XMLNode.prototype.document=function(){var node;for(node=this;node;){if(node.isDocument)return node;node=node.parent}},XMLNode.prototype.end=function(options){return this.document().end(options)},XMLNode.prototype.prev=function(){var i;if(i=this.parent.children.indexOf(this),1>i)throw new Error("Already at the first node. "+this.debugInfo());return this.parent.children[i-1]},XMLNode.prototype.next=function(){var i;if(i=this.parent.children.indexOf(this),-1===i||i===this.parent.children.length-1)throw new Error("Already at the last node. "+this.debugInfo());return this.parent.children[i+1]},XMLNode.prototype.importDocument=function(doc){var clonedRoot;return clonedRoot=doc.root().clone(),clonedRoot.parent=this,clonedRoot.isRoot=!1,this.children.push(clonedRoot),this},XMLNode.prototype.debugInfo=function(name){var ref1,ref2;return name=name||this.name,null!=name||(null!=(ref1=this.parent)?ref1.name:void 0)?null==name?"parent: <"+this.parent.name+">":(null!=(ref2=this.parent)?ref2.name:void 0)?"node: <"+name+">, parent: <"+this.parent.name+">":"node: <"+name+">":""},XMLNode.prototype.ele=function(name,attributes,text){return this.element(name,attributes,text)},XMLNode.prototype.nod=function(name,attributes,text){return this.node(name,attributes,text)},XMLNode.prototype.txt=function(value){return this.text(value)},XMLNode.prototype.dat=function(value){return this.cdata(value)},XMLNode.prototype.com=function(value){return this.comment(value)},XMLNode.prototype.ins=function(target,value){return this.instruction(target,value)},XMLNode.prototype.doc=function(){return this.document()},XMLNode.prototype.dec=function(version,encoding,standalone){return this.declaration(version,encoding,standalone)},XMLNode.prototype.dtd=function(pubID,sysID){return this.doctype(pubID,sysID)},XMLNode.prototype.e=function(name,attributes,text){return this.element(name,attributes,text)},XMLNode.prototype.n=function(name,attributes,text){return this.node(name,attributes,text)},XMLNode.prototype.t=function(value){return this.text(value)},XMLNode.prototype.d=function(value){return this.cdata(value)},XMLNode.prototype.c=function(value){return this.comment(value)},XMLNode.prototype.r=function(value){return this.raw(value)},XMLNode.prototype.i=function(target,value){return this.instruction(target,value)},XMLNode.prototype.u=function(){return this.up()},XMLNode.prototype.importXMLBuilder=function(doc){return this.importDocument(doc)},XMLNode}()}).call(this)},{"./Utility":106,"./XMLCData":108,"./XMLComment":109,"./XMLDeclaration":114,"./XMLDocType":115,"./XMLElement":118,"./XMLProcessingInstruction":120,"./XMLRaw":121,"./XMLText":125}],120:[function(require,module,exports){(function(){var XMLNode,XMLProcessingInstruction,extend=function(child,parent){function ctor(){this.constructor=child}for(var key in parent)hasProp.call(parent,key)&&(child[key]=parent[key]);return ctor.prototype=parent.prototype,child.prototype=new ctor,child.__super__=parent.prototype,child},hasProp={}.hasOwnProperty;XMLNode=require("./XMLNode"),module.exports=XMLProcessingInstruction=function(superClass){function XMLProcessingInstruction(parent,target,value){if(XMLProcessingInstruction.__super__.constructor.call(this,parent),null==target)throw new Error("Missing instruction target. "+this.debugInfo());this.target=this.stringify.insTarget(target),value&&(this.value=this.stringify.insValue(value))}return extend(XMLProcessingInstruction,superClass),XMLProcessingInstruction.prototype.clone=function(){return Object.create(this)},XMLProcessingInstruction.prototype.toString=function(options){return this.options.writer.set(options).processingInstruction(this)},XMLProcessingInstruction}(XMLNode)}).call(this)},{"./XMLNode":119}],121:[function(require,module,exports){(function(){var XMLNode,XMLRaw,extend=function(child,parent){function ctor(){this.constructor=child}for(var key in parent)hasProp.call(parent,key)&&(child[key]=parent[key]);return ctor.prototype=parent.prototype,child.prototype=new ctor,child.__super__=parent.prototype,child},hasProp={}.hasOwnProperty;XMLNode=require("./XMLNode"),module.exports=XMLRaw=function(superClass){function XMLRaw(parent,text){if(XMLRaw.__super__.constructor.call(this,parent),null==text)throw new Error("Missing raw text. "+this.debugInfo());this.value=this.stringify.raw(text)}return extend(XMLRaw,superClass),XMLRaw.prototype.clone=function(){return Object.create(this)},XMLRaw.prototype.toString=function(options){return this.options.writer.set(options).raw(this)},XMLRaw}(XMLNode)}).call(this)},{"./XMLNode":119}],122:[function(require,module,exports){(function(){var XMLCData,XMLComment,XMLDTDAttList,XMLDTDElement,XMLDTDEntity,XMLDTDNotation,XMLDeclaration,XMLDocType,XMLElement,XMLProcessingInstruction,XMLRaw,XMLStreamWriter,XMLText,XMLWriterBase,extend=function(child,parent){function ctor(){this.constructor=child}for(var key in parent)hasProp.call(parent,key)&&(child[key]=parent[key]);return ctor.prototype=parent.prototype,child.prototype=new ctor,child.__super__=parent.prototype,child},hasProp={}.hasOwnProperty;XMLDeclaration=require("./XMLDeclaration"),XMLDocType=require("./XMLDocType"),XMLCData=require("./XMLCData"),XMLComment=require("./XMLComment"),XMLElement=require("./XMLElement"),XMLRaw=require("./XMLRaw"),XMLText=require("./XMLText"),XMLProcessingInstruction=require("./XMLProcessingInstruction"),XMLDTDAttList=require("./XMLDTDAttList"),XMLDTDElement=require("./XMLDTDElement"),XMLDTDEntity=require("./XMLDTDEntity"),XMLDTDNotation=require("./XMLDTDNotation"),XMLWriterBase=require("./XMLWriterBase"),module.exports=XMLStreamWriter=function(superClass){function XMLStreamWriter(stream,options){XMLStreamWriter.__super__.constructor.call(this,options),this.stream=stream}return extend(XMLStreamWriter,superClass),XMLStreamWriter.prototype.document=function(doc){var child,i,j,len,len1,ref,ref1,results;for(ref=doc.children,i=0,len=ref.length;len>i;i++)child=ref[i],child.isLastRootNode=!1;for(doc.children[doc.children.length-1].isLastRootNode=!0,ref1=doc.children,results=[],j=0,len1=ref1.length;len1>j;j++)switch(child=ref1[j],!1){case!(child instanceof XMLDeclaration):results.push(this.declaration(child));break;case!(child instanceof XMLDocType):results.push(this.docType(child));break;case!(child instanceof XMLComment):results.push(this.comment(child));break;case!(child instanceof XMLProcessingInstruction):results.push(this.processingInstruction(child));break;default:results.push(this.element(child))}return results},XMLStreamWriter.prototype.attribute=function(att){return this.stream.write(" "+att.name+'="'+att.value+'"')},XMLStreamWriter.prototype.cdata=function(node,level){return this.stream.write(this.space(level)+""+this.endline(node))},XMLStreamWriter.prototype.comment=function(node,level){return this.stream.write(this.space(level)+""+this.endline(node))},XMLStreamWriter.prototype.declaration=function(node,level){return this.stream.write(this.space(level)),this.stream.write('"),this.stream.write(this.endline(node))},XMLStreamWriter.prototype.docType=function(node,level){var child,i,len,ref;if(level||(level=0),this.stream.write(this.space(level)),this.stream.write("0){for(this.stream.write(" ["),this.stream.write(this.endline(node)),ref=node.children,i=0,len=ref.length;len>i;i++)switch(child=ref[i],!1){case!(child instanceof XMLDTDAttList):this.dtdAttList(child,level+1);break;case!(child instanceof XMLDTDElement):this.dtdElement(child,level+1);break;case!(child instanceof XMLDTDEntity):this.dtdEntity(child,level+1);break;case!(child instanceof XMLDTDNotation):this.dtdNotation(child,level+1);break;case!(child instanceof XMLCData):this.cdata(child,level+1);break;case!(child instanceof XMLComment):this.comment(child,level+1);break;case!(child instanceof XMLProcessingInstruction):this.processingInstruction(child,level+1);break;default:throw new Error("Unknown DTD node type: "+child.constructor.name)}this.stream.write("]")}return this.stream.write(this.spacebeforeslash+">"),this.stream.write(this.endline(node))},XMLStreamWriter.prototype.element=function(node,level){var att,child,i,len,name,ref,ref1,space;level||(level=0),space=this.space(level),this.stream.write(space+"<"+node.name),ref=node.attributes;for(name in ref)hasProp.call(ref,name)&&(att=ref[name],this.attribute(att));if(0===node.children.length||node.children.every(function(e){return""===e.value}))this.allowEmpty?this.stream.write(">"):this.stream.write(this.spacebeforeslash+"/>");else if(this.pretty&&1===node.children.length&&null!=node.children[0].value)this.stream.write(">"),this.stream.write(node.children[0].value),this.stream.write("");else{for(this.stream.write(">"+this.newline),ref1=node.children,i=0,len=ref1.length;len>i;i++)switch(child=ref1[i],!1){case!(child instanceof XMLCData):this.cdata(child,level+1);break;case!(child instanceof XMLComment):this.comment(child,level+1);break;case!(child instanceof XMLElement):this.element(child,level+1);break;case!(child instanceof XMLRaw):this.raw(child,level+1);break;case!(child instanceof XMLText):this.text(child,level+1);break;case!(child instanceof XMLProcessingInstruction):this.processingInstruction(child,level+1);break;default:throw new Error("Unknown XML node type: "+child.constructor.name)}this.stream.write(space+"")}return this.stream.write(this.endline(node))},XMLStreamWriter.prototype.processingInstruction=function(node,level){return this.stream.write(this.space(level)+""+this.endline(node))},XMLStreamWriter.prototype.raw=function(node,level){return this.stream.write(this.space(level)+node.value+this.endline(node))},XMLStreamWriter.prototype.text=function(node,level){return this.stream.write(this.space(level)+node.value+this.endline(node))},XMLStreamWriter.prototype.dtdAttList=function(node,level){return this.stream.write(this.space(level)+""+this.endline(node))},XMLStreamWriter.prototype.dtdElement=function(node,level){return this.stream.write(this.space(level)+""+this.endline(node))},XMLStreamWriter.prototype.dtdEntity=function(node,level){return this.stream.write(this.space(level)+""+this.endline(node))},XMLStreamWriter.prototype.dtdNotation=function(node,level){return this.stream.write(this.space(level)+""+this.endline(node))},XMLStreamWriter.prototype.endline=function(node){return node.isLastRootNode?"":this.newline},XMLStreamWriter}(XMLWriterBase)}).call(this)},{"./XMLCData":108,"./XMLComment":109,"./XMLDTDAttList":110,"./XMLDTDElement":111,"./XMLDTDEntity":112,"./XMLDTDNotation":113,"./XMLDeclaration":114,"./XMLDocType":115,"./XMLElement":118,"./XMLProcessingInstruction":120,"./XMLRaw":121,"./XMLText":125,"./XMLWriterBase":126}],123:[function(require,module,exports){(function(){var XMLCData,XMLComment,XMLDTDAttList,XMLDTDElement,XMLDTDEntity,XMLDTDNotation,XMLDeclaration,XMLDocType,XMLElement,XMLProcessingInstruction,XMLRaw,XMLStringWriter,XMLText,XMLWriterBase,extend=function(child,parent){function ctor(){this.constructor=child}for(var key in parent)hasProp.call(parent,key)&&(child[key]=parent[key]);return ctor.prototype=parent.prototype,child.prototype=new ctor,child.__super__=parent.prototype,child},hasProp={}.hasOwnProperty;XMLDeclaration=require("./XMLDeclaration"),XMLDocType=require("./XMLDocType"),XMLCData=require("./XMLCData"),XMLComment=require("./XMLComment"),XMLElement=require("./XMLElement"),XMLRaw=require("./XMLRaw"),XMLText=require("./XMLText"),XMLProcessingInstruction=require("./XMLProcessingInstruction"),XMLDTDAttList=require("./XMLDTDAttList"),XMLDTDElement=require("./XMLDTDElement"),XMLDTDEntity=require("./XMLDTDEntity"),XMLDTDNotation=require("./XMLDTDNotation"),XMLWriterBase=require("./XMLWriterBase"),module.exports=XMLStringWriter=function(superClass){function XMLStringWriter(options){XMLStringWriter.__super__.constructor.call(this,options)}return extend(XMLStringWriter,superClass),XMLStringWriter.prototype.document=function(doc){var child,i,len,r,ref;for(this.textispresent=!1,r="",ref=doc.children,i=0,len=ref.length;len>i;i++)child=ref[i],r+=function(){switch(!1){case!(child instanceof XMLDeclaration):return this.declaration(child);case!(child instanceof XMLDocType):return this.docType(child);case!(child instanceof XMLComment):return this.comment(child);case!(child instanceof XMLProcessingInstruction):return this.processingInstruction(child);default:return this.element(child,0)}}.call(this);return this.pretty&&r.slice(-this.newline.length)===this.newline&&(r=r.slice(0,-this.newline.length)),r},XMLStringWriter.prototype.attribute=function(att){return" "+att.name+'="'+att.value+'"'},XMLStringWriter.prototype.cdata=function(node,level){return this.space(level)+""+this.newline},XMLStringWriter.prototype.comment=function(node,level){return this.space(level)+""+this.newline},XMLStringWriter.prototype.declaration=function(node,level){var r;return r=this.space(level),r+='",r+=this.newline},XMLStringWriter.prototype.docType=function(node,level){var child,i,len,r,ref;if(level||(level=0),r=this.space(level),r+="0){for(r+=" [",r+=this.newline,ref=node.children,i=0,len=ref.length;len>i;i++)child=ref[i],r+=function(){switch(!1){case!(child instanceof XMLDTDAttList):return this.dtdAttList(child,level+1);case!(child instanceof XMLDTDElement):return this.dtdElement(child,level+1);case!(child instanceof XMLDTDEntity):return this.dtdEntity(child,level+1);case!(child instanceof XMLDTDNotation):return this.dtdNotation(child,level+1);case!(child instanceof XMLCData):return this.cdata(child,level+1);case!(child instanceof XMLComment):return this.comment(child,level+1);case!(child instanceof XMLProcessingInstruction):return this.processingInstruction(child,level+1);default:throw new Error("Unknown DTD node type: "+child.constructor.name)}}.call(this);r+="]"}return r+=this.spacebeforeslash+">",r+=this.newline},XMLStringWriter.prototype.element=function(node,level){var att,child,i,j,len,len1,name,r,ref,ref1,ref2,space,textispresentwasset;level||(level=0),textispresentwasset=!1,this.textispresent?(this.newline="",this.pretty=!1):(this.newline=this.newlinedefault,this.pretty=this.prettydefault),space=this.space(level),r="",r+=space+"<"+node.name,ref=node.attributes;for(name in ref)hasProp.call(ref,name)&&(att=ref[name],r+=this.attribute(att));if(0===node.children.length||node.children.every(function(e){return""===e.value}))r+=this.allowEmpty?">"+this.newline:this.spacebeforeslash+"/>"+this.newline;else if(this.pretty&&1===node.children.length&&null!=node.children[0].value)r+=">",r+=node.children[0].value,r+=""+this.newline;else{if(this.dontprettytextnodes)for(ref1=node.children,i=0,len=ref1.length;len>i;i++)if(child=ref1[i],null!=child.value){this.textispresent++,textispresentwasset=!0;break}for(this.textispresent&&(this.newline="",this.pretty=!1,space=this.space(level)),r+=">"+this.newline,ref2=node.children,j=0,len1=ref2.length;len1>j;j++)child=ref2[j],r+=function(){switch(!1){case!(child instanceof XMLCData):return this.cdata(child,level+1);case!(child instanceof XMLComment):return this.comment(child,level+1);case!(child instanceof XMLElement):return this.element(child,level+1);case!(child instanceof XMLRaw):return this.raw(child,level+1);case!(child instanceof XMLText):return this.text(child,level+1);case!(child instanceof XMLProcessingInstruction):return this.processingInstruction(child,level+1);default:throw new Error("Unknown XML node type: "+child.constructor.name)}}.call(this);textispresentwasset&&this.textispresent--,this.textispresent||(this.newline=this.newlinedefault,this.pretty=this.prettydefault),r+=space+""+this.newline}return r},XMLStringWriter.prototype.processingInstruction=function(node,level){var r;return r=this.space(level)+""+this.newline},XMLStringWriter.prototype.raw=function(node,level){return this.space(level)+node.value+this.newline},XMLStringWriter.prototype.text=function(node,level){return this.space(level)+node.value+this.newline},XMLStringWriter.prototype.dtdAttList=function(node,level){var r;return r=this.space(level)+""+this.newline},XMLStringWriter.prototype.dtdElement=function(node,level){return this.space(level)+""+this.newline},XMLStringWriter.prototype.dtdEntity=function(node,level){var r;return r=this.space(level)+""+this.newline},XMLStringWriter.prototype.dtdNotation=function(node,level){var r;return r=this.space(level)+""+this.newline},XMLStringWriter.prototype.openNode=function(node,level){var att,name,r,ref;if(level||(level=0),node instanceof XMLElement){r=this.space(level)+"<"+node.name,ref=node.attributes;for(name in ref)hasProp.call(ref,name)&&(att=ref[name],r+=this.attribute(att));return r+=(node.children?">":"/>")+this.newline}return r=this.space(level)+"")+this.newline},XMLStringWriter.prototype.closeNode=function(node,level){switch(level||(level=0),!1){case!(node instanceof XMLElement):return this.space(level)+""+this.newline;case!(node instanceof XMLDocType):return this.space(level)+"]>"+this.newline}},XMLStringWriter}(XMLWriterBase)}).call(this)},{"./XMLCData":108,"./XMLComment":109,"./XMLDTDAttList":110,"./XMLDTDElement":111,"./XMLDTDEntity":112,"./XMLDTDNotation":113,"./XMLDeclaration":114,"./XMLDocType":115,"./XMLElement":118,"./XMLProcessingInstruction":120,"./XMLRaw":121,"./XMLText":125,"./XMLWriterBase":126}],124:[function(require,module,exports){(function(){var XMLStringifier,bind=function(fn,me){return function(){return fn.apply(me,arguments)}},hasProp={}.hasOwnProperty;module.exports=XMLStringifier=function(){function XMLStringifier(options){this.assertLegalChar=bind(this.assertLegalChar,this);var key,ref,value;options||(options={}),this.noDoubleEncoding=options.noDoubleEncoding,ref=options.stringify||{};for(key in ref)hasProp.call(ref,key)&&(value=ref[key],this[key]=value)}return XMLStringifier.prototype.eleName=function(val){return val=""+val||"",this.assertLegalChar(val)},XMLStringifier.prototype.eleText=function(val){return val=""+val||"",this.assertLegalChar(this.elEscape(val))},XMLStringifier.prototype.cdata=function(val){return val=""+val||"",val=val.replace("]]>","]]]]>"),this.assertLegalChar(val)},XMLStringifier.prototype.comment=function(val){if(val=""+val||"",val.match(/--/))throw new Error("Comment text cannot contain double-hypen: "+val);return this.assertLegalChar(val)},XMLStringifier.prototype.raw=function(val){return""+val||""},XMLStringifier.prototype.attName=function(val){return val=""+val||""},XMLStringifier.prototype.attValue=function(val){return val=""+val||"",this.attEscape(val)},XMLStringifier.prototype.insTarget=function(val){return""+val||""},XMLStringifier.prototype.insValue=function(val){if(val=""+val||"",val.match(/\?>/))throw new Error("Invalid processing instruction value: "+val);return val},XMLStringifier.prototype.xmlVersion=function(val){if(val=""+val||"",!val.match(/1\.[0-9]+/))throw new Error("Invalid version number: "+val);return val},XMLStringifier.prototype.xmlEncoding=function(val){if(val=""+val||"",!val.match(/^[A-Za-z](?:[A-Za-z0-9._-])*$/))throw new Error("Invalid encoding: "+val);return val},XMLStringifier.prototype.xmlStandalone=function(val){return val?"yes":"no"},XMLStringifier.prototype.dtdPubID=function(val){return""+val||""},XMLStringifier.prototype.dtdSysID=function(val){return""+val||""},XMLStringifier.prototype.dtdElementValue=function(val){return""+val||""},XMLStringifier.prototype.dtdAttType=function(val){return""+val||""},XMLStringifier.prototype.dtdAttDefault=function(val){return null!=val?""+val||"":val},XMLStringifier.prototype.dtdEntityValue=function(val){return""+val||""},XMLStringifier.prototype.dtdNData=function(val){return""+val||""},XMLStringifier.prototype.convertAttKey="@",XMLStringifier.prototype.convertPIKey="?",XMLStringifier.prototype.convertTextKey="#text",XMLStringifier.prototype.convertCDataKey="#cdata",XMLStringifier.prototype.convertCommentKey="#comment",XMLStringifier.prototype.convertRawKey="#raw",XMLStringifier.prototype.assertLegalChar=function(str){var res;if(res=str.match(/[\0\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/))throw new Error("Invalid character in string: "+str+" at index "+res.index);return str},XMLStringifier.prototype.elEscape=function(str){var ampregex;return ampregex=this.noDoubleEncoding?/(?!&\S+;)&/g:/&/g,str.replace(ampregex,"&").replace(//g,">").replace(/\r/g," ")},XMLStringifier.prototype.attEscape=function(str){var ampregex;return ampregex=this.noDoubleEncoding?/(?!&\S+;)&/g:/&/g,str.replace(ampregex,"&").replace(/0?new Array(indent).join(this.indent):""):""},XMLWriterBase}()}).call(this); -},{}],127:[function(require,module,exports){(function(){var XMLDocument,XMLDocumentCB,XMLStreamWriter,XMLStringWriter,assign,isFunction,ref;ref=require("./Utility"),assign=ref.assign,isFunction=ref.isFunction,XMLDocument=require("./XMLDocument"),XMLDocumentCB=require("./XMLDocumentCB"),XMLStringWriter=require("./XMLStringWriter"),XMLStreamWriter=require("./XMLStreamWriter"),module.exports.create=function(name,xmldec,doctype,options){var doc,root;if(null==name)throw new Error("Root element needs a name.");return options=assign({},xmldec,doctype,options),doc=new XMLDocument(options),root=doc.element(name),options.headless||(doc.declaration(options),(null!=options.pubID||null!=options.sysID)&&doc.doctype(options)),root},module.exports.begin=function(options,onData,onEnd){var ref1;return isFunction(options)&&(ref1=[options,onData],onData=ref1[0],onEnd=ref1[1],options={}),onData?new XMLDocumentCB(options,onData,onEnd):new XMLDocument(options)},module.exports.stringWriter=function(options){return new XMLStringWriter(options)},module.exports.streamWriter=function(stream,options){return new XMLStreamWriter(stream,options)}}).call(this)},{"./Utility":106,"./XMLDocument":116,"./XMLDocumentCB":117,"./XMLStreamWriter":122,"./XMLStringWriter":123}]},{},[21])(21)}); diff --git a/srv/tesm/static/js/vendor/xlsx.full.min.js b/srv/tesm/static/js/vendor/xlsx.full.min.js deleted file mode 100644 index 16e013f..0000000 --- a/srv/tesm/static/js/vendor/xlsx.full.min.js +++ /dev/null @@ -1,22 +0,0 @@ -/*! xlsx.js (C) 2013-present SheetJS -- http://sheetjs.com */ -var DO_NOT_EXPORT_CODEPAGE=true;var cptable={version:"1.15.0"};cptable[437]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[620]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ÇüéâäàąçêëèïîćÄĄĘęłôöĆûùŚÖܢ٥śƒŹŻóÓńŃźż¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[737]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩαβγδεζηθικλμνξοπρσςτυφχψ░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀ωάέήϊίόύϋώΆΈΉΊΌΎΏ±≥≤ΪΫ÷≈°∙·√ⁿ²■ ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[850]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜø£Ø×ƒáíóúñѪº¿®¬½¼¡«»░▒▓│┤ÁÂÀ©╣║╗╝¢¥┐└┴┬├─┼ãÃ╚╔╩╦╠═╬¤ðÐÊËÈıÍÎÏ┘┌█▄¦Ì▀ÓßÔÒõÕµþÞÚÛÙýݯ´­±‗¾¶§÷¸°¨·¹³²■ ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[852]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ÇüéâäůćçłëŐőîŹÄĆÉĹĺôöĽľŚśÖÜŤťŁ×čáíóúĄąŽžĘ꬟Ⱥ«»░▒▓│┤ÁÂĚŞ╣║╗╝Żż┐└┴┬├─┼Ăă╚╔╩╦╠═╬¤đĐĎËďŇÍÎě┘┌█▄ŢŮ▀ÓßÔŃńňŠšŔÚŕŰýÝţ´­˝˛ˇ˘§÷¸°¨˙űŘř■ ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[857]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ÇüéâäàåçêëèïîıÄÅÉæÆôöòûùİÖÜø£ØŞşáíóúñÑĞ𿮬½¼¡«»░▒▓│┤ÁÂÀ©╣║╗╝¢¥┐└┴┬├─┼ãÃ╚╔╩╦╠═╬¤ºªÊËÈ�ÍÎÏ┘┌█▄¦Ì▀ÓßÔÒõÕµ�×ÚÛÙìÿ¯´­±�¾¶§÷¸°¨·¹³²■ ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[861]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ÇüéâäàåçêëèÐðÞÄÅÉæÆôöþûÝýÖÜø£Ø₧ƒáíóúÁÍÓÚ¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[865]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜø£Ø₧ƒáíóúñѪº¿⌐¬½¼¡«¤░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[866]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмноп░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀рстуфхцчшщъыьэюяЁёЄєЇїЎў°∙·√№¤■ ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[874]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€����…�����������‘’“”•–—�������� กขฃคฅฆงจฉชซฌญฎฏฐฑฒณดตถทธนบปผฝพฟภมยรฤลฦวศษสหฬอฮฯะัาำิีึืฺุู����฿เแโใไๅๆ็่้๊๋์ํ๎๏๐๑๒๓๔๕๖๗๘๙๚๛����",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[895]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ČüéďäĎŤčěĚĹÍľǪÄÁÉžŽôöÓůÚýÖÜŠĽÝŘťáíóúňŇŮÔšřŕŔ¼§«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[932]=function(){var e=[],r={},t=[],a;t[0]="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~���������������������������������。「」、・ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙゚��������������������������������".split("");for(a=0;a!=t[0].length;++a)if(t[0][a].charCodeAt(0)!==65533){r[t[0][a]]=0+a;e[0+a]=t[0][a]}t[129]="���������������������������������������������������������������� 、。,.・:;?!゛゜´`¨^ ̄_ヽヾゝゞ〃仝々〆〇ー―‐/\~∥|…‥‘’“”()〔〕[]{}〈〉《》「」『』【】+-±×�÷=≠<>≦≧∞∴♂♀°′″℃¥$¢£%#&*@§☆★○●◎◇◆□■△▲▽▼※〒→←↑↓〓�����������∈∋⊆⊇⊂⊃∪∩��������∧∨¬⇒⇔∀∃�����������∠⊥⌒∂∇≡≒≪≫√∽∝∵∫∬�������ʼn♯♭♪†‡¶����◯���".split("");for(a=0;a!=t[129].length;++a)if(t[129][a].charCodeAt(0)!==65533){r[t[129][a]]=33024+a;e[33024+a]=t[129][a]}t[130]="�������������������������������������������������������������������������������0123456789�������ABCDEFGHIJKLMNOPQRSTUVWXYZ�������abcdefghijklmnopqrstuvwxyz����ぁあぃいぅうぇえぉおかがきぎくぐけげこごさざしじすずせぜそぞただちぢっつづてでとどなにぬねのはばぱひびぴふぶぷへべぺほぼぽまみむめもゃやゅゆょよらりるれろゎわゐゑをん��������������".split("");for(a=0;a!=t[130].length;++a)if(t[130][a].charCodeAt(0)!==65533){r[t[130][a]]=33280+a;e[33280+a]=t[130][a]}t[131]="����������������������������������������������������������������ァアィイゥウェエォオカガキギクグケゲコゴサザシジスズセゼソゾタダチヂッツヅテデトドナニヌネノハバパヒビピフブプヘベペホボポマミ�ムメモャヤュユョヨラリルレロヮワヰヱヲンヴヵヶ��������ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ��������αβγδεζηθικλμνξοπρστυφχψω�����������������������������������������".split("");for(a=0;a!=t[131].length;++a)if(t[131][a].charCodeAt(0)!==65533){r[t[131][a]]=33536+a;e[33536+a]=t[131][a]}t[132]="����������������������������������������������������������������АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ���������������абвгдеёжзийклмн�опрстуфхцчшщъыьэюя�������������─│┌┐┘└├┬┤┴┼━┃┏┓┛┗┣┳┫┻╋┠┯┨┷┿┝┰┥┸╂�����������������������������������������������������������������".split("");for(a=0;a!=t[132].length;++a)if(t[132][a].charCodeAt(0)!==65533){r[t[132][a]]=33792+a;e[33792+a]=t[132][a]}t[135]="����������������������������������������������������������������①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ�㍉㌔㌢㍍㌘㌧㌃㌶㍑㍗㌍㌦㌣㌫㍊㌻㎜㎝㎞㎎㎏㏄㎡��������㍻�〝〟№㏍℡㊤㊥㊦㊧㊨㈱㈲㈹㍾㍽㍼≒≡∫∮∑√⊥∠∟⊿∵∩∪���������������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[135].length;++a)if(t[135][a].charCodeAt(0)!==65533){r[t[135][a]]=34560+a;e[34560+a]=t[135][a]}t[136]="���������������������������������������������������������������������������������������������������������������������������������������������������������������亜唖娃阿哀愛挨姶逢葵茜穐悪握渥旭葦芦鯵梓圧斡扱宛姐虻飴絢綾鮎或粟袷安庵按暗案闇鞍杏以伊位依偉囲夷委威尉惟意慰易椅為畏異移維緯胃萎衣謂違遺医井亥域育郁磯一壱溢逸稲茨芋鰯允印咽員因姻引飲淫胤蔭���".split("");for(a=0;a!=t[136].length;++a)if(t[136][a].charCodeAt(0)!==65533){r[t[136][a]]=34816+a;e[34816+a]=t[136][a]}t[137]="����������������������������������������������������������������院陰隠韻吋右宇烏羽迂雨卯鵜窺丑碓臼渦嘘唄欝蔚鰻姥厩浦瓜閏噂云運雲荏餌叡営嬰影映曳栄永泳洩瑛盈穎頴英衛詠鋭液疫益駅悦謁越閲榎厭円�園堰奄宴延怨掩援沿演炎焔煙燕猿縁艶苑薗遠鉛鴛塩於汚甥凹央奥往応押旺横欧殴王翁襖鴬鴎黄岡沖荻億屋憶臆桶牡乙俺卸恩温穏音下化仮何伽価佳加可嘉夏嫁家寡科暇果架歌河火珂禍禾稼箇花苛茄荷華菓蝦課嘩貨迦過霞蚊俄峨我牙画臥芽蛾賀雅餓駕介会解回塊壊廻快怪悔恢懐戒拐改���".split("");for(a=0;a!=t[137].length;++a)if(t[137][a].charCodeAt(0)!==65533){r[t[137][a]]=35072+a;e[35072+a]=t[137][a]}t[138]="����������������������������������������������������������������魁晦械海灰界皆絵芥蟹開階貝凱劾外咳害崖慨概涯碍蓋街該鎧骸浬馨蛙垣柿蛎鈎劃嚇各廓拡撹格核殻獲確穫覚角赫較郭閣隔革学岳楽額顎掛笠樫�橿梶鰍潟割喝恰括活渇滑葛褐轄且鰹叶椛樺鞄株兜竃蒲釜鎌噛鴨栢茅萱粥刈苅瓦乾侃冠寒刊勘勧巻喚堪姦完官寛干幹患感慣憾換敢柑桓棺款歓汗漢澗潅環甘監看竿管簡緩缶翰肝艦莞観諌貫還鑑間閑関陥韓館舘丸含岸巌玩癌眼岩翫贋雁頑顔願企伎危喜器基奇嬉寄岐希幾忌揮机旗既期棋棄���".split("");for(a=0;a!=t[138].length;++a)if(t[138][a].charCodeAt(0)!==65533){r[t[138][a]]=35328+a;e[35328+a]=t[138][a]}t[139]="����������������������������������������������������������������機帰毅気汽畿祈季稀紀徽規記貴起軌輝飢騎鬼亀偽儀妓宜戯技擬欺犠疑祇義蟻誼議掬菊鞠吉吃喫桔橘詰砧杵黍却客脚虐逆丘久仇休及吸宮弓急救�朽求汲泣灸球究窮笈級糾給旧牛去居巨拒拠挙渠虚許距鋸漁禦魚亨享京供侠僑兇競共凶協匡卿叫喬境峡強彊怯恐恭挟教橋況狂狭矯胸脅興蕎郷鏡響饗驚仰凝尭暁業局曲極玉桐粁僅勤均巾錦斤欣欽琴禁禽筋緊芹菌衿襟謹近金吟銀九倶句区狗玖矩苦躯駆駈駒具愚虞喰空偶寓遇隅串櫛釧屑屈���".split("");for(a=0;a!=t[139].length;++a)if(t[139][a].charCodeAt(0)!==65533){r[t[139][a]]=35584+a;e[35584+a]=t[139][a]}t[140]="����������������������������������������������������������������掘窟沓靴轡窪熊隈粂栗繰桑鍬勲君薫訓群軍郡卦袈祁係傾刑兄啓圭珪型契形径恵慶慧憩掲携敬景桂渓畦稽系経継繋罫茎荊蛍計詣警軽頚鶏芸迎鯨�劇戟撃激隙桁傑欠決潔穴結血訣月件倹倦健兼券剣喧圏堅嫌建憲懸拳捲検権牽犬献研硯絹県肩見謙賢軒遣鍵険顕験鹸元原厳幻弦減源玄現絃舷言諺限乎個古呼固姑孤己庫弧戸故枯湖狐糊袴股胡菰虎誇跨鈷雇顧鼓五互伍午呉吾娯後御悟梧檎瑚碁語誤護醐乞鯉交佼侯候倖光公功効勾厚口向���".split("");for(a=0;a!=t[140].length;++a)if(t[140][a].charCodeAt(0)!==65533){r[t[140][a]]=35840+a;e[35840+a]=t[140][a]}t[141]="����������������������������������������������������������������后喉坑垢好孔孝宏工巧巷幸広庚康弘恒慌抗拘控攻昂晃更杭校梗構江洪浩港溝甲皇硬稿糠紅紘絞綱耕考肯肱腔膏航荒行衡講貢購郊酵鉱砿鋼閤降�項香高鴻剛劫号合壕拷濠豪轟麹克刻告国穀酷鵠黒獄漉腰甑忽惚骨狛込此頃今困坤墾婚恨懇昏昆根梱混痕紺艮魂些佐叉唆嵯左差査沙瑳砂詐鎖裟坐座挫債催再最哉塞妻宰彩才採栽歳済災采犀砕砦祭斎細菜裁載際剤在材罪財冴坂阪堺榊肴咲崎埼碕鷺作削咋搾昨朔柵窄策索錯桜鮭笹匙冊刷���".split("");for(a=0;a!=t[141].length;++a)if(t[141][a].charCodeAt(0)!==65533){r[t[141][a]]=36096+a;e[36096+a]=t[141][a]}t[142]="����������������������������������������������������������������察拶撮擦札殺薩雑皐鯖捌錆鮫皿晒三傘参山惨撒散桟燦珊産算纂蚕讃賛酸餐斬暫残仕仔伺使刺司史嗣四士始姉姿子屍市師志思指支孜斯施旨枝止�死氏獅祉私糸紙紫肢脂至視詞詩試誌諮資賜雌飼歯事似侍児字寺慈持時次滋治爾璽痔磁示而耳自蒔辞汐鹿式識鴫竺軸宍雫七叱執失嫉室悉湿漆疾質実蔀篠偲柴芝屡蕊縞舎写射捨赦斜煮社紗者謝車遮蛇邪借勺尺杓灼爵酌釈錫若寂弱惹主取守手朱殊狩珠種腫趣酒首儒受呪寿授樹綬需囚収周���".split("");for(a=0;a!=t[142].length;++a)if(t[142][a].charCodeAt(0)!==65533){r[t[142][a]]=36352+a;e[36352+a]=t[142][a]}t[143]="����������������������������������������������������������������宗就州修愁拾洲秀秋終繍習臭舟蒐衆襲讐蹴輯週酋酬集醜什住充十従戎柔汁渋獣縦重銃叔夙宿淑祝縮粛塾熟出術述俊峻春瞬竣舜駿准循旬楯殉淳�準潤盾純巡遵醇順処初所暑曙渚庶緒署書薯藷諸助叙女序徐恕鋤除傷償勝匠升召哨商唱嘗奨妾娼宵将小少尚庄床廠彰承抄招掌捷昇昌昭晶松梢樟樵沼消渉湘焼焦照症省硝礁祥称章笑粧紹肖菖蒋蕉衝裳訟証詔詳象賞醤鉦鍾鐘障鞘上丈丞乗冗剰城場壌嬢常情擾条杖浄状畳穣蒸譲醸錠嘱埴飾���".split("");for(a=0;a!=t[143].length;++a)if(t[143][a].charCodeAt(0)!==65533){r[t[143][a]]=36608+a;e[36608+a]=t[143][a]}t[144]="����������������������������������������������������������������拭植殖燭織職色触食蝕辱尻伸信侵唇娠寝審心慎振新晋森榛浸深申疹真神秦紳臣芯薪親診身辛進針震人仁刃塵壬尋甚尽腎訊迅陣靭笥諏須酢図厨�逗吹垂帥推水炊睡粋翠衰遂酔錐錘随瑞髄崇嵩数枢趨雛据杉椙菅頗雀裾澄摺寸世瀬畝是凄制勢姓征性成政整星晴棲栖正清牲生盛精聖声製西誠誓請逝醒青静斉税脆隻席惜戚斥昔析石積籍績脊責赤跡蹟碩切拙接摂折設窃節説雪絶舌蝉仙先千占宣専尖川戦扇撰栓栴泉浅洗染潜煎煽旋穿箭線���".split("");for(a=0;a!=t[144].length;++a)if(t[144][a].charCodeAt(0)!==65533){r[t[144][a]]=36864+a;e[36864+a]=t[144][a]}t[145]="����������������������������������������������������������������繊羨腺舛船薦詮賎践選遷銭銑閃鮮前善漸然全禅繕膳糎噌塑岨措曾曽楚狙疏疎礎祖租粗素組蘇訴阻遡鼠僧創双叢倉喪壮奏爽宋層匝惣想捜掃挿掻�操早曹巣槍槽漕燥争痩相窓糟総綜聡草荘葬蒼藻装走送遭鎗霜騒像増憎臓蔵贈造促側則即息捉束測足速俗属賊族続卒袖其揃存孫尊損村遜他多太汰詑唾堕妥惰打柁舵楕陀駄騨体堆対耐岱帯待怠態戴替泰滞胎腿苔袋貸退逮隊黛鯛代台大第醍題鷹滝瀧卓啄宅托択拓沢濯琢託鐸濁諾茸凧蛸只���".split("");for(a=0;a!=t[145].length;++a)if(t[145][a].charCodeAt(0)!==65533){r[t[145][a]]=37120+a;e[37120+a]=t[145][a]}t[146]="����������������������������������������������������������������叩但達辰奪脱巽竪辿棚谷狸鱈樽誰丹単嘆坦担探旦歎淡湛炭短端箪綻耽胆蛋誕鍛団壇弾断暖檀段男談値知地弛恥智池痴稚置致蜘遅馳築畜竹筑蓄�逐秩窒茶嫡着中仲宙忠抽昼柱注虫衷註酎鋳駐樗瀦猪苧著貯丁兆凋喋寵帖帳庁弔張彫徴懲挑暢朝潮牒町眺聴脹腸蝶調諜超跳銚長頂鳥勅捗直朕沈珍賃鎮陳津墜椎槌追鎚痛通塚栂掴槻佃漬柘辻蔦綴鍔椿潰坪壷嬬紬爪吊釣鶴亭低停偵剃貞呈堤定帝底庭廷弟悌抵挺提梯汀碇禎程締艇訂諦蹄逓���".split("");for(a=0;a!=t[146].length;++a)if(t[146][a].charCodeAt(0)!==65533){r[t[146][a]]=37376+a;e[37376+a]=t[146][a]}t[147]="����������������������������������������������������������������邸鄭釘鼎泥摘擢敵滴的笛適鏑溺哲徹撤轍迭鉄典填天展店添纏甜貼転顛点伝殿澱田電兎吐堵塗妬屠徒斗杜渡登菟賭途都鍍砥砺努度土奴怒倒党冬�凍刀唐塔塘套宕島嶋悼投搭東桃梼棟盗淘湯涛灯燈当痘祷等答筒糖統到董蕩藤討謄豆踏逃透鐙陶頭騰闘働動同堂導憧撞洞瞳童胴萄道銅峠鴇匿得徳涜特督禿篤毒独読栃橡凸突椴届鳶苫寅酉瀞噸屯惇敦沌豚遁頓呑曇鈍奈那内乍凪薙謎灘捺鍋楢馴縄畷南楠軟難汝二尼弐迩匂賑肉虹廿日乳入���".split("");for(a=0;a!=t[147].length;++a)if(t[147][a].charCodeAt(0)!==65533){r[t[147][a]]=37632+a;e[37632+a]=t[147][a]}t[148]="����������������������������������������������������������������如尿韮任妊忍認濡禰祢寧葱猫熱年念捻撚燃粘乃廼之埜嚢悩濃納能脳膿農覗蚤巴把播覇杷波派琶破婆罵芭馬俳廃拝排敗杯盃牌背肺輩配倍培媒梅�楳煤狽買売賠陪這蝿秤矧萩伯剥博拍柏泊白箔粕舶薄迫曝漠爆縛莫駁麦函箱硲箸肇筈櫨幡肌畑畠八鉢溌発醗髪伐罰抜筏閥鳩噺塙蛤隼伴判半反叛帆搬斑板氾汎版犯班畔繁般藩販範釆煩頒飯挽晩番盤磐蕃蛮匪卑否妃庇彼悲扉批披斐比泌疲皮碑秘緋罷肥被誹費避非飛樋簸備尾微枇毘琵眉美���".split("");for(a=0;a!=t[148].length;++a)if(t[148][a].charCodeAt(0)!==65533){r[t[148][a]]=37888+a;e[37888+a]=t[148][a]}t[149]="����������������������������������������������������������������鼻柊稗匹疋髭彦膝菱肘弼必畢筆逼桧姫媛紐百謬俵彪標氷漂瓢票表評豹廟描病秒苗錨鋲蒜蛭鰭品彬斌浜瀕貧賓頻敏瓶不付埠夫婦富冨布府怖扶敷�斧普浮父符腐膚芙譜負賦赴阜附侮撫武舞葡蕪部封楓風葺蕗伏副復幅服福腹複覆淵弗払沸仏物鮒分吻噴墳憤扮焚奮粉糞紛雰文聞丙併兵塀幣平弊柄並蔽閉陛米頁僻壁癖碧別瞥蔑箆偏変片篇編辺返遍便勉娩弁鞭保舗鋪圃捕歩甫補輔穂募墓慕戊暮母簿菩倣俸包呆報奉宝峰峯崩庖抱捧放方朋���".split("");for(a=0;a!=t[149].length;++a)if(t[149][a].charCodeAt(0)!==65533){r[t[149][a]]=38144+a;e[38144+a]=t[149][a]}t[150]="����������������������������������������������������������������法泡烹砲縫胞芳萌蓬蜂褒訪豊邦鋒飽鳳鵬乏亡傍剖坊妨帽忘忙房暴望某棒冒紡肪膨謀貌貿鉾防吠頬北僕卜墨撲朴牧睦穆釦勃没殆堀幌奔本翻凡盆�摩磨魔麻埋妹昧枚毎哩槙幕膜枕鮪柾鱒桝亦俣又抹末沫迄侭繭麿万慢満漫蔓味未魅巳箕岬密蜜湊蓑稔脈妙粍民眠務夢無牟矛霧鵡椋婿娘冥名命明盟迷銘鳴姪牝滅免棉綿緬面麺摸模茂妄孟毛猛盲網耗蒙儲木黙目杢勿餅尤戻籾貰問悶紋門匁也冶夜爺耶野弥矢厄役約薬訳躍靖柳薮鑓愉愈油癒���".split("");for(a=0;a!=t[150].length;++a)if(t[150][a].charCodeAt(0)!==65533){r[t[150][a]]=38400+a;e[38400+a]=t[150][a]}t[151]="����������������������������������������������������������������諭輸唯佑優勇友宥幽悠憂揖有柚湧涌猶猷由祐裕誘遊邑郵雄融夕予余与誉輿預傭幼妖容庸揚揺擁曜楊様洋溶熔用窯羊耀葉蓉要謡踊遥陽養慾抑欲�沃浴翌翼淀羅螺裸来莱頼雷洛絡落酪乱卵嵐欄濫藍蘭覧利吏履李梨理璃痢裏裡里離陸律率立葎掠略劉流溜琉留硫粒隆竜龍侶慮旅虜了亮僚両凌寮料梁涼猟療瞭稜糧良諒遼量陵領力緑倫厘林淋燐琳臨輪隣鱗麟瑠塁涙累類令伶例冷励嶺怜玲礼苓鈴隷零霊麗齢暦歴列劣烈裂廉恋憐漣煉簾練聯���".split("");for(a=0;a!=t[151].length;++a)if(t[151][a].charCodeAt(0)!==65533){r[t[151][a]]=38656+a;e[38656+a]=t[151][a]}t[152]="����������������������������������������������������������������蓮連錬呂魯櫓炉賂路露労婁廊弄朗楼榔浪漏牢狼篭老聾蝋郎六麓禄肋録論倭和話歪賄脇惑枠鷲亙亘鰐詫藁蕨椀湾碗腕��������������������������������������������弌丐丕个丱丶丼丿乂乖乘亂亅豫亊舒弍于亞亟亠亢亰亳亶从仍仄仆仂仗仞仭仟价伉佚估佛佝佗佇佶侈侏侘佻佩佰侑佯來侖儘俔俟俎俘俛俑俚俐俤俥倚倨倔倪倥倅伜俶倡倩倬俾俯們倆偃假會偕偐偈做偖偬偸傀傚傅傴傲���".split("");for(a=0;a!=t[152].length;++a)if(t[152][a].charCodeAt(0)!==65533){r[t[152][a]]=38912+a;e[38912+a]=t[152][a]}t[153]="����������������������������������������������������������������僉僊傳僂僖僞僥僭僣僮價僵儉儁儂儖儕儔儚儡儺儷儼儻儿兀兒兌兔兢竸兩兪兮冀冂囘册冉冏冑冓冕冖冤冦冢冩冪冫决冱冲冰况冽凅凉凛几處凩凭�凰凵凾刄刋刔刎刧刪刮刳刹剏剄剋剌剞剔剪剴剩剳剿剽劍劔劒剱劈劑辨辧劬劭劼劵勁勍勗勞勣勦飭勠勳勵勸勹匆匈甸匍匐匏匕匚匣匯匱匳匸區卆卅丗卉卍凖卞卩卮夘卻卷厂厖厠厦厥厮厰厶參簒雙叟曼燮叮叨叭叺吁吽呀听吭吼吮吶吩吝呎咏呵咎呟呱呷呰咒呻咀呶咄咐咆哇咢咸咥咬哄哈咨���".split("");for(a=0;a!=t[153].length;++a)if(t[153][a].charCodeAt(0)!==65533){r[t[153][a]]=39168+a;e[39168+a]=t[153][a]}t[154]="����������������������������������������������������������������咫哂咤咾咼哘哥哦唏唔哽哮哭哺哢唹啀啣啌售啜啅啖啗唸唳啝喙喀咯喊喟啻啾喘喞單啼喃喩喇喨嗚嗅嗟嗄嗜嗤嗔嘔嗷嘖嗾嗽嘛嗹噎噐營嘴嘶嘲嘸�噫噤嘯噬噪嚆嚀嚊嚠嚔嚏嚥嚮嚶嚴囂嚼囁囃囀囈囎囑囓囗囮囹圀囿圄圉圈國圍圓團圖嗇圜圦圷圸坎圻址坏坩埀垈坡坿垉垓垠垳垤垪垰埃埆埔埒埓堊埖埣堋堙堝塲堡塢塋塰毀塒堽塹墅墹墟墫墺壞墻墸墮壅壓壑壗壙壘壥壜壤壟壯壺壹壻壼壽夂夊夐夛梦夥夬夭夲夸夾竒奕奐奎奚奘奢奠奧奬奩���".split("");for(a=0;a!=t[154].length;++a)if(t[154][a].charCodeAt(0)!==65533){r[t[154][a]]=39424+a;e[39424+a]=t[154][a]}t[155]="����������������������������������������������������������������奸妁妝佞侫妣妲姆姨姜妍姙姚娥娟娑娜娉娚婀婬婉娵娶婢婪媚媼媾嫋嫂媽嫣嫗嫦嫩嫖嫺嫻嬌嬋嬖嬲嫐嬪嬶嬾孃孅孀孑孕孚孛孥孩孰孳孵學斈孺宀�它宦宸寃寇寉寔寐寤實寢寞寥寫寰寶寳尅將專對尓尠尢尨尸尹屁屆屎屓屐屏孱屬屮乢屶屹岌岑岔妛岫岻岶岼岷峅岾峇峙峩峽峺峭嶌峪崋崕崗嵜崟崛崑崔崢崚崙崘嵌嵒嵎嵋嵬嵳嵶嶇嶄嶂嶢嶝嶬嶮嶽嶐嶷嶼巉巍巓巒巖巛巫已巵帋帚帙帑帛帶帷幄幃幀幎幗幔幟幢幤幇幵并幺麼广庠廁廂廈廐廏���".split("");for(a=0;a!=t[155].length;++a)if(t[155][a].charCodeAt(0)!==65533){r[t[155][a]]=39680+a;e[39680+a]=t[155][a]}t[156]="����������������������������������������������������������������廖廣廝廚廛廢廡廨廩廬廱廳廰廴廸廾弃弉彝彜弋弑弖弩弭弸彁彈彌彎弯彑彖彗彙彡彭彳彷徃徂彿徊很徑徇從徙徘徠徨徭徼忖忻忤忸忱忝悳忿怡恠�怙怐怩怎怱怛怕怫怦怏怺恚恁恪恷恟恊恆恍恣恃恤恂恬恫恙悁悍惧悃悚悄悛悖悗悒悧悋惡悸惠惓悴忰悽惆悵惘慍愕愆惶惷愀惴惺愃愡惻惱愍愎慇愾愨愧慊愿愼愬愴愽慂慄慳慷慘慙慚慫慴慯慥慱慟慝慓慵憙憖憇憬憔憚憊憑憫憮懌懊應懷懈懃懆憺懋罹懍懦懣懶懺懴懿懽懼懾戀戈戉戍戌戔戛���".split("");for(a=0;a!=t[156].length;++a)if(t[156][a].charCodeAt(0)!==65533){r[t[156][a]]=39936+a;e[39936+a]=t[156][a]}t[157]="����������������������������������������������������������������戞戡截戮戰戲戳扁扎扞扣扛扠扨扼抂抉找抒抓抖拔抃抔拗拑抻拏拿拆擔拈拜拌拊拂拇抛拉挌拮拱挧挂挈拯拵捐挾捍搜捏掖掎掀掫捶掣掏掉掟掵捫�捩掾揩揀揆揣揉插揶揄搖搴搆搓搦搶攝搗搨搏摧摯摶摎攪撕撓撥撩撈撼據擒擅擇撻擘擂擱擧舉擠擡抬擣擯攬擶擴擲擺攀擽攘攜攅攤攣攫攴攵攷收攸畋效敖敕敍敘敞敝敲數斂斃變斛斟斫斷旃旆旁旄旌旒旛旙无旡旱杲昊昃旻杳昵昶昴昜晏晄晉晁晞晝晤晧晨晟晢晰暃暈暎暉暄暘暝曁暹曉暾暼���".split("");for(a=0;a!=t[157].length;++a)if(t[157][a].charCodeAt(0)!==65533){r[t[157][a]]=40192+a;e[40192+a]=t[157][a]}t[158]="����������������������������������������������������������������曄暸曖曚曠昿曦曩曰曵曷朏朖朞朦朧霸朮朿朶杁朸朷杆杞杠杙杣杤枉杰枩杼杪枌枋枦枡枅枷柯枴柬枳柩枸柤柞柝柢柮枹柎柆柧檜栞框栩桀桍栲桎�梳栫桙档桷桿梟梏梭梔條梛梃檮梹桴梵梠梺椏梍桾椁棊椈棘椢椦棡椌棍棔棧棕椶椒椄棗棣椥棹棠棯椨椪椚椣椡棆楹楷楜楸楫楔楾楮椹楴椽楙椰楡楞楝榁楪榲榮槐榿槁槓榾槎寨槊槝榻槃榧樮榑榠榜榕榴槞槨樂樛槿權槹槲槧樅榱樞槭樔槫樊樒櫁樣樓橄樌橲樶橸橇橢橙橦橈樸樢檐檍檠檄檢檣���".split("");for(a=0;a!=t[158].length;++a)if(t[158][a].charCodeAt(0)!==65533){r[t[158][a]]=40448+a;e[40448+a]=t[158][a]}t[159]="����������������������������������������������������������������檗蘗檻櫃櫂檸檳檬櫞櫑櫟檪櫚櫪櫻欅蘖櫺欒欖鬱欟欸欷盜欹飮歇歃歉歐歙歔歛歟歡歸歹歿殀殄殃殍殘殕殞殤殪殫殯殲殱殳殷殼毆毋毓毟毬毫毳毯�麾氈氓气氛氤氣汞汕汢汪沂沍沚沁沛汾汨汳沒沐泄泱泓沽泗泅泝沮沱沾沺泛泯泙泪洟衍洶洫洽洸洙洵洳洒洌浣涓浤浚浹浙涎涕濤涅淹渕渊涵淇淦涸淆淬淞淌淨淒淅淺淙淤淕淪淮渭湮渮渙湲湟渾渣湫渫湶湍渟湃渺湎渤滿渝游溂溪溘滉溷滓溽溯滄溲滔滕溏溥滂溟潁漑灌滬滸滾漿滲漱滯漲滌���".split("");for(a=0;a!=t[159].length;++a)if(t[159][a].charCodeAt(0)!==65533){r[t[159][a]]=40704+a;e[40704+a]=t[159][a]}t[224]="����������������������������������������������������������������漾漓滷澆潺潸澁澀潯潛濳潭澂潼潘澎澑濂潦澳澣澡澤澹濆澪濟濕濬濔濘濱濮濛瀉瀋濺瀑瀁瀏濾瀛瀚潴瀝瀘瀟瀰瀾瀲灑灣炙炒炯烱炬炸炳炮烟烋烝�烙焉烽焜焙煥煕熈煦煢煌煖煬熏燻熄熕熨熬燗熹熾燒燉燔燎燠燬燧燵燼燹燿爍爐爛爨爭爬爰爲爻爼爿牀牆牋牘牴牾犂犁犇犒犖犢犧犹犲狃狆狄狎狒狢狠狡狹狷倏猗猊猜猖猝猴猯猩猥猾獎獏默獗獪獨獰獸獵獻獺珈玳珎玻珀珥珮珞璢琅瑯琥珸琲琺瑕琿瑟瑙瑁瑜瑩瑰瑣瑪瑶瑾璋璞璧瓊瓏瓔珱���".split("");for(a=0;a!=t[224].length;++a)if(t[224][a].charCodeAt(0)!==65533){r[t[224][a]]=57344+a;e[57344+a]=t[224][a]}t[225]="����������������������������������������������������������������瓠瓣瓧瓩瓮瓲瓰瓱瓸瓷甄甃甅甌甎甍甕甓甞甦甬甼畄畍畊畉畛畆畚畩畤畧畫畭畸當疆疇畴疊疉疂疔疚疝疥疣痂疳痃疵疽疸疼疱痍痊痒痙痣痞痾痿�痼瘁痰痺痲痳瘋瘍瘉瘟瘧瘠瘡瘢瘤瘴瘰瘻癇癈癆癜癘癡癢癨癩癪癧癬癰癲癶癸發皀皃皈皋皎皖皓皙皚皰皴皸皹皺盂盍盖盒盞盡盥盧盪蘯盻眈眇眄眩眤眞眥眦眛眷眸睇睚睨睫睛睥睿睾睹瞎瞋瞑瞠瞞瞰瞶瞹瞿瞼瞽瞻矇矍矗矚矜矣矮矼砌砒礦砠礪硅碎硴碆硼碚碌碣碵碪碯磑磆磋磔碾碼磅磊磬���".split("");for(a=0;a!=t[225].length;++a)if(t[225][a].charCodeAt(0)!==65533){r[t[225][a]]=57600+a;e[57600+a]=t[225][a]}t[226]="����������������������������������������������������������������磧磚磽磴礇礒礑礙礬礫祀祠祗祟祚祕祓祺祿禊禝禧齋禪禮禳禹禺秉秕秧秬秡秣稈稍稘稙稠稟禀稱稻稾稷穃穗穉穡穢穩龝穰穹穽窈窗窕窘窖窩竈窰�窶竅竄窿邃竇竊竍竏竕竓站竚竝竡竢竦竭竰笂笏笊笆笳笘笙笞笵笨笶筐筺笄筍笋筌筅筵筥筴筧筰筱筬筮箝箘箟箍箜箚箋箒箏筝箙篋篁篌篏箴篆篝篩簑簔篦篥籠簀簇簓篳篷簗簍篶簣簧簪簟簷簫簽籌籃籔籏籀籐籘籟籤籖籥籬籵粃粐粤粭粢粫粡粨粳粲粱粮粹粽糀糅糂糘糒糜糢鬻糯糲糴糶糺紆���".split("");for(a=0;a!=t[226].length;++a)if(t[226][a].charCodeAt(0)!==65533){r[t[226][a]]=57856+a;e[57856+a]=t[226][a]}t[227]="����������������������������������������������������������������紂紜紕紊絅絋紮紲紿紵絆絳絖絎絲絨絮絏絣經綉絛綏絽綛綺綮綣綵緇綽綫總綢綯緜綸綟綰緘緝緤緞緻緲緡縅縊縣縡縒縱縟縉縋縢繆繦縻縵縹繃縷�縲縺繧繝繖繞繙繚繹繪繩繼繻纃緕繽辮繿纈纉續纒纐纓纔纖纎纛纜缸缺罅罌罍罎罐网罕罔罘罟罠罨罩罧罸羂羆羃羈羇羌羔羞羝羚羣羯羲羹羮羶羸譱翅翆翊翕翔翡翦翩翳翹飜耆耄耋耒耘耙耜耡耨耿耻聊聆聒聘聚聟聢聨聳聲聰聶聹聽聿肄肆肅肛肓肚肭冐肬胛胥胙胝胄胚胖脉胯胱脛脩脣脯腋���".split("");for(a=0;a!=t[227].length;++a)if(t[227][a].charCodeAt(0)!==65533){r[t[227][a]]=58112+a;e[58112+a]=t[227][a]}t[228]="����������������������������������������������������������������隋腆脾腓腑胼腱腮腥腦腴膃膈膊膀膂膠膕膤膣腟膓膩膰膵膾膸膽臀臂膺臉臍臑臙臘臈臚臟臠臧臺臻臾舁舂舅與舊舍舐舖舩舫舸舳艀艙艘艝艚艟艤�艢艨艪艫舮艱艷艸艾芍芒芫芟芻芬苡苣苟苒苴苳苺莓范苻苹苞茆苜茉苙茵茴茖茲茱荀茹荐荅茯茫茗茘莅莚莪莟莢莖茣莎莇莊荼莵荳荵莠莉莨菴萓菫菎菽萃菘萋菁菷萇菠菲萍萢萠莽萸蔆菻葭萪萼蕚蒄葷葫蒭葮蒂葩葆萬葯葹萵蓊葢蒹蒿蒟蓙蓍蒻蓚蓐蓁蓆蓖蒡蔡蓿蓴蔗蔘蔬蔟蔕蔔蓼蕀蕣蕘蕈���".split("");for(a=0;a!=t[228].length;++a)if(t[228][a].charCodeAt(0)!==65533){r[t[228][a]]=58368+a;e[58368+a]=t[228][a]}t[229]="����������������������������������������������������������������蕁蘂蕋蕕薀薤薈薑薊薨蕭薔薛藪薇薜蕷蕾薐藉薺藏薹藐藕藝藥藜藹蘊蘓蘋藾藺蘆蘢蘚蘰蘿虍乕虔號虧虱蚓蚣蚩蚪蚋蚌蚶蚯蛄蛆蚰蛉蠣蚫蛔蛞蛩蛬�蛟蛛蛯蜒蜆蜈蜀蜃蛻蜑蜉蜍蛹蜊蜴蜿蜷蜻蜥蜩蜚蝠蝟蝸蝌蝎蝴蝗蝨蝮蝙蝓蝣蝪蠅螢螟螂螯蟋螽蟀蟐雖螫蟄螳蟇蟆螻蟯蟲蟠蠏蠍蟾蟶蟷蠎蟒蠑蠖蠕蠢蠡蠱蠶蠹蠧蠻衄衂衒衙衞衢衫袁衾袞衵衽袵衲袂袗袒袮袙袢袍袤袰袿袱裃裄裔裘裙裝裹褂裼裴裨裲褄褌褊褓襃褞褥褪褫襁襄褻褶褸襌褝襠襞���".split("");for(a=0;a!=t[229].length;++a)if(t[229][a].charCodeAt(0)!==65533){r[t[229][a]]=58624+a;e[58624+a]=t[229][a]}t[230]="����������������������������������������������������������������襦襤襭襪襯襴襷襾覃覈覊覓覘覡覩覦覬覯覲覺覽覿觀觚觜觝觧觴觸訃訖訐訌訛訝訥訶詁詛詒詆詈詼詭詬詢誅誂誄誨誡誑誥誦誚誣諄諍諂諚諫諳諧�諤諱謔諠諢諷諞諛謌謇謚諡謖謐謗謠謳鞫謦謫謾謨譁譌譏譎證譖譛譚譫譟譬譯譴譽讀讌讎讒讓讖讙讚谺豁谿豈豌豎豐豕豢豬豸豺貂貉貅貊貍貎貔豼貘戝貭貪貽貲貳貮貶賈賁賤賣賚賽賺賻贄贅贊贇贏贍贐齎贓賍贔贖赧赭赱赳趁趙跂趾趺跏跚跖跌跛跋跪跫跟跣跼踈踉跿踝踞踐踟蹂踵踰踴蹊���".split("");for(a=0;a!=t[230].length;++a)if(t[230][a].charCodeAt(0)!==65533){r[t[230][a]]=58880+a;e[58880+a]=t[230][a]}t[231]="����������������������������������������������������������������蹇蹉蹌蹐蹈蹙蹤蹠踪蹣蹕蹶蹲蹼躁躇躅躄躋躊躓躑躔躙躪躡躬躰軆躱躾軅軈軋軛軣軼軻軫軾輊輅輕輒輙輓輜輟輛輌輦輳輻輹轅轂輾轌轉轆轎轗轜�轢轣轤辜辟辣辭辯辷迚迥迢迪迯邇迴逅迹迺逑逕逡逍逞逖逋逧逶逵逹迸遏遐遑遒逎遉逾遖遘遞遨遯遶隨遲邂遽邁邀邊邉邏邨邯邱邵郢郤扈郛鄂鄒鄙鄲鄰酊酖酘酣酥酩酳酲醋醉醂醢醫醯醪醵醴醺釀釁釉釋釐釖釟釡釛釼釵釶鈞釿鈔鈬鈕鈑鉞鉗鉅鉉鉤鉈銕鈿鉋鉐銜銖銓銛鉚鋏銹銷鋩錏鋺鍄錮���".split("");for(a=0;a!=t[231].length;++a)if(t[231][a].charCodeAt(0)!==65533){r[t[231][a]]=59136+a;e[59136+a]=t[231][a]}t[232]="����������������������������������������������������������������錙錢錚錣錺錵錻鍜鍠鍼鍮鍖鎰鎬鎭鎔鎹鏖鏗鏨鏥鏘鏃鏝鏐鏈鏤鐚鐔鐓鐃鐇鐐鐶鐫鐵鐡鐺鑁鑒鑄鑛鑠鑢鑞鑪鈩鑰鑵鑷鑽鑚鑼鑾钁鑿閂閇閊閔閖閘閙�閠閨閧閭閼閻閹閾闊濶闃闍闌闕闔闖關闡闥闢阡阨阮阯陂陌陏陋陷陜陞陝陟陦陲陬隍隘隕隗險隧隱隲隰隴隶隸隹雎雋雉雍襍雜霍雕雹霄霆霈霓霎霑霏霖霙霤霪霰霹霽霾靄靆靈靂靉靜靠靤靦靨勒靫靱靹鞅靼鞁靺鞆鞋鞏鞐鞜鞨鞦鞣鞳鞴韃韆韈韋韜韭齏韲竟韶韵頏頌頸頤頡頷頽顆顏顋顫顯顰���".split("");for(a=0;a!=t[232].length;++a)if(t[232][a].charCodeAt(0)!==65533){r[t[232][a]]=59392+a;e[59392+a]=t[232][a]}t[233]="����������������������������������������������������������������顱顴顳颪颯颱颶飄飃飆飩飫餃餉餒餔餘餡餝餞餤餠餬餮餽餾饂饉饅饐饋饑饒饌饕馗馘馥馭馮馼駟駛駝駘駑駭駮駱駲駻駸騁騏騅駢騙騫騷驅驂驀驃�騾驕驍驛驗驟驢驥驤驩驫驪骭骰骼髀髏髑髓體髞髟髢髣髦髯髫髮髴髱髷髻鬆鬘鬚鬟鬢鬣鬥鬧鬨鬩鬪鬮鬯鬲魄魃魏魍魎魑魘魴鮓鮃鮑鮖鮗鮟鮠鮨鮴鯀鯊鮹鯆鯏鯑鯒鯣鯢鯤鯔鯡鰺鯲鯱鯰鰕鰔鰉鰓鰌鰆鰈鰒鰊鰄鰮鰛鰥鰤鰡鰰鱇鰲鱆鰾鱚鱠鱧鱶鱸鳧鳬鳰鴉鴈鳫鴃鴆鴪鴦鶯鴣鴟鵄鴕鴒鵁鴿鴾鵆鵈���".split("");for(a=0;a!=t[233].length;++a)if(t[233][a].charCodeAt(0)!==65533){r[t[233][a]]=59648+a;e[59648+a]=t[233][a]}t[234]="����������������������������������������������������������������鵝鵞鵤鵑鵐鵙鵲鶉鶇鶫鵯鵺鶚鶤鶩鶲鷄鷁鶻鶸鶺鷆鷏鷂鷙鷓鷸鷦鷭鷯鷽鸚鸛鸞鹵鹹鹽麁麈麋麌麒麕麑麝麥麩麸麪麭靡黌黎黏黐黔黜點黝黠黥黨黯�黴黶黷黹黻黼黽鼇鼈皷鼕鼡鼬鼾齊齒齔齣齟齠齡齦齧齬齪齷齲齶龕龜龠堯槇遙瑤凜熙�������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[234].length;++a)if(t[234][a].charCodeAt(0)!==65533){r[t[234][a]]=59904+a;e[59904+a]=t[234][a]}t[237]="����������������������������������������������������������������纊褜鍈銈蓜俉炻昱棈鋹曻彅丨仡仼伀伃伹佖侒侊侚侔俍偀倢俿倞偆偰偂傔僴僘兊兤冝冾凬刕劜劦勀勛匀匇匤卲厓厲叝﨎咜咊咩哿喆坙坥垬埈埇﨏�塚增墲夋奓奛奝奣妤妺孖寀甯寘寬尞岦岺峵崧嵓﨑嵂嵭嶸嶹巐弡弴彧德忞恝悅悊惞惕愠惲愑愷愰憘戓抦揵摠撝擎敎昀昕昻昉昮昞昤晥晗晙晴晳暙暠暲暿曺朎朗杦枻桒柀栁桄棏﨓楨﨔榘槢樰橫橆橳橾櫢櫤毖氿汜沆汯泚洄涇浯涖涬淏淸淲淼渹湜渧渼溿澈澵濵瀅瀇瀨炅炫焏焄煜煆煇凞燁燾犱���".split("");for(a=0;a!=t[237].length;++a)if(t[237][a].charCodeAt(0)!==65533){r[t[237][a]]=60672+a;e[60672+a]=t[237][a]}t[238]="����������������������������������������������������������������犾猤猪獷玽珉珖珣珒琇珵琦琪琩琮瑢璉璟甁畯皂皜皞皛皦益睆劯砡硎硤硺礰礼神祥禔福禛竑竧靖竫箞精絈絜綷綠緖繒罇羡羽茁荢荿菇菶葈蒴蕓蕙�蕫﨟薰蘒﨡蠇裵訒訷詹誧誾諟諸諶譓譿賰賴贒赶﨣軏﨤逸遧郞都鄕鄧釚釗釞釭釮釤釥鈆鈐鈊鈺鉀鈼鉎鉙鉑鈹鉧銧鉷鉸鋧鋗鋙鋐﨧鋕鋠鋓錥錡鋻﨨錞鋿錝錂鍰鍗鎤鏆鏞鏸鐱鑅鑈閒隆﨩隝隯霳霻靃靍靏靑靕顗顥飯飼餧館馞驎髙髜魵魲鮏鮱鮻鰀鵰鵫鶴鸙黑��ⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹ¬¦'"���".split("");for(a=0;a!=t[238].length;++a)if(t[238][a].charCodeAt(0)!==65533){r[t[238][a]]=60928+a;e[60928+a]=t[238][a]}t[250]="����������������������������������������������������������������ⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ¬¦'"㈱№℡∵纊褜鍈銈蓜俉炻昱棈鋹曻彅丨仡仼伀伃伹佖侒侊侚侔俍偀倢俿倞偆偰偂傔僴僘兊�兤冝冾凬刕劜劦勀勛匀匇匤卲厓厲叝﨎咜咊咩哿喆坙坥垬埈埇﨏塚增墲夋奓奛奝奣妤妺孖寀甯寘寬尞岦岺峵崧嵓﨑嵂嵭嶸嶹巐弡弴彧德忞恝悅悊惞惕愠惲愑愷愰憘戓抦揵摠撝擎敎昀昕昻昉昮昞昤晥晗晙晴晳暙暠暲暿曺朎朗杦枻桒柀栁桄棏﨓楨﨔榘槢樰橫橆橳橾櫢櫤毖氿汜沆汯泚洄涇浯���".split("");for(a=0;a!=t[250].length;++a)if(t[250][a].charCodeAt(0)!==65533){r[t[250][a]]=64e3+a;e[64e3+a]=t[250][a]}t[251]="����������������������������������������������������������������涖涬淏淸淲淼渹湜渧渼溿澈澵濵瀅瀇瀨炅炫焏焄煜煆煇凞燁燾犱犾猤猪獷玽珉珖珣珒琇珵琦琪琩琮瑢璉璟甁畯皂皜皞皛皦益睆劯砡硎硤硺礰礼神�祥禔福禛竑竧靖竫箞精絈絜綷綠緖繒罇羡羽茁荢荿菇菶葈蒴蕓蕙蕫﨟薰蘒﨡蠇裵訒訷詹誧誾諟諸諶譓譿賰賴贒赶﨣軏﨤逸遧郞都鄕鄧釚釗釞釭釮釤釥鈆鈐鈊鈺鉀鈼鉎鉙鉑鈹鉧銧鉷鉸鋧鋗鋙鋐﨧鋕鋠鋓錥錡鋻﨨錞鋿錝錂鍰鍗鎤鏆鏞鏸鐱鑅鑈閒隆﨩隝隯霳霻靃靍靏靑靕顗顥飯飼餧館馞驎髙���".split("");for(a=0;a!=t[251].length;++a)if(t[251][a].charCodeAt(0)!==65533){r[t[251][a]]=64256+a;e[64256+a]=t[251][a]}t[252]="����������������������������������������������������������������髜魵魲鮏鮱鮻鰀鵰鵫鶴鸙黑������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[252].length;++a)if(t[252][a].charCodeAt(0)!==65533){r[t[252][a]]=64512+a;e[64512+a]=t[252][a]}return{enc:r,dec:e}}();cptable[936]=function(){var e=[],r={},t=[],a;t[0]="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€�������������������������������������������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[0].length;++a)if(t[0][a].charCodeAt(0)!==65533){r[t[0][a]]=0+a;e[0+a]=t[0][a]}t[129]="����������������������������������������������������������������丂丄丅丆丏丒丗丟丠両丣並丩丮丯丱丳丵丷丼乀乁乂乄乆乊乑乕乗乚乛乢乣乤乥乧乨乪乫乬乭乮乯乲乴乵乶乷乸乹乺乻乼乽乿亀亁亂亃亄亅亇亊�亐亖亗亙亜亝亞亣亪亯亰亱亴亶亷亸亹亼亽亾仈仌仏仐仒仚仛仜仠仢仦仧仩仭仮仯仱仴仸仹仺仼仾伀伂伃伄伅伆伇伈伋伌伒伓伔伕伖伜伝伡伣伨伩伬伭伮伱伳伵伷伹伻伾伿佀佁佂佄佅佇佈佉佊佋佌佒佔佖佡佢佦佨佪佫佭佮佱佲併佷佸佹佺佽侀侁侂侅來侇侊侌侎侐侒侓侕侖侘侙侚侜侞侟価侢�".split("");for(a=0;a!=t[129].length;++a)if(t[129][a].charCodeAt(0)!==65533){r[t[129][a]]=33024+a;e[33024+a]=t[129][a]}t[130]="����������������������������������������������������������������侤侫侭侰侱侲侳侴侶侷侸侹侺侻侼侽侾俀俁係俆俇俈俉俋俌俍俒俓俔俕俖俙俛俠俢俤俥俧俫俬俰俲俴俵俶俷俹俻俼俽俿倀倁倂倃倄倅倆倇倈倉倊�個倎倐們倓倕倖倗倛倝倞倠倢倣値倧倫倯倰倱倲倳倴倵倶倷倸倹倻倽倿偀偁偂偄偅偆偉偊偋偍偐偑偒偓偔偖偗偘偙偛偝偞偟偠偡偢偣偤偦偧偨偩偪偫偭偮偯偰偱偲偳側偵偸偹偺偼偽傁傂傃傄傆傇傉傊傋傌傎傏傐傑傒傓傔傕傖傗傘備傚傛傜傝傞傟傠傡傢傤傦傪傫傭傮傯傰傱傳傴債傶傷傸傹傼�".split("");for(a=0;a!=t[130].length;++a)if(t[130][a].charCodeAt(0)!==65533){r[t[130][a]]=33280+a;e[33280+a]=t[130][a]}t[131]="����������������������������������������������������������������傽傾傿僀僁僂僃僄僅僆僇僈僉僊僋僌働僎僐僑僒僓僔僕僗僘僙僛僜僝僞僟僠僡僢僣僤僥僨僩僪僫僯僰僱僲僴僶僷僸價僺僼僽僾僿儀儁儂儃億儅儈�儉儊儌儍儎儏儐儑儓儔儕儖儗儘儙儚儛儜儝儞償儠儢儣儤儥儦儧儨儩優儫儬儭儮儯儰儱儲儳儴儵儶儷儸儹儺儻儼儽儾兂兇兊兌兎兏児兒兓兗兘兙兛兝兞兟兠兡兣兤兦內兩兪兯兲兺兾兿冃冄円冇冊冋冎冏冐冑冓冔冘冚冝冞冟冡冣冦冧冨冩冪冭冮冴冸冹冺冾冿凁凂凃凅凈凊凍凎凐凒凓凔凕凖凗�".split("");for(a=0;a!=t[131].length;++a)if(t[131][a].charCodeAt(0)!==65533){r[t[131][a]]=33536+a;e[33536+a]=t[131][a]}t[132]="����������������������������������������������������������������凘凙凚凜凞凟凢凣凥処凧凨凩凪凬凮凱凲凴凷凾刄刅刉刋刌刏刐刓刔刕刜刞刟刡刢刣別刦刧刪刬刯刱刲刴刵刼刾剄剅剆則剈剉剋剎剏剒剓剕剗剘�剙剚剛剝剟剠剢剣剤剦剨剫剬剭剮剰剱剳剴創剶剷剸剹剺剻剼剾劀劃劄劅劆劇劉劊劋劌劍劎劏劑劒劔劕劖劗劘劙劚劜劤劥劦劧劮劯劰労劵劶劷劸効劺劻劼劽勀勁勂勄勅勆勈勊勌勍勎勏勑勓勔動勗務勚勛勜勝勞勠勡勢勣勥勦勧勨勩勪勫勬勭勮勯勱勲勳勴勵勶勷勸勻勼勽匁匂匃匄匇匉匊匋匌匎�".split("");for(a=0;a!=t[132].length;++a)if(t[132][a].charCodeAt(0)!==65533){r[t[132][a]]=33792+a;e[33792+a]=t[132][a]}t[133]="����������������������������������������������������������������匑匒匓匔匘匛匜匞匟匢匤匥匧匨匩匫匬匭匯匰匱匲匳匴匵匶匷匸匼匽區卂卄卆卋卌卍卐協単卙卛卝卥卨卪卬卭卲卶卹卻卼卽卾厀厁厃厇厈厊厎厏�厐厑厒厓厔厖厗厙厛厜厞厠厡厤厧厪厫厬厭厯厰厱厲厳厴厵厷厸厹厺厼厽厾叀參叄叅叆叇収叏叐叒叓叕叚叜叝叞叡叢叧叴叺叾叿吀吂吅吇吋吔吘吙吚吜吢吤吥吪吰吳吶吷吺吽吿呁呂呄呅呇呉呌呍呎呏呑呚呝呞呟呠呡呣呥呧呩呪呫呬呭呮呯呰呴呹呺呾呿咁咃咅咇咈咉咊咍咑咓咗咘咜咞咟咠咡�".split("");for(a=0;a!=t[133].length;++a)if(t[133][a].charCodeAt(0)!==65533){r[t[133][a]]=34048+a;e[34048+a]=t[133][a]}t[134]="����������������������������������������������������������������咢咥咮咰咲咵咶咷咹咺咼咾哃哅哊哋哖哘哛哠員哢哣哤哫哬哯哰哱哴哵哶哷哸哹哻哾唀唂唃唄唅唈唊唋唌唍唎唒唓唕唖唗唘唙唚唜唝唞唟唡唥唦�唨唩唫唭唲唴唵唶唸唹唺唻唽啀啂啅啇啈啋啌啍啎問啑啒啓啔啗啘啙啚啛啝啞啟啠啢啣啨啩啫啯啰啱啲啳啴啹啺啽啿喅喆喌喍喎喐喒喓喕喖喗喚喛喞喠喡喢喣喤喥喦喨喩喪喫喬喭單喯喰喲喴営喸喺喼喿嗀嗁嗂嗃嗆嗇嗈嗊嗋嗎嗏嗐嗕嗗嗘嗙嗚嗛嗞嗠嗢嗧嗩嗭嗮嗰嗱嗴嗶嗸嗹嗺嗻嗼嗿嘂嘃嘄嘅�".split("");for(a=0;a!=t[134].length;++a)if(t[134][a].charCodeAt(0)!==65533){r[t[134][a]]=34304+a;e[34304+a]=t[134][a]}t[135]="����������������������������������������������������������������嘆嘇嘊嘋嘍嘐嘑嘒嘓嘔嘕嘖嘗嘙嘚嘜嘝嘠嘡嘢嘥嘦嘨嘩嘪嘫嘮嘯嘰嘳嘵嘷嘸嘺嘼嘽嘾噀噁噂噃噄噅噆噇噈噉噊噋噏噐噑噒噓噕噖噚噛噝噞噟噠噡�噣噥噦噧噭噮噯噰噲噳噴噵噷噸噹噺噽噾噿嚀嚁嚂嚃嚄嚇嚈嚉嚊嚋嚌嚍嚐嚑嚒嚔嚕嚖嚗嚘嚙嚚嚛嚜嚝嚞嚟嚠嚡嚢嚤嚥嚦嚧嚨嚩嚪嚫嚬嚭嚮嚰嚱嚲嚳嚴嚵嚶嚸嚹嚺嚻嚽嚾嚿囀囁囂囃囄囅囆囇囈囉囋囌囍囎囏囐囑囒囓囕囖囘囙囜団囥囦囧囨囩囪囬囮囯囲図囶囷囸囻囼圀圁圂圅圇國圌圍圎圏圐圑�".split("");for(a=0;a!=t[135].length;++a)if(t[135][a].charCodeAt(0)!==65533){r[t[135][a]]=34560+a;e[34560+a]=t[135][a]}t[136]="����������������������������������������������������������������園圓圔圕圖圗團圙圚圛圝圞圠圡圢圤圥圦圧圫圱圲圴圵圶圷圸圼圽圿坁坃坄坅坆坈坉坋坒坓坔坕坖坘坙坢坣坥坧坬坮坰坱坲坴坵坸坹坺坽坾坿垀�垁垇垈垉垊垍垎垏垐垑垔垕垖垗垘垙垚垜垝垞垟垥垨垪垬垯垰垱垳垵垶垷垹垺垻垼垽垾垿埀埁埄埅埆埇埈埉埊埌埍埐埑埓埖埗埛埜埞埡埢埣埥埦埧埨埩埪埫埬埮埰埱埲埳埵埶執埻埼埾埿堁堃堄堅堈堉堊堌堎堏堐堒堓堔堖堗堘堚堛堜堝堟堢堣堥堦堧堨堩堫堬堭堮堯報堲堳場堶堷堸堹堺堻堼堽�".split("");for(a=0;a!=t[136].length;++a)if(t[136][a].charCodeAt(0)!==65533){r[t[136][a]]=34816+a;e[34816+a]=t[136][a]}t[137]="����������������������������������������������������������������堾堿塀塁塂塃塅塆塇塈塉塊塋塎塏塐塒塓塕塖塗塙塚塛塜塝塟塠塡塢塣塤塦塧塨塩塪塭塮塯塰塱塲塳塴塵塶塷塸塹塺塻塼塽塿墂墄墆墇墈墊墋墌�墍墎墏墐墑墔墕墖増墘墛墜墝墠墡墢墣墤墥墦墧墪墫墬墭墮墯墰墱墲墳墴墵墶墷墸墹墺墻墽墾墿壀壂壃壄壆壇壈壉壊壋壌壍壎壏壐壒壓壔壖壗壘壙壚壛壜壝壞壟壠壡壢壣壥壦壧壨壩壪壭壯壱売壴壵壷壸壺壻壼壽壾壿夀夁夃夅夆夈変夊夋夌夎夐夑夒夓夗夘夛夝夞夠夡夢夣夦夨夬夰夲夳夵夶夻�".split("");for(a=0;a!=t[137].length;++a)if(t[137][a].charCodeAt(0)!==65533){r[t[137][a]]=35072+a;e[35072+a]=t[137][a]}t[138]="����������������������������������������������������������������夽夾夿奀奃奅奆奊奌奍奐奒奓奙奛奜奝奞奟奡奣奤奦奧奨奩奪奫奬奭奮奯奰奱奲奵奷奺奻奼奾奿妀妅妉妋妌妎妏妐妑妔妕妘妚妛妜妝妟妠妡妢妦�妧妬妭妰妱妳妴妵妶妷妸妺妼妽妿姀姁姂姃姄姅姇姈姉姌姍姎姏姕姖姙姛姞姟姠姡姢姤姦姧姩姪姫姭姮姯姰姱姲姳姴姵姶姷姸姺姼姽姾娀娂娊娋娍娎娏娐娒娔娕娖娗娙娚娛娝娞娡娢娤娦娧娨娪娫娬娭娮娯娰娳娵娷娸娹娺娻娽娾娿婁婂婃婄婅婇婈婋婌婍婎婏婐婑婒婓婔婖婗婘婙婛婜婝婞婟婠�".split("");for(a=0;a!=t[138].length;++a)if(t[138][a].charCodeAt(0)!==65533){r[t[138][a]]=35328+a;e[35328+a]=t[138][a]}t[139]="����������������������������������������������������������������婡婣婤婥婦婨婩婫婬婭婮婯婰婱婲婳婸婹婻婼婽婾媀媁媂媃媄媅媆媇媈媉媊媋媌媍媎媏媐媑媓媔媕媖媗媘媙媜媝媞媟媠媡媢媣媤媥媦媧媨媩媫媬�媭媮媯媰媱媴媶媷媹媺媻媼媽媿嫀嫃嫄嫅嫆嫇嫈嫊嫋嫍嫎嫏嫐嫑嫓嫕嫗嫙嫚嫛嫝嫞嫟嫢嫤嫥嫧嫨嫪嫬嫭嫮嫯嫰嫲嫳嫴嫵嫶嫷嫸嫹嫺嫻嫼嫽嫾嫿嬀嬁嬂嬃嬄嬅嬆嬇嬈嬊嬋嬌嬍嬎嬏嬐嬑嬒嬓嬔嬕嬘嬙嬚嬛嬜嬝嬞嬟嬠嬡嬢嬣嬤嬥嬦嬧嬨嬩嬪嬫嬬嬭嬮嬯嬰嬱嬳嬵嬶嬸嬹嬺嬻嬼嬽嬾嬿孁孂孃孄孅孆孇�".split("");for(a=0;a!=t[139].length;++a)if(t[139][a].charCodeAt(0)!==65533){r[t[139][a]]=35584+a;e[35584+a]=t[139][a]}t[140]="����������������������������������������������������������������孈孉孊孋孌孍孎孏孒孖孞孠孡孧孨孫孭孮孯孲孴孶孷學孹孻孼孾孿宂宆宊宍宎宐宑宒宔宖実宧宨宩宬宭宮宯宱宲宷宺宻宼寀寁寃寈寉寊寋寍寎寏�寑寔寕寖寗寘寙寚寛寜寠寢寣實寧審寪寫寬寭寯寱寲寳寴寵寶寷寽対尀専尃尅將專尋尌對導尐尒尓尗尙尛尞尟尠尡尣尦尨尩尪尫尭尮尯尰尲尳尵尶尷屃屄屆屇屌屍屒屓屔屖屗屘屚屛屜屝屟屢層屧屨屩屪屫屬屭屰屲屳屴屵屶屷屸屻屼屽屾岀岃岄岅岆岇岉岊岋岎岏岒岓岕岝岞岟岠岡岤岥岦岧岨�".split("");for(a=0;a!=t[140].length;++a)if(t[140][a].charCodeAt(0)!==65533){r[t[140][a]]=35840+a;e[35840+a]=t[140][a]}t[141]="����������������������������������������������������������������岪岮岯岰岲岴岶岹岺岻岼岾峀峂峃峅峆峇峈峉峊峌峍峎峏峐峑峓峔峕峖峗峘峚峛峜峝峞峟峠峢峣峧峩峫峬峮峯峱峲峳峴峵島峷峸峹峺峼峽峾峿崀�崁崄崅崈崉崊崋崌崍崏崐崑崒崓崕崗崘崙崚崜崝崟崠崡崢崣崥崨崪崫崬崯崰崱崲崳崵崶崷崸崹崺崻崼崿嵀嵁嵂嵃嵄嵅嵆嵈嵉嵍嵎嵏嵐嵑嵒嵓嵔嵕嵖嵗嵙嵚嵜嵞嵟嵠嵡嵢嵣嵤嵥嵦嵧嵨嵪嵭嵮嵰嵱嵲嵳嵵嵶嵷嵸嵹嵺嵻嵼嵽嵾嵿嶀嶁嶃嶄嶅嶆嶇嶈嶉嶊嶋嶌嶍嶎嶏嶐嶑嶒嶓嶔嶕嶖嶗嶘嶚嶛嶜嶞嶟嶠�".split("");for(a=0;a!=t[141].length;++a)if(t[141][a].charCodeAt(0)!==65533){r[t[141][a]]=36096+a;e[36096+a]=t[141][a]}t[142]="����������������������������������������������������������������嶡嶢嶣嶤嶥嶦嶧嶨嶩嶪嶫嶬嶭嶮嶯嶰嶱嶲嶳嶴嶵嶶嶸嶹嶺嶻嶼嶽嶾嶿巀巁巂巃巄巆巇巈巉巊巋巌巎巏巐巑巒巓巔巕巖巗巘巙巚巜巟巠巣巤巪巬巭�巰巵巶巸巹巺巻巼巿帀帄帇帉帊帋帍帎帒帓帗帞帟帠帡帢帣帤帥帨帩帪師帬帯帰帲帳帴帵帶帹帺帾帿幀幁幃幆幇幈幉幊幋幍幎幏幐幑幒幓幖幗幘幙幚幜幝幟幠幣幤幥幦幧幨幩幪幫幬幭幮幯幰幱幵幷幹幾庁庂広庅庈庉庌庍庎庒庘庛庝庡庢庣庤庨庩庪庫庬庮庯庰庱庲庴庺庻庼庽庿廀廁廂廃廄廅�".split("");for(a=0;a!=t[142].length;++a)if(t[142][a].charCodeAt(0)!==65533){r[t[142][a]]=36352+a;e[36352+a]=t[142][a]}t[143]="����������������������������������������������������������������廆廇廈廋廌廍廎廏廐廔廕廗廘廙廚廜廝廞廟廠廡廢廣廤廥廦廧廩廫廬廭廮廯廰廱廲廳廵廸廹廻廼廽弅弆弇弉弌弍弎弐弒弔弖弙弚弜弝弞弡弢弣弤�弨弫弬弮弰弲弳弴張弶強弸弻弽弾弿彁彂彃彄彅彆彇彈彉彊彋彌彍彎彏彑彔彙彚彛彜彞彟彠彣彥彧彨彫彮彯彲彴彵彶彸彺彽彾彿徃徆徍徎徏徑従徔徖徚徛徝從徟徠徢徣徤徥徦徧復徫徬徯徰徱徲徳徴徶徸徹徺徻徾徿忀忁忂忇忈忊忋忎忓忔忕忚忛応忞忟忢忣忥忦忨忩忬忯忰忲忳忴忶忷忹忺忼怇�".split("");for(a=0;a!=t[143].length;++a)if(t[143][a].charCodeAt(0)!==65533){r[t[143][a]]=36608+a;e[36608+a]=t[143][a]}t[144]="����������������������������������������������������������������怈怉怋怌怐怑怓怗怘怚怞怟怢怣怤怬怭怮怰怱怲怳怴怶怷怸怹怺怽怾恀恄恅恆恇恈恉恊恌恎恏恑恓恔恖恗恘恛恜恞恟恠恡恥恦恮恱恲恴恵恷恾悀�悁悂悅悆悇悈悊悋悎悏悐悑悓悕悗悘悙悜悞悡悢悤悥悧悩悪悮悰悳悵悶悷悹悺悽悾悿惀惁惂惃惄惇惈惉惌惍惎惏惐惒惓惔惖惗惙惛惞惡惢惣惤惥惪惱惲惵惷惸惻惼惽惾惿愂愃愄愅愇愊愋愌愐愑愒愓愔愖愗愘愙愛愜愝愞愡愢愥愨愩愪愬愭愮愯愰愱愲愳愴愵愶愷愸愹愺愻愼愽愾慀慁慂慃慄慅慆�".split("");for(a=0;a!=t[144].length;++a)if(t[144][a].charCodeAt(0)!==65533){r[t[144][a]]=36864+a;e[36864+a]=t[144][a]}t[145]="����������������������������������������������������������������慇慉態慍慏慐慒慓慔慖慗慘慙慚慛慜慞慟慠慡慣慤慥慦慩慪慫慬慭慮慯慱慲慳慴慶慸慹慺慻慼慽慾慿憀憁憂憃憄憅憆憇憈憉憊憌憍憏憐憑憒憓憕�憖憗憘憙憚憛憜憞憟憠憡憢憣憤憥憦憪憫憭憮憯憰憱憲憳憴憵憶憸憹憺憻憼憽憿懀懁懃懄懅懆懇應懌懍懎懏懐懓懕懖懗懘懙懚懛懜懝懞懟懠懡懢懣懤懥懧懨懩懪懫懬懭懮懯懰懱懲懳懴懶懷懸懹懺懻懼懽懾戀戁戂戃戄戅戇戉戓戔戙戜戝戞戠戣戦戧戨戩戫戭戯戰戱戲戵戶戸戹戺戻戼扂扄扅扆扊�".split("");for(a=0;a!=t[145].length;++a)if(t[145][a].charCodeAt(0)!==65533){r[t[145][a]]=37120+a;e[37120+a]=t[145][a]}t[146]="����������������������������������������������������������������扏扐払扖扗扙扚扜扝扞扟扠扡扢扤扥扨扱扲扴扵扷扸扺扻扽抁抂抃抅抆抇抈抋抌抍抎抏抐抔抙抜抝択抣抦抧抩抪抭抮抯抰抲抳抴抶抷抸抺抾拀拁�拃拋拏拑拕拝拞拠拡拤拪拫拰拲拵拸拹拺拻挀挃挄挅挆挊挋挌挍挏挐挒挓挔挕挗挘挙挜挦挧挩挬挭挮挰挱挳挴挵挶挷挸挻挼挾挿捀捁捄捇捈捊捑捒捓捔捖捗捘捙捚捛捜捝捠捤捥捦捨捪捫捬捯捰捲捳捴捵捸捹捼捽捾捿掁掃掄掅掆掋掍掑掓掔掕掗掙掚掛掜掝掞掟採掤掦掫掯掱掲掵掶掹掻掽掿揀�".split("");for(a=0;a!=t[146].length;++a)if(t[146][a].charCodeAt(0)!==65533){r[t[146][a]]=37376+a;e[37376+a]=t[146][a]}t[147]="����������������������������������������������������������������揁揂揃揅揇揈揊揋揌揑揓揔揕揗揘揙揚換揜揝揟揢揤揥揦揧揨揫揬揮揯揰揱揳揵揷揹揺揻揼揾搃搄搆搇搈搉搊損搎搑搒搕搖搗搘搙搚搝搟搢搣搤�搥搧搨搩搫搮搯搰搱搲搳搵搶搷搸搹搻搼搾摀摂摃摉摋摌摍摎摏摐摑摓摕摖摗摙摚摛摜摝摟摠摡摢摣摤摥摦摨摪摫摬摮摯摰摱摲摳摴摵摶摷摻摼摽摾摿撀撁撃撆撈撉撊撋撌撍撎撏撐撓撔撗撘撚撛撜撝撟撠撡撢撣撥撦撧撨撪撫撯撱撲撳撴撶撹撻撽撾撿擁擃擄擆擇擈擉擊擋擌擏擑擓擔擕擖擙據�".split("");for(a=0;a!=t[147].length;++a)if(t[147][a].charCodeAt(0)!==65533){r[t[147][a]]=37632+a;e[37632+a]=t[147][a]}t[148]="����������������������������������������������������������������擛擜擝擟擠擡擣擥擧擨擩擪擫擬擭擮擯擰擱擲擳擴擵擶擷擸擹擺擻擼擽擾擿攁攂攃攄攅攆攇攈攊攋攌攍攎攏攐攑攓攔攕攖攗攙攚攛攜攝攞攟攠攡�攢攣攤攦攧攨攩攪攬攭攰攱攲攳攷攺攼攽敀敁敂敃敄敆敇敊敋敍敎敐敒敓敔敗敘敚敜敟敠敡敤敥敧敨敩敪敭敮敯敱敳敵敶數敹敺敻敼敽敾敿斀斁斂斃斄斅斆斈斉斊斍斎斏斒斔斕斖斘斚斝斞斠斢斣斦斨斪斬斮斱斲斳斴斵斶斷斸斺斻斾斿旀旂旇旈旉旊旍旐旑旓旔旕旘旙旚旛旜旝旞旟旡旣旤旪旫�".split("");for(a=0;a!=t[148].length;++a)if(t[148][a].charCodeAt(0)!==65533){r[t[148][a]]=37888+a;e[37888+a]=t[148][a]}t[149]="����������������������������������������������������������������旲旳旴旵旸旹旻旼旽旾旿昁昄昅昇昈昉昋昍昐昑昒昖昗昘昚昛昜昞昡昢昣昤昦昩昪昫昬昮昰昲昳昷昸昹昺昻昽昿晀時晄晅晆晇晈晉晊晍晎晐晑晘�晙晛晜晝晞晠晢晣晥晧晩晪晫晬晭晱晲晳晵晸晹晻晼晽晿暀暁暃暅暆暈暉暊暋暍暎暏暐暒暓暔暕暘暙暚暛暜暞暟暠暡暢暣暤暥暦暩暪暫暬暭暯暰暱暲暳暵暶暷暸暺暻暼暽暿曀曁曂曃曄曅曆曇曈曉曊曋曌曍曎曏曐曑曒曓曔曕曖曗曘曚曞曟曠曡曢曣曤曥曧曨曪曫曬曭曮曯曱曵曶書曺曻曽朁朂會�".split("");for(a=0;a!=t[149].length;++a)if(t[149][a].charCodeAt(0)!==65533){r[t[149][a]]=38144+a;e[38144+a]=t[149][a]}t[150]="����������������������������������������������������������������朄朅朆朇朌朎朏朑朒朓朖朘朙朚朜朞朠朡朢朣朤朥朧朩朮朰朲朳朶朷朸朹朻朼朾朿杁杄杅杇杊杋杍杒杔杕杗杘杙杚杛杝杢杣杤杦杧杫杬杮東杴杶�杸杹杺杻杽枀枂枃枅枆枈枊枌枍枎枏枑枒枓枔枖枙枛枟枠枡枤枦枩枬枮枱枲枴枹枺枻枼枽枾枿柀柂柅柆柇柈柉柊柋柌柍柎柕柖柗柛柟柡柣柤柦柧柨柪柫柭柮柲柵柶柷柸柹柺査柼柾栁栂栃栄栆栍栐栒栔栕栘栙栚栛栜栞栟栠栢栣栤栥栦栧栨栫栬栭栮栯栰栱栴栵栶栺栻栿桇桋桍桏桒桖桗桘桙桚桛�".split("");for(a=0;a!=t[150].length;++a)if(t[150][a].charCodeAt(0)!==65533){r[t[150][a]]=38400+a;e[38400+a]=t[150][a]}t[151]="����������������������������������������������������������������桜桝桞桟桪桬桭桮桯桰桱桲桳桵桸桹桺桻桼桽桾桿梀梂梄梇梈梉梊梋梌梍梎梐梑梒梔梕梖梘梙梚梛梜條梞梟梠梡梣梤梥梩梪梫梬梮梱梲梴梶梷梸�梹梺梻梼梽梾梿棁棃棄棅棆棇棈棊棌棎棏棐棑棓棔棖棗棙棛棜棝棞棟棡棢棤棥棦棧棨棩棪棫棬棭棯棲棳棴棶棷棸棻棽棾棿椀椂椃椄椆椇椈椉椊椌椏椑椓椔椕椖椗椘椙椚椛検椝椞椡椢椣椥椦椧椨椩椪椫椬椮椯椱椲椳椵椶椷椸椺椻椼椾楀楁楃楄楅楆楇楈楉楊楋楌楍楎楏楐楑楒楓楕楖楘楙楛楜楟�".split("");for(a=0;a!=t[151].length;++a)if(t[151][a].charCodeAt(0)!==65533){r[t[151][a]]=38656+a;e[38656+a]=t[151][a]}t[152]="����������������������������������������������������������������楡楢楤楥楧楨楩楪楬業楯楰楲楳楴極楶楺楻楽楾楿榁榃榅榊榋榌榎榏榐榑榒榓榖榗榙榚榝榞榟榠榡榢榣榤榥榦榩榪榬榮榯榰榲榳榵榶榸榹榺榼榽�榾榿槀槂槃槄槅槆槇槈槉構槍槏槑槒槓槕槖槗様槙槚槜槝槞槡槢槣槤槥槦槧槨槩槪槫槬槮槯槰槱槳槴槵槶槷槸槹槺槻槼槾樀樁樂樃樄樅樆樇樈樉樋樌樍樎樏樐樑樒樓樔樕樖標樚樛樜樝樞樠樢樣樤樥樦樧権樫樬樭樮樰樲樳樴樶樷樸樹樺樻樼樿橀橁橂橃橅橆橈橉橊橋橌橍橎橏橑橒橓橔橕橖橗橚�".split("");for(a=0;a!=t[152].length;++a)if(t[152][a].charCodeAt(0)!==65533){r[t[152][a]]=38912+a;e[38912+a]=t[152][a]; -}t[153]="����������������������������������������������������������������橜橝橞機橠橢橣橤橦橧橨橩橪橫橬橭橮橯橰橲橳橴橵橶橷橸橺橻橽橾橿檁檂檃檅檆檇檈檉檊檋檌檍檏檒檓檔檕檖檘檙檚檛檜檝檞檟檡檢檣檤檥檦�檧檨檪檭檮檯檰檱檲檳檴檵檶檷檸檹檺檻檼檽檾檿櫀櫁櫂櫃櫄櫅櫆櫇櫈櫉櫊櫋櫌櫍櫎櫏櫐櫑櫒櫓櫔櫕櫖櫗櫘櫙櫚櫛櫜櫝櫞櫟櫠櫡櫢櫣櫤櫥櫦櫧櫨櫩櫪櫫櫬櫭櫮櫯櫰櫱櫲櫳櫴櫵櫶櫷櫸櫹櫺櫻櫼櫽櫾櫿欀欁欂欃欄欅欆欇欈欉權欋欌欍欎欏欐欑欒欓欔欕欖欗欘欙欚欛欜欝欞欟欥欦欨欩欪欫欬欭欮�".split("");for(a=0;a!=t[153].length;++a)if(t[153][a].charCodeAt(0)!==65533){r[t[153][a]]=39168+a;e[39168+a]=t[153][a]}t[154]="����������������������������������������������������������������欯欰欱欳欴欵欶欸欻欼欽欿歀歁歂歄歅歈歊歋歍歎歏歐歑歒歓歔歕歖歗歘歚歛歜歝歞歟歠歡歨歩歫歬歭歮歯歰歱歲歳歴歵歶歷歸歺歽歾歿殀殅殈�殌殎殏殐殑殔殕殗殘殙殜殝殞殟殠殢殣殤殥殦殧殨殩殫殬殭殮殯殰殱殲殶殸殹殺殻殼殽殾毀毃毄毆毇毈毉毊毌毎毐毑毘毚毜毝毞毟毠毢毣毤毥毦毧毨毩毬毭毮毰毱毲毴毶毷毸毺毻毼毾毿氀氁氂氃氄氈氉氊氋氌氎氒気氜氝氞氠氣氥氫氬氭氱氳氶氷氹氺氻氼氾氿汃汄汅汈汋汌汍汎汏汑汒汓汖汘�".split("");for(a=0;a!=t[154].length;++a)if(t[154][a].charCodeAt(0)!==65533){r[t[154][a]]=39424+a;e[39424+a]=t[154][a]}t[155]="����������������������������������������������������������������汙汚汢汣汥汦汧汫汬汭汮汯汱汳汵汷汸決汻汼汿沀沄沇沊沋沍沎沑沒沕沖沗沘沚沜沝沞沠沢沨沬沯沰沴沵沶沷沺泀況泂泃泆泇泈泋泍泎泏泑泒泘�泙泚泜泝泟泤泦泧泩泬泭泲泴泹泿洀洂洃洅洆洈洉洊洍洏洐洑洓洔洕洖洘洜洝洟洠洡洢洣洤洦洨洩洬洭洯洰洴洶洷洸洺洿浀浂浄浉浌浐浕浖浗浘浛浝浟浡浢浤浥浧浨浫浬浭浰浱浲浳浵浶浹浺浻浽浾浿涀涁涃涄涆涇涊涋涍涏涐涒涖涗涘涙涚涜涢涥涬涭涰涱涳涴涶涷涹涺涻涼涽涾淁淂淃淈淉淊�".split("");for(a=0;a!=t[155].length;++a)if(t[155][a].charCodeAt(0)!==65533){r[t[155][a]]=39680+a;e[39680+a]=t[155][a]}t[156]="����������������������������������������������������������������淍淎淏淐淒淓淔淕淗淚淛淜淟淢淣淥淧淨淩淪淭淯淰淲淴淵淶淸淺淽淾淿渀渁渂渃渄渆渇済渉渋渏渒渓渕渘渙減渜渞渟渢渦渧渨渪測渮渰渱渳渵�渶渷渹渻渼渽渾渿湀湁湂湅湆湇湈湉湊湋湌湏湐湑湒湕湗湙湚湜湝湞湠湡湢湣湤湥湦湧湨湩湪湬湭湯湰湱湲湳湴湵湶湷湸湹湺湻湼湽満溁溂溄溇溈溊溋溌溍溎溑溒溓溔溕準溗溙溚溛溝溞溠溡溣溤溦溨溩溫溬溭溮溰溳溵溸溹溼溾溿滀滃滄滅滆滈滉滊滌滍滎滐滒滖滘滙滛滜滝滣滧滪滫滬滭滮滯�".split("");for(a=0;a!=t[156].length;++a)if(t[156][a].charCodeAt(0)!==65533){r[t[156][a]]=39936+a;e[39936+a]=t[156][a]}t[157]="����������������������������������������������������������������滰滱滲滳滵滶滷滸滺滻滼滽滾滿漀漁漃漄漅漇漈漊漋漌漍漎漐漑漒漖漗漘漙漚漛漜漝漞漟漡漢漣漥漦漧漨漬漮漰漲漴漵漷漸漹漺漻漼漽漿潀潁潂�潃潄潅潈潉潊潌潎潏潐潑潒潓潔潕潖潗潙潚潛潝潟潠潡潣潤潥潧潨潩潪潫潬潯潰潱潳潵潶潷潹潻潽潾潿澀澁澂澃澅澆澇澊澋澏澐澑澒澓澔澕澖澗澘澙澚澛澝澞澟澠澢澣澤澥澦澨澩澪澫澬澭澮澯澰澱澲澴澵澷澸澺澻澼澽澾澿濁濃濄濅濆濇濈濊濋濌濍濎濏濐濓濔濕濖濗濘濙濚濛濜濝濟濢濣濤濥�".split("");for(a=0;a!=t[157].length;++a)if(t[157][a].charCodeAt(0)!==65533){r[t[157][a]]=40192+a;e[40192+a]=t[157][a]}t[158]="����������������������������������������������������������������濦濧濨濩濪濫濬濭濰濱濲濳濴濵濶濷濸濹濺濻濼濽濾濿瀀瀁瀂瀃瀄瀅瀆瀇瀈瀉瀊瀋瀌瀍瀎瀏瀐瀒瀓瀔瀕瀖瀗瀘瀙瀜瀝瀞瀟瀠瀡瀢瀤瀥瀦瀧瀨瀩瀪�瀫瀬瀭瀮瀯瀰瀱瀲瀳瀴瀶瀷瀸瀺瀻瀼瀽瀾瀿灀灁灂灃灄灅灆灇灈灉灊灋灍灎灐灑灒灓灔灕灖灗灘灙灚灛灜灝灟灠灡灢灣灤灥灦灧灨灩灪灮灱灲灳灴灷灹灺灻災炁炂炃炄炆炇炈炋炌炍炏炐炑炓炗炘炚炛炞炟炠炡炢炣炤炥炦炧炨炩炪炰炲炴炵炶為炾炿烄烅烆烇烉烋烌烍烎烏烐烑烒烓烔烕烖烗烚�".split("");for(a=0;a!=t[158].length;++a)if(t[158][a].charCodeAt(0)!==65533){r[t[158][a]]=40448+a;e[40448+a]=t[158][a]}t[159]="����������������������������������������������������������������烜烝烞烠烡烢烣烥烪烮烰烱烲烳烴烵烶烸烺烻烼烾烿焀焁焂焃焄焅焆焇焈焋焌焍焎焏焑焒焔焗焛焜焝焞焟焠無焢焣焤焥焧焨焩焪焫焬焭焮焲焳焴�焵焷焸焹焺焻焼焽焾焿煀煁煂煃煄煆煇煈煉煋煍煏煐煑煒煓煔煕煖煗煘煙煚煛煝煟煠煡煢煣煥煩煪煫煬煭煯煰煱煴煵煶煷煹煻煼煾煿熀熁熂熃熅熆熇熈熉熋熌熍熎熐熑熒熓熕熖熗熚熛熜熝熞熡熢熣熤熥熦熧熩熪熫熭熮熯熰熱熲熴熶熷熸熺熻熼熽熾熿燀燁燂燄燅燆燇燈燉燊燋燌燍燏燐燑燒燓�".split("");for(a=0;a!=t[159].length;++a)if(t[159][a].charCodeAt(0)!==65533){r[t[159][a]]=40704+a;e[40704+a]=t[159][a]}t[160]="����������������������������������������������������������������燖燗燘燙燚燛燜燝燞營燡燢燣燤燦燨燩燪燫燬燭燯燰燱燲燳燴燵燶燷燸燺燻燼燽燾燿爀爁爂爃爄爅爇爈爉爊爋爌爍爎爏爐爑爒爓爔爕爖爗爘爙爚�爛爜爞爟爠爡爢爣爤爥爦爧爩爫爭爮爯爲爳爴爺爼爾牀牁牂牃牄牅牆牉牊牋牎牏牐牑牓牔牕牗牘牚牜牞牠牣牤牥牨牪牫牬牭牰牱牳牴牶牷牸牻牼牽犂犃犅犆犇犈犉犌犎犐犑犓犔犕犖犗犘犙犚犛犜犝犞犠犡犢犣犤犥犦犧犨犩犪犫犮犱犲犳犵犺犻犼犽犾犿狀狅狆狇狉狊狋狌狏狑狓狔狕狖狘狚狛�".split("");for(a=0;a!=t[160].length;++a)if(t[160][a].charCodeAt(0)!==65533){r[t[160][a]]=40960+a;e[40960+a]=t[160][a]}t[161]="����������������������������������������������������������������������������������������������������������������������������������������������������������������� 、。·ˉˇ¨〃々—~‖…‘’“”〔〕〈〉《》「」『』〖〗【】±×÷∶∧∨∑∏∪∩∈∷√⊥∥∠⌒⊙∫∮≡≌≈∽∝≠≮≯≤≥∞∵∴♂♀°′″℃$¤¢£‰§№☆★○●◎◇◆□■△▲※→←↑↓〓�".split("");for(a=0;a!=t[161].length;++a)if(t[161][a].charCodeAt(0)!==65533){r[t[161][a]]=41216+a;e[41216+a]=t[161][a]}t[162]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������ⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹ������⒈⒉⒊⒋⒌⒍⒎⒏⒐⒑⒒⒓⒔⒕⒖⒗⒘⒙⒚⒛⑴⑵⑶⑷⑸⑹⑺⑻⑼⑽⑾⑿⒀⒁⒂⒃⒄⒅⒆⒇①②③④⑤⑥⑦⑧⑨⑩��㈠㈡㈢㈣㈤㈥㈦㈧㈨㈩��ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫ���".split("");for(a=0;a!=t[162].length;++a)if(t[162][a].charCodeAt(0)!==65533){r[t[162][a]]=41472+a;e[41472+a]=t[162][a]}t[163]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������!"#¥%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|} ̄�".split("");for(a=0;a!=t[163].length;++a)if(t[163][a].charCodeAt(0)!==65533){r[t[163][a]]=41728+a;e[41728+a]=t[163][a]}t[164]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������ぁあぃいぅうぇえぉおかがきぎくぐけげこごさざしじすずせぜそぞただちぢっつづてでとどなにぬねのはばぱひびぴふぶぷへべぺほぼぽまみむめもゃやゅゆょよらりるれろゎわゐゑをん������������".split("");for(a=0;a!=t[164].length;++a)if(t[164][a].charCodeAt(0)!==65533){r[t[164][a]]=41984+a;e[41984+a]=t[164][a]}t[165]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������ァアィイゥウェエォオカガキギクグケゲコゴサザシジスズセゼソゾタダチヂッツヅテデトドナニヌネノハバパヒビピフブプヘベペホボポマミムメモャヤュユョヨラリルレロヮワヰヱヲンヴヵヶ���������".split("");for(a=0;a!=t[165].length;++a)if(t[165][a].charCodeAt(0)!==65533){r[t[165][a]]=42240+a;e[42240+a]=t[165][a]}t[166]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ��������αβγδεζηθικλμνξοπρστυφχψω�������︵︶︹︺︿﹀︽︾﹁﹂﹃﹄��︻︼︷︸︱�︳︴����������".split("");for(a=0;a!=t[166].length;++a)if(t[166][a].charCodeAt(0)!==65533){r[t[166][a]]=42496+a;e[42496+a]=t[166][a]}t[167]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ���������������абвгдеёжзийклмнопрстуфхцчшщъыьэюя��������������".split("");for(a=0;a!=t[167].length;++a)if(t[167][a].charCodeAt(0)!==65533){r[t[167][a]]=42752+a;e[42752+a]=t[167][a]}t[168]="����������������������������������������������������������������ˊˋ˙–―‥‵℅℉↖↗↘↙∕∟∣≒≦≧⊿═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬╭╮╯╰╱╲╳▁▂▃▄▅▆▇�█▉▊▋▌▍▎▏▓▔▕▼▽◢◣◤◥☉⊕〒〝〞�����������āáǎàēéěèīíǐìōóǒòūúǔùǖǘǚǜüêɑ�ńň�ɡ����ㄅㄆㄇㄈㄉㄊㄋㄌㄍㄎㄏㄐㄑㄒㄓㄔㄕㄖㄗㄘㄙㄚㄛㄜㄝㄞㄟㄠㄡㄢㄣㄤㄥㄦㄧㄨㄩ����������������������".split("");for(a=0;a!=t[168].length;++a)if(t[168][a].charCodeAt(0)!==65533){r[t[168][a]]=43008+a;e[43008+a]=t[168][a]}t[169]="����������������������������������������������������������������〡〢〣〤〥〦〧〨〩㊣㎎㎏㎜㎝㎞㎡㏄㏎㏑㏒㏕︰¬¦�℡㈱�‐���ー゛゜ヽヾ〆ゝゞ﹉﹊﹋﹌﹍﹎﹏﹐﹑﹒﹔﹕﹖﹗﹙﹚﹛﹜﹝﹞﹟﹠﹡�﹢﹣﹤﹥﹦﹨﹩﹪﹫�������������〇�������������─━│┃┄┅┆┇┈┉┊┋┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋����������������".split("");for(a=0;a!=t[169].length;++a)if(t[169][a].charCodeAt(0)!==65533){r[t[169][a]]=43264+a;e[43264+a]=t[169][a]}t[170]="����������������������������������������������������������������狜狝狟狢狣狤狥狦狧狪狫狵狶狹狽狾狿猀猂猄猅猆猇猈猉猋猌猍猏猐猑猒猔猘猙猚猟猠猣猤猦猧猨猭猯猰猲猳猵猶猺猻猼猽獀獁獂獃獄獅獆獇獈�獉獊獋獌獎獏獑獓獔獕獖獘獙獚獛獜獝獞獟獡獢獣獤獥獦獧獨獩獪獫獮獰獱�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[170].length;++a)if(t[170][a].charCodeAt(0)!==65533){r[t[170][a]]=43520+a;e[43520+a]=t[170][a]}t[171]="����������������������������������������������������������������獲獳獴獵獶獷獸獹獺獻獼獽獿玀玁玂玃玅玆玈玊玌玍玏玐玒玓玔玕玗玘玙玚玜玝玞玠玡玣玤玥玦玧玨玪玬玭玱玴玵玶玸玹玼玽玾玿珁珃珄珅珆珇�珋珌珎珒珓珔珕珖珗珘珚珛珜珝珟珡珢珣珤珦珨珪珫珬珮珯珰珱珳珴珵珶珷�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[171].length;++a)if(t[171][a].charCodeAt(0)!==65533){r[t[171][a]]=43776+a;e[43776+a]=t[171][a]}t[172]="����������������������������������������������������������������珸珹珺珻珼珽現珿琀琁琂琄琇琈琋琌琍琎琑琒琓琔琕琖琗琘琙琜琝琞琟琠琡琣琤琧琩琫琭琯琱琲琷琸琹琺琻琽琾琿瑀瑂瑃瑄瑅瑆瑇瑈瑉瑊瑋瑌瑍�瑎瑏瑐瑑瑒瑓瑔瑖瑘瑝瑠瑡瑢瑣瑤瑥瑦瑧瑨瑩瑪瑫瑬瑮瑯瑱瑲瑳瑴瑵瑸瑹瑺�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[172].length;++a)if(t[172][a].charCodeAt(0)!==65533){r[t[172][a]]=44032+a;e[44032+a]=t[172][a]}t[173]="����������������������������������������������������������������瑻瑼瑽瑿璂璄璅璆璈璉璊璌璍璏璑璒璓璔璕璖璗璘璙璚璛璝璟璠璡璢璣璤璥璦璪璫璬璭璮璯環璱璲璳璴璵璶璷璸璹璻璼璽璾璿瓀瓁瓂瓃瓄瓅瓆瓇�瓈瓉瓊瓋瓌瓍瓎瓏瓐瓑瓓瓔瓕瓖瓗瓘瓙瓚瓛瓝瓟瓡瓥瓧瓨瓩瓪瓫瓬瓭瓰瓱瓲�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[173].length;++a)if(t[173][a].charCodeAt(0)!==65533){r[t[173][a]]=44288+a;e[44288+a]=t[173][a]}t[174]="����������������������������������������������������������������瓳瓵瓸瓹瓺瓻瓼瓽瓾甀甁甂甃甅甆甇甈甉甊甋甌甎甐甒甔甕甖甗甛甝甞甠甡產産甤甦甧甪甮甴甶甹甼甽甿畁畂畃畄畆畇畉畊畍畐畑畒畓畕畖畗畘�畝畞畟畠畡畢畣畤畧畨畩畫畬畭畮畯異畱畳畵當畷畺畻畼畽畾疀疁疂疄疅疇�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[174].length;++a)if(t[174][a].charCodeAt(0)!==65533){r[t[174][a]]=44544+a;e[44544+a]=t[174][a]}t[175]="����������������������������������������������������������������疈疉疊疌疍疎疐疓疕疘疛疜疞疢疦疧疨疩疪疭疶疷疺疻疿痀痁痆痋痌痎痏痐痑痓痗痙痚痜痝痟痠痡痥痩痬痭痮痯痲痳痵痶痷痸痺痻痽痾瘂瘄瘆瘇�瘈瘉瘋瘍瘎瘏瘑瘒瘓瘔瘖瘚瘜瘝瘞瘡瘣瘧瘨瘬瘮瘯瘱瘲瘶瘷瘹瘺瘻瘽癁療癄�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[175].length;++a)if(t[175][a].charCodeAt(0)!==65533){r[t[175][a]]=44800+a;e[44800+a]=t[175][a]}t[176]="����������������������������������������������������������������癅癆癇癈癉癊癋癎癏癐癑癒癓癕癗癘癙癚癛癝癟癠癡癢癤癥癦癧癨癩癪癬癭癮癰癱癲癳癴癵癶癷癹発發癿皀皁皃皅皉皊皌皍皏皐皒皔皕皗皘皚皛�皜皝皞皟皠皡皢皣皥皦皧皨皩皪皫皬皭皯皰皳皵皶皷皸皹皺皻皼皽皾盀盁盃啊阿埃挨哎唉哀皑癌蔼矮艾碍爱隘鞍氨安俺按暗岸胺案肮昂盎凹敖熬翱袄傲奥懊澳芭捌扒叭吧笆八疤巴拔跋靶把耙坝霸罢爸白柏百摆佰败拜稗斑班搬扳般颁板版扮拌伴瓣半办绊邦帮梆榜膀绑棒磅蚌镑傍谤苞胞包褒剥�".split("");for(a=0;a!=t[176].length;++a)if(t[176][a].charCodeAt(0)!==65533){r[t[176][a]]=45056+a;e[45056+a]=t[176][a]}t[177]="����������������������������������������������������������������盄盇盉盋盌盓盕盙盚盜盝盞盠盡盢監盤盦盧盨盩盪盫盬盭盰盳盵盶盷盺盻盽盿眀眂眃眅眆眊県眎眏眐眑眒眓眔眕眖眗眘眛眜眝眞眡眣眤眥眧眪眫�眬眮眰眱眲眳眴眹眻眽眾眿睂睄睅睆睈睉睊睋睌睍睎睏睒睓睔睕睖睗睘睙睜薄雹保堡饱宝抱报暴豹鲍爆杯碑悲卑北辈背贝钡倍狈备惫焙被奔苯本笨崩绷甭泵蹦迸逼鼻比鄙笔彼碧蓖蔽毕毙毖币庇痹闭敝弊必辟壁臂避陛鞭边编贬扁便变卞辨辩辫遍标彪膘表鳖憋别瘪彬斌濒滨宾摈兵冰柄丙秉饼炳�".split("");for(a=0;a!=t[177].length;++a)if(t[177][a].charCodeAt(0)!==65533){r[t[177][a]]=45312+a;e[45312+a]=t[177][a]}t[178]="����������������������������������������������������������������睝睞睟睠睤睧睩睪睭睮睯睰睱睲睳睴睵睶睷睸睺睻睼瞁瞂瞃瞆瞇瞈瞉瞊瞋瞏瞐瞓瞔瞕瞖瞗瞘瞙瞚瞛瞜瞝瞞瞡瞣瞤瞦瞨瞫瞭瞮瞯瞱瞲瞴瞶瞷瞸瞹瞺�瞼瞾矀矁矂矃矄矅矆矇矈矉矊矋矌矎矏矐矑矒矓矔矕矖矘矙矚矝矞矟矠矡矤病并玻菠播拨钵波博勃搏铂箔伯帛舶脖膊渤泊驳捕卜哺补埠不布步簿部怖擦猜裁材才财睬踩采彩菜蔡餐参蚕残惭惨灿苍舱仓沧藏操糙槽曹草厕策侧册测层蹭插叉茬茶查碴搽察岔差诧拆柴豺搀掺蝉馋谗缠铲产阐颤昌猖�".split("");for(a=0;a!=t[178].length;++a)if(t[178][a].charCodeAt(0)!==65533){r[t[178][a]]=45568+a;e[45568+a]=t[178][a]}t[179]="����������������������������������������������������������������矦矨矪矯矰矱矲矴矵矷矹矺矻矼砃砄砅砆砇砈砊砋砎砏砐砓砕砙砛砞砠砡砢砤砨砪砫砮砯砱砲砳砵砶砽砿硁硂硃硄硆硈硉硊硋硍硏硑硓硔硘硙硚�硛硜硞硟硠硡硢硣硤硥硦硧硨硩硯硰硱硲硳硴硵硶硸硹硺硻硽硾硿碀碁碂碃场尝常长偿肠厂敞畅唱倡超抄钞朝嘲潮巢吵炒车扯撤掣彻澈郴臣辰尘晨忱沉陈趁衬撑称城橙成呈乘程惩澄诚承逞骋秤吃痴持匙池迟弛驰耻齿侈尺赤翅斥炽充冲虫崇宠抽酬畴踌稠愁筹仇绸瞅丑臭初出橱厨躇锄雏滁除楚�".split("");for(a=0;a!=t[179].length;++a)if(t[179][a].charCodeAt(0)!==65533){r[t[179][a]]=45824+a;e[45824+a]=t[179][a]}t[180]="����������������������������������������������������������������碄碅碆碈碊碋碏碐碒碔碕碖碙碝碞碠碢碤碦碨碩碪碫碬碭碮碯碵碶碷碸確碻碼碽碿磀磂磃磄磆磇磈磌磍磎磏磑磒磓磖磗磘磚磛磜磝磞磟磠磡磢磣�磤磥磦磧磩磪磫磭磮磯磰磱磳磵磶磸磹磻磼磽磾磿礀礂礃礄礆礇礈礉礊礋礌础储矗搐触处揣川穿椽传船喘串疮窗幢床闯创吹炊捶锤垂春椿醇唇淳纯蠢戳绰疵茨磁雌辞慈瓷词此刺赐次聪葱囱匆从丛凑粗醋簇促蹿篡窜摧崔催脆瘁粹淬翠村存寸磋撮搓措挫错搭达答瘩打大呆歹傣戴带殆代贷袋待逮�".split("");for(a=0;a!=t[180].length;++a)if(t[180][a].charCodeAt(0)!==65533){r[t[180][a]]=46080+a;e[46080+a]=t[180][a]}t[181]="����������������������������������������������������������������礍礎礏礐礑礒礔礕礖礗礘礙礚礛礜礝礟礠礡礢礣礥礦礧礨礩礪礫礬礭礮礯礰礱礲礳礵礶礷礸礹礽礿祂祃祄祅祇祊祋祌祍祎祏祐祑祒祔祕祘祙祡祣�祤祦祩祪祫祬祮祰祱祲祳祴祵祶祹祻祼祽祾祿禂禃禆禇禈禉禋禌禍禎禐禑禒怠耽担丹单郸掸胆旦氮但惮淡诞弹蛋当挡党荡档刀捣蹈倒岛祷导到稻悼道盗德得的蹬灯登等瞪凳邓堤低滴迪敌笛狄涤翟嫡抵底地蒂第帝弟递缔颠掂滇碘点典靛垫电佃甸店惦奠淀殿碉叼雕凋刁掉吊钓调跌爹碟蝶迭谍叠�".split("");for(a=0;a!=t[181].length;++a)if(t[181][a].charCodeAt(0)!==65533){r[t[181][a]]=46336+a;e[46336+a]=t[181][a]}t[182]="����������������������������������������������������������������禓禔禕禖禗禘禙禛禜禝禞禟禠禡禢禣禤禥禦禨禩禪禫禬禭禮禯禰禱禲禴禵禶禷禸禼禿秂秄秅秇秈秊秌秎秏秐秓秔秖秗秙秚秛秜秝秞秠秡秢秥秨秪�秬秮秱秲秳秴秵秶秷秹秺秼秾秿稁稄稅稇稈稉稊稌稏稐稑稒稓稕稖稘稙稛稜丁盯叮钉顶鼎锭定订丢东冬董懂动栋侗恫冻洞兜抖斗陡豆逗痘都督毒犊独读堵睹赌杜镀肚度渡妒端短锻段断缎堆兑队对墩吨蹲敦顿囤钝盾遁掇哆多夺垛躲朵跺舵剁惰堕蛾峨鹅俄额讹娥恶厄扼遏鄂饿恩而儿耳尔饵洱二�".split("");for(a=0;a!=t[182].length;++a)if(t[182][a].charCodeAt(0)!==65533){r[t[182][a]]=46592+a;e[46592+a]=t[182][a]}t[183]="����������������������������������������������������������������稝稟稡稢稤稥稦稧稨稩稪稫稬稭種稯稰稱稲稴稵稶稸稺稾穀穁穂穃穄穅穇穈穉穊穋穌積穎穏穐穒穓穔穕穖穘穙穚穛穜穝穞穟穠穡穢穣穤穥穦穧穨�穩穪穫穬穭穮穯穱穲穳穵穻穼穽穾窂窅窇窉窊窋窌窎窏窐窓窔窙窚窛窞窡窢贰发罚筏伐乏阀法珐藩帆番翻樊矾钒繁凡烦反返范贩犯饭泛坊芳方肪房防妨仿访纺放菲非啡飞肥匪诽吠肺废沸费芬酚吩氛分纷坟焚汾粉奋份忿愤粪丰封枫蜂峰锋风疯烽逢冯缝讽奉凤佛否夫敷肤孵扶拂辐幅氟符伏俘服�".split("");for(a=0;a!=t[183].length;++a)if(t[183][a].charCodeAt(0)!==65533){r[t[183][a]]=46848+a;e[46848+a]=t[183][a]}t[184]="����������������������������������������������������������������窣窤窧窩窪窫窮窯窰窱窲窴窵窶窷窸窹窺窻窼窽窾竀竁竂竃竄竅竆竇竈竉竊竌竍竎竏竐竑竒竓竔竕竗竘竚竛竜竝竡竢竤竧竨竩竪竫竬竮竰竱竲竳�竴竵競竷竸竻竼竾笀笁笂笅笇笉笌笍笎笐笒笓笖笗笘笚笜笝笟笡笢笣笧笩笭浮涪福袱弗甫抚辅俯釜斧脯腑府腐赴副覆赋复傅付阜父腹负富讣附妇缚咐噶嘎该改概钙盖溉干甘杆柑竿肝赶感秆敢赣冈刚钢缸肛纲岗港杠篙皋高膏羔糕搞镐稿告哥歌搁戈鸽胳疙割革葛格蛤阁隔铬个各给根跟耕更庚羹�".split("");for(a=0;a!=t[184].length;++a)if(t[184][a].charCodeAt(0)!==65533){r[t[184][a]]=47104+a;e[47104+a]=t[184][a]}t[185]="����������������������������������������������������������������笯笰笲笴笵笶笷笹笻笽笿筀筁筂筃筄筆筈筊筍筎筓筕筗筙筜筞筟筡筣筤筥筦筧筨筩筪筫筬筭筯筰筳筴筶筸筺筼筽筿箁箂箃箄箆箇箈箉箊箋箌箎箏�箑箒箓箖箘箙箚箛箞箟箠箣箤箥箮箯箰箲箳箵箶箷箹箺箻箼箽箾箿節篂篃範埂耿梗工攻功恭龚供躬公宫弓巩汞拱贡共钩勾沟苟狗垢构购够辜菇咕箍估沽孤姑鼓古蛊骨谷股故顾固雇刮瓜剐寡挂褂乖拐怪棺关官冠观管馆罐惯灌贯光广逛瑰规圭硅归龟闺轨鬼诡癸桂柜跪贵刽辊滚棍锅郭国果裹过哈�".split("");for(a=0;a!=t[185].length;++a)if(t[185][a].charCodeAt(0)!==65533){r[t[185][a]]=47360+a;e[47360+a]=t[185][a]}t[186]="����������������������������������������������������������������篅篈築篊篋篍篎篏篐篒篔篕篖篗篘篛篜篞篟篠篢篣篤篧篨篩篫篬篭篯篰篲篳篴篵篶篸篹篺篻篽篿簀簁簂簃簄簅簆簈簉簊簍簎簐簑簒簓簔簕簗簘簙�簚簛簜簝簞簠簡簢簣簤簥簨簩簫簬簭簮簯簰簱簲簳簴簵簶簷簹簺簻簼簽簾籂骸孩海氦亥害骇酣憨邯韩含涵寒函喊罕翰撼捍旱憾悍焊汗汉夯杭航壕嚎豪毫郝好耗号浩呵喝荷菏核禾和何合盒貉阂河涸赫褐鹤贺嘿黑痕很狠恨哼亨横衡恒轰哄烘虹鸿洪宏弘红喉侯猴吼厚候后呼乎忽瑚壶葫胡蝴狐糊湖�".split("");for(a=0;a!=t[186].length;++a)if(t[186][a].charCodeAt(0)!==65533){r[t[186][a]]=47616+a;e[47616+a]=t[186][a]}t[187]="����������������������������������������������������������������籃籄籅籆籇籈籉籊籋籌籎籏籐籑籒籓籔籕籖籗籘籙籚籛籜籝籞籟籠籡籢籣籤籥籦籧籨籩籪籫籬籭籮籯籰籱籲籵籶籷籸籹籺籾籿粀粁粂粃粄粅粆粇�粈粊粋粌粍粎粏粐粓粔粖粙粚粛粠粡粣粦粧粨粩粫粬粭粯粰粴粵粶粷粸粺粻弧虎唬护互沪户花哗华猾滑画划化话槐徊怀淮坏欢环桓还缓换患唤痪豢焕涣宦幻荒慌黄磺蝗簧皇凰惶煌晃幌恍谎灰挥辉徽恢蛔回毁悔慧卉惠晦贿秽会烩汇讳诲绘荤昏婚魂浑混豁活伙火获或惑霍货祸击圾基机畸稽积箕�".split("");for(a=0;a!=t[187].length;++a)if(t[187][a].charCodeAt(0)!==65533){r[t[187][a]]=47872+a;e[47872+a]=t[187][a]}t[188]="����������������������������������������������������������������粿糀糂糃糄糆糉糋糎糏糐糑糒糓糔糘糚糛糝糞糡糢糣糤糥糦糧糩糪糫糬糭糮糰糱糲糳糴糵糶糷糹糺糼糽糾糿紀紁紂紃約紅紆紇紈紉紋紌納紎紏紐�紑紒紓純紕紖紗紘紙級紛紜紝紞紟紡紣紤紥紦紨紩紪紬紭紮細紱紲紳紴紵紶肌饥迹激讥鸡姬绩缉吉极棘辑籍集及急疾汲即嫉级挤几脊己蓟技冀季伎祭剂悸济寄寂计记既忌际妓继纪嘉枷夹佳家加荚颊贾甲钾假稼价架驾嫁歼监坚尖笺间煎兼肩艰奸缄茧检柬碱硷拣捡简俭剪减荐槛鉴践贱见键箭件�".split("");for(a=0;a!=t[188].length;++a)if(t[188][a].charCodeAt(0)!==65533){r[t[188][a]]=48128+a;e[48128+a]=t[188][a]}t[189]="����������������������������������������������������������������紷紸紹紺紻紼紽紾紿絀絁終絃組絅絆絇絈絉絊絋経絍絎絏結絑絒絓絔絕絖絗絘絙絚絛絜絝絞絟絠絡絢絣絤絥給絧絨絩絪絫絬絭絯絰統絲絳絴絵絶�絸絹絺絻絼絽絾絿綀綁綂綃綄綅綆綇綈綉綊綋綌綍綎綏綐綑綒經綔綕綖綗綘健舰剑饯渐溅涧建僵姜将浆江疆蒋桨奖讲匠酱降蕉椒礁焦胶交郊浇骄娇嚼搅铰矫侥脚狡角饺缴绞剿教酵轿较叫窖揭接皆秸街阶截劫节桔杰捷睫竭洁结解姐戒藉芥界借介疥诫届巾筋斤金今津襟紧锦仅谨进靳晋禁近烬浸�".split("");for(a=0;a!=t[189].length;++a)if(t[189][a].charCodeAt(0)!==65533){r[t[189][a]]=48384+a;e[48384+a]=t[189][a]}t[190]="����������������������������������������������������������������継続綛綜綝綞綟綠綡綢綣綤綥綧綨綩綪綫綬維綯綰綱網綳綴綵綶綷綸綹綺綻綼綽綾綿緀緁緂緃緄緅緆緇緈緉緊緋緌緍緎総緐緑緒緓緔緕緖緗緘緙�線緛緜緝緞緟締緡緢緣緤緥緦緧編緩緪緫緬緭緮緯緰緱緲緳練緵緶緷緸緹緺尽劲荆兢茎睛晶鲸京惊精粳经井警景颈静境敬镜径痉靖竟竞净炯窘揪究纠玖韭久灸九酒厩救旧臼舅咎就疚鞠拘狙疽居驹菊局咀矩举沮聚拒据巨具距踞锯俱句惧炬剧捐鹃娟倦眷卷绢撅攫抉掘倔爵觉决诀绝均菌钧军君峻�".split("");for(a=0;a!=t[190].length;++a)if(t[190][a].charCodeAt(0)!==65533){r[t[190][a]]=48640+a;e[48640+a]=t[190][a]}t[191]="����������������������������������������������������������������緻緼緽緾緿縀縁縂縃縄縅縆縇縈縉縊縋縌縍縎縏縐縑縒縓縔縕縖縗縘縙縚縛縜縝縞縟縠縡縢縣縤縥縦縧縨縩縪縫縬縭縮縯縰縱縲縳縴縵縶縷縸縹�縺縼總績縿繀繂繃繄繅繆繈繉繊繋繌繍繎繏繐繑繒繓織繕繖繗繘繙繚繛繜繝俊竣浚郡骏喀咖卡咯开揩楷凯慨刊堪勘坎砍看康慷糠扛抗亢炕考拷烤靠坷苛柯棵磕颗科壳咳可渴克刻客课肯啃垦恳坑吭空恐孔控抠口扣寇枯哭窟苦酷库裤夸垮挎跨胯块筷侩快宽款匡筐狂框矿眶旷况亏盔岿窥葵奎魁傀�".split("");for(a=0;a!=t[191].length;++a)if(t[191][a].charCodeAt(0)!==65533){r[t[191][a]]=48896+a;e[48896+a]=t[191][a]}t[192]="����������������������������������������������������������������繞繟繠繡繢繣繤繥繦繧繨繩繪繫繬繭繮繯繰繱繲繳繴繵繶繷繸繹繺繻繼繽繾繿纀纁纃纄纅纆纇纈纉纊纋續纍纎纏纐纑纒纓纔纕纖纗纘纙纚纜纝纞�纮纴纻纼绖绤绬绹缊缐缞缷缹缻缼缽缾缿罀罁罃罆罇罈罉罊罋罌罍罎罏罒罓馈愧溃坤昆捆困括扩廓阔垃拉喇蜡腊辣啦莱来赖蓝婪栏拦篮阑兰澜谰揽览懒缆烂滥琅榔狼廊郎朗浪捞劳牢老佬姥酪烙涝勒乐雷镭蕾磊累儡垒擂肋类泪棱楞冷厘梨犁黎篱狸离漓理李里鲤礼莉荔吏栗丽厉励砾历利傈例俐�".split("");for(a=0;a!=t[192].length;++a)if(t[192][a].charCodeAt(0)!==65533){r[t[192][a]]=49152+a;e[49152+a]=t[192][a]}t[193]="����������������������������������������������������������������罖罙罛罜罝罞罠罣罤罥罦罧罫罬罭罯罰罳罵罶罷罸罺罻罼罽罿羀羂羃羄羅羆羇羈羉羋羍羏羐羑羒羓羕羖羗羘羙羛羜羠羢羣羥羦羨義羪羫羬羭羮羱�羳羴羵羶羷羺羻羾翀翂翃翄翆翇翈翉翋翍翏翐翑習翓翖翗翙翚翛翜翝翞翢翣痢立粒沥隶力璃哩俩联莲连镰廉怜涟帘敛脸链恋炼练粮凉梁粱良两辆量晾亮谅撩聊僚疗燎寥辽潦了撂镣廖料列裂烈劣猎琳林磷霖临邻鳞淋凛赁吝拎玲菱零龄铃伶羚凌灵陵岭领另令溜琉榴硫馏留刘瘤流柳六龙聋咙笼窿�".split("");for(a=0;a!=t[193].length;++a)if(t[193][a].charCodeAt(0)!==65533){r[t[193][a]]=49408+a;e[49408+a]=t[193][a]}t[194]="����������������������������������������������������������������翤翧翨翪翫翬翭翯翲翴翵翶翷翸翹翺翽翾翿耂耇耈耉耊耎耏耑耓耚耛耝耞耟耡耣耤耫耬耭耮耯耰耲耴耹耺耼耾聀聁聄聅聇聈聉聎聏聐聑聓聕聖聗�聙聛聜聝聞聟聠聡聢聣聤聥聦聧聨聫聬聭聮聯聰聲聳聴聵聶職聸聹聺聻聼聽隆垄拢陇楼娄搂篓漏陋芦卢颅庐炉掳卤虏鲁麓碌露路赂鹿潞禄录陆戮驴吕铝侣旅履屡缕虑氯律率滤绿峦挛孪滦卵乱掠略抡轮伦仑沦纶论萝螺罗逻锣箩骡裸落洛骆络妈麻玛码蚂马骂嘛吗埋买麦卖迈脉瞒馒蛮满蔓曼慢漫�".split("");for(a=0;a!=t[194].length;++a)if(t[194][a].charCodeAt(0)!==65533){r[t[194][a]]=49664+a;e[49664+a]=t[194][a]}t[195]="����������������������������������������������������������������聾肁肂肅肈肊肍肎肏肐肑肒肔肕肗肙肞肣肦肧肨肬肰肳肵肶肸肹肻胅胇胈胉胊胋胏胐胑胒胓胔胕胘胟胠胢胣胦胮胵胷胹胻胾胿脀脁脃脄脅脇脈脋�脌脕脗脙脛脜脝脟脠脡脢脣脤脥脦脧脨脩脪脫脭脮脰脳脴脵脷脹脺脻脼脽脿谩芒茫盲氓忙莽猫茅锚毛矛铆卯茂冒帽貌贸么玫枚梅酶霉煤没眉媒镁每美昧寐妹媚门闷们萌蒙檬盟锰猛梦孟眯醚靡糜迷谜弥米秘觅泌蜜密幂棉眠绵冕免勉娩缅面苗描瞄藐秒渺庙妙蔑灭民抿皿敏悯闽明螟鸣铭名命谬摸�".split("");for(a=0;a!=t[195].length;++a)if(t[195][a].charCodeAt(0)!==65533){r[t[195][a]]=49920+a;e[49920+a]=t[195][a]}t[196]="����������������������������������������������������������������腀腁腂腃腄腅腇腉腍腎腏腒腖腗腘腛腜腝腞腟腡腢腣腤腦腨腪腫腬腯腲腳腵腶腷腸膁膃膄膅膆膇膉膋膌膍膎膐膒膓膔膕膖膗膙膚膞膟膠膡膢膤膥�膧膩膫膬膭膮膯膰膱膲膴膵膶膷膸膹膼膽膾膿臄臅臇臈臉臋臍臎臏臐臑臒臓摹蘑模膜磨摩魔抹末莫墨默沫漠寞陌谋牟某拇牡亩姆母墓暮幕募慕木目睦牧穆拿哪呐钠那娜纳氖乃奶耐奈南男难囊挠脑恼闹淖呢馁内嫩能妮霓倪泥尼拟你匿腻逆溺蔫拈年碾撵捻念娘酿鸟尿捏聂孽啮镊镍涅您柠狞凝宁�".split("");for(a=0;a!=t[196].length;++a)if(t[196][a].charCodeAt(0)!==65533){r[t[196][a]]=50176+a;e[50176+a]=t[196][a]}t[197]="����������������������������������������������������������������臔臕臖臗臘臙臚臛臜臝臞臟臠臡臢臤臥臦臨臩臫臮臯臰臱臲臵臶臷臸臹臺臽臿舃與興舉舊舋舎舏舑舓舕舖舗舘舙舚舝舠舤舥舦舧舩舮舲舺舼舽舿�艀艁艂艃艅艆艈艊艌艍艎艐艑艒艓艔艕艖艗艙艛艜艝艞艠艡艢艣艤艥艦艧艩拧泞牛扭钮纽脓浓农弄奴努怒女暖虐疟挪懦糯诺哦欧鸥殴藕呕偶沤啪趴爬帕怕琶拍排牌徘湃派攀潘盘磐盼畔判叛乓庞旁耪胖抛咆刨炮袍跑泡呸胚培裴赔陪配佩沛喷盆砰抨烹澎彭蓬棚硼篷膨朋鹏捧碰坯砒霹批披劈琵毗�".split("");for(a=0;a!=t[197].length;++a)if(t[197][a].charCodeAt(0)!==65533){r[t[197][a]]=50432+a;e[50432+a]=t[197][a]}t[198]="����������������������������������������������������������������艪艫艬艭艱艵艶艷艸艻艼芀芁芃芅芆芇芉芌芐芓芔芕芖芚芛芞芠芢芣芧芲芵芶芺芻芼芿苀苂苃苅苆苉苐苖苙苚苝苢苧苨苩苪苬苭苮苰苲苳苵苶苸�苺苼苽苾苿茀茊茋茍茐茒茓茖茘茙茝茞茟茠茡茢茣茤茥茦茩茪茮茰茲茷茻茽啤脾疲皮匹痞僻屁譬篇偏片骗飘漂瓢票撇瞥拼频贫品聘乒坪苹萍平凭瓶评屏坡泼颇婆破魄迫粕剖扑铺仆莆葡菩蒲埔朴圃普浦谱曝瀑期欺栖戚妻七凄漆柒沏其棋奇歧畦崎脐齐旗祈祁骑起岂乞企启契砌器气迄弃汽泣讫掐�".split("");for(a=0;a!=t[198].length;++a)if(t[198][a].charCodeAt(0)!==65533){r[t[198][a]]=50688+a;e[50688+a]=t[198][a]}t[199]="����������������������������������������������������������������茾茿荁荂荄荅荈荊荋荌荍荎荓荕荖荗荘荙荝荢荰荱荲荳荴荵荶荹荺荾荿莀莁莂莃莄莇莈莊莋莌莍莏莐莑莔莕莖莗莙莚莝莟莡莢莣莤莥莦莧莬莭莮�莯莵莻莾莿菂菃菄菆菈菉菋菍菎菐菑菒菓菕菗菙菚菛菞菢菣菤菦菧菨菫菬菭恰洽牵扦钎铅千迁签仟谦乾黔钱钳前潜遣浅谴堑嵌欠歉枪呛腔羌墙蔷强抢橇锹敲悄桥瞧乔侨巧鞘撬翘峭俏窍切茄且怯窃钦侵亲秦琴勤芹擒禽寝沁青轻氢倾卿清擎晴氰情顷请庆琼穷秋丘邱球求囚酋泅趋区蛆曲躯屈驱渠�".split("");for(a=0;a!=t[199].length;++a)if(t[199][a].charCodeAt(0)!==65533){r[t[199][a]]=50944+a;e[50944+a]=t[199][a]}t[200]="����������������������������������������������������������������菮華菳菴菵菶菷菺菻菼菾菿萀萂萅萇萈萉萊萐萒萓萔萕萖萗萙萚萛萞萟萠萡萢萣萩萪萫萬萭萮萯萰萲萳萴萵萶萷萹萺萻萾萿葀葁葂葃葄葅葇葈葉�葊葋葌葍葎葏葐葒葓葔葕葖葘葝葞葟葠葢葤葥葦葧葨葪葮葯葰葲葴葷葹葻葼取娶龋趣去圈颧权醛泉全痊拳犬券劝缺炔瘸却鹊榷确雀裙群然燃冉染瓤壤攘嚷让饶扰绕惹热壬仁人忍韧任认刃妊纫扔仍日戎茸蓉荣融熔溶容绒冗揉柔肉茹蠕儒孺如辱乳汝入褥软阮蕊瑞锐闰润若弱撒洒萨腮鳃塞赛三叁�".split("");for(a=0;a!=t[200].length;++a)if(t[200][a].charCodeAt(0)!==65533){r[t[200][a]]=51200+a;e[51200+a]=t[200][a]}t[201]="����������������������������������������������������������������葽葾葿蒀蒁蒃蒄蒅蒆蒊蒍蒏蒐蒑蒒蒓蒔蒕蒖蒘蒚蒛蒝蒞蒟蒠蒢蒣蒤蒥蒦蒧蒨蒩蒪蒫蒬蒭蒮蒰蒱蒳蒵蒶蒷蒻蒼蒾蓀蓂蓃蓅蓆蓇蓈蓋蓌蓎蓏蓒蓔蓕蓗�蓘蓙蓚蓛蓜蓞蓡蓢蓤蓧蓨蓩蓪蓫蓭蓮蓯蓱蓲蓳蓴蓵蓶蓷蓸蓹蓺蓻蓽蓾蔀蔁蔂伞散桑嗓丧搔骚扫嫂瑟色涩森僧莎砂杀刹沙纱傻啥煞筛晒珊苫杉山删煽衫闪陕擅赡膳善汕扇缮墒伤商赏晌上尚裳梢捎稍烧芍勺韶少哨邵绍奢赊蛇舌舍赦摄射慑涉社设砷申呻伸身深娠绅神沈审婶甚肾慎渗声生甥牲升绳�".split("");for(a=0;a!=t[201].length;++a)if(t[201][a].charCodeAt(0)!==65533){r[t[201][a]]=51456+a;e[51456+a]=t[201][a]}t[202]="����������������������������������������������������������������蔃蔄蔅蔆蔇蔈蔉蔊蔋蔍蔎蔏蔐蔒蔔蔕蔖蔘蔙蔛蔜蔝蔞蔠蔢蔣蔤蔥蔦蔧蔨蔩蔪蔭蔮蔯蔰蔱蔲蔳蔴蔵蔶蔾蔿蕀蕁蕂蕄蕅蕆蕇蕋蕌蕍蕎蕏蕐蕑蕒蕓蕔蕕�蕗蕘蕚蕛蕜蕝蕟蕠蕡蕢蕣蕥蕦蕧蕩蕪蕫蕬蕭蕮蕯蕰蕱蕳蕵蕶蕷蕸蕼蕽蕿薀薁省盛剩胜圣师失狮施湿诗尸虱十石拾时什食蚀实识史矢使屎驶始式示士世柿事拭誓逝势是嗜噬适仕侍释饰氏市恃室视试收手首守寿授售受瘦兽蔬枢梳殊抒输叔舒淑疏书赎孰熟薯暑曙署蜀黍鼠属术述树束戍竖墅庶数漱�".split("");for(a=0;a!=t[202].length;++a)if(t[202][a].charCodeAt(0)!==65533){r[t[202][a]]=51712+a;e[51712+a]=t[202][a]}t[203]="����������������������������������������������������������������薂薃薆薈薉薊薋薌薍薎薐薑薒薓薔薕薖薗薘薙薚薝薞薟薠薡薢薣薥薦薧薩薫薬薭薱薲薳薴薵薶薸薺薻薼薽薾薿藀藂藃藄藅藆藇藈藊藋藌藍藎藑藒�藔藖藗藘藙藚藛藝藞藟藠藡藢藣藥藦藧藨藪藫藬藭藮藯藰藱藲藳藴藵藶藷藸恕刷耍摔衰甩帅栓拴霜双爽谁水睡税吮瞬顺舜说硕朔烁斯撕嘶思私司丝死肆寺嗣四伺似饲巳松耸怂颂送宋讼诵搜艘擞嗽苏酥俗素速粟僳塑溯宿诉肃酸蒜算虽隋随绥髓碎岁穗遂隧祟孙损笋蓑梭唆缩琐索锁所塌他它她塔�".split("");for(a=0;a!=t[203].length;++a)if(t[203][a].charCodeAt(0)!==65533){r[t[203][a]]=51968+a;e[51968+a]=t[203][a]}t[204]="����������������������������������������������������������������藹藺藼藽藾蘀蘁蘂蘃蘄蘆蘇蘈蘉蘊蘋蘌蘍蘎蘏蘐蘒蘓蘔蘕蘗蘘蘙蘚蘛蘜蘝蘞蘟蘠蘡蘢蘣蘤蘥蘦蘨蘪蘫蘬蘭蘮蘯蘰蘱蘲蘳蘴蘵蘶蘷蘹蘺蘻蘽蘾蘿虀�虁虂虃虄虅虆虇虈虉虊虋虌虒虓處虖虗虘虙虛虜虝號虠虡虣虤虥虦虧虨虩虪獭挞蹋踏胎苔抬台泰酞太态汰坍摊贪瘫滩坛檀痰潭谭谈坦毯袒碳探叹炭汤塘搪堂棠膛唐糖倘躺淌趟烫掏涛滔绦萄桃逃淘陶讨套特藤腾疼誊梯剔踢锑提题蹄啼体替嚏惕涕剃屉天添填田甜恬舔腆挑条迢眺跳贴铁帖厅听烃�".split("");for(a=0;a!=t[204].length;++a)if(t[204][a].charCodeAt(0)!==65533){r[t[204][a]]=52224+a;e[52224+a]=t[204][a]}t[205]="����������������������������������������������������������������虭虯虰虲虳虴虵虶虷虸蚃蚄蚅蚆蚇蚈蚉蚎蚏蚐蚑蚒蚔蚖蚗蚘蚙蚚蚛蚞蚟蚠蚡蚢蚥蚦蚫蚭蚮蚲蚳蚷蚸蚹蚻蚼蚽蚾蚿蛁蛂蛃蛅蛈蛌蛍蛒蛓蛕蛖蛗蛚蛜�蛝蛠蛡蛢蛣蛥蛦蛧蛨蛪蛫蛬蛯蛵蛶蛷蛺蛻蛼蛽蛿蜁蜄蜅蜆蜋蜌蜎蜏蜐蜑蜔蜖汀廷停亭庭挺艇通桐酮瞳同铜彤童桶捅筒统痛偷投头透凸秃突图徒途涂屠土吐兔湍团推颓腿蜕褪退吞屯臀拖托脱鸵陀驮驼椭妥拓唾挖哇蛙洼娃瓦袜歪外豌弯湾玩顽丸烷完碗挽晚皖惋宛婉万腕汪王亡枉网往旺望忘妄威�".split("");for(a=0;a!=t[205].length;++a)if(t[205][a].charCodeAt(0)!==65533){r[t[205][a]]=52480+a;e[52480+a]=t[205][a]}t[206]="����������������������������������������������������������������蜙蜛蜝蜟蜠蜤蜦蜧蜨蜪蜫蜬蜭蜯蜰蜲蜳蜵蜶蜸蜹蜺蜼蜽蝀蝁蝂蝃蝄蝅蝆蝊蝋蝍蝏蝐蝑蝒蝔蝕蝖蝘蝚蝛蝜蝝蝞蝟蝡蝢蝦蝧蝨蝩蝪蝫蝬蝭蝯蝱蝲蝳蝵�蝷蝸蝹蝺蝿螀螁螄螆螇螉螊螌螎螏螐螑螒螔螕螖螘螙螚螛螜螝螞螠螡螢螣螤巍微危韦违桅围唯惟为潍维苇萎委伟伪尾纬未蔚味畏胃喂魏位渭谓尉慰卫瘟温蚊文闻纹吻稳紊问嗡翁瓮挝蜗涡窝我斡卧握沃巫呜钨乌污诬屋无芜梧吾吴毋武五捂午舞伍侮坞戊雾晤物勿务悟误昔熙析西硒矽晰嘻吸锡牺�".split("");for(a=0;a!=t[206].length;++a)if(t[206][a].charCodeAt(0)!==65533){r[t[206][a]]=52736+a;e[52736+a]=t[206][a]}t[207]="����������������������������������������������������������������螥螦螧螩螪螮螰螱螲螴螶螷螸螹螻螼螾螿蟁蟂蟃蟄蟅蟇蟈蟉蟌蟍蟎蟏蟐蟔蟕蟖蟗蟘蟙蟚蟜蟝蟞蟟蟡蟢蟣蟤蟦蟧蟨蟩蟫蟬蟭蟯蟰蟱蟲蟳蟴蟵蟶蟷蟸�蟺蟻蟼蟽蟿蠀蠁蠂蠄蠅蠆蠇蠈蠉蠋蠌蠍蠎蠏蠐蠑蠒蠔蠗蠘蠙蠚蠜蠝蠞蠟蠠蠣稀息希悉膝夕惜熄烯溪汐犀檄袭席习媳喜铣洗系隙戏细瞎虾匣霞辖暇峡侠狭下厦夏吓掀锨先仙鲜纤咸贤衔舷闲涎弦嫌显险现献县腺馅羡宪陷限线相厢镶香箱襄湘乡翔祥详想响享项巷橡像向象萧硝霄削哮嚣销消宵淆晓�".split("");for(a=0;a!=t[207].length;++a)if(t[207][a].charCodeAt(0)!==65533){r[t[207][a]]=52992+a;e[52992+a]=t[207][a]}t[208]="����������������������������������������������������������������蠤蠥蠦蠧蠨蠩蠪蠫蠬蠭蠮蠯蠰蠱蠳蠴蠵蠶蠷蠸蠺蠻蠽蠾蠿衁衂衃衆衇衈衉衊衋衎衏衐衑衒術衕衖衘衚衛衜衝衞衟衠衦衧衪衭衯衱衳衴衵衶衸衹衺�衻衼袀袃袆袇袉袊袌袎袏袐袑袓袔袕袗袘袙袚袛袝袞袟袠袡袣袥袦袧袨袩袪小孝校肖啸笑效楔些歇蝎鞋协挟携邪斜胁谐写械卸蟹懈泄泻谢屑薪芯锌欣辛新忻心信衅星腥猩惺兴刑型形邢行醒幸杏性姓兄凶胸匈汹雄熊休修羞朽嗅锈秀袖绣墟戌需虚嘘须徐许蓄酗叙旭序畜恤絮婿绪续轩喧宣悬旋玄�".split("");for(a=0;a!=t[208].length;++a)if(t[208][a].charCodeAt(0)!==65533){r[t[208][a]]=53248+a;e[53248+a]=t[208][a]}t[209]="����������������������������������������������������������������袬袮袯袰袲袳袴袵袶袸袹袺袻袽袾袿裀裃裄裇裈裊裋裌裍裏裐裑裓裖裗裚裛補裝裞裠裡裦裧裩裪裫裬裭裮裯裲裵裶裷裺裻製裿褀褁褃褄褅褆複褈�褉褋褌褍褎褏褑褔褕褖褗褘褜褝褞褟褠褢褣褤褦褧褨褩褬褭褮褯褱褲褳褵褷选癣眩绚靴薛学穴雪血勋熏循旬询寻驯巡殉汛训讯逊迅压押鸦鸭呀丫芽牙蚜崖衙涯雅哑亚讶焉咽阉烟淹盐严研蜒岩延言颜阎炎沿奄掩眼衍演艳堰燕厌砚雁唁彦焰宴谚验殃央鸯秧杨扬佯疡羊洋阳氧仰痒养样漾邀腰妖瑶�".split("");for(a=0;a!=t[209].length;++a)if(t[209][a].charCodeAt(0)!==65533){r[t[209][a]]=53504+a;e[53504+a]=t[209][a]}t[210]="����������������������������������������������������������������褸褹褺褻褼褽褾褿襀襂襃襅襆襇襈襉襊襋襌襍襎襏襐襑襒襓襔襕襖襗襘襙襚襛襜襝襠襡襢襣襤襥襧襨襩襪襫襬襭襮襯襰襱襲襳襴襵襶襷襸襹襺襼�襽襾覀覂覄覅覇覈覉覊見覌覍覎規覐覑覒覓覔覕視覗覘覙覚覛覜覝覞覟覠覡摇尧遥窑谣姚咬舀药要耀椰噎耶爷野冶也页掖业叶曳腋夜液一壹医揖铱依伊衣颐夷遗移仪胰疑沂宜姨彝椅蚁倚已乙矣以艺抑易邑屹亿役臆逸肄疫亦裔意毅忆义益溢诣议谊译异翼翌绎茵荫因殷音阴姻吟银淫寅饮尹引隐�".split("");for(a=0;a!=t[210].length;++a)if(t[210][a].charCodeAt(0)!==65533){r[t[210][a]]=53760+a;e[53760+a]=t[210][a]}t[211]="����������������������������������������������������������������覢覣覤覥覦覧覨覩親覫覬覭覮覯覰覱覲観覴覵覶覷覸覹覺覻覼覽覾覿觀觃觍觓觔觕觗觘觙觛觝觟觠觡觢觤觧觨觩觪觬觭觮觰觱觲觴觵觶觷觸觹觺�觻觼觽觾觿訁訂訃訄訅訆計訉訊訋訌訍討訏訐訑訒訓訔訕訖託記訙訚訛訜訝印英樱婴鹰应缨莹萤营荧蝇迎赢盈影颖硬映哟拥佣臃痈庸雍踊蛹咏泳涌永恿勇用幽优悠忧尤由邮铀犹油游酉有友右佑釉诱又幼迂淤于盂榆虞愚舆余俞逾鱼愉渝渔隅予娱雨与屿禹宇语羽玉域芋郁吁遇喻峪御愈欲狱育誉�".split("");for(a=0;a!=t[211].length;++a)if(t[211][a].charCodeAt(0)!==65533){r[t[211][a]]=54016+a;e[54016+a]=t[211][a]}t[212]="����������������������������������������������������������������訞訟訠訡訢訣訤訥訦訧訨訩訪訫訬設訮訯訰許訲訳訴訵訶訷訸訹診註証訽訿詀詁詂詃詄詅詆詇詉詊詋詌詍詎詏詐詑詒詓詔評詖詗詘詙詚詛詜詝詞�詟詠詡詢詣詤詥試詧詨詩詪詫詬詭詮詯詰話該詳詴詵詶詷詸詺詻詼詽詾詿誀浴寓裕预豫驭鸳渊冤元垣袁原援辕园员圆猿源缘远苑愿怨院曰约越跃钥岳粤月悦阅耘云郧匀陨允运蕴酝晕韵孕匝砸杂栽哉灾宰载再在咱攒暂赞赃脏葬遭糟凿藻枣早澡蚤躁噪造皂灶燥责择则泽贼怎增憎曾赠扎喳渣札轧�".split("");for(a=0;a!=t[212].length;++a)if(t[212][a].charCodeAt(0)!==65533){r[t[212][a]]=54272+a;e[54272+a]=t[212][a]}t[213]="����������������������������������������������������������������誁誂誃誄誅誆誇誈誋誌認誎誏誐誑誒誔誕誖誗誘誙誚誛誜誝語誟誠誡誢誣誤誥誦誧誨誩說誫説読誮誯誰誱課誳誴誵誶誷誸誹誺誻誼誽誾調諀諁諂�諃諄諅諆談諈諉諊請諌諍諎諏諐諑諒諓諔諕論諗諘諙諚諛諜諝諞諟諠諡諢諣铡闸眨栅榨咋乍炸诈摘斋宅窄债寨瞻毡詹粘沾盏斩辗崭展蘸栈占战站湛绽樟章彰漳张掌涨杖丈帐账仗胀瘴障招昭找沼赵照罩兆肇召遮折哲蛰辙者锗蔗这浙珍斟真甄砧臻贞针侦枕疹诊震振镇阵蒸挣睁征狰争怔整拯正政�".split("");for(a=0;a!=t[213].length;++a)if(t[213][a].charCodeAt(0)!==65533){r[t[213][a]]=54528+a;e[54528+a]=t[213][a]}t[214]="����������������������������������������������������������������諤諥諦諧諨諩諪諫諬諭諮諯諰諱諲諳諴諵諶諷諸諹諺諻諼諽諾諿謀謁謂謃謄謅謆謈謉謊謋謌謍謎謏謐謑謒謓謔謕謖謗謘謙謚講謜謝謞謟謠謡謢謣�謤謥謧謨謩謪謫謬謭謮謯謰謱謲謳謴謵謶謷謸謹謺謻謼謽謾謿譀譁譂譃譄譅帧症郑证芝枝支吱蜘知肢脂汁之织职直植殖执值侄址指止趾只旨纸志挚掷至致置帜峙制智秩稚质炙痔滞治窒中盅忠钟衷终种肿重仲众舟周州洲诌粥轴肘帚咒皱宙昼骤珠株蛛朱猪诸诛逐竹烛煮拄瞩嘱主著柱助蛀贮铸筑�".split("");for(a=0;a!=t[214].length;++a)if(t[214][a].charCodeAt(0)!==65533){r[t[214][a]]=54784+a;e[54784+a]=t[214][a]}t[215]="����������������������������������������������������������������譆譇譈證譊譋譌譍譎譏譐譑譒譓譔譕譖譗識譙譚譛譜譝譞譟譠譡譢譣譤譥譧譨譩譪譫譭譮譯議譱譲譳譴譵譶護譸譹譺譻譼譽譾譿讀讁讂讃讄讅讆�讇讈讉變讋讌讍讎讏讐讑讒讓讔讕讖讗讘讙讚讛讜讝讞讟讬讱讻诇诐诪谉谞住注祝驻抓爪拽专砖转撰赚篆桩庄装妆撞壮状椎锥追赘坠缀谆准捉拙卓桌琢茁酌啄着灼浊兹咨资姿滋淄孜紫仔籽滓子自渍字鬃棕踪宗综总纵邹走奏揍租足卒族祖诅阻组钻纂嘴醉最罪尊遵昨左佐柞做作坐座������".split("");for(a=0;a!=t[215].length;++a)if(t[215][a].charCodeAt(0)!==65533){r[t[215][a]]=55040+a;e[55040+a]=t[215][a]}t[216]="����������������������������������������������������������������谸谹谺谻谼谽谾谿豀豂豃豄豅豈豊豋豍豎豏豐豑豒豓豔豖豗豘豙豛豜豝豞豟豠豣豤豥豦豧豨豩豬豭豮豯豰豱豲豴豵豶豷豻豼豽豾豿貀貁貃貄貆貇�貈貋貍貎貏貐貑貒貓貕貖貗貙貚貛貜貝貞貟負財貢貣貤貥貦貧貨販貪貫責貭亍丌兀丐廿卅丕亘丞鬲孬噩丨禺丿匕乇夭爻卮氐囟胤馗毓睾鼗丶亟鼐乜乩亓芈孛啬嘏仄厍厝厣厥厮靥赝匚叵匦匮匾赜卦卣刂刈刎刭刳刿剀剌剞剡剜蒯剽劂劁劐劓冂罔亻仃仉仂仨仡仫仞伛仳伢佤仵伥伧伉伫佞佧攸佚佝�".split("");for(a=0;a!=t[216].length;++a)if(t[216][a].charCodeAt(0)!==65533){r[t[216][a]]=55296+a;e[55296+a]=t[216][a]}t[217]="����������������������������������������������������������������貮貯貰貱貲貳貴貵貶買貸貹貺費貼貽貾貿賀賁賂賃賄賅賆資賈賉賊賋賌賍賎賏賐賑賒賓賔賕賖賗賘賙賚賛賜賝賞賟賠賡賢賣賤賥賦賧賨賩質賫賬�賭賮賯賰賱賲賳賴賵賶賷賸賹賺賻購賽賾賿贀贁贂贃贄贅贆贇贈贉贊贋贌贍佟佗伲伽佶佴侑侉侃侏佾佻侪佼侬侔俦俨俪俅俚俣俜俑俟俸倩偌俳倬倏倮倭俾倜倌倥倨偾偃偕偈偎偬偻傥傧傩傺僖儆僭僬僦僮儇儋仝氽佘佥俎龠汆籴兮巽黉馘冁夔勹匍訇匐凫夙兕亠兖亳衮袤亵脔裒禀嬴蠃羸冫冱冽冼�".split("");for(a=0;a!=t[217].length;++a)if(t[217][a].charCodeAt(0)!==65533){r[t[217][a]]=55552+a;e[55552+a]=t[217][a]}t[218]="����������������������������������������������������������������贎贏贐贑贒贓贔贕贖贗贘贙贚贛贜贠赑赒赗赟赥赨赩赪赬赮赯赱赲赸赹赺赻赼赽赾赿趀趂趃趆趇趈趉趌趍趎趏趐趒趓趕趖趗趘趙趚趛趜趝趞趠趡�趢趤趥趦趧趨趩趪趫趬趭趮趯趰趲趶趷趹趻趽跀跁跂跅跇跈跉跊跍跐跒跓跔凇冖冢冥讠讦讧讪讴讵讷诂诃诋诏诎诒诓诔诖诘诙诜诟诠诤诨诩诮诰诳诶诹诼诿谀谂谄谇谌谏谑谒谔谕谖谙谛谘谝谟谠谡谥谧谪谫谮谯谲谳谵谶卩卺阝阢阡阱阪阽阼陂陉陔陟陧陬陲陴隈隍隗隰邗邛邝邙邬邡邴邳邶邺�".split("");for(a=0;a!=t[218].length;++a)if(t[218][a].charCodeAt(0)!==65533){r[t[218][a]]=55808+a;e[55808+a]=t[218][a]}t[219]="����������������������������������������������������������������跕跘跙跜跠跡跢跥跦跧跩跭跮跰跱跲跴跶跼跾跿踀踁踂踃踄踆踇踈踋踍踎踐踑踒踓踕踖踗踘踙踚踛踜踠踡踤踥踦踧踨踫踭踰踲踳踴踶踷踸踻踼踾�踿蹃蹅蹆蹌蹍蹎蹏蹐蹓蹔蹕蹖蹗蹘蹚蹛蹜蹝蹞蹟蹠蹡蹢蹣蹤蹥蹧蹨蹪蹫蹮蹱邸邰郏郅邾郐郄郇郓郦郢郜郗郛郫郯郾鄄鄢鄞鄣鄱鄯鄹酃酆刍奂劢劬劭劾哿勐勖勰叟燮矍廴凵凼鬯厶弁畚巯坌垩垡塾墼壅壑圩圬圪圳圹圮圯坜圻坂坩垅坫垆坼坻坨坭坶坳垭垤垌垲埏垧垴垓垠埕埘埚埙埒垸埴埯埸埤埝�".split("");for(a=0;a!=t[219].length;++a)if(t[219][a].charCodeAt(0)!==65533){r[t[219][a]]=56064+a;e[56064+a]=t[219][a]}t[220]="����������������������������������������������������������������蹳蹵蹷蹸蹹蹺蹻蹽蹾躀躂躃躄躆躈躉躊躋躌躍躎躑躒躓躕躖躗躘躙躚躛躝躟躠躡躢躣躤躥躦躧躨躩躪躭躮躰躱躳躴躵躶躷躸躹躻躼躽躾躿軀軁軂�軃軄軅軆軇軈軉車軋軌軍軏軐軑軒軓軔軕軖軗軘軙軚軛軜軝軞軟軠軡転軣軤堋堍埽埭堀堞堙塄堠塥塬墁墉墚墀馨鼙懿艹艽艿芏芊芨芄芎芑芗芙芫芸芾芰苈苊苣芘芷芮苋苌苁芩芴芡芪芟苄苎芤苡茉苷苤茏茇苜苴苒苘茌苻苓茑茚茆茔茕苠苕茜荑荛荜茈莒茼茴茱莛荞茯荏荇荃荟荀茗荠茭茺茳荦荥�".split("");for(a=0;a!=t[220].length;++a)if(t[220][a].charCodeAt(0)!==65533){r[t[220][a]]=56320+a;e[56320+a]=t[220][a]}t[221]="����������������������������������������������������������������軥軦軧軨軩軪軫軬軭軮軯軰軱軲軳軴軵軶軷軸軹軺軻軼軽軾軿輀輁輂較輄輅輆輇輈載輊輋輌輍輎輏輐輑輒輓輔輕輖輗輘輙輚輛輜輝輞輟輠輡輢輣�輤輥輦輧輨輩輪輫輬輭輮輯輰輱輲輳輴輵輶輷輸輹輺輻輼輽輾輿轀轁轂轃轄荨茛荩荬荪荭荮莰荸莳莴莠莪莓莜莅荼莶莩荽莸荻莘莞莨莺莼菁萁菥菘堇萘萋菝菽菖萜萸萑萆菔菟萏萃菸菹菪菅菀萦菰菡葜葑葚葙葳蒇蒈葺蒉葸萼葆葩葶蒌蒎萱葭蓁蓍蓐蓦蒽蓓蓊蒿蒺蓠蒡蒹蒴蒗蓥蓣蔌甍蔸蓰蔹蔟蔺�".split("");for(a=0;a!=t[221].length;++a)if(t[221][a].charCodeAt(0)!==65533){r[t[221][a]]=56576+a;e[56576+a]=t[221][a]}t[222]="����������������������������������������������������������������轅轆轇轈轉轊轋轌轍轎轏轐轑轒轓轔轕轖轗轘轙轚轛轜轝轞轟轠轡轢轣轤轥轪辀辌辒辝辠辡辢辤辥辦辧辪辬辭辮辯農辳辴辵辷辸辺辻込辿迀迃迆�迉迊迋迌迍迏迒迖迗迚迠迡迣迧迬迯迱迲迴迵迶迺迻迼迾迿逇逈逌逎逓逕逘蕖蔻蓿蓼蕙蕈蕨蕤蕞蕺瞢蕃蕲蕻薤薨薇薏蕹薮薜薅薹薷薰藓藁藜藿蘧蘅蘩蘖蘼廾弈夼奁耷奕奚奘匏尢尥尬尴扌扪抟抻拊拚拗拮挢拶挹捋捃掭揶捱捺掎掴捭掬掊捩掮掼揲揸揠揿揄揞揎摒揆掾摅摁搋搛搠搌搦搡摞撄摭撖�".split("");for(a=0;a!=t[222].length;++a)if(t[222][a].charCodeAt(0)!==65533){r[t[222][a]]=56832+a;e[56832+a]=t[222][a]}t[223]="����������������������������������������������������������������這逜連逤逥逧逨逩逪逫逬逰週進逳逴逷逹逺逽逿遀遃遅遆遈遉遊運遌過達違遖遙遚遜遝遞遟遠遡遤遦遧適遪遫遬遯遰遱遲遳遶遷選遹遺遻遼遾邁�還邅邆邇邉邊邌邍邎邏邐邒邔邖邘邚邜邞邟邠邤邥邧邨邩邫邭邲邷邼邽邿郀摺撷撸撙撺擀擐擗擤擢攉攥攮弋忒甙弑卟叱叽叩叨叻吒吖吆呋呒呓呔呖呃吡呗呙吣吲咂咔呷呱呤咚咛咄呶呦咝哐咭哂咴哒咧咦哓哔呲咣哕咻咿哌哙哚哜咩咪咤哝哏哞唛哧唠哽唔哳唢唣唏唑唧唪啧喏喵啉啭啁啕唿啐唼�".split("");for(a=0;a!=t[223].length;++a)if(t[223][a].charCodeAt(0)!==65533){r[t[223][a]]=57088+a;e[57088+a]=t[223][a]}t[224]="����������������������������������������������������������������郂郃郆郈郉郋郌郍郒郔郕郖郘郙郚郞郟郠郣郤郥郩郪郬郮郰郱郲郳郵郶郷郹郺郻郼郿鄀鄁鄃鄅鄆鄇鄈鄉鄊鄋鄌鄍鄎鄏鄐鄑鄒鄓鄔鄕鄖鄗鄘鄚鄛鄜�鄝鄟鄠鄡鄤鄥鄦鄧鄨鄩鄪鄫鄬鄭鄮鄰鄲鄳鄴鄵鄶鄷鄸鄺鄻鄼鄽鄾鄿酀酁酂酄唷啖啵啶啷唳唰啜喋嗒喃喱喹喈喁喟啾嗖喑啻嗟喽喾喔喙嗪嗷嗉嘟嗑嗫嗬嗔嗦嗝嗄嗯嗥嗲嗳嗌嗍嗨嗵嗤辔嘞嘈嘌嘁嘤嘣嗾嘀嘧嘭噘嘹噗嘬噍噢噙噜噌噔嚆噤噱噫噻噼嚅嚓嚯囔囗囝囡囵囫囹囿圄圊圉圜帏帙帔帑帱帻帼�".split("");for(a=0;a!=t[224].length;++a)if(t[224][a].charCodeAt(0)!==65533){r[t[224][a]]=57344+a;e[57344+a]=t[224][a]}t[225]="����������������������������������������������������������������酅酇酈酑酓酔酕酖酘酙酛酜酟酠酦酧酨酫酭酳酺酻酼醀醁醂醃醄醆醈醊醎醏醓醔醕醖醗醘醙醜醝醞醟醠醡醤醥醦醧醨醩醫醬醰醱醲醳醶醷醸醹醻�醼醽醾醿釀釁釂釃釄釅釆釈釋釐釒釓釔釕釖釗釘釙釚釛針釞釟釠釡釢釣釤釥帷幄幔幛幞幡岌屺岍岐岖岈岘岙岑岚岜岵岢岽岬岫岱岣峁岷峄峒峤峋峥崂崃崧崦崮崤崞崆崛嵘崾崴崽嵬嵛嵯嵝嵫嵋嵊嵩嵴嶂嶙嶝豳嶷巅彳彷徂徇徉後徕徙徜徨徭徵徼衢彡犭犰犴犷犸狃狁狎狍狒狨狯狩狲狴狷猁狳猃狺�".split("");for(a=0;a!=t[225].length;++a)if(t[225][a].charCodeAt(0)!==65533){r[t[225][a]]=57600+a;e[57600+a]=t[225][a]}t[226]="����������������������������������������������������������������釦釧釨釩釪釫釬釭釮釯釰釱釲釳釴釵釶釷釸釹釺釻釼釽釾釿鈀鈁鈂鈃鈄鈅鈆鈇鈈鈉鈊鈋鈌鈍鈎鈏鈐鈑鈒鈓鈔鈕鈖鈗鈘鈙鈚鈛鈜鈝鈞鈟鈠鈡鈢鈣鈤�鈥鈦鈧鈨鈩鈪鈫鈬鈭鈮鈯鈰鈱鈲鈳鈴鈵鈶鈷鈸鈹鈺鈻鈼鈽鈾鈿鉀鉁鉂鉃鉄鉅狻猗猓猡猊猞猝猕猢猹猥猬猸猱獐獍獗獠獬獯獾舛夥飧夤夂饣饧饨饩饪饫饬饴饷饽馀馄馇馊馍馐馑馓馔馕庀庑庋庖庥庠庹庵庾庳赓廒廑廛廨廪膺忄忉忖忏怃忮怄忡忤忾怅怆忪忭忸怙怵怦怛怏怍怩怫怊怿怡恸恹恻恺恂�".split("");for(a=0;a!=t[226].length;++a)if(t[226][a].charCodeAt(0)!==65533){r[t[226][a]]=57856+a;e[57856+a]=t[226][a]}t[227]="����������������������������������������������������������������鉆鉇鉈鉉鉊鉋鉌鉍鉎鉏鉐鉑鉒鉓鉔鉕鉖鉗鉘鉙鉚鉛鉜鉝鉞鉟鉠鉡鉢鉣鉤鉥鉦鉧鉨鉩鉪鉫鉬鉭鉮鉯鉰鉱鉲鉳鉵鉶鉷鉸鉹鉺鉻鉼鉽鉾鉿銀銁銂銃銄銅�銆銇銈銉銊銋銌銍銏銐銑銒銓銔銕銖銗銘銙銚銛銜銝銞銟銠銡銢銣銤銥銦銧恪恽悖悚悭悝悃悒悌悛惬悻悱惝惘惆惚悴愠愦愕愣惴愀愎愫慊慵憬憔憧憷懔懵忝隳闩闫闱闳闵闶闼闾阃阄阆阈阊阋阌阍阏阒阕阖阗阙阚丬爿戕氵汔汜汊沣沅沐沔沌汨汩汴汶沆沩泐泔沭泷泸泱泗沲泠泖泺泫泮沱泓泯泾�".split("");for(a=0;a!=t[227].length;++a)if(t[227][a].charCodeAt(0)!==65533){r[t[227][a]]=58112+a;e[58112+a]=t[227][a]}t[228]="����������������������������������������������������������������銨銩銪銫銬銭銯銰銱銲銳銴銵銶銷銸銹銺銻銼銽銾銿鋀鋁鋂鋃鋄鋅鋆鋇鋉鋊鋋鋌鋍鋎鋏鋐鋑鋒鋓鋔鋕鋖鋗鋘鋙鋚鋛鋜鋝鋞鋟鋠鋡鋢鋣鋤鋥鋦鋧鋨�鋩鋪鋫鋬鋭鋮鋯鋰鋱鋲鋳鋴鋵鋶鋷鋸鋹鋺鋻鋼鋽鋾鋿錀錁錂錃錄錅錆錇錈錉洹洧洌浃浈洇洄洙洎洫浍洮洵洚浏浒浔洳涑浯涞涠浞涓涔浜浠浼浣渚淇淅淞渎涿淠渑淦淝淙渖涫渌涮渫湮湎湫溲湟溆湓湔渲渥湄滟溱溘滠漭滢溥溧溽溻溷滗溴滏溏滂溟潢潆潇漤漕滹漯漶潋潴漪漉漩澉澍澌潸潲潼潺濑�".split("");for(a=0;a!=t[228].length;++a)if(t[228][a].charCodeAt(0)!==65533){r[t[228][a]]=58368+a;e[58368+a]=t[228][a]}t[229]="����������������������������������������������������������������錊錋錌錍錎錏錐錑錒錓錔錕錖錗錘錙錚錛錜錝錞錟錠錡錢錣錤錥錦錧錨錩錪錫錬錭錮錯錰錱録錳錴錵錶錷錸錹錺錻錼錽錿鍀鍁鍂鍃鍄鍅鍆鍇鍈鍉�鍊鍋鍌鍍鍎鍏鍐鍑鍒鍓鍔鍕鍖鍗鍘鍙鍚鍛鍜鍝鍞鍟鍠鍡鍢鍣鍤鍥鍦鍧鍨鍩鍫濉澧澹澶濂濡濮濞濠濯瀚瀣瀛瀹瀵灏灞宀宄宕宓宥宸甯骞搴寤寮褰寰蹇謇辶迓迕迥迮迤迩迦迳迨逅逄逋逦逑逍逖逡逵逶逭逯遄遑遒遐遨遘遢遛暹遴遽邂邈邃邋彐彗彖彘尻咫屐屙孱屣屦羼弪弩弭艴弼鬻屮妁妃妍妩妪妣�".split("");for(a=0;a!=t[229].length;++a)if(t[229][a].charCodeAt(0)!==65533){r[t[229][a]]=58624+a;e[58624+a]=t[229][a]}t[230]="����������������������������������������������������������������鍬鍭鍮鍯鍰鍱鍲鍳鍴鍵鍶鍷鍸鍹鍺鍻鍼鍽鍾鍿鎀鎁鎂鎃鎄鎅鎆鎇鎈鎉鎊鎋鎌鎍鎎鎐鎑鎒鎓鎔鎕鎖鎗鎘鎙鎚鎛鎜鎝鎞鎟鎠鎡鎢鎣鎤鎥鎦鎧鎨鎩鎪鎫�鎬鎭鎮鎯鎰鎱鎲鎳鎴鎵鎶鎷鎸鎹鎺鎻鎼鎽鎾鎿鏀鏁鏂鏃鏄鏅鏆鏇鏈鏉鏋鏌鏍妗姊妫妞妤姒妲妯姗妾娅娆姝娈姣姘姹娌娉娲娴娑娣娓婀婧婊婕娼婢婵胬媪媛婷婺媾嫫媲嫒嫔媸嫠嫣嫱嫖嫦嫘嫜嬉嬗嬖嬲嬷孀尕尜孚孥孳孑孓孢驵驷驸驺驿驽骀骁骅骈骊骐骒骓骖骘骛骜骝骟骠骢骣骥骧纟纡纣纥纨纩�".split("");for(a=0;a!=t[230].length;++a)if(t[230][a].charCodeAt(0)!==65533){r[t[230][a]]=58880+a;e[58880+a]=t[230][a]}t[231]="����������������������������������������������������������������鏎鏏鏐鏑鏒鏓鏔鏕鏗鏘鏙鏚鏛鏜鏝鏞鏟鏠鏡鏢鏣鏤鏥鏦鏧鏨鏩鏪鏫鏬鏭鏮鏯鏰鏱鏲鏳鏴鏵鏶鏷鏸鏹鏺鏻鏼鏽鏾鏿鐀鐁鐂鐃鐄鐅鐆鐇鐈鐉鐊鐋鐌鐍�鐎鐏鐐鐑鐒鐓鐔鐕鐖鐗鐘鐙鐚鐛鐜鐝鐞鐟鐠鐡鐢鐣鐤鐥鐦鐧鐨鐩鐪鐫鐬鐭鐮纭纰纾绀绁绂绉绋绌绐绔绗绛绠绡绨绫绮绯绱绲缍绶绺绻绾缁缂缃缇缈缋缌缏缑缒缗缙缜缛缟缡缢缣缤缥缦缧缪缫缬缭缯缰缱缲缳缵幺畿巛甾邕玎玑玮玢玟珏珂珑玷玳珀珉珈珥珙顼琊珩珧珞玺珲琏琪瑛琦琥琨琰琮琬�".split("");for(a=0;a!=t[231].length;++a)if(t[231][a].charCodeAt(0)!==65533){r[t[231][a]]=59136+a;e[59136+a]=t[231][a]}t[232]="����������������������������������������������������������������鐯鐰鐱鐲鐳鐴鐵鐶鐷鐸鐹鐺鐻鐼鐽鐿鑀鑁鑂鑃鑄鑅鑆鑇鑈鑉鑊鑋鑌鑍鑎鑏鑐鑑鑒鑓鑔鑕鑖鑗鑘鑙鑚鑛鑜鑝鑞鑟鑠鑡鑢鑣鑤鑥鑦鑧鑨鑩鑪鑬鑭鑮鑯�鑰鑱鑲鑳鑴鑵鑶鑷鑸鑹鑺鑻鑼鑽鑾鑿钀钁钂钃钄钑钖钘铇铏铓铔铚铦铻锜锠琛琚瑁瑜瑗瑕瑙瑷瑭瑾璜璎璀璁璇璋璞璨璩璐璧瓒璺韪韫韬杌杓杞杈杩枥枇杪杳枘枧杵枨枞枭枋杷杼柰栉柘栊柩枰栌柙枵柚枳柝栀柃枸柢栎柁柽栲栳桠桡桎桢桄桤梃栝桕桦桁桧桀栾桊桉栩梵梏桴桷梓桫棂楮棼椟椠棹�".split("");for(a=0;a!=t[232].length;++a)if(t[232][a].charCodeAt(0)!==65533){r[t[232][a]]=59392+a;e[59392+a]=t[232][a]}t[233]="����������������������������������������������������������������锧锳锽镃镈镋镕镚镠镮镴镵長镸镹镺镻镼镽镾門閁閂閃閄閅閆閇閈閉閊開閌閍閎閏閐閑閒間閔閕閖閗閘閙閚閛閜閝閞閟閠閡関閣閤閥閦閧閨閩閪�閫閬閭閮閯閰閱閲閳閴閵閶閷閸閹閺閻閼閽閾閿闀闁闂闃闄闅闆闇闈闉闊闋椤棰椋椁楗棣椐楱椹楠楂楝榄楫榀榘楸椴槌榇榈槎榉楦楣楹榛榧榻榫榭槔榱槁槊槟榕槠榍槿樯槭樗樘橥槲橄樾檠橐橛樵檎橹樽樨橘橼檑檐檩檗檫猷獒殁殂殇殄殒殓殍殚殛殡殪轫轭轱轲轳轵轶轸轷轹轺轼轾辁辂辄辇辋�".split("");for(a=0;a!=t[233].length;++a)if(t[233][a].charCodeAt(0)!==65533){r[t[233][a]]=59648+a;e[59648+a]=t[233][a]}t[234]="����������������������������������������������������������������闌闍闎闏闐闑闒闓闔闕闖闗闘闙闚闛關闝闞闟闠闡闢闣闤闥闦闧闬闿阇阓阘阛阞阠阣阤阥阦阧阨阩阫阬阭阯阰阷阸阹阺阾陁陃陊陎陏陑陒陓陖陗�陘陙陚陜陝陞陠陣陥陦陫陭陮陯陰陱陳陸陹険陻陼陽陾陿隀隁隂隃隄隇隉隊辍辎辏辘辚軎戋戗戛戟戢戡戥戤戬臧瓯瓴瓿甏甑甓攴旮旯旰昊昙杲昃昕昀炅曷昝昴昱昶昵耆晟晔晁晏晖晡晗晷暄暌暧暝暾曛曜曦曩贲贳贶贻贽赀赅赆赈赉赇赍赕赙觇觊觋觌觎觏觐觑牮犟牝牦牯牾牿犄犋犍犏犒挈挲掰�".split("");for(a=0;a!=t[234].length;++a)if(t[234][a].charCodeAt(0)!==65533){r[t[234][a]]=59904+a;e[59904+a]=t[234][a]}t[235]="����������������������������������������������������������������隌階隑隒隓隕隖隚際隝隞隟隠隡隢隣隤隥隦隨隩險隫隬隭隮隯隱隲隴隵隷隸隺隻隿雂雃雈雊雋雐雑雓雔雖雗雘雙雚雛雜雝雞雟雡離難雤雥雦雧雫�雬雭雮雰雱雲雴雵雸雺電雼雽雿霂霃霅霊霋霌霐霑霒霔霕霗霘霙霚霛霝霟霠搿擘耄毪毳毽毵毹氅氇氆氍氕氘氙氚氡氩氤氪氲攵敕敫牍牒牖爰虢刖肟肜肓肼朊肽肱肫肭肴肷胧胨胩胪胛胂胄胙胍胗朐胝胫胱胴胭脍脎胲胼朕脒豚脶脞脬脘脲腈腌腓腴腙腚腱腠腩腼腽腭腧塍媵膈膂膑滕膣膪臌朦臊膻�".split("");for(a=0;a!=t[235].length;++a)if(t[235][a].charCodeAt(0)!==65533){r[t[235][a]]=60160+a;e[60160+a]=t[235][a]}t[236]="����������������������������������������������������������������霡霢霣霤霥霦霧霨霩霫霬霮霯霱霳霴霵霶霷霺霻霼霽霿靀靁靂靃靄靅靆靇靈靉靊靋靌靍靎靏靐靑靔靕靗靘靚靜靝靟靣靤靦靧靨靪靫靬靭靮靯靰靱�靲靵靷靸靹靺靻靽靾靿鞀鞁鞂鞃鞄鞆鞇鞈鞉鞊鞌鞎鞏鞐鞓鞕鞖鞗鞙鞚鞛鞜鞝臁膦欤欷欹歃歆歙飑飒飓飕飙飚殳彀毂觳斐齑斓於旆旄旃旌旎旒旖炀炜炖炝炻烀炷炫炱烨烊焐焓焖焯焱煳煜煨煅煲煊煸煺熘熳熵熨熠燠燔燧燹爝爨灬焘煦熹戾戽扃扈扉礻祀祆祉祛祜祓祚祢祗祠祯祧祺禅禊禚禧禳忑忐�".split(""); -for(a=0;a!=t[236].length;++a)if(t[236][a].charCodeAt(0)!==65533){r[t[236][a]]=60416+a;e[60416+a]=t[236][a]}t[237]="����������������������������������������������������������������鞞鞟鞡鞢鞤鞥鞦鞧鞨鞩鞪鞬鞮鞰鞱鞳鞵鞶鞷鞸鞹鞺鞻鞼鞽鞾鞿韀韁韂韃韄韅韆韇韈韉韊韋韌韍韎韏韐韑韒韓韔韕韖韗韘韙韚韛韜韝韞韟韠韡韢韣�韤韥韨韮韯韰韱韲韴韷韸韹韺韻韼韽韾響頀頁頂頃頄項順頇須頉頊頋頌頍頎怼恝恚恧恁恙恣悫愆愍慝憩憝懋懑戆肀聿沓泶淼矶矸砀砉砗砘砑斫砭砜砝砹砺砻砟砼砥砬砣砩硎硭硖硗砦硐硇硌硪碛碓碚碇碜碡碣碲碹碥磔磙磉磬磲礅磴礓礤礞礴龛黹黻黼盱眄眍盹眇眈眚眢眙眭眦眵眸睐睑睇睃睚睨�".split("");for(a=0;a!=t[237].length;++a)if(t[237][a].charCodeAt(0)!==65533){r[t[237][a]]=60672+a;e[60672+a]=t[237][a]}t[238]="����������������������������������������������������������������頏預頑頒頓頔頕頖頗領頙頚頛頜頝頞頟頠頡頢頣頤頥頦頧頨頩頪頫頬頭頮頯頰頱頲頳頴頵頶頷頸頹頺頻頼頽頾頿顀顁顂顃顄顅顆顇顈顉顊顋題額�顎顏顐顑顒顓顔顕顖顗願顙顚顛顜顝類顟顠顡顢顣顤顥顦顧顨顩顪顫顬顭顮睢睥睿瞍睽瞀瞌瞑瞟瞠瞰瞵瞽町畀畎畋畈畛畲畹疃罘罡罟詈罨罴罱罹羁罾盍盥蠲钅钆钇钋钊钌钍钏钐钔钗钕钚钛钜钣钤钫钪钭钬钯钰钲钴钶钷钸钹钺钼钽钿铄铈铉铊铋铌铍铎铐铑铒铕铖铗铙铘铛铞铟铠铢铤铥铧铨铪�".split("");for(a=0;a!=t[238].length;++a)if(t[238][a].charCodeAt(0)!==65533){r[t[238][a]]=60928+a;e[60928+a]=t[238][a]}t[239]="����������������������������������������������������������������顯顰顱顲顳顴颋颎颒颕颙颣風颩颪颫颬颭颮颯颰颱颲颳颴颵颶颷颸颹颺颻颼颽颾颿飀飁飂飃飄飅飆飇飈飉飊飋飌飍飏飐飔飖飗飛飜飝飠飡飢飣飤�飥飦飩飪飫飬飭飮飯飰飱飲飳飴飵飶飷飸飹飺飻飼飽飾飿餀餁餂餃餄餅餆餇铩铫铮铯铳铴铵铷铹铼铽铿锃锂锆锇锉锊锍锎锏锒锓锔锕锖锘锛锝锞锟锢锪锫锩锬锱锲锴锶锷锸锼锾锿镂锵镄镅镆镉镌镎镏镒镓镔镖镗镘镙镛镞镟镝镡镢镤镥镦镧镨镩镪镫镬镯镱镲镳锺矧矬雉秕秭秣秫稆嵇稃稂稞稔�".split("");for(a=0;a!=t[239].length;++a)if(t[239][a].charCodeAt(0)!==65533){r[t[239][a]]=61184+a;e[61184+a]=t[239][a]}t[240]="����������������������������������������������������������������餈餉養餋餌餎餏餑餒餓餔餕餖餗餘餙餚餛餜餝餞餟餠餡餢餣餤餥餦餧館餩餪餫餬餭餯餰餱餲餳餴餵餶餷餸餹餺餻餼餽餾餿饀饁饂饃饄饅饆饇饈饉�饊饋饌饍饎饏饐饑饒饓饖饗饘饙饚饛饜饝饞饟饠饡饢饤饦饳饸饹饻饾馂馃馉稹稷穑黏馥穰皈皎皓皙皤瓞瓠甬鸠鸢鸨鸩鸪鸫鸬鸲鸱鸶鸸鸷鸹鸺鸾鹁鹂鹄鹆鹇鹈鹉鹋鹌鹎鹑鹕鹗鹚鹛鹜鹞鹣鹦鹧鹨鹩鹪鹫鹬鹱鹭鹳疒疔疖疠疝疬疣疳疴疸痄疱疰痃痂痖痍痣痨痦痤痫痧瘃痱痼痿瘐瘀瘅瘌瘗瘊瘥瘘瘕瘙�".split("");for(a=0;a!=t[240].length;++a)if(t[240][a].charCodeAt(0)!==65533){r[t[240][a]]=61440+a;e[61440+a]=t[240][a]}t[241]="����������������������������������������������������������������馌馎馚馛馜馝馞馟馠馡馢馣馤馦馧馩馪馫馬馭馮馯馰馱馲馳馴馵馶馷馸馹馺馻馼馽馾馿駀駁駂駃駄駅駆駇駈駉駊駋駌駍駎駏駐駑駒駓駔駕駖駗駘�駙駚駛駜駝駞駟駠駡駢駣駤駥駦駧駨駩駪駫駬駭駮駯駰駱駲駳駴駵駶駷駸駹瘛瘼瘢瘠癀瘭瘰瘿瘵癃瘾瘳癍癞癔癜癖癫癯翊竦穸穹窀窆窈窕窦窠窬窨窭窳衤衩衲衽衿袂袢裆袷袼裉裢裎裣裥裱褚裼裨裾裰褡褙褓褛褊褴褫褶襁襦襻疋胥皲皴矜耒耔耖耜耠耢耥耦耧耩耨耱耋耵聃聆聍聒聩聱覃顸颀颃�".split("");for(a=0;a!=t[241].length;++a)if(t[241][a].charCodeAt(0)!==65533){r[t[241][a]]=61696+a;e[61696+a]=t[241][a]}t[242]="����������������������������������������������������������������駺駻駼駽駾駿騀騁騂騃騄騅騆騇騈騉騊騋騌騍騎騏騐騑騒験騔騕騖騗騘騙騚騛騜騝騞騟騠騡騢騣騤騥騦騧騨騩騪騫騬騭騮騯騰騱騲騳騴騵騶騷騸�騹騺騻騼騽騾騿驀驁驂驃驄驅驆驇驈驉驊驋驌驍驎驏驐驑驒驓驔驕驖驗驘驙颉颌颍颏颔颚颛颞颟颡颢颥颦虍虔虬虮虿虺虼虻蚨蚍蚋蚬蚝蚧蚣蚪蚓蚩蚶蛄蚵蛎蚰蚺蚱蚯蛉蛏蚴蛩蛱蛲蛭蛳蛐蜓蛞蛴蛟蛘蛑蜃蜇蛸蜈蜊蜍蜉蜣蜻蜞蜥蜮蜚蜾蝈蜴蜱蜩蜷蜿螂蜢蝽蝾蝻蝠蝰蝌蝮螋蝓蝣蝼蝤蝙蝥螓螯螨蟒�".split("");for(a=0;a!=t[242].length;++a)if(t[242][a].charCodeAt(0)!==65533){r[t[242][a]]=61952+a;e[61952+a]=t[242][a]}t[243]="����������������������������������������������������������������驚驛驜驝驞驟驠驡驢驣驤驥驦驧驨驩驪驫驲骃骉骍骎骔骕骙骦骩骪骫骬骭骮骯骲骳骴骵骹骻骽骾骿髃髄髆髇髈髉髊髍髎髏髐髒體髕髖髗髙髚髛髜�髝髞髠髢髣髤髥髧髨髩髪髬髮髰髱髲髳髴髵髶髷髸髺髼髽髾髿鬀鬁鬂鬄鬅鬆蟆螈螅螭螗螃螫蟥螬螵螳蟋蟓螽蟑蟀蟊蟛蟪蟠蟮蠖蠓蟾蠊蠛蠡蠹蠼缶罂罄罅舐竺竽笈笃笄笕笊笫笏筇笸笪笙笮笱笠笥笤笳笾笞筘筚筅筵筌筝筠筮筻筢筲筱箐箦箧箸箬箝箨箅箪箜箢箫箴篑篁篌篝篚篥篦篪簌篾篼簏簖簋�".split("");for(a=0;a!=t[243].length;++a)if(t[243][a].charCodeAt(0)!==65533){r[t[243][a]]=62208+a;e[62208+a]=t[243][a]}t[244]="����������������������������������������������������������������鬇鬉鬊鬋鬌鬍鬎鬐鬑鬒鬔鬕鬖鬗鬘鬙鬚鬛鬜鬝鬞鬠鬡鬢鬤鬥鬦鬧鬨鬩鬪鬫鬬鬭鬮鬰鬱鬳鬴鬵鬶鬷鬸鬹鬺鬽鬾鬿魀魆魊魋魌魎魐魒魓魕魖魗魘魙魚�魛魜魝魞魟魠魡魢魣魤魥魦魧魨魩魪魫魬魭魮魯魰魱魲魳魴魵魶魷魸魹魺魻簟簪簦簸籁籀臾舁舂舄臬衄舡舢舣舭舯舨舫舸舻舳舴舾艄艉艋艏艚艟艨衾袅袈裘裟襞羝羟羧羯羰羲籼敉粑粝粜粞粢粲粼粽糁糇糌糍糈糅糗糨艮暨羿翎翕翥翡翦翩翮翳糸絷綦綮繇纛麸麴赳趄趔趑趱赧赭豇豉酊酐酎酏酤�".split("");for(a=0;a!=t[244].length;++a)if(t[244][a].charCodeAt(0)!==65533){r[t[244][a]]=62464+a;e[62464+a]=t[244][a]}t[245]="����������������������������������������������������������������魼魽魾魿鮀鮁鮂鮃鮄鮅鮆鮇鮈鮉鮊鮋鮌鮍鮎鮏鮐鮑鮒鮓鮔鮕鮖鮗鮘鮙鮚鮛鮜鮝鮞鮟鮠鮡鮢鮣鮤鮥鮦鮧鮨鮩鮪鮫鮬鮭鮮鮯鮰鮱鮲鮳鮴鮵鮶鮷鮸鮹鮺�鮻鮼鮽鮾鮿鯀鯁鯂鯃鯄鯅鯆鯇鯈鯉鯊鯋鯌鯍鯎鯏鯐鯑鯒鯓鯔鯕鯖鯗鯘鯙鯚鯛酢酡酰酩酯酽酾酲酴酹醌醅醐醍醑醢醣醪醭醮醯醵醴醺豕鹾趸跫踅蹙蹩趵趿趼趺跄跖跗跚跞跎跏跛跆跬跷跸跣跹跻跤踉跽踔踝踟踬踮踣踯踺蹀踹踵踽踱蹉蹁蹂蹑蹒蹊蹰蹶蹼蹯蹴躅躏躔躐躜躞豸貂貊貅貘貔斛觖觞觚觜�".split("");for(a=0;a!=t[245].length;++a)if(t[245][a].charCodeAt(0)!==65533){r[t[245][a]]=62720+a;e[62720+a]=t[245][a]}t[246]="����������������������������������������������������������������鯜鯝鯞鯟鯠鯡鯢鯣鯤鯥鯦鯧鯨鯩鯪鯫鯬鯭鯮鯯鯰鯱鯲鯳鯴鯵鯶鯷鯸鯹鯺鯻鯼鯽鯾鯿鰀鰁鰂鰃鰄鰅鰆鰇鰈鰉鰊鰋鰌鰍鰎鰏鰐鰑鰒鰓鰔鰕鰖鰗鰘鰙鰚�鰛鰜鰝鰞鰟鰠鰡鰢鰣鰤鰥鰦鰧鰨鰩鰪鰫鰬鰭鰮鰯鰰鰱鰲鰳鰴鰵鰶鰷鰸鰹鰺鰻觥觫觯訾謦靓雩雳雯霆霁霈霏霎霪霭霰霾龀龃龅龆龇龈龉龊龌黾鼋鼍隹隼隽雎雒瞿雠銎銮鋈錾鍪鏊鎏鐾鑫鱿鲂鲅鲆鲇鲈稣鲋鲎鲐鲑鲒鲔鲕鲚鲛鲞鲟鲠鲡鲢鲣鲥鲦鲧鲨鲩鲫鲭鲮鲰鲱鲲鲳鲴鲵鲶鲷鲺鲻鲼鲽鳄鳅鳆鳇鳊鳋�".split("");for(a=0;a!=t[246].length;++a)if(t[246][a].charCodeAt(0)!==65533){r[t[246][a]]=62976+a;e[62976+a]=t[246][a]}t[247]="����������������������������������������������������������������鰼鰽鰾鰿鱀鱁鱂鱃鱄鱅鱆鱇鱈鱉鱊鱋鱌鱍鱎鱏鱐鱑鱒鱓鱔鱕鱖鱗鱘鱙鱚鱛鱜鱝鱞鱟鱠鱡鱢鱣鱤鱥鱦鱧鱨鱩鱪鱫鱬鱭鱮鱯鱰鱱鱲鱳鱴鱵鱶鱷鱸鱹鱺�鱻鱽鱾鲀鲃鲄鲉鲊鲌鲏鲓鲖鲗鲘鲙鲝鲪鲬鲯鲹鲾鲿鳀鳁鳂鳈鳉鳑鳒鳚鳛鳠鳡鳌鳍鳎鳏鳐鳓鳔鳕鳗鳘鳙鳜鳝鳟鳢靼鞅鞑鞒鞔鞯鞫鞣鞲鞴骱骰骷鹘骶骺骼髁髀髅髂髋髌髑魅魃魇魉魈魍魑飨餍餮饕饔髟髡髦髯髫髻髭髹鬈鬏鬓鬟鬣麽麾縻麂麇麈麋麒鏖麝麟黛黜黝黠黟黢黩黧黥黪黯鼢鼬鼯鼹鼷鼽鼾齄�".split("");for(a=0;a!=t[247].length;++a)if(t[247][a].charCodeAt(0)!==65533){r[t[247][a]]=63232+a;e[63232+a]=t[247][a]}t[248]="����������������������������������������������������������������鳣鳤鳥鳦鳧鳨鳩鳪鳫鳬鳭鳮鳯鳰鳱鳲鳳鳴鳵鳶鳷鳸鳹鳺鳻鳼鳽鳾鳿鴀鴁鴂鴃鴄鴅鴆鴇鴈鴉鴊鴋鴌鴍鴎鴏鴐鴑鴒鴓鴔鴕鴖鴗鴘鴙鴚鴛鴜鴝鴞鴟鴠鴡�鴢鴣鴤鴥鴦鴧鴨鴩鴪鴫鴬鴭鴮鴯鴰鴱鴲鴳鴴鴵鴶鴷鴸鴹鴺鴻鴼鴽鴾鴿鵀鵁鵂�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[248].length;++a)if(t[248][a].charCodeAt(0)!==65533){r[t[248][a]]=63488+a;e[63488+a]=t[248][a]}t[249]="����������������������������������������������������������������鵃鵄鵅鵆鵇鵈鵉鵊鵋鵌鵍鵎鵏鵐鵑鵒鵓鵔鵕鵖鵗鵘鵙鵚鵛鵜鵝鵞鵟鵠鵡鵢鵣鵤鵥鵦鵧鵨鵩鵪鵫鵬鵭鵮鵯鵰鵱鵲鵳鵴鵵鵶鵷鵸鵹鵺鵻鵼鵽鵾鵿鶀鶁�鶂鶃鶄鶅鶆鶇鶈鶉鶊鶋鶌鶍鶎鶏鶐鶑鶒鶓鶔鶕鶖鶗鶘鶙鶚鶛鶜鶝鶞鶟鶠鶡鶢�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[249].length;++a)if(t[249][a].charCodeAt(0)!==65533){r[t[249][a]]=63744+a;e[63744+a]=t[249][a]}t[250]="����������������������������������������������������������������鶣鶤鶥鶦鶧鶨鶩鶪鶫鶬鶭鶮鶯鶰鶱鶲鶳鶴鶵鶶鶷鶸鶹鶺鶻鶼鶽鶾鶿鷀鷁鷂鷃鷄鷅鷆鷇鷈鷉鷊鷋鷌鷍鷎鷏鷐鷑鷒鷓鷔鷕鷖鷗鷘鷙鷚鷛鷜鷝鷞鷟鷠鷡�鷢鷣鷤鷥鷦鷧鷨鷩鷪鷫鷬鷭鷮鷯鷰鷱鷲鷳鷴鷵鷶鷷鷸鷹鷺鷻鷼鷽鷾鷿鸀鸁鸂�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[250].length;++a)if(t[250][a].charCodeAt(0)!==65533){r[t[250][a]]=64e3+a;e[64e3+a]=t[250][a]}t[251]="����������������������������������������������������������������鸃鸄鸅鸆鸇鸈鸉鸊鸋鸌鸍鸎鸏鸐鸑鸒鸓鸔鸕鸖鸗鸘鸙鸚鸛鸜鸝鸞鸤鸧鸮鸰鸴鸻鸼鹀鹍鹐鹒鹓鹔鹖鹙鹝鹟鹠鹡鹢鹥鹮鹯鹲鹴鹵鹶鹷鹸鹹鹺鹻鹼鹽麀�麁麃麄麅麆麉麊麌麍麎麏麐麑麔麕麖麗麘麙麚麛麜麞麠麡麢麣麤麥麧麨麩麪�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[251].length;++a)if(t[251][a].charCodeAt(0)!==65533){r[t[251][a]]=64256+a;e[64256+a]=t[251][a]}t[252]="����������������������������������������������������������������麫麬麭麮麯麰麱麲麳麵麶麷麹麺麼麿黀黁黂黃黅黆黇黈黊黋黌黐黒黓黕黖黗黙黚點黡黣黤黦黨黫黬黭黮黰黱黲黳黴黵黶黷黸黺黽黿鼀鼁鼂鼃鼄鼅�鼆鼇鼈鼉鼊鼌鼏鼑鼒鼔鼕鼖鼘鼚鼛鼜鼝鼞鼟鼡鼣鼤鼥鼦鼧鼨鼩鼪鼫鼭鼮鼰鼱�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[252].length;++a)if(t[252][a].charCodeAt(0)!==65533){r[t[252][a]]=64512+a;e[64512+a]=t[252][a]}t[253]="����������������������������������������������������������������鼲鼳鼴鼵鼶鼸鼺鼼鼿齀齁齂齃齅齆齇齈齉齊齋齌齍齎齏齒齓齔齕齖齗齘齙齚齛齜齝齞齟齠齡齢齣齤齥齦齧齨齩齪齫齬齭齮齯齰齱齲齳齴齵齶齷齸�齹齺齻齼齽齾龁龂龍龎龏龐龑龒龓龔龕龖龗龘龜龝龞龡龢龣龤龥郎凉秊裏隣�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[253].length;++a)if(t[253][a].charCodeAt(0)!==65533){r[t[253][a]]=64768+a;e[64768+a]=t[253][a]}t[254]="����������������������������������������������������������������兀嗀﨎﨏﨑﨓﨔礼﨟蘒﨡﨣﨤﨧﨨﨩��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[254].length;++a)if(t[254][a].charCodeAt(0)!==65533){r[t[254][a]]=65024+a;e[65024+a]=t[254][a]}return{enc:r,dec:e}}();cptable[949]=function(){var e=[],r={},t=[],a;t[0]="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~��������������������������������������������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[0].length;++a)if(t[0][a].charCodeAt(0)!==65533){r[t[0][a]]=0+a;e[0+a]=t[0][a]}t[129]="�����������������������������������������������������������������갂갃갅갆갋갌갍갎갏갘갞갟갡갢갣갥갦갧갨갩갪갫갮갲갳갴������갵갶갷갺갻갽갾갿걁걂걃걄걅걆걇걈걉걊걌걎걏걐걑걒걓걕������걖걗걙걚걛걝걞걟걠걡걢걣걤걥걦걧걨걩걪걫걬걭걮걯걲걳걵걶걹걻걼걽걾걿겂겇겈겍겎겏겑겒겓겕겖겗겘겙겚겛겞겢겣겤겥겦겧겫겭겮겱겲겳겴겵겶겷겺겾겿곀곂곃곅곆곇곉곊곋곍곎곏곐곑곒곓곔곖곘곙곚곛곜곝곞곟곢곣곥곦곩곫곭곮곲곴곷곸곹곺곻곾곿괁괂괃괅괇괈괉괊괋괎괐괒괓�".split("");for(a=0;a!=t[129].length;++a)if(t[129][a].charCodeAt(0)!==65533){r[t[129][a]]=33024+a;e[33024+a]=t[129][a]}t[130]="�����������������������������������������������������������������괔괕괖괗괙괚괛괝괞괟괡괢괣괤괥괦괧괨괪괫괮괯괰괱괲괳������괶괷괹괺괻괽괾괿굀굁굂굃굆굈굊굋굌굍굎굏굑굒굓굕굖굗������굙굚굛굜굝굞굟굠굢굤굥굦굧굨굩굪굫굮굯굱굲굷굸굹굺굾궀궃궄궅궆궇궊궋궍궎궏궑궒궓궔궕궖궗궘궙궚궛궞궟궠궡궢궣궥궦궧궨궩궪궫궬궭궮궯궰궱궲궳궴궵궶궸궹궺궻궼궽궾궿귂귃귅귆귇귉귊귋귌귍귎귏귒귔귕귖귗귘귙귚귛귝귞귟귡귢귣귥귦귧귨귩귪귫귬귭귮귯귰귱귲귳귴귵귶귷�".split("");for(a=0;a!=t[130].length;++a)if(t[130][a].charCodeAt(0)!==65533){r[t[130][a]]=33280+a;e[33280+a]=t[130][a]}t[131]="�����������������������������������������������������������������귺귻귽귾긂긃긄긅긆긇긊긌긎긏긐긑긒긓긕긖긗긘긙긚긛긜������긝긞긟긠긡긢긣긤긥긦긧긨긩긪긫긬긭긮긯긲긳긵긶긹긻긼������긽긾긿깂깄깇깈깉깋깏깑깒깓깕깗깘깙깚깛깞깢깣깤깦깧깪깫깭깮깯깱깲깳깴깵깶깷깺깾깿꺀꺁꺂꺃꺆꺇꺈꺉꺊꺋꺍꺎꺏꺐꺑꺒꺓꺔꺕꺖꺗꺘꺙꺚꺛꺜꺝꺞꺟꺠꺡꺢꺣꺤꺥꺦꺧꺨꺩꺪꺫꺬꺭꺮꺯꺰꺱꺲꺳꺴꺵꺶꺷꺸꺹꺺꺻꺿껁껂껃껅껆껇껈껉껊껋껎껒껓껔껕껖껗껚껛껝껞껟껠껡껢껣껤껥�".split("");for(a=0;a!=t[131].length;++a)if(t[131][a].charCodeAt(0)!==65533){r[t[131][a]]=33536+a;e[33536+a]=t[131][a]}t[132]="�����������������������������������������������������������������껦껧껩껪껬껮껯껰껱껲껳껵껶껷껹껺껻껽껾껿꼀꼁꼂꼃꼄꼅������꼆꼉꼊꼋꼌꼎꼏꼑꼒꼓꼔꼕꼖꼗꼘꼙꼚꼛꼜꼝꼞꼟꼠꼡꼢꼣������꼤꼥꼦꼧꼨꼩꼪꼫꼮꼯꼱꼳꼵꼶꼷꼸꼹꼺꼻꼾꽀꽄꽅꽆꽇꽊꽋꽌꽍꽎꽏꽑꽒꽓꽔꽕꽖꽗꽘꽙꽚꽛꽞꽟꽠꽡꽢꽣꽦꽧꽨꽩꽪꽫꽬꽭꽮꽯꽰꽱꽲꽳꽴꽵꽶꽷꽸꽺꽻꽼꽽꽾꽿꾁꾂꾃꾅꾆꾇꾉꾊꾋꾌꾍꾎꾏꾒꾓꾔꾖꾗꾘꾙꾚꾛꾝꾞꾟꾠꾡꾢꾣꾤꾥꾦꾧꾨꾩꾪꾫꾬꾭꾮꾯꾰꾱꾲꾳꾴꾵꾶꾷꾺꾻꾽꾾�".split("");for(a=0;a!=t[132].length;++a)if(t[132][a].charCodeAt(0)!==65533){r[t[132][a]]=33792+a;e[33792+a]=t[132][a]}t[133]="�����������������������������������������������������������������꾿꿁꿂꿃꿄꿅꿆꿊꿌꿏꿐꿑꿒꿓꿕꿖꿗꿘꿙꿚꿛꿝꿞꿟꿠꿡������꿢꿣꿤꿥꿦꿧꿪꿫꿬꿭꿮꿯꿲꿳꿵꿶꿷꿹꿺꿻꿼꿽꿾꿿뀂뀃������뀅뀆뀇뀈뀉뀊뀋뀍뀎뀏뀑뀒뀓뀕뀖뀗뀘뀙뀚뀛뀞뀟뀠뀡뀢뀣뀤뀥뀦뀧뀩뀪뀫뀬뀭뀮뀯뀰뀱뀲뀳뀴뀵뀶뀷뀸뀹뀺뀻뀼뀽뀾뀿끀끁끂끃끆끇끉끋끍끏끐끑끒끖끘끚끛끜끞끟끠끡끢끣끤끥끦끧끨끩끪끫끬끭끮끯끰끱끲끳끴끵끶끷끸끹끺끻끾끿낁낂낃낅낆낇낈낉낊낋낎낐낒낓낔낕낖낗낛낝낞낣낤�".split("");for(a=0;a!=t[133].length;++a)if(t[133][a].charCodeAt(0)!==65533){r[t[133][a]]=34048+a;e[34048+a]=t[133][a]}t[134]="�����������������������������������������������������������������낥낦낧낪낰낲낶낷낹낺낻낽낾낿냀냁냂냃냆냊냋냌냍냎냏냒������냓냕냖냗냙냚냛냜냝냞냟냡냢냣냤냦냧냨냩냪냫냬냭냮냯냰������냱냲냳냴냵냶냷냸냹냺냻냼냽냾냿넀넁넂넃넄넅넆넇넊넍넎넏넑넔넕넖넗넚넞넟넠넡넢넦넧넩넪넫넭넮넯넰넱넲넳넶넺넻넼넽넾넿녂녃녅녆녇녉녊녋녌녍녎녏녒녓녖녗녙녚녛녝녞녟녡녢녣녤녥녦녧녨녩녪녫녬녭녮녯녰녱녲녳녴녵녶녷녺녻녽녾녿놁놃놄놅놆놇놊놌놎놏놐놑놕놖놗놙놚놛놝�".split("");for(a=0;a!=t[134].length;++a)if(t[134][a].charCodeAt(0)!==65533){r[t[134][a]]=34304+a;e[34304+a]=t[134][a]}t[135]="�����������������������������������������������������������������놞놟놠놡놢놣놤놥놦놧놩놪놫놬놭놮놯놰놱놲놳놴놵놶놷놸������놹놺놻놼놽놾놿뇀뇁뇂뇃뇄뇅뇆뇇뇈뇉뇊뇋뇍뇎뇏뇑뇒뇓뇕������뇖뇗뇘뇙뇚뇛뇞뇠뇡뇢뇣뇤뇥뇦뇧뇪뇫뇭뇮뇯뇱뇲뇳뇴뇵뇶뇷뇸뇺뇼뇾뇿눀눁눂눃눆눇눉눊눍눎눏눐눑눒눓눖눘눚눛눜눝눞눟눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눵눶눷눸눹눺눻눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉙뉚뉛뉝뉞뉟뉡뉢뉣뉤뉥뉦뉧뉪뉫뉬뉭뉮�".split("");for(a=0;a!=t[135].length;++a)if(t[135][a].charCodeAt(0)!==65533){r[t[135][a]]=34560+a;e[34560+a]=t[135][a]}t[136]="�����������������������������������������������������������������뉯뉰뉱뉲뉳뉶뉷뉸뉹뉺뉻뉽뉾뉿늀늁늂늃늆늇늈늊늋늌늍늎������늏늒늓늕늖늗늛늜늝늞늟늢늤늧늨늩늫늭늮늯늱늲늳늵늶늷������늸늹늺늻늼늽늾늿닀닁닂닃닄닅닆닇닊닋닍닎닏닑닓닔닕닖닗닚닜닞닟닠닡닣닧닩닪닰닱닲닶닼닽닾댂댃댅댆댇댉댊댋댌댍댎댏댒댖댗댘댙댚댛댝댞댟댠댡댢댣댤댥댦댧댨댩댪댫댬댭댮댯댰댱댲댳댴댵댶댷댸댹댺댻댼댽댾댿덀덁덂덃덄덅덆덇덈덉덊덋덌덍덎덏덐덑덒덓덗덙덚덝덠덡덢덣�".split("");for(a=0;a!=t[136].length;++a)if(t[136][a].charCodeAt(0)!==65533){r[t[136][a]]=34816+a;e[34816+a]=t[136][a]}t[137]="�����������������������������������������������������������������덦덨덪덬덭덯덲덳덵덶덷덹덺덻덼덽덾덿뎂뎆뎇뎈뎉뎊뎋뎍������뎎뎏뎑뎒뎓뎕뎖뎗뎘뎙뎚뎛뎜뎝뎞뎟뎢뎣뎤뎥뎦뎧뎩뎪뎫뎭������뎮뎯뎰뎱뎲뎳뎴뎵뎶뎷뎸뎹뎺뎻뎼뎽뎾뎿돀돁돂돃돆돇돉돊돍돏돑돒돓돖돘돚돜돞돟돡돢돣돥돦돧돩돪돫돬돭돮돯돰돱돲돳돴돵돶돷돸돹돺돻돽돾돿됀됁됂됃됄됅됆됇됈됉됊됋됌됍됎됏됑됒됓됔됕됖됗됙됚됛됝됞됟됡됢됣됤됥됦됧됪됬됭됮됯됰됱됲됳됵됶됷됸됹됺됻됼됽됾됿둀둁둂둃둄�".split("");for(a=0;a!=t[137].length;++a)if(t[137][a].charCodeAt(0)!==65533){r[t[137][a]]=35072+a;e[35072+a]=t[137][a]}t[138]="�����������������������������������������������������������������둅둆둇둈둉둊둋둌둍둎둏둒둓둕둖둗둙둚둛둜둝둞둟둢둤둦������둧둨둩둪둫둭둮둯둰둱둲둳둴둵둶둷둸둹둺둻둼둽둾둿뒁뒂������뒃뒄뒅뒆뒇뒉뒊뒋뒌뒍뒎뒏뒐뒑뒒뒓뒔뒕뒖뒗뒘뒙뒚뒛뒜뒞뒟뒠뒡뒢뒣뒥뒦뒧뒩뒪뒫뒭뒮뒯뒰뒱뒲뒳뒴뒶뒸뒺뒻뒼뒽뒾뒿듁듂듃듅듆듇듉듊듋듌듍듎듏듑듒듓듔듖듗듘듙듚듛듞듟듡듢듥듧듨듩듪듫듮듰듲듳듴듵듶듷듹듺듻듼듽듾듿딀딁딂딃딄딅딆딇딈딉딊딋딌딍딎딏딐딑딒딓딖딗딙딚딝�".split("");for(a=0;a!=t[138].length;++a)if(t[138][a].charCodeAt(0)!==65533){r[t[138][a]]=35328+a;e[35328+a]=t[138][a]}t[139]="�����������������������������������������������������������������딞딟딠딡딢딣딦딫딬딭딮딯딲딳딵딶딷딹딺딻딼딽딾딿땂땆������땇땈땉땊땎땏땑땒땓땕땖땗땘땙땚땛땞땢땣땤땥땦땧땨땩땪������땫땬땭땮땯땰땱땲땳땴땵땶땷땸땹땺땻땼땽땾땿떀떁떂떃떄떅떆떇떈떉떊떋떌떍떎떏떐떑떒떓떔떕떖떗떘떙떚떛떜떝떞떟떢떣떥떦떧떩떬떭떮떯떲떶떷떸떹떺떾떿뗁뗂뗃뗅뗆뗇뗈뗉뗊뗋뗎뗒뗓뗔뗕뗖뗗뗙뗚뗛뗜뗝뗞뗟뗠뗡뗢뗣뗤뗥뗦뗧뗨뗩뗪뗫뗭뗮뗯뗰뗱뗲뗳뗴뗵뗶뗷뗸뗹뗺뗻뗼뗽뗾뗿�".split("");for(a=0;a!=t[139].length;++a)if(t[139][a].charCodeAt(0)!==65533){r[t[139][a]]=35584+a;e[35584+a]=t[139][a]}t[140]="�����������������������������������������������������������������똀똁똂똃똄똅똆똇똈똉똊똋똌똍똎똏똒똓똕똖똗똙똚똛똜똝������똞똟똠똡똢똣똤똦똧똨똩똪똫똭똮똯똰똱똲똳똵똶똷똸똹똺������똻똼똽똾똿뙀뙁뙂뙃뙄뙅뙆뙇뙉뙊뙋뙌뙍뙎뙏뙐뙑뙒뙓뙔뙕뙖뙗뙘뙙뙚뙛뙜뙝뙞뙟뙠뙡뙢뙣뙥뙦뙧뙩뙪뙫뙬뙭뙮뙯뙰뙱뙲뙳뙴뙵뙶뙷뙸뙹뙺뙻뙼뙽뙾뙿뚀뚁뚂뚃뚄뚅뚆뚇뚈뚉뚊뚋뚌뚍뚎뚏뚐뚑뚒뚓뚔뚕뚖뚗뚘뚙뚚뚛뚞뚟뚡뚢뚣뚥뚦뚧뚨뚩뚪뚭뚮뚯뚰뚲뚳뚴뚵뚶뚷뚸뚹뚺뚻뚼뚽뚾뚿뛀뛁뛂�".split("");for(a=0;a!=t[140].length;++a)if(t[140][a].charCodeAt(0)!==65533){r[t[140][a]]=35840+a;e[35840+a]=t[140][a]}t[141]="�����������������������������������������������������������������뛃뛄뛅뛆뛇뛈뛉뛊뛋뛌뛍뛎뛏뛐뛑뛒뛓뛕뛖뛗뛘뛙뛚뛛뛜뛝������뛞뛟뛠뛡뛢뛣뛤뛥뛦뛧뛨뛩뛪뛫뛬뛭뛮뛯뛱뛲뛳뛵뛶뛷뛹뛺������뛻뛼뛽뛾뛿뜂뜃뜄뜆뜇뜈뜉뜊뜋뜌뜍뜎뜏뜐뜑뜒뜓뜔뜕뜖뜗뜘뜙뜚뜛뜜뜝뜞뜟뜠뜡뜢뜣뜤뜥뜦뜧뜪뜫뜭뜮뜱뜲뜳뜴뜵뜶뜷뜺뜼뜽뜾뜿띀띁띂띃띅띆띇띉띊띋띍띎띏띐띑띒띓띖띗띘띙띚띛띜띝띞띟띡띢띣띥띦띧띩띪띫띬띭띮띯띲띴띶띷띸띹띺띻띾띿랁랂랃랅랆랇랈랉랊랋랎랓랔랕랚랛랝랞�".split("");for(a=0;a!=t[141].length;++a)if(t[141][a].charCodeAt(0)!==65533){r[t[141][a]]=36096+a;e[36096+a]=t[141][a]}t[142]="�����������������������������������������������������������������랟랡랢랣랤랥랦랧랪랮랯랰랱랲랳랶랷랹랺랻랼랽랾랿럀럁������럂럃럄럅럆럈럊럋럌럍럎럏럐럑럒럓럔럕럖럗럘럙럚럛럜럝������럞럟럠럡럢럣럤럥럦럧럨럩럪럫럮럯럱럲럳럵럶럷럸럹럺럻럾렂렃렄렅렆렊렋렍렎렏렑렒렓렔렕렖렗렚렜렞렟렠렡렢렣렦렧렩렪렫렭렮렯렰렱렲렳렶렺렻렼렽렾렿롁롂롃롅롆롇롈롉롊롋롌롍롎롏롐롒롔롕롖롗롘롙롚롛롞롟롡롢롣롥롦롧롨롩롪롫롮롰롲롳롴롵롶롷롹롺롻롽롾롿뢀뢁뢂뢃뢄�".split("");for(a=0;a!=t[142].length;++a)if(t[142][a].charCodeAt(0)!==65533){r[t[142][a]]=36352+a;e[36352+a]=t[142][a]}t[143]="�����������������������������������������������������������������뢅뢆뢇뢈뢉뢊뢋뢌뢎뢏뢐뢑뢒뢓뢔뢕뢖뢗뢘뢙뢚뢛뢜뢝뢞뢟������뢠뢡뢢뢣뢤뢥뢦뢧뢩뢪뢫뢬뢭뢮뢯뢱뢲뢳뢵뢶뢷뢹뢺뢻뢼뢽������뢾뢿룂룄룆룇룈룉룊룋룍룎룏룑룒룓룕룖룗룘룙룚룛룜룞룠룢룣룤룥룦룧룪룫룭룮룯룱룲룳룴룵룶룷룺룼룾룿뤀뤁뤂뤃뤅뤆뤇뤈뤉뤊뤋뤌뤍뤎뤏뤐뤑뤒뤓뤔뤕뤖뤗뤙뤚뤛뤜뤝뤞뤟뤡뤢뤣뤤뤥뤦뤧뤨뤩뤪뤫뤬뤭뤮뤯뤰뤱뤲뤳뤴뤵뤶뤷뤸뤹뤺뤻뤾뤿륁륂륃륅륆륇륈륉륊륋륍륎륐륒륓륔륕륖륗�".split("");for(a=0;a!=t[143].length;++a)if(t[143][a].charCodeAt(0)!==65533){r[t[143][a]]=36608+a;e[36608+a]=t[143][a]}t[144]="�����������������������������������������������������������������륚륛륝륞륟륡륢륣륤륥륦륧륪륬륮륯륰륱륲륳륶륷륹륺륻륽������륾륿릀릁릂릃릆릈릋릌릏릐릑릒릓릔릕릖릗릘릙릚릛릜릝릞������릟릠릡릢릣릤릥릦릧릨릩릪릫릮릯릱릲릳릵릶릷릸릹릺릻릾맀맂맃맄맅맆맇맊맋맍맓맔맕맖맗맚맜맟맠맢맦맧맩맪맫맭맮맯맰맱맲맳맶맻맼맽맾맿먂먃먄먅먆먇먉먊먋먌먍먎먏먐먑먒먓먔먖먗먘먙먚먛먜먝먞먟먠먡먢먣먤먥먦먧먨먩먪먫먬먭먮먯먰먱먲먳먴먵먶먷먺먻먽먾먿멁멃멄멅멆�".split("");for(a=0;a!=t[144].length;++a)if(t[144][a].charCodeAt(0)!==65533){r[t[144][a]]=36864+a;e[36864+a]=t[144][a]}t[145]="�����������������������������������������������������������������멇멊멌멏멐멑멒멖멗멙멚멛멝멞멟멠멡멢멣멦멪멫멬멭멮멯������멲멳멵멶멷멹멺멻멼멽멾멿몀몁몂몆몈몉몊몋몍몎몏몐몑몒������몓몔몕몖몗몘몙몚몛몜몝몞몟몠몡몢몣몤몥몦몧몪몭몮몯몱몳몴몵몶몷몺몼몾몿뫀뫁뫂뫃뫅뫆뫇뫉뫊뫋뫌뫍뫎뫏뫐뫑뫒뫓뫔뫕뫖뫗뫚뫛뫜뫝뫞뫟뫠뫡뫢뫣뫤뫥뫦뫧뫨뫩뫪뫫뫬뫭뫮뫯뫰뫱뫲뫳뫴뫵뫶뫷뫸뫹뫺뫻뫽뫾뫿묁묂묃묅묆묇묈묉묊묋묌묎묐묒묓묔묕묖묗묙묚묛묝묞묟묡묢묣묤묥묦묧�".split("");for(a=0;a!=t[145].length;++a)if(t[145][a].charCodeAt(0)!==65533){r[t[145][a]]=37120+a;e[37120+a]=t[145][a]}t[146]="�����������������������������������������������������������������묨묪묬묭묮묯묰묱묲묳묷묹묺묿뭀뭁뭂뭃뭆뭈뭊뭋뭌뭎뭑뭒������뭓뭕뭖뭗뭙뭚뭛뭜뭝뭞뭟뭠뭢뭤뭥뭦뭧뭨뭩뭪뭫뭭뭮뭯뭰뭱������뭲뭳뭴뭵뭶뭷뭸뭹뭺뭻뭼뭽뭾뭿뮀뮁뮂뮃뮄뮅뮆뮇뮉뮊뮋뮍뮎뮏뮑뮒뮓뮔뮕뮖뮗뮘뮙뮚뮛뮜뮝뮞뮟뮠뮡뮢뮣뮥뮦뮧뮩뮪뮫뮭뮮뮯뮰뮱뮲뮳뮵뮶뮸뮹뮺뮻뮼뮽뮾뮿믁믂믃믅믆믇믉믊믋믌믍믎믏믑믒믔믕믖믗믘믙믚믛믜믝믞믟믠믡믢믣믤믥믦믧믨믩믪믫믬믭믮믯믰믱믲믳믴믵믶믷믺믻믽믾밁�".split("");for(a=0;a!=t[146].length;++a)if(t[146][a].charCodeAt(0)!==65533){r[t[146][a]]=37376+a;e[37376+a]=t[146][a]}t[147]="�����������������������������������������������������������������밃밄밅밆밇밊밎밐밒밓밙밚밠밡밢밣밦밨밪밫밬밮밯밲밳밵������밶밷밹밺밻밼밽밾밿뱂뱆뱇뱈뱊뱋뱎뱏뱑뱒뱓뱔뱕뱖뱗뱘뱙������뱚뱛뱜뱞뱟뱠뱡뱢뱣뱤뱥뱦뱧뱨뱩뱪뱫뱬뱭뱮뱯뱰뱱뱲뱳뱴뱵뱶뱷뱸뱹뱺뱻뱼뱽뱾뱿벀벁벂벃벆벇벉벊벍벏벐벑벒벓벖벘벛벜벝벞벟벢벣벥벦벩벪벫벬벭벮벯벲벶벷벸벹벺벻벾벿볁볂볃볅볆볇볈볉볊볋볌볎볒볓볔볖볗볙볚볛볝볞볟볠볡볢볣볤볥볦볧볨볩볪볫볬볭볮볯볰볱볲볳볷볹볺볻볽�".split("");for(a=0;a!=t[147].length;++a)if(t[147][a].charCodeAt(0)!==65533){r[t[147][a]]=37632+a;e[37632+a]=t[147][a]}t[148]="�����������������������������������������������������������������볾볿봀봁봂봃봆봈봊봋봌봍봎봏봑봒봓봕봖봗봘봙봚봛봜봝������봞봟봠봡봢봣봥봦봧봨봩봪봫봭봮봯봰봱봲봳봴봵봶봷봸봹������봺봻봼봽봾봿뵁뵂뵃뵄뵅뵆뵇뵊뵋뵍뵎뵏뵑뵒뵓뵔뵕뵖뵗뵚뵛뵜뵝뵞뵟뵠뵡뵢뵣뵥뵦뵧뵩뵪뵫뵬뵭뵮뵯뵰뵱뵲뵳뵴뵵뵶뵷뵸뵹뵺뵻뵼뵽뵾뵿붂붃붅붆붋붌붍붎붏붒붔붖붗붘붛붝붞붟붠붡붢붣붥붦붧붨붩붪붫붬붭붮붯붱붲붳붴붵붶붷붹붺붻붼붽붾붿뷀뷁뷂뷃뷄뷅뷆뷇뷈뷉뷊뷋뷌뷍뷎뷏뷐뷑�".split("");for(a=0;a!=t[148].length;++a)if(t[148][a].charCodeAt(0)!==65533){r[t[148][a]]=37888+a;e[37888+a]=t[148][a]}t[149]="�����������������������������������������������������������������뷒뷓뷖뷗뷙뷚뷛뷝뷞뷟뷠뷡뷢뷣뷤뷥뷦뷧뷨뷪뷫뷬뷭뷮뷯뷱������뷲뷳뷵뷶뷷뷹뷺뷻뷼뷽뷾뷿븁븂븄븆븇븈븉븊븋븎븏븑븒븓������븕븖븗븘븙븚븛븞븠븡븢븣븤븥븦븧븨븩븪븫븬븭븮븯븰븱븲븳븴븵븶븷븸븹븺븻븼븽븾븿빀빁빂빃빆빇빉빊빋빍빏빐빑빒빓빖빘빜빝빞빟빢빣빥빦빧빩빫빬빭빮빯빲빶빷빸빹빺빾빿뺁뺂뺃뺅뺆뺇뺈뺉뺊뺋뺎뺒뺓뺔뺕뺖뺗뺚뺛뺜뺝뺞뺟뺠뺡뺢뺣뺤뺥뺦뺧뺩뺪뺫뺬뺭뺮뺯뺰뺱뺲뺳뺴뺵뺶뺷�".split("");for(a=0;a!=t[149].length;++a)if(t[149][a].charCodeAt(0)!==65533){r[t[149][a]]=38144+a;e[38144+a]=t[149][a]}t[150]="�����������������������������������������������������������������뺸뺹뺺뺻뺼뺽뺾뺿뻀뻁뻂뻃뻄뻅뻆뻇뻈뻉뻊뻋뻌뻍뻎뻏뻒뻓������뻕뻖뻙뻚뻛뻜뻝뻞뻟뻡뻢뻦뻧뻨뻩뻪뻫뻭뻮뻯뻰뻱뻲뻳뻴뻵������뻶뻷뻸뻹뻺뻻뻼뻽뻾뻿뼀뼂뼃뼄뼅뼆뼇뼊뼋뼌뼍뼎뼏뼐뼑뼒뼓뼔뼕뼖뼗뼚뼞뼟뼠뼡뼢뼣뼤뼥뼦뼧뼨뼩뼪뼫뼬뼭뼮뼯뼰뼱뼲뼳뼴뼵뼶뼷뼸뼹뼺뼻뼼뼽뼾뼿뽂뽃뽅뽆뽇뽉뽊뽋뽌뽍뽎뽏뽒뽓뽔뽖뽗뽘뽙뽚뽛뽜뽝뽞뽟뽠뽡뽢뽣뽤뽥뽦뽧뽨뽩뽪뽫뽬뽭뽮뽯뽰뽱뽲뽳뽴뽵뽶뽷뽸뽹뽺뽻뽼뽽뽾뽿뾀뾁뾂�".split("");for(a=0;a!=t[150].length;++a)if(t[150][a].charCodeAt(0)!==65533){r[t[150][a]]=38400+a;e[38400+a]=t[150][a]}t[151]="�����������������������������������������������������������������뾃뾄뾅뾆뾇뾈뾉뾊뾋뾌뾍뾎뾏뾐뾑뾒뾓뾕뾖뾗뾘뾙뾚뾛뾜뾝������뾞뾟뾠뾡뾢뾣뾤뾥뾦뾧뾨뾩뾪뾫뾬뾭뾮뾯뾱뾲뾳뾴뾵뾶뾷뾸������뾹뾺뾻뾼뾽뾾뾿뿀뿁뿂뿃뿄뿆뿇뿈뿉뿊뿋뿎뿏뿑뿒뿓뿕뿖뿗뿘뿙뿚뿛뿝뿞뿠뿢뿣뿤뿥뿦뿧뿨뿩뿪뿫뿬뿭뿮뿯뿰뿱뿲뿳뿴뿵뿶뿷뿸뿹뿺뿻뿼뿽뿾뿿쀀쀁쀂쀃쀄쀅쀆쀇쀈쀉쀊쀋쀌쀍쀎쀏쀐쀑쀒쀓쀔쀕쀖쀗쀘쀙쀚쀛쀜쀝쀞쀟쀠쀡쀢쀣쀤쀥쀦쀧쀨쀩쀪쀫쀬쀭쀮쀯쀰쀱쀲쀳쀴쀵쀶쀷쀸쀹쀺쀻쀽쀾쀿�".split("");for(a=0;a!=t[151].length;++a)if(t[151][a].charCodeAt(0)!==65533){r[t[151][a]]=38656+a;e[38656+a]=t[151][a]}t[152]="�����������������������������������������������������������������쁀쁁쁂쁃쁄쁅쁆쁇쁈쁉쁊쁋쁌쁍쁎쁏쁐쁒쁓쁔쁕쁖쁗쁙쁚쁛������쁝쁞쁟쁡쁢쁣쁤쁥쁦쁧쁪쁫쁬쁭쁮쁯쁰쁱쁲쁳쁴쁵쁶쁷쁸쁹������쁺쁻쁼쁽쁾쁿삀삁삂삃삄삅삆삇삈삉삊삋삌삍삎삏삒삓삕삖삗삙삚삛삜삝삞삟삢삤삦삧삨삩삪삫삮삱삲삷삸삹삺삻삾샂샃샄샆샇샊샋샍샎샏샑샒샓샔샕샖샗샚샞샟샠샡샢샣샦샧샩샪샫샭샮샯샰샱샲샳샶샸샺샻샼샽샾샿섁섂섃섅섆섇섉섊섋섌섍섎섏섑섒섓섔섖섗섘섙섚섛섡섢섥섨섩섪섫섮�".split("");for(a=0;a!=t[152].length;++a)if(t[152][a].charCodeAt(0)!==65533){r[t[152][a]]=38912+a;e[38912+a]=t[152][a]}t[153]="�����������������������������������������������������������������섲섳섴섵섷섺섻섽섾섿셁셂셃셄셅셆셇셊셎셏셐셑셒셓셖셗������셙셚셛셝셞셟셠셡셢셣셦셪셫셬셭셮셯셱셲셳셵셶셷셹셺셻������셼셽셾셿솀솁솂솃솄솆솇솈솉솊솋솏솑솒솓솕솗솘솙솚솛솞솠솢솣솤솦솧솪솫솭솮솯솱솲솳솴솵솶솷솸솹솺솻솼솾솿쇀쇁쇂쇃쇅쇆쇇쇉쇊쇋쇍쇎쇏쇐쇑쇒쇓쇕쇖쇙쇚쇛쇜쇝쇞쇟쇡쇢쇣쇥쇦쇧쇩쇪쇫쇬쇭쇮쇯쇲쇴쇵쇶쇷쇸쇹쇺쇻쇾쇿숁숂숃숅숆숇숈숉숊숋숎숐숒숓숔숕숖숗숚숛숝숞숡숢숣�".split("");for(a=0;a!=t[153].length;++a)if(t[153][a].charCodeAt(0)!==65533){r[t[153][a]]=39168+a;e[39168+a]=t[153][a]}t[154]="�����������������������������������������������������������������숤숥숦숧숪숬숮숰숳숵숶숷숸숹숺숻숼숽숾숿쉀쉁쉂쉃쉄쉅������쉆쉇쉉쉊쉋쉌쉍쉎쉏쉒쉓쉕쉖쉗쉙쉚쉛쉜쉝쉞쉟쉡쉢쉣쉤쉦������쉧쉨쉩쉪쉫쉮쉯쉱쉲쉳쉵쉶쉷쉸쉹쉺쉻쉾슀슂슃슄슅슆슇슊슋슌슍슎슏슑슒슓슔슕슖슗슙슚슜슞슟슠슡슢슣슦슧슩슪슫슮슯슰슱슲슳슶슸슺슻슼슽슾슿싀싁싂싃싄싅싆싇싈싉싊싋싌싍싎싏싐싑싒싓싔싕싖싗싘싙싚싛싞싟싡싢싥싦싧싨싩싪싮싰싲싳싴싵싷싺싽싾싿쌁쌂쌃쌄쌅쌆쌇쌊쌋쌎쌏�".split("");for(a=0;a!=t[154].length;++a)if(t[154][a].charCodeAt(0)!==65533){r[t[154][a]]=39424+a;e[39424+a]=t[154][a]}t[155]="�����������������������������������������������������������������쌐쌑쌒쌖쌗쌙쌚쌛쌝쌞쌟쌠쌡쌢쌣쌦쌧쌪쌫쌬쌭쌮쌯쌰쌱쌲������쌳쌴쌵쌶쌷쌸쌹쌺쌻쌼쌽쌾쌿썀썁썂썃썄썆썇썈썉썊썋썌썍������썎썏썐썑썒썓썔썕썖썗썘썙썚썛썜썝썞썟썠썡썢썣썤썥썦썧썪썫썭썮썯썱썳썴썵썶썷썺썻썾썿쎀쎁쎂쎃쎅쎆쎇쎉쎊쎋쎍쎎쎏쎐쎑쎒쎓쎔쎕쎖쎗쎘쎙쎚쎛쎜쎝쎞쎟쎠쎡쎢쎣쎤쎥쎦쎧쎨쎩쎪쎫쎬쎭쎮쎯쎰쎱쎲쎳쎴쎵쎶쎷쎸쎹쎺쎻쎼쎽쎾쎿쏁쏂쏃쏄쏅쏆쏇쏈쏉쏊쏋쏌쏍쏎쏏쏐쏑쏒쏓쏔쏕쏖쏗쏚�".split("");for(a=0;a!=t[155].length;++a)if(t[155][a].charCodeAt(0)!==65533){r[t[155][a]]=39680+a;e[39680+a]=t[155][a]}t[156]="�����������������������������������������������������������������쏛쏝쏞쏡쏣쏤쏥쏦쏧쏪쏫쏬쏮쏯쏰쏱쏲쏳쏶쏷쏹쏺쏻쏼쏽쏾������쏿쐀쐁쐂쐃쐄쐅쐆쐇쐉쐊쐋쐌쐍쐎쐏쐑쐒쐓쐔쐕쐖쐗쐘쐙쐚������쐛쐜쐝쐞쐟쐠쐡쐢쐣쐥쐦쐧쐨쐩쐪쐫쐭쐮쐯쐱쐲쐳쐵쐶쐷쐸쐹쐺쐻쐾쐿쑀쑁쑂쑃쑄쑅쑆쑇쑉쑊쑋쑌쑍쑎쑏쑐쑑쑒쑓쑔쑕쑖쑗쑘쑙쑚쑛쑜쑝쑞쑟쑠쑡쑢쑣쑦쑧쑩쑪쑫쑭쑮쑯쑰쑱쑲쑳쑶쑷쑸쑺쑻쑼쑽쑾쑿쒁쒂쒃쒄쒅쒆쒇쒈쒉쒊쒋쒌쒍쒎쒏쒐쒑쒒쒓쒕쒖쒗쒘쒙쒚쒛쒝쒞쒟쒠쒡쒢쒣쒤쒥쒦쒧쒨쒩�".split("");for(a=0;a!=t[156].length;++a)if(t[156][a].charCodeAt(0)!==65533){r[t[156][a]]=39936+a;e[39936+a]=t[156][a]}t[157]="�����������������������������������������������������������������쒪쒫쒬쒭쒮쒯쒰쒱쒲쒳쒴쒵쒶쒷쒹쒺쒻쒽쒾쒿쓀쓁쓂쓃쓄쓅������쓆쓇쓈쓉쓊쓋쓌쓍쓎쓏쓐쓑쓒쓓쓔쓕쓖쓗쓘쓙쓚쓛쓜쓝쓞쓟������쓠쓡쓢쓣쓤쓥쓦쓧쓨쓪쓫쓬쓭쓮쓯쓲쓳쓵쓶쓷쓹쓻쓼쓽쓾씂씃씄씅씆씇씈씉씊씋씍씎씏씑씒씓씕씖씗씘씙씚씛씝씞씟씠씡씢씣씤씥씦씧씪씫씭씮씯씱씲씳씴씵씶씷씺씼씾씿앀앁앂앃앆앇앋앏앐앑앒앖앚앛앜앟앢앣앥앦앧앩앪앫앬앭앮앯앲앶앷앸앹앺앻앾앿얁얂얃얅얆얈얉얊얋얎얐얒얓얔�".split("");for(a=0;a!=t[157].length;++a)if(t[157][a].charCodeAt(0)!==65533){r[t[157][a]]=40192+a;e[40192+a]=t[157][a]}t[158]="�����������������������������������������������������������������얖얙얚얛얝얞얟얡얢얣얤얥얦얧얨얪얫얬얭얮얯얰얱얲얳얶������얷얺얿엀엁엂엃엋엍엏엒엓엕엖엗엙엚엛엜엝엞엟엢엤엦엧������엨엩엪엫엯엱엲엳엵엸엹엺엻옂옃옄옉옊옋옍옎옏옑옒옓옔옕옖옗옚옝옞옟옠옡옢옣옦옧옩옪옫옯옱옲옶옸옺옼옽옾옿왂왃왅왆왇왉왊왋왌왍왎왏왒왖왗왘왙왚왛왞왟왡왢왣왤왥왦왧왨왩왪왫왭왮왰왲왳왴왵왶왷왺왻왽왾왿욁욂욃욄욅욆욇욊욌욎욏욐욑욒욓욖욗욙욚욛욝욞욟욠욡욢욣욦�".split("");for(a=0;a!=t[158].length;++a)if(t[158][a].charCodeAt(0)!==65533){r[t[158][a]]=40448+a;e[40448+a]=t[158][a]}t[159]="�����������������������������������������������������������������욨욪욫욬욭욮욯욲욳욵욶욷욻욼욽욾욿웂웄웆웇웈웉웊웋웎������웏웑웒웓웕웖웗웘웙웚웛웞웟웢웣웤웥웦웧웪웫웭웮웯웱웲������웳웴웵웶웷웺웻웼웾웿윀윁윂윃윆윇윉윊윋윍윎윏윐윑윒윓윖윘윚윛윜윝윞윟윢윣윥윦윧윩윪윫윬윭윮윯윲윴윶윸윹윺윻윾윿읁읂읃읅읆읇읈읉읋읎읐읙읚읛읝읞읟읡읢읣읤읥읦읧읩읪읬읭읮읯읰읱읲읳읶읷읹읺읻읿잀잁잂잆잋잌잍잏잒잓잕잙잛잜잝잞잟잢잧잨잩잪잫잮잯잱잲잳잵잶잷�".split("");for(a=0;a!=t[159].length;++a)if(t[159][a].charCodeAt(0)!==65533){r[t[159][a]]=40704+a;e[40704+a]=t[159][a]}t[160]="�����������������������������������������������������������������잸잹잺잻잾쟂쟃쟄쟅쟆쟇쟊쟋쟍쟏쟑쟒쟓쟔쟕쟖쟗쟙쟚쟛쟜������쟞쟟쟠쟡쟢쟣쟥쟦쟧쟩쟪쟫쟭쟮쟯쟰쟱쟲쟳쟴쟵쟶쟷쟸쟹쟺������쟻쟼쟽쟾쟿젂젃젅젆젇젉젋젌젍젎젏젒젔젗젘젙젚젛젞젟젡젢젣젥젦젧젨젩젪젫젮젰젲젳젴젵젶젷젹젺젻젽젾젿졁졂졃졄졅졆졇졊졋졎졏졐졑졒졓졕졖졗졘졙졚졛졜졝졞졟졠졡졢졣졤졥졦졧졨졩졪졫졬졭졮졯졲졳졵졶졷졹졻졼졽졾졿좂좄좈좉좊좎좏좐좑좒좓좕좖좗좘좙좚좛좜좞좠좢좣좤�".split("");for(a=0;a!=t[160].length;++a)if(t[160][a].charCodeAt(0)!==65533){r[t[160][a]]=40960+a;e[40960+a]=t[160][a]}t[161]="�����������������������������������������������������������������좥좦좧좩좪좫좬좭좮좯좰좱좲좳좴좵좶좷좸좹좺좻좾좿죀죁������죂죃죅죆죇죉죊죋죍죎죏죐죑죒죓죖죘죚죛죜죝죞죟죢죣죥������죦죧죨죩죪죫죬죭죮죯죰죱죲죳죴죶죷죸죹죺죻죾죿줁줂줃줇줈줉줊줋줎 、。·‥…¨〃­―∥\∼‘’“”〔〕〈〉《》「」『』【】±×÷≠≤≥∞∴°′″℃Å¢£¥♂♀∠⊥⌒∂∇≡≒§※☆★○●◎◇◆□■△▲▽▼→←↑↓↔〓≪≫√∽∝∵∫∬∈∋⊆⊇⊂⊃∪∩∧∨¬�".split("");for(a=0;a!=t[161].length;++a)if(t[161][a].charCodeAt(0)!==65533){r[t[161][a]]=41216+a;e[41216+a]=t[161][a]}t[162]="�����������������������������������������������������������������줐줒줓줔줕줖줗줙줚줛줜줝줞줟줠줡줢줣줤줥줦줧줨줩줪줫������줭줮줯줰줱줲줳줵줶줷줸줹줺줻줼줽줾줿쥀쥁쥂쥃쥄쥅쥆쥇������쥈쥉쥊쥋쥌쥍쥎쥏쥒쥓쥕쥖쥗쥙쥚쥛쥜쥝쥞쥟쥢쥤쥥쥦쥧쥨쥩쥪쥫쥭쥮쥯⇒⇔∀∃´~ˇ˘˝˚˙¸˛¡¿ː∮∑∏¤℉‰◁◀▷▶♤♠♡♥♧♣⊙◈▣◐◑▒▤▥▨▧▦▩♨☏☎☜☞¶†‡↕↗↙↖↘♭♩♪♬㉿㈜№㏇™㏂㏘℡€®������������������������".split("");for(a=0;a!=t[162].length;++a)if(t[162][a].charCodeAt(0)!==65533){r[t[162][a]]=41472+a;e[41472+a]=t[162][a]}t[163]="�����������������������������������������������������������������쥱쥲쥳쥵쥶쥷쥸쥹쥺쥻쥽쥾쥿즀즁즂즃즄즅즆즇즊즋즍즎즏������즑즒즓즔즕즖즗즚즜즞즟즠즡즢즣즤즥즦즧즨즩즪즫즬즭즮������즯즰즱즲즳즴즵즶즷즸즹즺즻즼즽즾즿짂짃짅짆짉짋짌짍짎짏짒짔짗짘짛!"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[₩]^_`abcdefghijklmnopqrstuvwxyz{|} ̄�".split("");for(a=0;a!=t[163].length;++a)if(t[163][a].charCodeAt(0)!==65533){r[t[163][a]]=41728+a;e[41728+a]=t[163][a]}t[164]="�����������������������������������������������������������������짞짟짡짣짥짦짨짩짪짫짮짲짳짴짵짶짷짺짻짽짾짿쨁쨂쨃쨄������쨅쨆쨇쨊쨎쨏쨐쨑쨒쨓쨕쨖쨗쨙쨚쨛쨜쨝쨞쨟쨠쨡쨢쨣쨤쨥������쨦쨧쨨쨪쨫쨬쨭쨮쨯쨰쨱쨲쨳쨴쨵쨶쨷쨸쨹쨺쨻쨼쨽쨾쨿쩀쩁쩂쩃쩄쩅쩆ㄱㄲㄳㄴㄵㄶㄷㄸㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅃㅄㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣㅤㅥㅦㅧㅨㅩㅪㅫㅬㅭㅮㅯㅰㅱㅲㅳㅴㅵㅶㅷㅸㅹㅺㅻㅼㅽㅾㅿㆀㆁㆂㆃㆄㆅㆆㆇㆈㆉㆊㆋㆌㆍㆎ�".split("");for(a=0;a!=t[164].length;++a)if(t[164][a].charCodeAt(0)!==65533){r[t[164][a]]=41984+a;e[41984+a]=t[164][a]}t[165]="�����������������������������������������������������������������쩇쩈쩉쩊쩋쩎쩏쩑쩒쩓쩕쩖쩗쩘쩙쩚쩛쩞쩢쩣쩤쩥쩦쩧쩩쩪������쩫쩬쩭쩮쩯쩰쩱쩲쩳쩴쩵쩶쩷쩸쩹쩺쩻쩼쩾쩿쪀쪁쪂쪃쪅쪆������쪇쪈쪉쪊쪋쪌쪍쪎쪏쪐쪑쪒쪓쪔쪕쪖쪗쪙쪚쪛쪜쪝쪞쪟쪠쪡쪢쪣쪤쪥쪦쪧ⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹ�����ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ�������ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ��������αβγδεζηθικλμνξοπρστυφχψω�������".split("");for(a=0;a!=t[165].length;++a)if(t[165][a].charCodeAt(0)!==65533){r[t[165][a]]=42240+a;e[42240+a]=t[165][a]}t[166]="�����������������������������������������������������������������쪨쪩쪪쪫쪬쪭쪮쪯쪰쪱쪲쪳쪴쪵쪶쪷쪸쪹쪺쪻쪾쪿쫁쫂쫃쫅������쫆쫇쫈쫉쫊쫋쫎쫐쫒쫔쫕쫖쫗쫚쫛쫜쫝쫞쫟쫡쫢쫣쫤쫥쫦쫧������쫨쫩쫪쫫쫭쫮쫯쫰쫱쫲쫳쫵쫶쫷쫸쫹쫺쫻쫼쫽쫾쫿쬀쬁쬂쬃쬄쬅쬆쬇쬉쬊─│┌┐┘└├┬┤┴┼━┃┏┓┛┗┣┳┫┻╋┠┯┨┷┿┝┰┥┸╂┒┑┚┙┖┕┎┍┞┟┡┢┦┧┩┪┭┮┱┲┵┶┹┺┽┾╀╁╃╄╅╆╇╈╉╊���������������������������".split("");for(a=0;a!=t[166].length;++a)if(t[166][a].charCodeAt(0)!==65533){r[t[166][a]]=42496+a;e[42496+a]=t[166][a]}t[167]="�����������������������������������������������������������������쬋쬌쬍쬎쬏쬑쬒쬓쬕쬖쬗쬙쬚쬛쬜쬝쬞쬟쬢쬣쬤쬥쬦쬧쬨쬩������쬪쬫쬬쬭쬮쬯쬰쬱쬲쬳쬴쬵쬶쬷쬸쬹쬺쬻쬼쬽쬾쬿쭀쭂쭃쭄������쭅쭆쭇쭊쭋쭍쭎쭏쭑쭒쭓쭔쭕쭖쭗쭚쭛쭜쭞쭟쭠쭡쭢쭣쭥쭦쭧쭨쭩쭪쭫쭬㎕㎖㎗ℓ㎘㏄㎣㎤㎥㎦㎙㎚㎛㎜㎝㎞㎟㎠㎡㎢㏊㎍㎎㎏㏏㎈㎉㏈㎧㎨㎰㎱㎲㎳㎴㎵㎶㎷㎸㎹㎀㎁㎂㎃㎄㎺㎻㎼㎽㎾㎿㎐㎑㎒㎓㎔Ω㏀㏁㎊㎋㎌㏖㏅㎭㎮㎯㏛㎩㎪㎫㎬㏝㏐㏓㏃㏉㏜㏆����������������".split("");for(a=0;a!=t[167].length;++a)if(t[167][a].charCodeAt(0)!==65533){r[t[167][a]]=42752+a;e[42752+a]=t[167][a]}t[168]="�����������������������������������������������������������������쭭쭮쭯쭰쭱쭲쭳쭴쭵쭶쭷쭺쭻쭼쭽쭾쭿쮀쮁쮂쮃쮄쮅쮆쮇쮈������쮉쮊쮋쮌쮍쮎쮏쮐쮑쮒쮓쮔쮕쮖쮗쮘쮙쮚쮛쮝쮞쮟쮠쮡쮢쮣������쮤쮥쮦쮧쮨쮩쮪쮫쮬쮭쮮쮯쮰쮱쮲쮳쮴쮵쮶쮷쮹쮺쮻쮼쮽쮾쮿쯀쯁쯂쯃쯄ÆÐªĦ�IJ�ĿŁØŒºÞŦŊ�㉠㉡㉢㉣㉤㉥㉦㉧㉨㉩㉪㉫㉬㉭㉮㉯㉰㉱㉲㉳㉴㉵㉶㉷㉸㉹㉺㉻ⓐⓑⓒⓓⓔⓕⓖⓗⓘⓙⓚⓛⓜⓝⓞⓟⓠⓡⓢⓣⓤⓥⓦⓧⓨⓩ①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮½⅓⅔¼¾⅛⅜⅝⅞�".split("");for(a=0;a!=t[168].length;++a)if(t[168][a].charCodeAt(0)!==65533){r[t[168][a]]=43008+a;e[43008+a]=t[168][a]}t[169]="�����������������������������������������������������������������쯅쯆쯇쯈쯉쯊쯋쯌쯍쯎쯏쯐쯑쯒쯓쯕쯖쯗쯘쯙쯚쯛쯜쯝쯞쯟������쯠쯡쯢쯣쯥쯦쯨쯪쯫쯬쯭쯮쯯쯰쯱쯲쯳쯴쯵쯶쯷쯸쯹쯺쯻쯼������쯽쯾쯿찀찁찂찃찄찅찆찇찈찉찊찋찎찏찑찒찓찕찖찗찘찙찚찛찞찟찠찣찤æđðħıijĸŀłøœßþŧŋʼn㈀㈁㈂㈃㈄㈅㈆㈇㈈㈉㈊㈋㈌㈍㈎㈏㈐㈑㈒㈓㈔㈕㈖㈗㈘㈙㈚㈛⒜⒝⒞⒟⒠⒡⒢⒣⒤⒥⒦⒧⒨⒩⒪⒫⒬⒭⒮⒯⒰⒱⒲⒳⒴⒵⑴⑵⑶⑷⑸⑹⑺⑻⑼⑽⑾⑿⒀⒁⒂¹²³⁴ⁿ₁₂₃₄�".split("");for(a=0;a!=t[169].length;++a)if(t[169][a].charCodeAt(0)!==65533){r[t[169][a]]=43264+a;e[43264+a]=t[169][a]}t[170]="�����������������������������������������������������������������찥찦찪찫찭찯찱찲찳찴찵찶찷찺찿챀챁챂챃챆챇챉챊챋챍챎������챏챐챑챒챓챖챚챛챜챝챞챟챡챢챣챥챧챩챪챫챬챭챮챯챱챲������챳챴챶챷챸챹챺챻챼챽챾챿첀첁첂첃첄첅첆첇첈첉첊첋첌첍첎첏첐첑첒첓ぁあぃいぅうぇえぉおかがきぎくぐけげこごさざしじすずせぜそぞただちぢっつづてでとどなにぬねのはばぱひびぴふぶぷへべぺほぼぽまみむめもゃやゅゆょよらりるれろゎわゐゑをん������������".split("");for(a=0;a!=t[170].length;++a)if(t[170][a].charCodeAt(0)!==65533){r[t[170][a]]=43520+a;e[43520+a]=t[170][a]}t[171]="�����������������������������������������������������������������첔첕첖첗첚첛첝첞첟첡첢첣첤첥첦첧첪첮첯첰첱첲첳첶첷첹������첺첻첽첾첿쳀쳁쳂쳃쳆쳈쳊쳋쳌쳍쳎쳏쳑쳒쳓쳕쳖쳗쳘쳙쳚������쳛쳜쳝쳞쳟쳠쳡쳢쳣쳥쳦쳧쳨쳩쳪쳫쳭쳮쳯쳱쳲쳳쳴쳵쳶쳷쳸쳹쳺쳻쳼쳽ァアィイゥウェエォオカガキギクグケゲコゴサザシジスズセゼソゾタダチヂッツヅテデトドナニヌネノハバパヒビピフブプヘベペホボポマミムメモャヤュユョヨラリルレロヮワヰヱヲンヴヵヶ���������".split("");for(a=0;a!=t[171].length;++a)if(t[171][a].charCodeAt(0)!==65533){r[t[171][a]]=43776+a;e[43776+a]=t[171][a]}t[172]="�����������������������������������������������������������������쳾쳿촀촂촃촄촅촆촇촊촋촍촎촏촑촒촓촔촕촖촗촚촜촞촟촠������촡촢촣촥촦촧촩촪촫촭촮촯촰촱촲촳촴촵촶촷촸촺촻촼촽촾������촿쵀쵁쵂쵃쵄쵅쵆쵇쵈쵉쵊쵋쵌쵍쵎쵏쵐쵑쵒쵓쵔쵕쵖쵗쵘쵙쵚쵛쵝쵞쵟АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ���������������абвгдеёжзийклмнопрстуфхцчшщъыьэюя��������������".split("");for(a=0;a!=t[172].length;++a)if(t[172][a].charCodeAt(0)!==65533){r[t[172][a]]=44032+a;e[44032+a]=t[172][a]}t[173]="�����������������������������������������������������������������쵡쵢쵣쵥쵦쵧쵨쵩쵪쵫쵮쵰쵲쵳쵴쵵쵶쵷쵹쵺쵻쵼쵽쵾쵿춀������춁춂춃춄춅춆춇춉춊춋춌춍춎춏춐춑춒춓춖춗춙춚춛춝춞춟������춠춡춢춣춦춨춪춫춬춭춮춯춱춲춳춴춵춶춷춸춹춺춻춼춽춾춿췀췁췂췃췅�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[173].length;++a)if(t[173][a].charCodeAt(0)!==65533){r[t[173][a]]=44288+a;e[44288+a]=t[173][a]}t[174]="�����������������������������������������������������������������췆췇췈췉췊췋췍췎췏췑췒췓췔췕췖췗췘췙췚췛췜췝췞췟췠췡������췢췣췤췥췦췧췩췪췫췭췮췯췱췲췳췴췵췶췷췺췼췾췿츀츁츂������츃츅츆츇츉츊츋츍츎츏츐츑츒츓츕츖츗츘츚츛츜츝츞츟츢츣츥츦츧츩츪츫�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[174].length;++a)if(t[174][a].charCodeAt(0)!==65533){r[t[174][a]]=44544+a;e[44544+a]=t[174][a]}t[175]="�����������������������������������������������������������������츬츭츮츯츲츴츶츷츸츹츺츻츼츽츾츿칀칁칂칃칄칅칆칇칈칉������칊칋칌칍칎칏칐칑칒칓칔칕칖칗칚칛칝칞칢칣칤칥칦칧칪칬������칮칯칰칱칲칳칶칷칹칺칻칽칾칿캀캁캂캃캆캈캊캋캌캍캎캏캒캓캕캖캗캙�����������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[175].length;++a)if(t[175][a].charCodeAt(0)!==65533){r[t[175][a]]=44800+a;e[44800+a]=t[175][a]}t[176]="�����������������������������������������������������������������캚캛캜캝캞캟캢캦캧캨캩캪캫캮캯캰캱캲캳캴캵캶캷캸캹캺������캻캼캽캾캿컀컂컃컄컅컆컇컈컉컊컋컌컍컎컏컐컑컒컓컔컕������컖컗컘컙컚컛컜컝컞컟컠컡컢컣컦컧컩컪컭컮컯컰컱컲컳컶컺컻컼컽컾컿가각간갇갈갉갊감갑값갓갔강갖갗같갚갛개객갠갤갬갭갯갰갱갸갹갼걀걋걍걔걘걜거걱건걷걸걺검겁것겄겅겆겉겊겋게겐겔겜겝겟겠겡겨격겪견겯결겸겹겻겼경곁계곈곌곕곗고곡곤곧골곪곬곯곰곱곳공곶과곽관괄괆�".split("");for(a=0;a!=t[176].length;++a)if(t[176][a].charCodeAt(0)!==65533){r[t[176][a]]=45056+a;e[45056+a]=t[176][a]}t[177]="�����������������������������������������������������������������켂켃켅켆켇켉켊켋켌켍켎켏켒켔켖켗켘켙켚켛켝켞켟켡켢켣������켥켦켧켨켩켪켫켮켲켳켴켵켶켷켹켺켻켼켽켾켿콀콁콂콃콄������콅콆콇콈콉콊콋콌콍콎콏콐콑콒콓콖콗콙콚콛콝콞콟콠콡콢콣콦콨콪콫콬괌괍괏광괘괜괠괩괬괭괴괵괸괼굄굅굇굉교굔굘굡굣구국군굳굴굵굶굻굼굽굿궁궂궈궉권궐궜궝궤궷귀귁귄귈귐귑귓규균귤그극근귿글긁금급긋긍긔기긱긴긷길긺김깁깃깅깆깊까깍깎깐깔깖깜깝깟깠깡깥깨깩깬깰깸�".split("");for(a=0;a!=t[177].length;++a)if(t[177][a].charCodeAt(0)!==65533){r[t[177][a]]=45312+a;e[45312+a]=t[177][a]}t[178]="�����������������������������������������������������������������콭콮콯콲콳콵콶콷콹콺콻콼콽콾콿쾁쾂쾃쾄쾆쾇쾈쾉쾊쾋쾍������쾎쾏쾐쾑쾒쾓쾔쾕쾖쾗쾘쾙쾚쾛쾜쾝쾞쾟쾠쾢쾣쾤쾥쾦쾧쾩������쾪쾫쾬쾭쾮쾯쾱쾲쾳쾴쾵쾶쾷쾸쾹쾺쾻쾼쾽쾾쾿쿀쿁쿂쿃쿅쿆쿇쿈쿉쿊쿋깹깻깼깽꺄꺅꺌꺼꺽꺾껀껄껌껍껏껐껑께껙껜껨껫껭껴껸껼꼇꼈꼍꼐꼬꼭꼰꼲꼴꼼꼽꼿꽁꽂꽃꽈꽉꽐꽜꽝꽤꽥꽹꾀꾄꾈꾐꾑꾕꾜꾸꾹꾼꿀꿇꿈꿉꿋꿍꿎꿔꿜꿨꿩꿰꿱꿴꿸뀀뀁뀄뀌뀐뀔뀜뀝뀨끄끅끈끊끌끎끓끔끕끗끙�".split("");for(a=0;a!=t[178].length;++a)if(t[178][a].charCodeAt(0)!==65533){r[t[178][a]]=45568+a;e[45568+a]=t[178][a]}t[179]="�����������������������������������������������������������������쿌쿍쿎쿏쿐쿑쿒쿓쿔쿕쿖쿗쿘쿙쿚쿛쿜쿝쿞쿟쿢쿣쿥쿦쿧쿩������쿪쿫쿬쿭쿮쿯쿲쿴쿶쿷쿸쿹쿺쿻쿽쿾쿿퀁퀂퀃퀅퀆퀇퀈퀉퀊������퀋퀌퀍퀎퀏퀐퀒퀓퀔퀕퀖퀗퀙퀚퀛퀜퀝퀞퀟퀠퀡퀢퀣퀤퀥퀦퀧퀨퀩퀪퀫퀬끝끼끽낀낄낌낍낏낑나낙낚난낟날낡낢남납낫났낭낮낯낱낳내낵낸낼냄냅냇냈냉냐냑냔냘냠냥너넉넋넌널넒넓넘넙넛넜넝넣네넥넨넬넴넵넷넸넹녀녁년녈념녑녔녕녘녜녠노녹논놀놂놈놉놋농높놓놔놘놜놨뇌뇐뇔뇜뇝�".split("");for(a=0;a!=t[179].length;++a)if(t[179][a].charCodeAt(0)!==65533){r[t[179][a]]=45824+a;e[45824+a]=t[179][a]}t[180]="�����������������������������������������������������������������퀮퀯퀰퀱퀲퀳퀶퀷퀹퀺퀻퀽퀾퀿큀큁큂큃큆큈큊큋큌큍큎큏������큑큒큓큕큖큗큙큚큛큜큝큞큟큡큢큣큤큥큦큧큨큩큪큫큮큯������큱큲큳큵큶큷큸큹큺큻큾큿킀킂킃킄킅킆킇킈킉킊킋킌킍킎킏킐킑킒킓킔뇟뇨뇩뇬뇰뇹뇻뇽누눅눈눋눌눔눕눗눙눠눴눼뉘뉜뉠뉨뉩뉴뉵뉼늄늅늉느늑는늘늙늚늠늡늣능늦늪늬늰늴니닉닌닐닒님닙닛닝닢다닥닦단닫달닭닮닯닳담답닷닸당닺닻닿대댁댄댈댐댑댓댔댕댜더덕덖던덛덜덞덟덤덥�".split("");for(a=0;a!=t[180].length;++a)if(t[180][a].charCodeAt(0)!==65533){r[t[180][a]]=46080+a;e[46080+a]=t[180][a]}t[181]="�����������������������������������������������������������������킕킖킗킘킙킚킛킜킝킞킟킠킡킢킣킦킧킩킪킫킭킮킯킰킱킲������킳킶킸킺킻킼킽킾킿탂탃탅탆탇탊탋탌탍탎탏탒탖탗탘탙탚������탛탞탟탡탢탣탥탦탧탨탩탪탫탮탲탳탴탵탶탷탹탺탻탼탽탾탿턀턁턂턃턄덧덩덫덮데덱덴델뎀뎁뎃뎄뎅뎌뎐뎔뎠뎡뎨뎬도독돈돋돌돎돐돔돕돗동돛돝돠돤돨돼됐되된될됨됩됫됴두둑둔둘둠둡둣둥둬뒀뒈뒝뒤뒨뒬뒵뒷뒹듀듄듈듐듕드득든듣들듦듬듭듯등듸디딕딘딛딜딤딥딧딨딩딪따딱딴딸�".split("");for(a=0;a!=t[181].length;++a)if(t[181][a].charCodeAt(0)!==65533){r[t[181][a]]=46336+a;e[46336+a]=t[181][a]}t[182]="�����������������������������������������������������������������턅턆턇턈턉턊턋턌턎턏턐턑턒턓턔턕턖턗턘턙턚턛턜턝턞턟������턠턡턢턣턤턥턦턧턨턩턪턫턬턭턮턯턲턳턵턶턷턹턻턼턽턾������턿텂텆텇텈텉텊텋텎텏텑텒텓텕텖텗텘텙텚텛텞텠텢텣텤텥텦텧텩텪텫텭땀땁땃땄땅땋때땍땐땔땜땝땟땠땡떠떡떤떨떪떫떰떱떳떴떵떻떼떽뗀뗄뗌뗍뗏뗐뗑뗘뗬또똑똔똘똥똬똴뙈뙤뙨뚜뚝뚠뚤뚫뚬뚱뛔뛰뛴뛸뜀뜁뜅뜨뜩뜬뜯뜰뜸뜹뜻띄띈띌띔띕띠띤띨띰띱띳띵라락란랄람랍랏랐랑랒랖랗�".split("");for(a=0;a!=t[182].length;++a)if(t[182][a].charCodeAt(0)!==65533){r[t[182][a]]=46592+a;e[46592+a]=t[182][a]}t[183]="�����������������������������������������������������������������텮텯텰텱텲텳텴텵텶텷텸텹텺텻텽텾텿톀톁톂톃톅톆톇톉톊������톋톌톍톎톏톐톑톒톓톔톕톖톗톘톙톚톛톜톝톞톟톢톣톥톦톧������톩톪톫톬톭톮톯톲톴톶톷톸톹톻톽톾톿퇁퇂퇃퇄퇅퇆퇇퇈퇉퇊퇋퇌퇍퇎퇏래랙랜랠램랩랫랬랭랴략랸럇량러럭런럴럼럽럿렀렁렇레렉렌렐렘렙렛렝려력련렬렴렵렷렸령례롄롑롓로록론롤롬롭롯롱롸롼뢍뢨뢰뢴뢸룀룁룃룅료룐룔룝룟룡루룩룬룰룸룹룻룽뤄뤘뤠뤼뤽륀륄륌륏륑류륙륜률륨륩�".split("");for(a=0;a!=t[183].length;++a)if(t[183][a].charCodeAt(0)!==65533){r[t[183][a]]=46848+a;e[46848+a]=t[183][a]}t[184]="�����������������������������������������������������������������퇐퇑퇒퇓퇔퇕퇖퇗퇙퇚퇛퇜퇝퇞퇟퇠퇡퇢퇣퇤퇥퇦퇧퇨퇩퇪������퇫퇬퇭퇮퇯퇰퇱퇲퇳퇵퇶퇷퇹퇺퇻퇼퇽퇾퇿툀툁툂툃툄툅툆������툈툊툋툌툍툎툏툑툒툓툔툕툖툗툘툙툚툛툜툝툞툟툠툡툢툣툤툥툦툧툨툩륫륭르륵른를름릅릇릉릊릍릎리릭린릴림립릿링마막만많맏말맑맒맘맙맛망맞맡맣매맥맨맬맴맵맷맸맹맺먀먁먈먕머먹먼멀멂멈멉멋멍멎멓메멕멘멜멤멥멧멨멩며멱면멸몃몄명몇몌모목몫몬몰몲몸몹못몽뫄뫈뫘뫙뫼�".split("");for(a=0;a!=t[184].length;++a)if(t[184][a].charCodeAt(0)!==65533){r[t[184][a]]=47104+a;e[47104+a]=t[184][a]}t[185]="�����������������������������������������������������������������툪툫툮툯툱툲툳툵툶툷툸툹툺툻툾퉀퉂퉃퉄퉅퉆퉇퉉퉊퉋퉌������퉍퉎퉏퉐퉑퉒퉓퉔퉕퉖퉗퉘퉙퉚퉛퉝퉞퉟퉠퉡퉢퉣퉥퉦퉧퉨������퉩퉪퉫퉬퉭퉮퉯퉰퉱퉲퉳퉴퉵퉶퉷퉸퉹퉺퉻퉼퉽퉾퉿튂튃튅튆튇튉튊튋튌묀묄묍묏묑묘묜묠묩묫무묵묶문묻물묽묾뭄뭅뭇뭉뭍뭏뭐뭔뭘뭡뭣뭬뮈뮌뮐뮤뮨뮬뮴뮷므믄믈믐믓미믹민믿밀밂밈밉밋밌밍및밑바박밖밗반받발밝밞밟밤밥밧방밭배백밴밸뱀뱁뱃뱄뱅뱉뱌뱍뱐뱝버벅번벋벌벎범법벗�".split("");for(a=0;a!=t[185].length;++a)if(t[185][a].charCodeAt(0)!==65533){r[t[185][a]]=47360+a;e[47360+a]=t[185][a]}t[186]="�����������������������������������������������������������������튍튎튏튒튓튔튖튗튘튙튚튛튝튞튟튡튢튣튥튦튧튨튩튪튫튭������튮튯튰튲튳튴튵튶튷튺튻튽튾틁틃틄틅틆틇틊틌틍틎틏틐틑������틒틓틕틖틗틙틚틛틝틞틟틠틡틢틣틦틧틨틩틪틫틬틭틮틯틲틳틵틶틷틹틺벙벚베벡벤벧벨벰벱벳벴벵벼벽변별볍볏볐병볕볘볜보복볶본볼봄봅봇봉봐봔봤봬뵀뵈뵉뵌뵐뵘뵙뵤뵨부북분붇불붉붊붐붑붓붕붙붚붜붤붰붸뷔뷕뷘뷜뷩뷰뷴뷸븀븃븅브븍븐블븜븝븟비빅빈빌빎빔빕빗빙빚빛빠빡빤�".split("");for(a=0;a!=t[186].length;++a)if(t[186][a].charCodeAt(0)!==65533){r[t[186][a]]=47616+a;e[47616+a]=t[186][a]}t[187]="�����������������������������������������������������������������틻틼틽틾틿팂팄팆팇팈팉팊팋팏팑팒팓팕팗팘팙팚팛팞팢팣������팤팦팧팪팫팭팮팯팱팲팳팴팵팶팷팺팾팿퍀퍁퍂퍃퍆퍇퍈퍉������퍊퍋퍌퍍퍎퍏퍐퍑퍒퍓퍔퍕퍖퍗퍘퍙퍚퍛퍜퍝퍞퍟퍠퍡퍢퍣퍤퍥퍦퍧퍨퍩빨빪빰빱빳빴빵빻빼빽뺀뺄뺌뺍뺏뺐뺑뺘뺙뺨뻐뻑뻔뻗뻘뻠뻣뻤뻥뻬뼁뼈뼉뼘뼙뼛뼜뼝뽀뽁뽄뽈뽐뽑뽕뾔뾰뿅뿌뿍뿐뿔뿜뿟뿡쀼쁑쁘쁜쁠쁨쁩삐삑삔삘삠삡삣삥사삭삯산삳살삵삶삼삽삿샀상샅새색샌샐샘샙샛샜생샤�".split("");for(a=0;a!=t[187].length;++a)if(t[187][a].charCodeAt(0)!==65533){r[t[187][a]]=47872+a;e[47872+a]=t[187][a]}t[188]="�����������������������������������������������������������������퍪퍫퍬퍭퍮퍯퍰퍱퍲퍳퍴퍵퍶퍷퍸퍹퍺퍻퍾퍿펁펂펃펅펆펇������펈펉펊펋펎펒펓펔펕펖펗펚펛펝펞펟펡펢펣펤펥펦펧펪펬펮������펯펰펱펲펳펵펶펷펹펺펻펽펾펿폀폁폂폃폆폇폊폋폌폍폎폏폑폒폓폔폕폖샥샨샬샴샵샷샹섀섄섈섐섕서석섞섟선섣설섦섧섬섭섯섰성섶세섹센셀셈셉셋셌셍셔셕션셜셤셥셧셨셩셰셴셸솅소속솎손솔솖솜솝솟송솥솨솩솬솰솽쇄쇈쇌쇔쇗쇘쇠쇤쇨쇰쇱쇳쇼쇽숀숄숌숍숏숑수숙순숟술숨숩숫숭�".split("");for(a=0;a!=t[188].length;++a)if(t[188][a].charCodeAt(0)!==65533){r[t[188][a]]=48128+a;e[48128+a]=t[188][a]}t[189]="�����������������������������������������������������������������폗폙폚폛폜폝폞폟폠폢폤폥폦폧폨폩폪폫폮폯폱폲폳폵폶폷������폸폹폺폻폾퐀퐂퐃퐄퐅퐆퐇퐉퐊퐋퐌퐍퐎퐏퐐퐑퐒퐓퐔퐕퐖������퐗퐘퐙퐚퐛퐜퐞퐟퐠퐡퐢퐣퐤퐥퐦퐧퐨퐩퐪퐫퐬퐭퐮퐯퐰퐱퐲퐳퐴퐵퐶퐷숯숱숲숴쉈쉐쉑쉔쉘쉠쉥쉬쉭쉰쉴쉼쉽쉿슁슈슉슐슘슛슝스슥슨슬슭슴습슷승시식신싣실싫심십싯싱싶싸싹싻싼쌀쌈쌉쌌쌍쌓쌔쌕쌘쌜쌤쌥쌨쌩썅써썩썬썰썲썸썹썼썽쎄쎈쎌쏀쏘쏙쏜쏟쏠쏢쏨쏩쏭쏴쏵쏸쐈쐐쐤쐬쐰�".split("");for(a=0;a!=t[189].length;++a)if(t[189][a].charCodeAt(0)!==65533){r[t[189][a]]=48384+a;e[48384+a]=t[189][a]}t[190]="�����������������������������������������������������������������퐸퐹퐺퐻퐼퐽퐾퐿푁푂푃푅푆푇푈푉푊푋푌푍푎푏푐푑푒푓������푔푕푖푗푘푙푚푛푝푞푟푡푢푣푥푦푧푨푩푪푫푬푮푰푱푲������푳푴푵푶푷푺푻푽푾풁풃풄풅풆풇풊풌풎풏풐풑풒풓풕풖풗풘풙풚풛풜풝쐴쐼쐽쑈쑤쑥쑨쑬쑴쑵쑹쒀쒔쒜쒸쒼쓩쓰쓱쓴쓸쓺쓿씀씁씌씐씔씜씨씩씬씰씸씹씻씽아악안앉않알앍앎앓암압앗았앙앝앞애액앤앨앰앱앳앴앵야약얀얄얇얌얍얏양얕얗얘얜얠얩어억언얹얻얼얽얾엄업없엇었엉엊엌엎�".split("");for(a=0;a!=t[190].length;++a)if(t[190][a].charCodeAt(0)!==65533){r[t[190][a]]=48640+a;e[48640+a]=t[190][a]}t[191]="�����������������������������������������������������������������풞풟풠풡풢풣풤풥풦풧풨풪풫풬풭풮풯풰풱풲풳풴풵풶풷풸������풹풺풻풼풽풾풿퓀퓁퓂퓃퓄퓅퓆퓇퓈퓉퓊퓋퓍퓎퓏퓑퓒퓓퓕������퓖퓗퓘퓙퓚퓛퓝퓞퓠퓡퓢퓣퓤퓥퓦퓧퓩퓪퓫퓭퓮퓯퓱퓲퓳퓴퓵퓶퓷퓹퓺퓼에엑엔엘엠엡엣엥여역엮연열엶엷염엽엾엿였영옅옆옇예옌옐옘옙옛옜오옥온올옭옮옰옳옴옵옷옹옻와왁완왈왐왑왓왔왕왜왝왠왬왯왱외왹왼욀욈욉욋욍요욕욘욜욤욥욧용우욱운울욹욺움웁웃웅워웍원월웜웝웠웡웨�".split("");for(a=0;a!=t[191].length;++a)if(t[191][a].charCodeAt(0)!==65533){r[t[191][a]]=48896+a;e[48896+a]=t[191][a]}t[192]="�����������������������������������������������������������������퓾퓿픀픁픂픃픅픆픇픉픊픋픍픎픏픐픑픒픓픖픘픙픚픛픜픝������픞픟픠픡픢픣픤픥픦픧픨픩픪픫픬픭픮픯픰픱픲픳픴픵픶픷������픸픹픺픻픾픿핁핂핃핅핆핇핈핉핊핋핎핐핒핓핔핕핖핗핚핛핝핞핟핡핢핣웩웬웰웸웹웽위윅윈윌윔윕윗윙유육윤율윰윱윳융윷으윽은을읊음읍읏응읒읓읔읕읖읗의읜읠읨읫이익인일읽읾잃임입잇있잉잊잎자작잔잖잗잘잚잠잡잣잤장잦재잭잰잴잼잽잿쟀쟁쟈쟉쟌쟎쟐쟘쟝쟤쟨쟬저적전절젊�".split("");for(a=0;a!=t[192].length;++a)if(t[192][a].charCodeAt(0)!==65533){r[t[192][a]]=49152+a;e[49152+a]=t[192][a]}t[193]="�����������������������������������������������������������������핤핦핧핪핬핮핯핰핱핲핳핶핷핹핺핻핽핾핿햀햁햂햃햆햊햋������햌햍햎햏햑햒햓햔햕햖햗햘햙햚햛햜햝햞햟햠햡햢햣햤햦햧������햨햩햪햫햬햭햮햯햰햱햲햳햴햵햶햷햸햹햺햻햼햽햾햿헀헁헂헃헄헅헆헇점접젓정젖제젝젠젤젬젭젯젱져젼졀졈졉졌졍졔조족존졸졺좀좁좃종좆좇좋좌좍좔좝좟좡좨좼좽죄죈죌죔죕죗죙죠죡죤죵주죽준줄줅줆줌줍줏중줘줬줴쥐쥑쥔쥘쥠쥡쥣쥬쥰쥴쥼즈즉즌즐즘즙즛증지직진짇질짊짐집짓�".split(""); -for(a=0;a!=t[193].length;++a)if(t[193][a].charCodeAt(0)!==65533){r[t[193][a]]=49408+a;e[49408+a]=t[193][a]}t[194]="�����������������������������������������������������������������헊헋헍헎헏헑헓헔헕헖헗헚헜헞헟헠헡헢헣헦헧헩헪헫헭헮������헯헰헱헲헳헶헸헺헻헼헽헾헿혂혃혅혆혇혉혊혋혌혍혎혏혒������혖혗혘혙혚혛혝혞혟혡혢혣혥혦혧혨혩혪혫혬혮혯혰혱혲혳혴혵혶혷혺혻징짖짙짚짜짝짠짢짤짧짬짭짯짰짱째짹짼쨀쨈쨉쨋쨌쨍쨔쨘쨩쩌쩍쩐쩔쩜쩝쩟쩠쩡쩨쩽쪄쪘쪼쪽쫀쫄쫌쫍쫏쫑쫓쫘쫙쫠쫬쫴쬈쬐쬔쬘쬠쬡쭁쭈쭉쭌쭐쭘쭙쭝쭤쭸쭹쮜쮸쯔쯤쯧쯩찌찍찐찔찜찝찡찢찧차착찬찮찰참찹찻�".split("");for(a=0;a!=t[194].length;++a)if(t[194][a].charCodeAt(0)!==65533){r[t[194][a]]=49664+a;e[49664+a]=t[194][a]}t[195]="�����������������������������������������������������������������혽혾혿홁홂홃홄홆홇홊홌홎홏홐홒홓홖홗홙홚홛홝홞홟홠홡������홢홣홤홥홦홨홪홫홬홭홮홯홲홳홵홶홷홸홹홺홻홼홽홾홿횀������횁횂횄횆횇횈횉횊횋횎횏횑횒횓횕횖횗횘횙횚횛횜횞횠횢횣횤횥횦횧횩횪찼창찾채책챈챌챔챕챗챘챙챠챤챦챨챰챵처척천철첨첩첫첬청체첵첸첼쳄쳅쳇쳉쳐쳔쳤쳬쳰촁초촉촌촐촘촙촛총촤촨촬촹최쵠쵤쵬쵭쵯쵱쵸춈추축춘출춤춥춧충춰췄췌췐취췬췰췸췹췻췽츄츈츌츔츙츠측츤츨츰츱츳층�".split("");for(a=0;a!=t[195].length;++a)if(t[195][a].charCodeAt(0)!==65533){r[t[195][a]]=49920+a;e[49920+a]=t[195][a]}t[196]="�����������������������������������������������������������������횫횭횮횯횱횲횳횴횵횶횷횸횺횼횽횾횿훀훁훂훃훆훇훉훊훋������훍훎훏훐훒훓훕훖훘훚훛훜훝훞훟훡훢훣훥훦훧훩훪훫훬훭������훮훯훱훲훳훴훶훷훸훹훺훻훾훿휁휂휃휅휆휇휈휉휊휋휌휍휎휏휐휒휓휔치칙친칟칠칡침칩칫칭카칵칸칼캄캅캇캉캐캑캔캘캠캡캣캤캥캬캭컁커컥컨컫컬컴컵컷컸컹케켁켄켈켐켑켓켕켜켠켤켬켭켯켰켱켸코콕콘콜콤콥콧콩콰콱콴콸쾀쾅쾌쾡쾨쾰쿄쿠쿡쿤쿨쿰쿱쿳쿵쿼퀀퀄퀑퀘퀭퀴퀵퀸퀼�".split("");for(a=0;a!=t[196].length;++a)if(t[196][a].charCodeAt(0)!==65533){r[t[196][a]]=50176+a;e[50176+a]=t[196][a]}t[197]="�����������������������������������������������������������������휕휖휗휚휛휝휞휟휡휢휣휤휥휦휧휪휬휮휯휰휱휲휳휶휷휹������휺휻휽휾휿흀흁흂흃흅흆흈흊흋흌흍흎흏흒흓흕흚흛흜흝흞������흟흢흤흦흧흨흪흫흭흮흯흱흲흳흵흶흷흸흹흺흻흾흿힀힂힃힄힅힆힇힊힋큄큅큇큉큐큔큘큠크큭큰클큼큽킁키킥킨킬킴킵킷킹타탁탄탈탉탐탑탓탔탕태택탠탤탬탭탯탰탱탸턍터턱턴털턺텀텁텃텄텅테텍텐텔템텝텟텡텨텬텼톄톈토톡톤톨톰톱톳통톺톼퇀퇘퇴퇸툇툉툐투툭툰툴툼툽툿퉁퉈퉜�".split("");for(a=0;a!=t[197].length;++a)if(t[197][a].charCodeAt(0)!==65533){r[t[197][a]]=50432+a;e[50432+a]=t[197][a]}t[198]="�����������������������������������������������������������������힍힎힏힑힒힓힔힕힖힗힚힜힞힟힠힡힢힣������������������������������������������������������������������������������퉤튀튁튄튈튐튑튕튜튠튤튬튱트특튼튿틀틂틈틉틋틔틘틜틤틥티틱틴틸팀팁팃팅파팍팎판팔팖팜팝팟팠팡팥패팩팬팰팸팹팻팼팽퍄퍅퍼퍽펀펄펌펍펏펐펑페펙펜펠펨펩펫펭펴편펼폄폅폈평폐폘폡폣포폭폰폴폼폽폿퐁�".split("");for(a=0;a!=t[198].length;++a)if(t[198][a].charCodeAt(0)!==65533){r[t[198][a]]=50688+a;e[50688+a]=t[198][a]}t[199]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������퐈퐝푀푄표푠푤푭푯푸푹푼푿풀풂품풉풋풍풔풩퓌퓐퓔퓜퓟퓨퓬퓰퓸퓻퓽프픈플픔픕픗피픽핀필핌핍핏핑하학한할핥함합핫항해핵핸핼햄햅햇했행햐향허헉헌헐헒험헙헛헝헤헥헨헬헴헵헷헹혀혁현혈혐협혓혔형혜혠�".split("");for(a=0;a!=t[199].length;++a)if(t[199][a].charCodeAt(0)!==65533){r[t[199][a]]=50944+a;e[50944+a]=t[199][a]}t[200]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������혤혭호혹혼홀홅홈홉홋홍홑화확환활홧황홰홱홴횃횅회획횐횔횝횟횡효횬횰횹횻후훅훈훌훑훔훗훙훠훤훨훰훵훼훽휀휄휑휘휙휜휠휨휩휫휭휴휵휸휼흄흇흉흐흑흔흖흗흘흙흠흡흣흥흩희흰흴흼흽힁히힉힌힐힘힙힛힝�".split("");for(a=0;a!=t[200].length;++a)if(t[200][a].charCodeAt(0)!==65533){r[t[200][a]]=51200+a;e[51200+a]=t[200][a]}t[202]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������伽佳假價加可呵哥嘉嫁家暇架枷柯歌珂痂稼苛茄街袈訶賈跏軻迦駕刻却各恪慤殼珏脚覺角閣侃刊墾奸姦干幹懇揀杆柬桿澗癎看磵稈竿簡肝艮艱諫間乫喝曷渴碣竭葛褐蝎鞨勘坎堪嵌感憾戡敢柑橄減甘疳監瞰紺邯鑑鑒龕�".split("");for(a=0;a!=t[202].length;++a)if(t[202][a].charCodeAt(0)!==65533){r[t[202][a]]=51712+a;e[51712+a]=t[202][a]}t[203]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������匣岬甲胛鉀閘剛堈姜岡崗康强彊慷江畺疆糠絳綱羌腔舡薑襁講鋼降鱇介价個凱塏愷愾慨改槪漑疥皆盖箇芥蓋豈鎧開喀客坑更粳羹醵倨去居巨拒据據擧渠炬祛距踞車遽鉅鋸乾件健巾建愆楗腱虔蹇鍵騫乞傑杰桀儉劍劒檢�".split("");for(a=0;a!=t[203].length;++a)if(t[203][a].charCodeAt(0)!==65533){r[t[203][a]]=51968+a;e[51968+a]=t[203][a]}t[204]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������瞼鈐黔劫怯迲偈憩揭擊格檄激膈覡隔堅牽犬甄絹繭肩見譴遣鵑抉決潔結缺訣兼慊箝謙鉗鎌京俓倞傾儆勁勍卿坰境庚徑慶憬擎敬景暻更梗涇炅烱璟璥瓊痙硬磬竟競絅經耕耿脛莖警輕逕鏡頃頸驚鯨係啓堺契季屆悸戒桂械�".split("");for(a=0;a!=t[204].length;++a)if(t[204][a].charCodeAt(0)!==65533){r[t[204][a]]=52224+a;e[52224+a]=t[204][a]}t[205]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������棨溪界癸磎稽系繫繼計誡谿階鷄古叩告呱固姑孤尻庫拷攷故敲暠枯槁沽痼皐睾稿羔考股膏苦苽菰藁蠱袴誥賈辜錮雇顧高鼓哭斛曲梏穀谷鵠困坤崑昆梱棍滾琨袞鯤汨滑骨供公共功孔工恐恭拱控攻珙空蚣貢鞏串寡戈果瓜�".split("");for(a=0;a!=t[205].length;++a)if(t[205][a].charCodeAt(0)!==65533){r[t[205][a]]=52480+a;e[52480+a]=t[205][a]}t[206]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������科菓誇課跨過鍋顆廓槨藿郭串冠官寬慣棺款灌琯瓘管罐菅觀貫關館刮恝括适侊光匡壙廣曠洸炚狂珖筐胱鑛卦掛罫乖傀塊壞怪愧拐槐魁宏紘肱轟交僑咬喬嬌嶠巧攪敎校橋狡皎矯絞翹膠蕎蛟較轎郊餃驕鮫丘久九仇俱具勾�".split("");for(a=0;a!=t[206].length;++a)if(t[206][a].charCodeAt(0)!==65533){r[t[206][a]]=52736+a;e[52736+a]=t[206][a]}t[207]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������區口句咎嘔坵垢寇嶇廐懼拘救枸柩構歐毆毬求溝灸狗玖球瞿矩究絿耉臼舅舊苟衢謳購軀逑邱鉤銶駒驅鳩鷗龜國局菊鞠鞫麴君窘群裙軍郡堀屈掘窟宮弓穹窮芎躬倦券勸卷圈拳捲權淃眷厥獗蕨蹶闕机櫃潰詭軌饋句晷歸貴�".split("");for(a=0;a!=t[207].length;++a)if(t[207][a].charCodeAt(0)!==65533){r[t[207][a]]=52992+a;e[52992+a]=t[207][a]}t[208]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������鬼龜叫圭奎揆槻珪硅窺竅糾葵規赳逵閨勻均畇筠菌鈞龜橘克剋劇戟棘極隙僅劤勤懃斤根槿瑾筋芹菫覲謹近饉契今妗擒昑檎琴禁禽芩衾衿襟金錦伋及急扱汲級給亘兢矜肯企伎其冀嗜器圻基埼夔奇妓寄岐崎己幾忌技旗旣�".split("");for(a=0;a!=t[208].length;++a)if(t[208][a].charCodeAt(0)!==65533){r[t[208][a]]=53248+a;e[53248+a]=t[208][a]}t[209]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������朞期杞棋棄機欺氣汽沂淇玘琦琪璂璣畸畿碁磯祁祇祈祺箕紀綺羈耆耭肌記譏豈起錡錤飢饑騎騏驥麒緊佶吉拮桔金喫儺喇奈娜懦懶拏拿癩羅蘿螺裸邏那樂洛烙珞落諾酪駱亂卵暖欄煖爛蘭難鸞捏捺南嵐枏楠湳濫男藍襤拉�".split("");for(a=0;a!=t[209].length;++a)if(t[209][a].charCodeAt(0)!==65533){r[t[209][a]]=53504+a;e[53504+a]=t[209][a]}t[210]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������納臘蠟衲囊娘廊朗浪狼郎乃來內奈柰耐冷女年撚秊念恬拈捻寧寗努勞奴弩怒擄櫓爐瑙盧老蘆虜路露駑魯鷺碌祿綠菉錄鹿論壟弄濃籠聾膿農惱牢磊腦賂雷尿壘屢樓淚漏累縷陋嫩訥杻紐勒肋凜凌稜綾能菱陵尼泥匿溺多茶�".split("");for(a=0;a!=t[210].length;++a)if(t[210][a].charCodeAt(0)!==65533){r[t[210][a]]=53760+a;e[53760+a]=t[210][a]}t[211]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������丹亶但單團壇彖斷旦檀段湍短端簞緞蛋袒鄲鍛撻澾獺疸達啖坍憺擔曇淡湛潭澹痰聃膽蕁覃談譚錟沓畓答踏遝唐堂塘幢戇撞棠當糖螳黨代垈坮大對岱帶待戴擡玳臺袋貸隊黛宅德悳倒刀到圖堵塗導屠島嶋度徒悼挑掉搗桃�".split("");for(a=0;a!=t[211].length;++a)if(t[211][a].charCodeAt(0)!==65533){r[t[211][a]]=54016+a;e[54016+a]=t[211][a]}t[212]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������棹櫂淘渡滔濤燾盜睹禱稻萄覩賭跳蹈逃途道都鍍陶韜毒瀆牘犢獨督禿篤纛讀墩惇敦旽暾沌焞燉豚頓乭突仝冬凍動同憧東桐棟洞潼疼瞳童胴董銅兜斗杜枓痘竇荳讀豆逗頭屯臀芚遁遯鈍得嶝橙燈登等藤謄鄧騰喇懶拏癩羅�".split("");for(a=0;a!=t[212].length;++a)if(t[212][a].charCodeAt(0)!==65533){r[t[212][a]]=54272+a;e[54272+a]=t[212][a]}t[213]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������蘿螺裸邏樂洛烙珞絡落諾酪駱丹亂卵欄欒瀾爛蘭鸞剌辣嵐擥攬欖濫籃纜藍襤覽拉臘蠟廊朗浪狼琅瑯螂郞來崍徠萊冷掠略亮倆兩凉梁樑粮粱糧良諒輛量侶儷勵呂廬慮戾旅櫚濾礪藜蠣閭驢驪麗黎力曆歷瀝礫轢靂憐戀攣漣�".split("");for(a=0;a!=t[213].length;++a)if(t[213][a].charCodeAt(0)!==65533){r[t[213][a]]=54528+a;e[54528+a]=t[213][a]}t[214]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������煉璉練聯蓮輦連鍊冽列劣洌烈裂廉斂殮濂簾獵令伶囹寧岺嶺怜玲笭羚翎聆逞鈴零靈領齡例澧禮醴隷勞怒撈擄櫓潞瀘爐盧老蘆虜路輅露魯鷺鹵碌祿綠菉錄鹿麓論壟弄朧瀧瓏籠聾儡瀨牢磊賂賚賴雷了僚寮廖料燎療瞭聊蓼�".split("");for(a=0;a!=t[214].length;++a)if(t[214][a].charCodeAt(0)!==65533){r[t[214][a]]=54784+a;e[54784+a]=t[214][a]}t[215]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������遼鬧龍壘婁屢樓淚漏瘻累縷蔞褸鏤陋劉旒柳榴流溜瀏琉瑠留瘤硫謬類六戮陸侖倫崙淪綸輪律慄栗率隆勒肋凜凌楞稜綾菱陵俚利厘吏唎履悧李梨浬犁狸理璃異痢籬罹羸莉裏裡里釐離鯉吝潾燐璘藺躪隣鱗麟林淋琳臨霖砬�".split("");for(a=0;a!=t[215].length;++a)if(t[215][a].charCodeAt(0)!==65533){r[t[215][a]]=55040+a;e[55040+a]=t[215][a]}t[216]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������立笠粒摩瑪痲碼磨馬魔麻寞幕漠膜莫邈万卍娩巒彎慢挽晩曼滿漫灣瞞萬蔓蠻輓饅鰻唜抹末沫茉襪靺亡妄忘忙望網罔芒茫莽輞邙埋妹媒寐昧枚梅每煤罵買賣邁魅脈貊陌驀麥孟氓猛盲盟萌冪覓免冕勉棉沔眄眠綿緬面麵滅�".split("");for(a=0;a!=t[216].length;++a)if(t[216][a].charCodeAt(0)!==65533){r[t[216][a]]=55296+a;e[55296+a]=t[216][a]}t[217]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������蔑冥名命明暝椧溟皿瞑茗蓂螟酩銘鳴袂侮冒募姆帽慕摸摹暮某模母毛牟牡瑁眸矛耗芼茅謀謨貌木沐牧目睦穆鶩歿沒夢朦蒙卯墓妙廟描昴杳渺猫竗苗錨務巫憮懋戊拇撫无楙武毋無珷畝繆舞茂蕪誣貿霧鵡墨默們刎吻問文�".split("");for(a=0;a!=t[217].length;++a)if(t[217][a].charCodeAt(0)!==65533){r[t[217][a]]=55552+a;e[55552+a]=t[217][a]}t[218]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������汶紊紋聞蚊門雯勿沕物味媚尾嵋彌微未梶楣渼湄眉米美薇謎迷靡黴岷悶愍憫敏旻旼民泯玟珉緡閔密蜜謐剝博拍搏撲朴樸泊珀璞箔粕縛膊舶薄迫雹駁伴半反叛拌搬攀斑槃泮潘班畔瘢盤盼磐磻礬絆般蟠返頒飯勃拔撥渤潑�".split("");for(a=0;a!=t[218].length;++a)if(t[218][a].charCodeAt(0)!==65533){r[t[218][a]]=55808+a;e[55808+a]=t[218][a]}t[219]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������發跋醱鉢髮魃倣傍坊妨尨幇彷房放方旁昉枋榜滂磅紡肪膀舫芳蒡蚌訪謗邦防龐倍俳北培徘拜排杯湃焙盃背胚裴裵褙賠輩配陪伯佰帛柏栢白百魄幡樊煩燔番磻繁蕃藩飜伐筏罰閥凡帆梵氾汎泛犯範范法琺僻劈壁擘檗璧癖�".split("");for(a=0;a!=t[219].length;++a)if(t[219][a].charCodeAt(0)!==65533){r[t[219][a]]=56064+a;e[56064+a]=t[219][a]}t[220]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������碧蘗闢霹便卞弁變辨辯邊別瞥鱉鼈丙倂兵屛幷昞昺柄棅炳甁病秉竝輧餠騈保堡報寶普步洑湺潽珤甫菩補褓譜輔伏僕匐卜宓復服福腹茯蔔複覆輹輻馥鰒本乶俸奉封峯峰捧棒烽熢琫縫蓬蜂逢鋒鳳不付俯傅剖副否咐埠夫婦�".split("");for(a=0;a!=t[220].length;++a)if(t[220][a].charCodeAt(0)!==65533){r[t[220][a]]=56320+a;e[56320+a]=t[220][a]}t[221]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������孚孵富府復扶敷斧浮溥父符簿缶腐腑膚艀芙莩訃負賦賻赴趺部釜阜附駙鳧北分吩噴墳奔奮忿憤扮昐汾焚盆粉糞紛芬賁雰不佛弗彿拂崩朋棚硼繃鵬丕備匕匪卑妃婢庇悲憊扉批斐枇榧比毖毗毘沸泌琵痺砒碑秕秘粃緋翡肥�".split("");for(a=0;a!=t[221].length;++a)if(t[221][a].charCodeAt(0)!==65533){r[t[221][a]]=56576+a;e[56576+a]=t[221][a]}t[222]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������脾臂菲蜚裨誹譬費鄙非飛鼻嚬嬪彬斌檳殯浜濱瀕牝玭貧賓頻憑氷聘騁乍事些仕伺似使俟僿史司唆嗣四士奢娑寫寺射巳師徙思捨斜斯柶査梭死沙泗渣瀉獅砂社祀祠私篩紗絲肆舍莎蓑蛇裟詐詞謝賜赦辭邪飼駟麝削數朔索�".split("");for(a=0;a!=t[222].length;++a)if(t[222][a].charCodeAt(0)!==65533){r[t[222][a]]=56832+a;e[56832+a]=t[222][a]}t[223]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������傘刪山散汕珊産疝算蒜酸霰乷撒殺煞薩三參杉森渗芟蔘衫揷澁鈒颯上傷像償商喪嘗孀尙峠常床庠廂想桑橡湘爽牀狀相祥箱翔裳觴詳象賞霜塞璽賽嗇塞穡索色牲生甥省笙墅壻嶼序庶徐恕抒捿敍暑曙書栖棲犀瑞筮絮緖署�".split("");for(a=0;a!=t[223].length;++a)if(t[223][a].charCodeAt(0)!==65533){r[t[223][a]]=57088+a;e[57088+a]=t[223][a]}t[224]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������胥舒薯西誓逝鋤黍鼠夕奭席惜昔晳析汐淅潟石碩蓆釋錫仙僊先善嬋宣扇敾旋渲煽琁瑄璇璿癬禪線繕羨腺膳船蘚蟬詵跣選銑鐥饍鮮卨屑楔泄洩渫舌薛褻設說雪齧剡暹殲纖蟾贍閃陝攝涉燮葉城姓宬性惺成星晟猩珹盛省筬�".split("");for(a=0;a!=t[224].length;++a)if(t[224][a].charCodeAt(0)!==65533){r[t[224][a]]=57344+a;e[57344+a]=t[224][a]}t[225]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������聖聲腥誠醒世勢歲洗稅笹細說貰召嘯塑宵小少巢所掃搔昭梳沼消溯瀟炤燒甦疏疎瘙笑篠簫素紹蔬蕭蘇訴逍遡邵銷韶騷俗屬束涑粟續謖贖速孫巽損蓀遜飡率宋悚松淞訟誦送頌刷殺灑碎鎖衰釗修受嗽囚垂壽嫂守岫峀帥愁�".split("");for(a=0;a!=t[225].length;++a)if(t[225][a].charCodeAt(0)!==65533){r[t[225][a]]=57600+a;e[57600+a]=t[225][a]}t[226]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������戍手授搜收數樹殊水洙漱燧狩獸琇璲瘦睡秀穗竪粹綏綬繡羞脩茱蒐蓚藪袖誰讐輸遂邃酬銖銹隋隧隨雖需須首髓鬚叔塾夙孰宿淑潚熟琡璹肅菽巡徇循恂旬栒楯橓殉洵淳珣盾瞬筍純脣舜荀蓴蕣詢諄醇錞順馴戌術述鉥崇崧�".split("");for(a=0;a!=t[226].length;++a)if(t[226][a].charCodeAt(0)!==65533){r[t[226][a]]=57856+a;e[57856+a]=t[226][a]}t[227]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������嵩瑟膝蝨濕拾習褶襲丞乘僧勝升承昇繩蠅陞侍匙嘶始媤尸屎屍市弑恃施是時枾柴猜矢示翅蒔蓍視試詩諡豕豺埴寔式息拭植殖湜熄篒蝕識軾食飾伸侁信呻娠宸愼新晨燼申神紳腎臣莘薪藎蜃訊身辛辰迅失室實悉審尋心沁�".split("");for(a=0;a!=t[227].length;++a)if(t[227][a].charCodeAt(0)!==65533){r[t[227][a]]=58112+a;e[58112+a]=t[227][a]}t[228]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������沈深瀋甚芯諶什十拾雙氏亞俄兒啞娥峨我牙芽莪蛾衙訝阿雅餓鴉鵝堊岳嶽幄惡愕握樂渥鄂鍔顎鰐齷安岸按晏案眼雁鞍顔鮟斡謁軋閼唵岩巖庵暗癌菴闇壓押狎鴨仰央怏昻殃秧鴦厓哀埃崖愛曖涯碍艾隘靄厄扼掖液縊腋額�".split("");for(a=0;a!=t[228].length;++a)if(t[228][a].charCodeAt(0)!==65533){r[t[228][a]]=58368+a;e[58368+a]=t[228][a]}t[229]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������櫻罌鶯鸚也倻冶夜惹揶椰爺耶若野弱掠略約若葯蒻藥躍亮佯兩凉壤孃恙揚攘敭暘梁楊樣洋瀁煬痒瘍禳穰糧羊良襄諒讓釀陽量養圄御於漁瘀禦語馭魚齬億憶抑檍臆偃堰彦焉言諺孼蘖俺儼嚴奄掩淹嶪業円予余勵呂女如廬�".split("");for(a=0;a!=t[229].length;++a)if(t[229][a].charCodeAt(0)!==65533){r[t[229][a]]=58624+a;e[58624+a]=t[229][a]}t[230]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������旅歟汝濾璵礖礪與艅茹輿轝閭餘驪麗黎亦力域役易曆歷疫繹譯轢逆驛嚥堧姸娟宴年延憐戀捐挻撚椽沇沿涎涓淵演漣烟然煙煉燃燕璉硏硯秊筵緣練縯聯衍軟輦蓮連鉛鍊鳶列劣咽悅涅烈熱裂說閱厭廉念捻染殮炎焰琰艶苒�".split("");for(a=0;a!=t[230].length;++a)if(t[230][a].charCodeAt(0)!==65533){r[t[230][a]]=58880+a;e[58880+a]=t[230][a]}t[231]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������簾閻髥鹽曄獵燁葉令囹塋寧嶺嶸影怜映暎楹榮永泳渶潁濚瀛瀯煐營獰玲瑛瑩瓔盈穎纓羚聆英詠迎鈴鍈零霙靈領乂倪例刈叡曳汭濊猊睿穢芮藝蘂禮裔詣譽豫醴銳隸霓預五伍俉傲午吾吳嗚塢墺奧娛寤悟惡懊敖旿晤梧汚澳�".split("");for(a=0;a!=t[231].length;++a)if(t[231][a].charCodeAt(0)!==65533){r[t[231][a]]=59136+a;e[59136+a]=t[231][a]}t[232]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������烏熬獒筽蜈誤鰲鼇屋沃獄玉鈺溫瑥瘟穩縕蘊兀壅擁瓮甕癰翁邕雍饔渦瓦窩窪臥蛙蝸訛婉完宛梡椀浣玩琓琬碗緩翫脘腕莞豌阮頑曰往旺枉汪王倭娃歪矮外嵬巍猥畏了僚僥凹堯夭妖姚寥寮尿嶢拗搖撓擾料曜樂橈燎燿瑤療�".split("");for(a=0;a!=t[232].length;++a)if(t[232][a].charCodeAt(0)!==65533){r[t[232][a]]=59392+a;e[59392+a]=t[232][a]}t[233]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������窈窯繇繞耀腰蓼蟯要謠遙遼邀饒慾欲浴縟褥辱俑傭冗勇埇墉容庸慂榕涌湧溶熔瑢用甬聳茸蓉踊鎔鏞龍于佑偶優又友右宇寓尤愚憂旴牛玗瑀盂祐禑禹紆羽芋藕虞迂遇郵釪隅雨雩勖彧旭昱栯煜稶郁頊云暈橒殞澐熉耘芸蕓�".split("");for(a=0;a!=t[233].length;++a)if(t[233][a].charCodeAt(0)!==65533){r[t[233][a]]=59648+a;e[59648+a]=t[233][a]}t[234]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������運隕雲韻蔚鬱亐熊雄元原員圓園垣媛嫄寃怨愿援沅洹湲源爰猿瑗苑袁轅遠阮院願鴛月越鉞位偉僞危圍委威尉慰暐渭爲瑋緯胃萎葦蔿蝟衛褘謂違韋魏乳侑儒兪劉唯喩孺宥幼幽庾悠惟愈愉揄攸有杻柔柚柳楡楢油洧流游溜�".split("");for(a=0;a!=t[234].length;++a)if(t[234][a].charCodeAt(0)!==65533){r[t[234][a]]=59904+a;e[59904+a]=t[234][a]}t[235]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������濡猶猷琉瑜由留癒硫紐維臾萸裕誘諛諭踰蹂遊逾遺酉釉鍮類六堉戮毓肉育陸倫允奫尹崙淪潤玧胤贇輪鈗閏律慄栗率聿戎瀜絨融隆垠恩慇殷誾銀隱乙吟淫蔭陰音飮揖泣邑凝應膺鷹依倚儀宜意懿擬椅毅疑矣義艤薏蟻衣誼�".split("");for(a=0;a!=t[235].length;++a)if(t[235][a].charCodeAt(0)!==65533){r[t[235][a]]=60160+a;e[60160+a]=t[235][a]}t[236]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������議醫二以伊利吏夷姨履已弛彛怡易李梨泥爾珥理異痍痢移罹而耳肄苡荑裏裡貽貳邇里離飴餌匿溺瀷益翊翌翼謚人仁刃印吝咽因姻寅引忍湮燐璘絪茵藺蚓認隣靭靷鱗麟一佚佾壹日溢逸鎰馹任壬妊姙恁林淋稔臨荏賃入卄�".split("");for(a=0;a!=t[236].length;++a)if(t[236][a].charCodeAt(0)!==65533){r[t[236][a]]=60416+a;e[60416+a]=t[236][a]}t[237]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������立笠粒仍剩孕芿仔刺咨姉姿子字孜恣慈滋炙煮玆瓷疵磁紫者自茨蔗藉諮資雌作勺嚼斫昨灼炸爵綽芍酌雀鵲孱棧殘潺盞岑暫潛箴簪蠶雜丈仗匠場墻壯奬將帳庄張掌暲杖樟檣欌漿牆狀獐璋章粧腸臟臧莊葬蔣薔藏裝贓醬長�".split("");for(a=0;a!=t[237].length;++a)if(t[237][a].charCodeAt(0)!==65533){r[t[237][a]]=60672+a;e[60672+a]=t[237][a]}t[238]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������障再哉在宰才材栽梓渽滓災縡裁財載齋齎爭箏諍錚佇低儲咀姐底抵杵楮樗沮渚狙猪疽箸紵苧菹著藷詛貯躇這邸雎齟勣吊嫡寂摘敵滴狄炙的積笛籍績翟荻謫賊赤跡蹟迪迹適鏑佃佺傳全典前剪塡塼奠專展廛悛戰栓殿氈澱�".split("");for(a=0;a!=t[238].length;++a)if(t[238][a].charCodeAt(0)!==65533){r[t[238][a]]=60928+a;e[60928+a]=t[238][a]}t[239]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������煎琠田甸畑癲筌箋箭篆纏詮輾轉鈿銓錢鐫電顚顫餞切截折浙癤竊節絶占岾店漸点粘霑鮎點接摺蝶丁井亭停偵呈姃定幀庭廷征情挺政整旌晶晸柾楨檉正汀淀淨渟湞瀞炡玎珽町睛碇禎程穽精綎艇訂諪貞鄭酊釘鉦鋌錠霆靖�".split("");for(a=0;a!=t[239].length;++a)if(t[239][a].charCodeAt(0)!==65533){r[t[239][a]]=61184+a;e[61184+a]=t[239][a]}t[240]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������靜頂鼎制劑啼堤帝弟悌提梯濟祭第臍薺製諸蹄醍除際霽題齊俎兆凋助嘲弔彫措操早晁曺曹朝條棗槽漕潮照燥爪璪眺祖祚租稠窕粗糟組繰肇藻蚤詔調趙躁造遭釣阻雕鳥族簇足鏃存尊卒拙猝倧宗從悰慫棕淙琮種終綜縱腫�".split("");for(a=0;a!=t[240].length;++a)if(t[240][a].charCodeAt(0)!==65533){r[t[240][a]]=61440+a;e[61440+a]=t[240][a]}t[241]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������踪踵鍾鐘佐坐左座挫罪主住侏做姝胄呪周嗾奏宙州廚晝朱柱株注洲湊澍炷珠疇籌紂紬綢舟蛛註誅走躊輳週酎酒鑄駐竹粥俊儁准埈寯峻晙樽浚準濬焌畯竣蠢逡遵雋駿茁中仲衆重卽櫛楫汁葺增憎曾拯烝甑症繒蒸證贈之只�".split("");for(a=0;a!=t[241].length;++a)if(t[241][a].charCodeAt(0)!==65533){r[t[241][a]]=61696+a;e[61696+a]=t[241][a]}t[242]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������咫地址志持指摯支旨智枝枳止池沚漬知砥祉祗紙肢脂至芝芷蜘誌識贄趾遲直稙稷織職唇嗔塵振搢晉晋桭榛殄津溱珍瑨璡畛疹盡眞瞋秦縉縝臻蔯袗診賑軫辰進鎭陣陳震侄叱姪嫉帙桎瓆疾秩窒膣蛭質跌迭斟朕什執潗緝輯�".split("");for(a=0;a!=t[242].length;++a)if(t[242][a].charCodeAt(0)!==65533){r[t[242][a]]=61952+a;e[61952+a]=t[242][a]}t[243]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������鏶集徵懲澄且侘借叉嗟嵯差次此磋箚茶蹉車遮捉搾着窄錯鑿齪撰澯燦璨瓚竄簒纂粲纘讚贊鑽餐饌刹察擦札紮僭參塹慘慙懺斬站讒讖倉倡創唱娼廠彰愴敞昌昶暢槍滄漲猖瘡窓脹艙菖蒼債埰寀寨彩採砦綵菜蔡采釵冊柵策�".split("");for(a=0;a!=t[243].length;++a)if(t[243][a].charCodeAt(0)!==65533){r[t[243][a]]=62208+a;e[62208+a]=t[243][a]}t[244]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������責凄妻悽處倜刺剔尺慽戚拓擲斥滌瘠脊蹠陟隻仟千喘天川擅泉淺玔穿舛薦賤踐遷釧闡阡韆凸哲喆徹撤澈綴輟轍鐵僉尖沾添甛瞻簽籤詹諂堞妾帖捷牒疊睫諜貼輒廳晴淸聽菁請靑鯖切剃替涕滯締諦逮遞體初剿哨憔抄招梢�".split("");for(a=0;a!=t[244].length;++a)if(t[244][a].charCodeAt(0)!==65533){r[t[244][a]]=62464+a;e[62464+a]=t[244][a]}t[245]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������椒楚樵炒焦硝礁礎秒稍肖艸苕草蕉貂超酢醋醮促囑燭矗蜀觸寸忖村邨叢塚寵悤憁摠總聰蔥銃撮催崔最墜抽推椎楸樞湫皺秋芻萩諏趨追鄒酋醜錐錘鎚雛騶鰍丑畜祝竺筑築縮蓄蹙蹴軸逐春椿瑃出朮黜充忠沖蟲衝衷悴膵萃�".split("");for(a=0;a!=t[245].length;++a)if(t[245][a].charCodeAt(0)!==65533){r[t[245][a]]=62720+a;e[62720+a]=t[245][a]}t[246]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������贅取吹嘴娶就炊翠聚脆臭趣醉驟鷲側仄厠惻測層侈値嗤峙幟恥梔治淄熾痔痴癡稚穉緇緻置致蚩輜雉馳齒則勅飭親七柒漆侵寢枕沈浸琛砧針鍼蟄秤稱快他咤唾墮妥惰打拖朶楕舵陀馱駝倬卓啄坼度托拓擢晫柝濁濯琢琸託�".split("");for(a=0;a!=t[246].length;++a)if(t[246][a].charCodeAt(0)!==65533){r[t[246][a]]=62976+a;e[62976+a]=t[246][a]}t[247]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������鐸呑嘆坦彈憚歎灘炭綻誕奪脫探眈耽貪塔搭榻宕帑湯糖蕩兌台太怠態殆汰泰笞胎苔跆邰颱宅擇澤撑攄兎吐土討慟桶洞痛筒統通堆槌腿褪退頹偸套妬投透鬪慝特闖坡婆巴把播擺杷波派爬琶破罷芭跛頗判坂板版瓣販辦鈑�".split("");for(a=0;a!=t[247].length;++a)if(t[247][a].charCodeAt(0)!==65533){r[t[247][a]]=63232+a;e[63232+a]=t[247][a]}t[248]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������阪八叭捌佩唄悖敗沛浿牌狽稗覇貝彭澎烹膨愎便偏扁片篇編翩遍鞭騙貶坪平枰萍評吠嬖幣廢弊斃肺蔽閉陛佈包匍匏咆哺圃布怖抛抱捕暴泡浦疱砲胞脯苞葡蒲袍褒逋鋪飽鮑幅暴曝瀑爆輻俵剽彪慓杓標漂瓢票表豹飇飄驃�".split("");for(a=0;a!=t[248].length;++a)if(t[248][a].charCodeAt(0)!==65533){r[t[248][a]]=63488+a;e[63488+a]=t[248][a]}t[249]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������品稟楓諷豊風馮彼披疲皮被避陂匹弼必泌珌畢疋筆苾馝乏逼下何厦夏廈昰河瑕荷蝦賀遐霞鰕壑學虐謔鶴寒恨悍旱汗漢澣瀚罕翰閑閒限韓割轄函含咸啣喊檻涵緘艦銜陷鹹合哈盒蛤閤闔陜亢伉姮嫦巷恒抗杭桁沆港缸肛航�".split("");for(a=0;a!=t[249].length;++a)if(t[249][a].charCodeAt(0)!==65533){r[t[249][a]]=63744+a;e[63744+a]=t[249][a]}t[250]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������行降項亥偕咳垓奚孩害懈楷海瀣蟹解該諧邂駭骸劾核倖幸杏荇行享向嚮珦鄕響餉饗香噓墟虛許憲櫶獻軒歇險驗奕爀赫革俔峴弦懸晛泫炫玄玹現眩睍絃絢縣舷衒見賢鉉顯孑穴血頁嫌俠協夾峽挾浹狹脅脇莢鋏頰亨兄刑型�".split("");for(a=0;a!=t[250].length;++a)if(t[250][a].charCodeAt(0)!==65533){r[t[250][a]]=64e3+a;e[64e3+a]=t[250][a]}t[251]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������形泂滎瀅灐炯熒珩瑩荊螢衡逈邢鎣馨兮彗惠慧暳蕙蹊醯鞋乎互呼壕壺好岵弧戶扈昊晧毫浩淏湖滸澔濠濩灝狐琥瑚瓠皓祜糊縞胡芦葫蒿虎號蝴護豪鎬頀顥惑或酷婚昏混渾琿魂忽惚笏哄弘汞泓洪烘紅虹訌鴻化和嬅樺火畵�".split("");for(a=0;a!=t[251].length;++a)if(t[251][a].charCodeAt(0)!==65533){r[t[251][a]]=64256+a;e[64256+a]=t[251][a]}t[252]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������禍禾花華話譁貨靴廓擴攫確碻穫丸喚奐宦幻患換歡晥桓渙煥環紈還驩鰥活滑猾豁闊凰幌徨恍惶愰慌晃晄榥況湟滉潢煌璜皇篁簧荒蝗遑隍黃匯回廻徊恢悔懷晦會檜淮澮灰獪繪膾茴蛔誨賄劃獲宖橫鐄哮嚆孝效斅曉梟涍淆�".split("");for(a=0;a!=t[252].length;++a)if(t[252][a].charCodeAt(0)!==65533){r[t[252][a]]=64512+a;e[64512+a]=t[252][a]}t[253]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������爻肴酵驍侯候厚后吼喉嗅帿後朽煦珝逅勛勳塤壎焄熏燻薰訓暈薨喧暄煊萱卉喙毁彙徽揮暉煇諱輝麾休携烋畦虧恤譎鷸兇凶匈洶胸黑昕欣炘痕吃屹紇訖欠欽歆吸恰洽翕興僖凞喜噫囍姬嬉希憙憘戱晞曦熙熹熺犧禧稀羲詰�".split("");for(a=0;a!=t[253].length;++a)if(t[253][a].charCodeAt(0)!==65533){r[t[253][a]]=64768+a;e[64768+a]=t[253][a]}return{enc:r,dec:e}}();cptable[950]=function(){var e=[],r={},t=[],a;t[0]="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~��������������������������������������������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[0].length;++a)if(t[0][a].charCodeAt(0)!==65533){r[t[0][a]]=0+a;e[0+a]=t[0][a]}t[161]="���������������������������������������������������������������� ,、。.‧;:?!︰…‥﹐﹑﹒·﹔﹕﹖﹗|–︱—︳╴︴﹏()︵︶{}︷︸〔〕︹︺【】︻︼《》︽︾〈〉︿﹀「」﹁﹂『』﹃﹄﹙﹚����������������������������������﹛﹜﹝﹞‘’“”〝〞‵′#&*※§〃○●△▲◎☆★◇◆□■▽▼㊣℅¯ ̄_ˍ﹉﹊﹍﹎﹋﹌﹟﹠﹡+-×÷±√<>=≦≧≠∞≒≡﹢﹣﹤﹥﹦~∩∪⊥∠∟⊿㏒㏑∫∮∵∴♀♂⊕⊙↑↓←→↖↗↙↘∥∣/�".split("");for(a=0;a!=t[161].length;++a)if(t[161][a].charCodeAt(0)!==65533){r[t[161][a]]=41216+a;e[41216+a]=t[161][a]}t[162]="����������������������������������������������������������������\∕﹨$¥〒¢£%@℃℉﹩﹪﹫㏕㎜㎝㎞㏎㎡㎎㎏㏄°兙兛兞兝兡兣嗧瓩糎▁▂▃▄▅▆▇█▏▎▍▌▋▊▉┼┴┬┤├▔─│▕┌┐└┘╭����������������������������������╮╰╯═╞╪╡◢◣◥◤╱╲╳0123456789ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ〡〢〣〤〥〦〧〨〩十卄卅ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv�".split("");for(a=0;a!=t[162].length;++a)if(t[162][a].charCodeAt(0)!==65533){r[t[162][a]]=41472+a;e[41472+a]=t[162][a]}t[163]="����������������������������������������������������������������wxyzΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩαβγδεζηθικλμνξοπρστυφχψωㄅㄆㄇㄈㄉㄊㄋㄌㄍㄎㄏ����������������������������������ㄐㄑㄒㄓㄔㄕㄖㄗㄘㄙㄚㄛㄜㄝㄞㄟㄠㄡㄢㄣㄤㄥㄦㄧㄨㄩ˙ˉˊˇˋ���������������������������������€������������������������������".split("");for(a=0;a!=t[163].length;++a)if(t[163][a].charCodeAt(0)!==65533){r[t[163][a]]=41728+a;e[41728+a]=t[163][a]}t[164]="����������������������������������������������������������������一乙丁七乃九了二人儿入八几刀刁力匕十卜又三下丈上丫丸凡久么也乞于亡兀刃勺千叉口土士夕大女子孑孓寸小尢尸山川工己已巳巾干廾弋弓才����������������������������������丑丐不中丰丹之尹予云井互五亢仁什仃仆仇仍今介仄元允內六兮公冗凶分切刈勻勾勿化匹午升卅卞厄友及反壬天夫太夭孔少尤尺屯巴幻廿弔引心戈戶手扎支文斗斤方日曰月木欠止歹毋比毛氏水火爪父爻片牙牛犬王丙�".split("");for(a=0;a!=t[164].length;++a)if(t[164][a].charCodeAt(0)!==65533){r[t[164][a]]=41984+a;e[41984+a]=t[164][a]}t[165]="����������������������������������������������������������������世丕且丘主乍乏乎以付仔仕他仗代令仙仞充兄冉冊冬凹出凸刊加功包匆北匝仟半卉卡占卯卮去可古右召叮叩叨叼司叵叫另只史叱台句叭叻四囚外����������������������������������央失奴奶孕它尼巨巧左市布平幼弁弘弗必戊打扔扒扑斥旦朮本未末札正母民氐永汁汀氾犯玄玉瓜瓦甘生用甩田由甲申疋白皮皿目矛矢石示禾穴立丞丟乒乓乩亙交亦亥仿伉伙伊伕伍伐休伏仲件任仰仳份企伋光兇兆先全�".split("");for(a=0;a!=t[165].length;++a)if(t[165][a].charCodeAt(0)!==65533){r[t[165][a]]=42240+a;e[42240+a]=t[165][a]}t[166]="����������������������������������������������������������������共再冰列刑划刎刖劣匈匡匠印危吉吏同吊吐吁吋各向名合吃后吆吒因回囝圳地在圭圬圯圩夙多夷夸妄奸妃好她如妁字存宇守宅安寺尖屹州帆并年����������������������������������式弛忙忖戎戌戍成扣扛托收早旨旬旭曲曳有朽朴朱朵次此死氖汝汗汙江池汐汕污汛汍汎灰牟牝百竹米糸缶羊羽老考而耒耳聿肉肋肌臣自至臼舌舛舟艮色艾虫血行衣西阡串亨位住佇佗佞伴佛何估佐佑伽伺伸佃佔似但佣�".split("");for(a=0;a!=t[166].length;++a)if(t[166][a].charCodeAt(0)!==65533){r[t[166][a]]=42496+a;e[42496+a]=t[166][a]}t[167]="����������������������������������������������������������������作你伯低伶余佝佈佚兌克免兵冶冷別判利刪刨劫助努劬匣即卵吝吭吞吾否呎吧呆呃吳呈呂君吩告吹吻吸吮吵吶吠吼呀吱含吟听囪困囤囫坊坑址坍����������������������������������均坎圾坐坏圻壯夾妝妒妨妞妣妙妖妍妤妓妊妥孝孜孚孛完宋宏尬局屁尿尾岐岑岔岌巫希序庇床廷弄弟彤形彷役忘忌志忍忱快忸忪戒我抄抗抖技扶抉扭把扼找批扳抒扯折扮投抓抑抆改攻攸旱更束李杏材村杜杖杞杉杆杠�".split("");for(a=0;a!=t[167].length;++a)if(t[167][a].charCodeAt(0)!==65533){r[t[167][a]]=42752+a;e[42752+a]=t[167][a]}t[168]="����������������������������������������������������������������杓杗步每求汞沙沁沈沉沅沛汪決沐汰沌汨沖沒汽沃汲汾汴沆汶沍沔沘沂灶灼災灸牢牡牠狄狂玖甬甫男甸皂盯矣私秀禿究系罕肖肓肝肘肛肚育良芒����������������������������������芋芍見角言谷豆豕貝赤走足身車辛辰迂迆迅迄巡邑邢邪邦那酉釆里防阮阱阪阬並乖乳事些亞享京佯依侍佳使佬供例來侃佰併侈佩佻侖佾侏侑佺兔兒兕兩具其典冽函刻券刷刺到刮制剁劾劻卒協卓卑卦卷卸卹取叔受味呵�".split("");for(a=0;a!=t[168].length;++a)if(t[168][a].charCodeAt(0)!==65533){r[t[168][a]]=43008+a;e[43008+a]=t[168][a]}t[169]="����������������������������������������������������������������咖呸咕咀呻呷咄咒咆呼咐呱呶和咚呢周咋命咎固垃坷坪坩坡坦坤坼夜奉奇奈奄奔妾妻委妹妮姑姆姐姍始姓姊妯妳姒姅孟孤季宗定官宜宙宛尚屈居����������������������������������屆岷岡岸岩岫岱岳帘帚帖帕帛帑幸庚店府底庖延弦弧弩往征彿彼忝忠忽念忿怏怔怯怵怖怪怕怡性怩怫怛或戕房戾所承拉拌拄抿拂抹拒招披拓拔拋拈抨抽押拐拙拇拍抵拚抱拘拖拗拆抬拎放斧於旺昔易昌昆昂明昀昏昕昊�".split("");for(a=0;a!=t[169].length;++a)if(t[169][a].charCodeAt(0)!==65533){r[t[169][a]]=43264+a;e[43264+a]=t[169][a]}t[170]="����������������������������������������������������������������昇服朋杭枋枕東果杳杷枇枝林杯杰板枉松析杵枚枓杼杪杲欣武歧歿氓氛泣注泳沱泌泥河沽沾沼波沫法泓沸泄油況沮泗泅泱沿治泡泛泊沬泯泜泖泠����������������������������������炕炎炒炊炙爬爭爸版牧物狀狎狙狗狐玩玨玟玫玥甽疝疙疚的盂盲直知矽社祀祁秉秈空穹竺糾罔羌羋者肺肥肢肱股肫肩肴肪肯臥臾舍芳芝芙芭芽芟芹花芬芥芯芸芣芰芾芷虎虱初表軋迎返近邵邸邱邶采金長門阜陀阿阻附�".split("");for(a=0;a!=t[170].length;++a)if(t[170][a].charCodeAt(0)!==65533){r[t[170][a]]=43520+a;e[43520+a]=t[170][a]}t[171]="����������������������������������������������������������������陂隹雨青非亟亭亮信侵侯便俠俑俏保促侶俘俟俊俗侮俐俄係俚俎俞侷兗冒冑冠剎剃削前剌剋則勇勉勃勁匍南卻厚叛咬哀咨哎哉咸咦咳哇哂咽咪品����������������������������������哄哈咯咫咱咻咩咧咿囿垂型垠垣垢城垮垓奕契奏奎奐姜姘姿姣姨娃姥姪姚姦威姻孩宣宦室客宥封屎屏屍屋峙峒巷帝帥帟幽庠度建弈弭彥很待徊律徇後徉怒思怠急怎怨恍恰恨恢恆恃恬恫恪恤扁拜挖按拼拭持拮拽指拱拷�".split("");for(a=0;a!=t[171].length;++a)if(t[171][a].charCodeAt(0)!==65533){r[t[171][a]]=43776+a;e[43776+a]=t[171][a]}t[172]="����������������������������������������������������������������拯括拾拴挑挂政故斫施既春昭映昧是星昨昱昤曷柿染柱柔某柬架枯柵柩柯柄柑枴柚查枸柏柞柳枰柙柢柝柒歪殃殆段毒毗氟泉洋洲洪流津洌洱洞洗����������������������������������活洽派洶洛泵洹洧洸洩洮洵洎洫炫為炳炬炯炭炸炮炤爰牲牯牴狩狠狡玷珊玻玲珍珀玳甚甭畏界畎畋疫疤疥疢疣癸皆皇皈盈盆盃盅省盹相眉看盾盼眇矜砂研砌砍祆祉祈祇禹禺科秒秋穿突竿竽籽紂紅紀紉紇約紆缸美羿耄�".split("");for(a=0;a!=t[172].length;++a)if(t[172][a].charCodeAt(0)!==65533){r[t[172][a]]=44032+a;e[44032+a]=t[172][a]}t[173]="����������������������������������������������������������������耐耍耑耶胖胥胚胃胄背胡胛胎胞胤胝致舢苧范茅苣苛苦茄若茂茉苒苗英茁苜苔苑苞苓苟苯茆虐虹虻虺衍衫要觔計訂訃貞負赴赳趴軍軌述迦迢迪迥����������������������������������迭迫迤迨郊郎郁郃酋酊重閂限陋陌降面革韋韭音頁風飛食首香乘亳倌倍倣俯倦倥俸倩倖倆值借倚倒們俺倀倔倨俱倡個候倘俳修倭倪俾倫倉兼冤冥冢凍凌准凋剖剜剔剛剝匪卿原厝叟哨唐唁唷哼哥哲唆哺唔哩哭員唉哮哪�".split("");for(a=0;a!=t[173].length;++a)if(t[173][a].charCodeAt(0)!==65533){r[t[173][a]]=44288+a;e[44288+a]=t[173][a]}t[174]="����������������������������������������������������������������哦唧唇哽唏圃圄埂埔埋埃堉夏套奘奚娑娘娜娟娛娓姬娠娣娩娥娌娉孫屘宰害家宴宮宵容宸射屑展屐峭峽峻峪峨峰島崁峴差席師庫庭座弱徒徑徐恙����������������������������������恣恥恐恕恭恩息悄悟悚悍悔悌悅悖扇拳挈拿捎挾振捕捂捆捏捉挺捐挽挪挫挨捍捌效敉料旁旅時晉晏晃晒晌晅晁書朔朕朗校核案框桓根桂桔栩梳栗桌桑栽柴桐桀格桃株桅栓栘桁殊殉殷氣氧氨氦氤泰浪涕消涇浦浸海浙涓�".split("");for(a=0;a!=t[174].length;++a)if(t[174][a].charCodeAt(0)!==65533){r[t[174][a]]=44544+a;e[44544+a]=t[174][a]}t[175]="����������������������������������������������������������������浬涉浮浚浴浩涌涊浹涅浥涔烊烘烤烙烈烏爹特狼狹狽狸狷玆班琉珮珠珪珞畔畝畜畚留疾病症疲疳疽疼疹痂疸皋皰益盍盎眩真眠眨矩砰砧砸砝破砷����������������������������������砥砭砠砟砲祕祐祠祟祖神祝祗祚秤秣秧租秦秩秘窄窈站笆笑粉紡紗紋紊素索純紐紕級紜納紙紛缺罟羔翅翁耆耘耕耙耗耽耿胱脂胰脅胭胴脆胸胳脈能脊胼胯臭臬舀舐航舫舨般芻茫荒荔荊茸荐草茵茴荏茲茹茶茗荀茱茨荃�".split("");for(a=0;a!=t[175].length;++a)if(t[175][a].charCodeAt(0)!==65533){r[t[175][a]]=44800+a;e[44800+a]=t[175][a]}t[176]="����������������������������������������������������������������虔蚊蚪蚓蚤蚩蚌蚣蚜衰衷袁袂衽衹記訐討訌訕訊託訓訖訏訑豈豺豹財貢起躬軒軔軏辱送逆迷退迺迴逃追逅迸邕郡郝郢酒配酌釘針釗釜釙閃院陣陡����������������������������������陛陝除陘陞隻飢馬骨高鬥鬲鬼乾偺偽停假偃偌做偉健偶偎偕偵側偷偏倏偯偭兜冕凰剪副勒務勘動匐匏匙匿區匾參曼商啪啦啄啞啡啃啊唱啖問啕唯啤唸售啜唬啣唳啁啗圈國圉域堅堊堆埠埤基堂堵執培夠奢娶婁婉婦婪婀�".split("");for(a=0;a!=t[176].length;++a)if(t[176][a].charCodeAt(0)!==65533){r[t[176][a]]=45056+a;e[45056+a]=t[176][a]}t[177]="����������������������������������������������������������������娼婢婚婆婊孰寇寅寄寂宿密尉專將屠屜屝崇崆崎崛崖崢崑崩崔崙崤崧崗巢常帶帳帷康庸庶庵庾張強彗彬彩彫得徙從徘御徠徜恿患悉悠您惋悴惦悽����������������������������������情悻悵惜悼惘惕惆惟悸惚惇戚戛扈掠控捲掖探接捷捧掘措捱掩掉掃掛捫推掄授掙採掬排掏掀捻捩捨捺敝敖救教敗啟敏敘敕敔斜斛斬族旋旌旎晝晚晤晨晦晞曹勗望梁梯梢梓梵桿桶梱梧梗械梃棄梭梆梅梔條梨梟梡梂欲殺�".split("");for(a=0;a!=t[177].length;++a)if(t[177][a].charCodeAt(0)!==65533){r[t[177][a]]=45312+a;e[45312+a]=t[177][a]}t[178]="����������������������������������������������������������������毫毬氫涎涼淳淙液淡淌淤添淺清淇淋涯淑涮淞淹涸混淵淅淒渚涵淚淫淘淪深淮淨淆淄涪淬涿淦烹焉焊烽烯爽牽犁猜猛猖猓猙率琅琊球理現琍瓠瓶����������������������������������瓷甜產略畦畢異疏痔痕疵痊痍皎盔盒盛眷眾眼眶眸眺硫硃硎祥票祭移窒窕笠笨笛第符笙笞笮粒粗粕絆絃統紮紹紼絀細紳組累終紲紱缽羞羚翌翎習耜聊聆脯脖脣脫脩脰脤舂舵舷舶船莎莞莘荸莢莖莽莫莒莊莓莉莠荷荻荼�".split("");for(a=0;a!=t[178].length;++a)if(t[178][a].charCodeAt(0)!==65533){r[t[178][a]]=45568+a;e[45568+a]=t[178][a]}t[179]="����������������������������������������������������������������莆莧處彪蛇蛀蚶蛄蚵蛆蛋蚱蚯蛉術袞袈被袒袖袍袋覓規訪訝訣訥許設訟訛訢豉豚販責貫貨貪貧赧赦趾趺軛軟這逍通逗連速逝逐逕逞造透逢逖逛途����������������������������������部郭都酗野釵釦釣釧釭釩閉陪陵陳陸陰陴陶陷陬雀雪雩章竟頂頃魚鳥鹵鹿麥麻傢傍傅備傑傀傖傘傚最凱割剴創剩勞勝勛博厥啻喀喧啼喊喝喘喂喜喪喔喇喋喃喳單喟唾喲喚喻喬喱啾喉喫喙圍堯堪場堤堰報堡堝堠壹壺奠�".split("");for(a=0;a!=t[179].length;++a)if(t[179][a].charCodeAt(0)!==65533){r[t[179][a]]=45824+a;e[45824+a]=t[179][a]}t[180]="����������������������������������������������������������������婷媚婿媒媛媧孳孱寒富寓寐尊尋就嵌嵐崴嵇巽幅帽幀幃幾廊廁廂廄弼彭復循徨惑惡悲悶惠愜愣惺愕惰惻惴慨惱愎惶愉愀愒戟扉掣掌描揀揩揉揆揍����������������������������������插揣提握揖揭揮捶援揪換摒揚揹敞敦敢散斑斐斯普晰晴晶景暑智晾晷曾替期朝棺棕棠棘棗椅棟棵森棧棹棒棲棣棋棍植椒椎棉棚楮棻款欺欽殘殖殼毯氮氯氬港游湔渡渲湧湊渠渥渣減湛湘渤湖湮渭渦湯渴湍渺測湃渝渾滋�".split("");for(a=0;a!=t[180].length;++a)if(t[180][a].charCodeAt(0)!==65533){r[t[180][a]]=46080+a;e[46080+a]=t[180][a]}t[181]="����������������������������������������������������������������溉渙湎湣湄湲湩湟焙焚焦焰無然煮焜牌犄犀猶猥猴猩琺琪琳琢琥琵琶琴琯琛琦琨甥甦畫番痢痛痣痙痘痞痠登發皖皓皴盜睏短硝硬硯稍稈程稅稀窘����������������������������������窗窖童竣等策筆筐筒答筍筋筏筑粟粥絞結絨絕紫絮絲絡給絢絰絳善翔翕耋聒肅腕腔腋腑腎脹腆脾腌腓腴舒舜菩萃菸萍菠菅萋菁華菱菴著萊菰萌菌菽菲菊萸萎萄菜萇菔菟虛蛟蛙蛭蛔蛛蛤蛐蛞街裁裂袱覃視註詠評詞証詁�".split("");for(a=0;a!=t[181].length;++a)if(t[181][a].charCodeAt(0)!==65533){r[t[181][a]]=46336+a;e[46336+a]=t[181][a]}t[182]="����������������������������������������������������������������詔詛詐詆訴診訶詖象貂貯貼貳貽賁費賀貴買貶貿貸越超趁跎距跋跚跑跌跛跆軻軸軼辜逮逵週逸進逶鄂郵鄉郾酣酥量鈔鈕鈣鈉鈞鈍鈐鈇鈑閔閏開閑����������������������������������間閒閎隊階隋陽隅隆隍陲隄雁雅雄集雇雯雲韌項順須飧飪飯飩飲飭馮馭黃黍黑亂傭債傲傳僅傾催傷傻傯僇剿剷剽募勦勤勢勣匯嗟嗨嗓嗦嗎嗜嗇嗑嗣嗤嗯嗚嗡嗅嗆嗥嗉園圓塞塑塘塗塚塔填塌塭塊塢塒塋奧嫁嫉嫌媾媽媼�".split("");for(a=0;a!=t[182].length;++a)if(t[182][a].charCodeAt(0)!==65533){r[t[182][a]]=46592+a;e[46592+a]=t[182][a]}t[183]="����������������������������������������������������������������媳嫂媲嵩嵯幌幹廉廈弒彙徬微愚意慈感想愛惹愁愈慎慌慄慍愾愴愧愍愆愷戡戢搓搾搞搪搭搽搬搏搜搔損搶搖搗搆敬斟新暗暉暇暈暖暄暘暍會榔業����������������������������������楚楷楠楔極椰概楊楨楫楞楓楹榆楝楣楛歇歲毀殿毓毽溢溯滓溶滂源溝滇滅溥溘溼溺溫滑準溜滄滔溪溧溴煎煙煩煤煉照煜煬煦煌煥煞煆煨煖爺牒猷獅猿猾瑯瑚瑕瑟瑞瑁琿瑙瑛瑜當畸瘀痰瘁痲痱痺痿痴痳盞盟睛睫睦睞督�".split("");for(a=0;a!=t[183].length;++a)if(t[183][a].charCodeAt(0)!==65533){r[t[183][a]]=46848+a;e[46848+a]=t[183][a]}t[184]="����������������������������������������������������������������睹睪睬睜睥睨睢矮碎碰碗碘碌碉硼碑碓硿祺祿禁萬禽稜稚稠稔稟稞窟窠筷節筠筮筧粱粳粵經絹綑綁綏絛置罩罪署義羨群聖聘肆肄腱腰腸腥腮腳腫����������������������������������腹腺腦舅艇蒂葷落萱葵葦葫葉葬葛萼萵葡董葩葭葆虞虜號蛹蜓蜈蜇蜀蛾蛻蜂蜃蜆蜊衙裟裔裙補裘裝裡裊裕裒覜解詫該詳試詩詰誇詼詣誠話誅詭詢詮詬詹詻訾詨豢貊貉賊資賈賄貲賃賂賅跡跟跨路跳跺跪跤跦躲較載軾輊�".split(""); -for(a=0;a!=t[184].length;++a)if(t[184][a].charCodeAt(0)!==65533){r[t[184][a]]=47104+a;e[47104+a]=t[184][a]}t[185]="����������������������������������������������������������������辟農運遊道遂達逼違遐遇遏過遍遑逾遁鄒鄗酬酪酩釉鈷鉗鈸鈽鉀鈾鉛鉋鉤鉑鈴鉉鉍鉅鈹鈿鉚閘隘隔隕雍雋雉雊雷電雹零靖靴靶預頑頓頊頒頌飼飴����������������������������������飽飾馳馱馴髡鳩麂鼎鼓鼠僧僮僥僖僭僚僕像僑僱僎僩兢凳劃劂匱厭嗾嘀嘛嘗嗽嘔嘆嘉嘍嘎嗷嘖嘟嘈嘐嗶團圖塵塾境墓墊塹墅塽壽夥夢夤奪奩嫡嫦嫩嫗嫖嫘嫣孵寞寧寡寥實寨寢寤察對屢嶄嶇幛幣幕幗幔廓廖弊彆彰徹慇�".split("");for(a=0;a!=t[185].length;++a)if(t[185][a].charCodeAt(0)!==65533){r[t[185][a]]=47360+a;e[47360+a]=t[185][a]}t[186]="����������������������������������������������������������������愿態慷慢慣慟慚慘慵截撇摘摔撤摸摟摺摑摧搴摭摻敲斡旗旖暢暨暝榜榨榕槁榮槓構榛榷榻榫榴槐槍榭槌榦槃榣歉歌氳漳演滾漓滴漩漾漠漬漏漂漢����������������������������������滿滯漆漱漸漲漣漕漫漯澈漪滬漁滲滌滷熔熙煽熊熄熒爾犒犖獄獐瑤瑣瑪瑰瑭甄疑瘧瘍瘋瘉瘓盡監瞄睽睿睡磁碟碧碳碩碣禎福禍種稱窪窩竭端管箕箋筵算箝箔箏箸箇箄粹粽精綻綰綜綽綾綠緊綴網綱綺綢綿綵綸維緒緇綬�".split("");for(a=0;a!=t[186].length;++a)if(t[186][a].charCodeAt(0)!==65533){r[t[186][a]]=47616+a;e[47616+a]=t[186][a]}t[187]="����������������������������������������������������������������罰翠翡翟聞聚肇腐膀膏膈膊腿膂臧臺與舔舞艋蓉蒿蓆蓄蒙蒞蒲蒜蓋蒸蓀蓓蒐蒼蓑蓊蜿蜜蜻蜢蜥蜴蜘蝕蜷蜩裳褂裴裹裸製裨褚裯誦誌語誣認誡誓誤����������������������������������說誥誨誘誑誚誧豪貍貌賓賑賒赫趙趕跼輔輒輕輓辣遠遘遜遣遙遞遢遝遛鄙鄘鄞酵酸酷酴鉸銀銅銘銖鉻銓銜銨鉼銑閡閨閩閣閥閤隙障際雌雒需靼鞅韶頗領颯颱餃餅餌餉駁骯骰髦魁魂鳴鳶鳳麼鼻齊億儀僻僵價儂儈儉儅凜�".split("");for(a=0;a!=t[187].length;++a)if(t[187][a].charCodeAt(0)!==65533){r[t[187][a]]=47872+a;e[47872+a]=t[187][a]}t[188]="����������������������������������������������������������������劇劈劉劍劊勰厲嘮嘻嘹嘲嘿嘴嘩噓噎噗噴嘶嘯嘰墀墟增墳墜墮墩墦奭嬉嫻嬋嫵嬌嬈寮寬審寫層履嶝嶔幢幟幡廢廚廟廝廣廠彈影德徵慶慧慮慝慕憂����������������������������������慼慰慫慾憧憐憫憎憬憚憤憔憮戮摩摯摹撞撲撈撐撰撥撓撕撩撒撮播撫撚撬撙撢撳敵敷數暮暫暴暱樣樟槨樁樞標槽模樓樊槳樂樅槭樑歐歎殤毅毆漿潼澄潑潦潔澆潭潛潸潮澎潺潰潤澗潘滕潯潠潟熟熬熱熨牖犛獎獗瑩璋璃�".split("");for(a=0;a!=t[188].length;++a)if(t[188][a].charCodeAt(0)!==65533){r[t[188][a]]=48128+a;e[48128+a]=t[188][a]}t[189]="����������������������������������������������������������������瑾璀畿瘠瘩瘟瘤瘦瘡瘢皚皺盤瞎瞇瞌瞑瞋磋磅確磊碾磕碼磐稿稼穀稽稷稻窯窮箭箱範箴篆篇篁箠篌糊締練緯緻緘緬緝編緣線緞緩綞緙緲緹罵罷羯����������������������������������翩耦膛膜膝膠膚膘蔗蔽蔚蓮蔬蔭蔓蔑蔣蔡蔔蓬蔥蓿蔆螂蝴蝶蝠蝦蝸蝨蝙蝗蝌蝓衛衝褐複褒褓褕褊誼諒談諄誕請諸課諉諂調誰論諍誶誹諛豌豎豬賠賞賦賤賬賭賢賣賜質賡赭趟趣踫踐踝踢踏踩踟踡踞躺輝輛輟輩輦輪輜輞�".split("");for(a=0;a!=t[189].length;++a)if(t[189][a].charCodeAt(0)!==65533){r[t[189][a]]=48384+a;e[48384+a]=t[189][a]}t[190]="����������������������������������������������������������������輥適遮遨遭遷鄰鄭鄧鄱醇醉醋醃鋅銻銷鋪銬鋤鋁銳銼鋒鋇鋰銲閭閱霄霆震霉靠鞍鞋鞏頡頫頜颳養餓餒餘駝駐駟駛駑駕駒駙骷髮髯鬧魅魄魷魯鴆鴉����������������������������������鴃麩麾黎墨齒儒儘儔儐儕冀冪凝劑劓勳噙噫噹噩噤噸噪器噥噱噯噬噢噶壁墾壇壅奮嬝嬴學寰導彊憲憑憩憊懍憶憾懊懈戰擅擁擋撻撼據擄擇擂操撿擒擔撾整曆曉暹曄曇暸樽樸樺橙橫橘樹橄橢橡橋橇樵機橈歙歷氅濂澱澡�".split("");for(a=0;a!=t[190].length;++a)if(t[190][a].charCodeAt(0)!==65533){r[t[190][a]]=48640+a;e[48640+a]=t[190][a]}t[191]="����������������������������������������������������������������濃澤濁澧澳激澹澶澦澠澴熾燉燐燒燈燕熹燎燙燜燃燄獨璜璣璘璟璞瓢甌甍瘴瘸瘺盧盥瞠瞞瞟瞥磨磚磬磧禦積穎穆穌穋窺篙簑築篤篛篡篩篦糕糖縊����������������������������������縑縈縛縣縞縝縉縐罹羲翰翱翮耨膳膩膨臻興艘艙蕊蕙蕈蕨蕩蕃蕉蕭蕪蕞螃螟螞螢融衡褪褲褥褫褡親覦諦諺諫諱謀諜諧諮諾謁謂諷諭諳諶諼豫豭貓賴蹄踱踴蹂踹踵輻輯輸輳辨辦遵遴選遲遼遺鄴醒錠錶鋸錳錯錢鋼錫錄錚�".split("");for(a=0;a!=t[191].length;++a)if(t[191][a].charCodeAt(0)!==65533){r[t[191][a]]=48896+a;e[48896+a]=t[191][a]}t[192]="����������������������������������������������������������������錐錦錡錕錮錙閻隧隨險雕霎霑霖霍霓霏靛靜靦鞘頰頸頻頷頭頹頤餐館餞餛餡餚駭駢駱骸骼髻髭鬨鮑鴕鴣鴦鴨鴒鴛默黔龍龜優償儡儲勵嚎嚀嚐嚅嚇����������������������������������嚏壕壓壑壎嬰嬪嬤孺尷屨嶼嶺嶽嶸幫彌徽應懂懇懦懋戲戴擎擊擘擠擰擦擬擱擢擭斂斃曙曖檀檔檄檢檜櫛檣橾檗檐檠歜殮毚氈濘濱濟濠濛濤濫濯澀濬濡濩濕濮濰燧營燮燦燥燭燬燴燠爵牆獰獲璩環璦璨癆療癌盪瞳瞪瞰瞬�".split("");for(a=0;a!=t[192].length;++a)if(t[192][a].charCodeAt(0)!==65533){r[t[192][a]]=49152+a;e[49152+a]=t[192][a]}t[193]="����������������������������������������������������������������瞧瞭矯磷磺磴磯礁禧禪穗窿簇簍篾篷簌篠糠糜糞糢糟糙糝縮績繆縷縲繃縫總縱繅繁縴縹繈縵縿縯罄翳翼聱聲聰聯聳臆臃膺臂臀膿膽臉膾臨舉艱薪����������������������������������薄蕾薜薑薔薯薛薇薨薊虧蟀蟑螳蟒蟆螫螻螺蟈蟋褻褶襄褸褽覬謎謗謙講謊謠謝謄謐豁谿豳賺賽購賸賻趨蹉蹋蹈蹊轄輾轂轅輿避遽還邁邂邀鄹醣醞醜鍍鎂錨鍵鍊鍥鍋錘鍾鍬鍛鍰鍚鍔闊闋闌闈闆隱隸雖霜霞鞠韓顆颶餵騁�".split("");for(a=0;a!=t[193].length;++a)if(t[193][a].charCodeAt(0)!==65533){r[t[193][a]]=49408+a;e[49408+a]=t[193][a]}t[194]="����������������������������������������������������������������駿鮮鮫鮪鮭鴻鴿麋黏點黜黝黛鼾齋叢嚕嚮壙壘嬸彝懣戳擴擲擾攆擺擻擷斷曜朦檳檬櫃檻檸櫂檮檯歟歸殯瀉瀋濾瀆濺瀑瀏燻燼燾燸獷獵璧璿甕癖癘����������������������������������癒瞽瞿瞻瞼礎禮穡穢穠竄竅簫簧簪簞簣簡糧織繕繞繚繡繒繙罈翹翻職聶臍臏舊藏薩藍藐藉薰薺薹薦蟯蟬蟲蟠覆覲觴謨謹謬謫豐贅蹙蹣蹦蹤蹟蹕軀轉轍邇邃邈醫醬釐鎔鎊鎖鎢鎳鎮鎬鎰鎘鎚鎗闔闖闐闕離雜雙雛雞霤鞣鞦�".split("");for(a=0;a!=t[194].length;++a)if(t[194][a].charCodeAt(0)!==65533){r[t[194][a]]=49664+a;e[49664+a]=t[194][a]}t[195]="����������������������������������������������������������������鞭韹額顏題顎顓颺餾餿餽餮馥騎髁鬃鬆魏魎魍鯊鯉鯽鯈鯀鵑鵝鵠黠鼕鼬儳嚥壞壟壢寵龐廬懲懷懶懵攀攏曠曝櫥櫝櫚櫓瀛瀟瀨瀚瀝瀕瀘爆爍牘犢獸����������������������������������獺璽瓊瓣疇疆癟癡矇礙禱穫穩簾簿簸簽簷籀繫繭繹繩繪羅繳羶羹羸臘藩藝藪藕藤藥藷蟻蠅蠍蟹蟾襠襟襖襞譁譜識證譚譎譏譆譙贈贊蹼蹲躇蹶蹬蹺蹴轔轎辭邊邋醱醮鏡鏑鏟鏃鏈鏜鏝鏖鏢鏍鏘鏤鏗鏨關隴難霪霧靡韜韻類�".split("");for(a=0;a!=t[195].length;++a)if(t[195][a].charCodeAt(0)!==65533){r[t[195][a]]=49920+a;e[49920+a]=t[195][a]}t[196]="����������������������������������������������������������������願顛颼饅饉騖騙鬍鯨鯧鯖鯛鶉鵡鵲鵪鵬麒麗麓麴勸嚨嚷嚶嚴嚼壤孀孃孽寶巉懸懺攘攔攙曦朧櫬瀾瀰瀲爐獻瓏癢癥礦礪礬礫竇競籌籃籍糯糰辮繽繼����������������������������������纂罌耀臚艦藻藹蘑藺蘆蘋蘇蘊蠔蠕襤覺觸議譬警譯譟譫贏贍躉躁躅躂醴釋鐘鐃鏽闡霰飄饒饑馨騫騰騷騵鰓鰍鹹麵黨鼯齟齣齡儷儸囁囀囂夔屬巍懼懾攝攜斕曩櫻欄櫺殲灌爛犧瓖瓔癩矓籐纏續羼蘗蘭蘚蠣蠢蠡蠟襪襬覽譴�".split("");for(a=0;a!=t[196].length;++a)if(t[196][a].charCodeAt(0)!==65533){r[t[196][a]]=50176+a;e[50176+a]=t[196][a]}t[197]="����������������������������������������������������������������護譽贓躊躍躋轟辯醺鐮鐳鐵鐺鐸鐲鐫闢霸霹露響顧顥饗驅驃驀騾髏魔魑鰭鰥鶯鶴鷂鶸麝黯鼙齜齦齧儼儻囈囊囉孿巔巒彎懿攤權歡灑灘玀瓤疊癮癬����������������������������������禳籠籟聾聽臟襲襯觼讀贖贗躑躓轡酈鑄鑑鑒霽霾韃韁顫饕驕驍髒鬚鱉鰱鰾鰻鷓鷗鼴齬齪龔囌巖戀攣攫攪曬欐瓚竊籤籣籥纓纖纔臢蘸蘿蠱變邐邏鑣鑠鑤靨顯饜驚驛驗髓體髑鱔鱗鱖鷥麟黴囑壩攬灞癱癲矗罐羈蠶蠹衢讓讒�".split("");for(a=0;a!=t[197].length;++a)if(t[197][a].charCodeAt(0)!==65533){r[t[197][a]]=50432+a;e[50432+a]=t[197][a]}t[198]="����������������������������������������������������������������讖艷贛釀鑪靂靈靄韆顰驟鬢魘鱟鷹鷺鹼鹽鼇齷齲廳欖灣籬籮蠻觀躡釁鑲鑰顱饞髖鬣黌灤矚讚鑷韉驢驥纜讜躪釅鑽鑾鑼鱷鱸黷豔鑿鸚爨驪鬱鸛鸞籲���������������������������������������������������������������������������������������������������������������������������������".split("");for(a=0;a!=t[198].length;++a)if(t[198][a].charCodeAt(0)!==65533){r[t[198][a]]=50688+a;e[50688+a]=t[198][a]}t[201]="����������������������������������������������������������������乂乜凵匚厂万丌乇亍囗兀屮彳丏冇与丮亓仂仉仈冘勼卬厹圠夃夬尐巿旡殳毌气爿丱丼仨仜仩仡仝仚刌匜卌圢圣夗夯宁宄尒尻屴屳帄庀庂忉戉扐氕����������������������������������氶汃氿氻犮犰玊禸肊阞伎优伬仵伔仱伀价伈伝伂伅伢伓伄仴伒冱刓刉刐劦匢匟卍厊吇囡囟圮圪圴夼妀奼妅奻奾奷奿孖尕尥屼屺屻屾巟幵庄异弚彴忕忔忏扜扞扤扡扦扢扙扠扚扥旯旮朾朹朸朻机朿朼朳氘汆汒汜汏汊汔汋�".split("");for(a=0;a!=t[201].length;++a)if(t[201][a].charCodeAt(0)!==65533){r[t[201][a]]=51456+a;e[51456+a]=t[201][a]}t[202]="����������������������������������������������������������������汌灱牞犴犵玎甪癿穵网艸艼芀艽艿虍襾邙邗邘邛邔阢阤阠阣佖伻佢佉体佤伾佧佒佟佁佘伭伳伿佡冏冹刜刞刡劭劮匉卣卲厎厏吰吷吪呔呅吙吜吥吘����������������������������������吽呏呁吨吤呇囮囧囥坁坅坌坉坋坒夆奀妦妘妠妗妎妢妐妏妧妡宎宒尨尪岍岏岈岋岉岒岊岆岓岕巠帊帎庋庉庌庈庍弅弝彸彶忒忑忐忭忨忮忳忡忤忣忺忯忷忻怀忴戺抃抌抎抏抔抇扱扻扺扰抁抈扷扽扲扴攷旰旴旳旲旵杅杇�".split("");for(a=0;a!=t[202].length;++a)if(t[202][a].charCodeAt(0)!==65533){r[t[202][a]]=51712+a;e[51712+a]=t[202][a]}t[203]="����������������������������������������������������������������杙杕杌杈杝杍杚杋毐氙氚汸汧汫沄沋沏汱汯汩沚汭沇沕沜汦汳汥汻沎灴灺牣犿犽狃狆狁犺狅玕玗玓玔玒町甹疔疕皁礽耴肕肙肐肒肜芐芏芅芎芑芓����������������������������������芊芃芄豸迉辿邟邡邥邞邧邠阰阨阯阭丳侘佼侅佽侀侇佶佴侉侄佷佌侗佪侚佹侁佸侐侜侔侞侒侂侕佫佮冞冼冾刵刲刳剆刱劼匊匋匼厒厔咇呿咁咑咂咈呫呺呾呥呬呴呦咍呯呡呠咘呣呧呤囷囹坯坲坭坫坱坰坶垀坵坻坳坴坢�".split("");for(a=0;a!=t[203].length;++a)if(t[203][a].charCodeAt(0)!==65533){r[t[203][a]]=51968+a;e[51968+a]=t[203][a]}t[204]="����������������������������������������������������������������坨坽夌奅妵妺姏姎妲姌姁妶妼姃姖妱妽姀姈妴姇孢孥宓宕屄屇岮岤岠岵岯岨岬岟岣岭岢岪岧岝岥岶岰岦帗帔帙弨弢弣弤彔徂彾彽忞忥怭怦怙怲怋����������������������������������怴怊怗怳怚怞怬怢怍怐怮怓怑怌怉怜戔戽抭抴拑抾抪抶拊抮抳抯抻抩抰抸攽斨斻昉旼昄昒昈旻昃昋昍昅旽昑昐曶朊枅杬枎枒杶杻枘枆构杴枍枌杺枟枑枙枃杽极杸杹枔欥殀歾毞氝沓泬泫泮泙沶泔沭泧沷泐泂沺泃泆泭泲�".split("");for(a=0;a!=t[204].length;++a)if(t[204][a].charCodeAt(0)!==65533){r[t[204][a]]=52224+a;e[52224+a]=t[204][a]}t[205]="����������������������������������������������������������������泒泝沴沊沝沀泞泀洰泍泇沰泹泏泩泑炔炘炅炓炆炄炑炖炂炚炃牪狖狋狘狉狜狒狔狚狌狑玤玡玭玦玢玠玬玝瓝瓨甿畀甾疌疘皯盳盱盰盵矸矼矹矻矺����������������������������������矷祂礿秅穸穻竻籵糽耵肏肮肣肸肵肭舠芠苀芫芚芘芛芵芧芮芼芞芺芴芨芡芩苂芤苃芶芢虰虯虭虮豖迒迋迓迍迖迕迗邲邴邯邳邰阹阽阼阺陃俍俅俓侲俉俋俁俔俜俙侻侳俛俇俖侺俀侹俬剄剉勀勂匽卼厗厖厙厘咺咡咭咥哏�".split("");for(a=0;a!=t[205].length;++a)if(t[205][a].charCodeAt(0)!==65533){r[t[205][a]]=52480+a;e[52480+a]=t[205][a]}t[206]="����������������������������������������������������������������哃茍咷咮哖咶哅哆咠呰咼咢咾呲哞咰垵垞垟垤垌垗垝垛垔垘垏垙垥垚垕壴复奓姡姞姮娀姱姝姺姽姼姶姤姲姷姛姩姳姵姠姾姴姭宨屌峐峘峌峗峋峛����������������������������������峞峚峉峇峊峖峓峔峏峈峆峎峟峸巹帡帢帣帠帤庰庤庢庛庣庥弇弮彖徆怷怹恔恲恞恅恓恇恉恛恌恀恂恟怤恄恘恦恮扂扃拏挍挋拵挎挃拫拹挏挌拸拶挀挓挔拺挕拻拰敁敃斪斿昶昡昲昵昜昦昢昳昫昺昝昴昹昮朏朐柁柲柈枺�".split("");for(a=0;a!=t[206].length;++a)if(t[206][a].charCodeAt(0)!==65533){r[t[206][a]]=52736+a;e[52736+a]=t[206][a]}t[207]="����������������������������������������������������������������柜枻柸柘柀枷柅柫柤柟枵柍枳柷柶柮柣柂枹柎柧柰枲柼柆柭柌枮柦柛柺柉柊柃柪柋欨殂殄殶毖毘毠氠氡洨洴洭洟洼洿洒洊泚洳洄洙洺洚洑洀洝浂����������������������������������洁洘洷洃洏浀洇洠洬洈洢洉洐炷炟炾炱炰炡炴炵炩牁牉牊牬牰牳牮狊狤狨狫狟狪狦狣玅珌珂珈珅玹玶玵玴珫玿珇玾珃珆玸珋瓬瓮甮畇畈疧疪癹盄眈眃眄眅眊盷盻盺矧矨砆砑砒砅砐砏砎砉砃砓祊祌祋祅祄秕种秏秖秎窀�".split("");for(a=0;a!=t[207].length;++a)if(t[207][a].charCodeAt(0)!==65533){r[t[207][a]]=52992+a;e[52992+a]=t[207][a]}t[208]="����������������������������������������������������������������穾竑笀笁籺籸籹籿粀粁紃紈紁罘羑羍羾耇耎耏耔耷胘胇胠胑胈胂胐胅胣胙胜胊胕胉胏胗胦胍臿舡芔苙苾苹茇苨茀苕茺苫苖苴苬苡苲苵茌苻苶苰苪����������������������������������苤苠苺苳苭虷虴虼虳衁衎衧衪衩觓訄訇赲迣迡迮迠郱邽邿郕郅邾郇郋郈釔釓陔陏陑陓陊陎倞倅倇倓倢倰倛俵俴倳倷倬俶俷倗倜倠倧倵倯倱倎党冔冓凊凄凅凈凎剡剚剒剞剟剕剢勍匎厞唦哢唗唒哧哳哤唚哿唄唈哫唑唅哱�".split("");for(a=0;a!=t[208].length;++a)if(t[208][a].charCodeAt(0)!==65533){r[t[208][a]]=53248+a;e[53248+a]=t[208][a]}t[209]="����������������������������������������������������������������唊哻哷哸哠唎唃唋圁圂埌堲埕埒垺埆垽垼垸垶垿埇埐垹埁夎奊娙娖娭娮娕娏娗娊娞娳孬宧宭宬尃屖屔峬峿峮峱峷崀峹帩帨庨庮庪庬弳弰彧恝恚恧����������������������������������恁悢悈悀悒悁悝悃悕悛悗悇悜悎戙扆拲挐捖挬捄捅挶捃揤挹捋捊挼挩捁挴捘捔捙挭捇挳捚捑挸捗捀捈敊敆旆旃旄旂晊晟晇晑朒朓栟栚桉栲栳栻桋桏栖栱栜栵栫栭栯桎桄栴栝栒栔栦栨栮桍栺栥栠欬欯欭欱欴歭肂殈毦毤�".split("");for(a=0;a!=t[209].length;++a)if(t[209][a].charCodeAt(0)!==65533){r[t[209][a]]=53504+a;e[53504+a]=t[209][a]}t[210]="����������������������������������������������������������������毨毣毢毧氥浺浣浤浶洍浡涒浘浢浭浯涑涍淯浿涆浞浧浠涗浰浼浟涂涘洯浨涋浾涀涄洖涃浻浽浵涐烜烓烑烝烋缹烢烗烒烞烠烔烍烅烆烇烚烎烡牂牸����������������������������������牷牶猀狺狴狾狶狳狻猁珓珙珥珖玼珧珣珩珜珒珛珔珝珚珗珘珨瓞瓟瓴瓵甡畛畟疰痁疻痄痀疿疶疺皊盉眝眛眐眓眒眣眑眕眙眚眢眧砣砬砢砵砯砨砮砫砡砩砳砪砱祔祛祏祜祓祒祑秫秬秠秮秭秪秜秞秝窆窉窅窋窌窊窇竘笐�".split("");for(a=0;a!=t[210].length;++a)if(t[210][a].charCodeAt(0)!==65533){r[t[210][a]]=53760+a;e[53760+a]=t[210][a]}t[211]="����������������������������������������������������������������笄笓笅笏笈笊笎笉笒粄粑粊粌粈粍粅紞紝紑紎紘紖紓紟紒紏紌罜罡罞罠罝罛羖羒翃翂翀耖耾耹胺胲胹胵脁胻脀舁舯舥茳茭荄茙荑茥荖茿荁茦茜茢����������������������������������荂荎茛茪茈茼荍茖茤茠茷茯茩荇荅荌荓茞茬荋茧荈虓虒蚢蚨蚖蚍蚑蚞蚇蚗蚆蚋蚚蚅蚥蚙蚡蚧蚕蚘蚎蚝蚐蚔衃衄衭衵衶衲袀衱衿衯袃衾衴衼訒豇豗豻貤貣赶赸趵趷趶軑軓迾迵适迿迻逄迼迶郖郠郙郚郣郟郥郘郛郗郜郤酐�".split("");for(a=0;a!=t[211].length;++a)if(t[211][a].charCodeAt(0)!==65533){r[t[211][a]]=54016+a;e[54016+a]=t[211][a]}t[212]="����������������������������������������������������������������酎酏釕釢釚陜陟隼飣髟鬯乿偰偪偡偞偠偓偋偝偲偈偍偁偛偊偢倕偅偟偩偫偣偤偆偀偮偳偗偑凐剫剭剬剮勖勓匭厜啵啶唼啍啐唴唪啑啢唶唵唰啒啅����������������������������������唌唲啥啎唹啈唭唻啀啋圊圇埻堔埢埶埜埴堀埭埽堈埸堋埳埏堇埮埣埲埥埬埡堎埼堐埧堁堌埱埩埰堍堄奜婠婘婕婧婞娸娵婭婐婟婥婬婓婤婗婃婝婒婄婛婈媎娾婍娹婌婰婩婇婑婖婂婜孲孮寁寀屙崞崋崝崚崠崌崨崍崦崥崏�".split("");for(a=0;a!=t[212].length;++a)if(t[212][a].charCodeAt(0)!==65533){r[t[212][a]]=54272+a;e[54272+a]=t[212][a]}t[213]="����������������������������������������������������������������崰崒崣崟崮帾帴庱庴庹庲庳弶弸徛徖徟悊悐悆悾悰悺惓惔惏惤惙惝惈悱惛悷惊悿惃惍惀挲捥掊掂捽掽掞掭掝掗掫掎捯掇掐据掯捵掜捭掮捼掤挻掟����������������������������������捸掅掁掑掍捰敓旍晥晡晛晙晜晢朘桹梇梐梜桭桮梮梫楖桯梣梬梩桵桴梲梏桷梒桼桫桲梪梀桱桾梛梖梋梠梉梤桸桻梑梌梊桽欶欳欷欸殑殏殍殎殌氪淀涫涴涳湴涬淩淢涷淶淔渀淈淠淟淖涾淥淜淝淛淴淊涽淭淰涺淕淂淏淉�".split("");for(a=0;a!=t[213].length;++a)if(t[213][a].charCodeAt(0)!==65533){r[t[213][a]]=54528+a;e[54528+a]=t[213][a]}t[214]="����������������������������������������������������������������淐淲淓淽淗淍淣涻烺焍烷焗烴焌烰焄烳焐烼烿焆焓焀烸烶焋焂焎牾牻牼牿猝猗猇猑猘猊猈狿猏猞玈珶珸珵琄琁珽琇琀珺珼珿琌琋珴琈畤畣痎痒痏����������������������������������痋痌痑痐皏皉盓眹眯眭眱眲眴眳眽眥眻眵硈硒硉硍硊硌砦硅硐祤祧祩祪祣祫祡离秺秸秶秷窏窔窐笵筇笴笥笰笢笤笳笘笪笝笱笫笭笯笲笸笚笣粔粘粖粣紵紽紸紶紺絅紬紩絁絇紾紿絊紻紨罣羕羜羝羛翊翋翍翐翑翇翏翉耟�".split("");for(a=0;a!=t[214].length;++a)if(t[214][a].charCodeAt(0)!==65533){r[t[214][a]]=54784+a;e[54784+a]=t[214][a]}t[215]="����������������������������������������������������������������耞耛聇聃聈脘脥脙脛脭脟脬脞脡脕脧脝脢舑舸舳舺舴舲艴莐莣莨莍荺荳莤荴莏莁莕莙荵莔莩荽莃莌莝莛莪莋荾莥莯莈莗莰荿莦莇莮荶莚虙虖蚿蚷����������������������������������蛂蛁蛅蚺蚰蛈蚹蚳蚸蛌蚴蚻蚼蛃蚽蚾衒袉袕袨袢袪袚袑袡袟袘袧袙袛袗袤袬袌袓袎覂觖觙觕訰訧訬訞谹谻豜豝豽貥赽赻赹趼跂趹趿跁軘軞軝軜軗軠軡逤逋逑逜逌逡郯郪郰郴郲郳郔郫郬郩酖酘酚酓酕釬釴釱釳釸釤釹釪�".split("");for(a=0;a!=t[215].length;++a)if(t[215][a].charCodeAt(0)!==65533){r[t[215][a]]=55040+a;e[55040+a]=t[215][a]}t[216]="����������������������������������������������������������������釫釷釨釮镺閆閈陼陭陫陱陯隿靪頄飥馗傛傕傔傞傋傣傃傌傎傝偨傜傒傂傇兟凔匒匑厤厧喑喨喥喭啷噅喢喓喈喏喵喁喣喒喤啽喌喦啿喕喡喎圌堩堷����������������������������������堙堞堧堣堨埵塈堥堜堛堳堿堶堮堹堸堭堬堻奡媯媔媟婺媢媞婸媦婼媥媬媕媮娷媄媊媗媃媋媩婻婽媌媜媏媓媝寪寍寋寔寑寊寎尌尰崷嵃嵫嵁嵋崿崵嵑嵎嵕崳崺嵒崽崱嵙嵂崹嵉崸崼崲崶嵀嵅幄幁彘徦徥徫惉悹惌惢惎惄愔�".split("");for(a=0;a!=t[216].length;++a)if(t[216][a].charCodeAt(0)!==65533){r[t[216][a]]=55296+a;e[55296+a]=t[216][a]}t[217]="����������������������������������������������������������������惲愊愖愅惵愓惸惼惾惁愃愘愝愐惿愄愋扊掔掱掰揎揥揨揯揃撝揳揊揠揶揕揲揵摡揟掾揝揜揄揘揓揂揇揌揋揈揰揗揙攲敧敪敤敜敨敥斌斝斞斮旐旒����������������������������������晼晬晻暀晱晹晪晲朁椌棓椄棜椪棬棪棱椏棖棷棫棤棶椓椐棳棡椇棌椈楰梴椑棯棆椔棸棐棽棼棨椋椊椗棎棈棝棞棦棴棑椆棔棩椕椥棇欹欻欿欼殔殗殙殕殽毰毲毳氰淼湆湇渟湉溈渼渽湅湢渫渿湁湝湳渜渳湋湀湑渻渃渮湞�".split("");for(a=0;a!=t[217].length;++a)if(t[217][a].charCodeAt(0)!==65533){r[t[217][a]]=55552+a;e[55552+a]=t[217][a]}t[218]="����������������������������������������������������������������湨湜湡渱渨湠湱湫渹渢渰湓湥渧湸湤湷湕湹湒湦渵渶湚焠焞焯烻焮焱焣焥焢焲焟焨焺焛牋牚犈犉犆犅犋猒猋猰猢猱猳猧猲猭猦猣猵猌琮琬琰琫琖����������������������������������琚琡琭琱琤琣琝琩琠琲瓻甯畯畬痧痚痡痦痝痟痤痗皕皒盚睆睇睄睍睅睊睎睋睌矞矬硠硤硥硜硭硱硪确硰硩硨硞硢祴祳祲祰稂稊稃稌稄窙竦竤筊笻筄筈筌筎筀筘筅粢粞粨粡絘絯絣絓絖絧絪絏絭絜絫絒絔絩絑絟絎缾缿罥�".split("");for(a=0;a!=t[218].length;++a)if(t[218][a].charCodeAt(0)!==65533){r[t[218][a]]=55808+a;e[55808+a]=t[218][a]}t[219]="����������������������������������������������������������������罦羢羠羡翗聑聏聐胾胔腃腊腒腏腇脽腍脺臦臮臷臸臹舄舼舽舿艵茻菏菹萣菀菨萒菧菤菼菶萐菆菈菫菣莿萁菝菥菘菿菡菋菎菖菵菉萉萏菞萑萆菂菳����������������������������������菕菺菇菑菪萓菃菬菮菄菻菗菢萛菛菾蛘蛢蛦蛓蛣蛚蛪蛝蛫蛜蛬蛩蛗蛨蛑衈衖衕袺裗袹袸裀袾袶袼袷袽袲褁裉覕覘覗觝觚觛詎詍訹詙詀詗詘詄詅詒詈詑詊詌詏豟貁貀貺貾貰貹貵趄趀趉跘跓跍跇跖跜跏跕跙跈跗跅軯軷軺�".split("");for(a=0;a!=t[219].length;++a)if(t[219][a].charCodeAt(0)!==65533){r[t[219][a]]=56064+a;e[56064+a]=t[219][a]}t[220]="����������������������������������������������������������������軹軦軮軥軵軧軨軶軫軱軬軴軩逭逴逯鄆鄬鄄郿郼鄈郹郻鄁鄀鄇鄅鄃酡酤酟酢酠鈁鈊鈥鈃鈚鈦鈏鈌鈀鈒釿釽鈆鈄鈧鈂鈜鈤鈙鈗鈅鈖镻閍閌閐隇陾隈����������������������������������隉隃隀雂雈雃雱雰靬靰靮頇颩飫鳦黹亃亄亶傽傿僆傮僄僊傴僈僂傰僁傺傱僋僉傶傸凗剺剸剻剼嗃嗛嗌嗐嗋嗊嗝嗀嗔嗄嗩喿嗒喍嗏嗕嗢嗖嗈嗲嗍嗙嗂圔塓塨塤塏塍塉塯塕塎塝塙塥塛堽塣塱壼嫇嫄嫋媺媸媱媵媰媿嫈媻嫆�".split("");for(a=0;a!=t[220].length;++a)if(t[220][a].charCodeAt(0)!==65533){r[t[220][a]]=56320+a;e[56320+a]=t[220][a]}t[221]="����������������������������������������������������������������媷嫀嫊媴媶嫍媹媐寖寘寙尟尳嵱嵣嵊嵥嵲嵬嵞嵨嵧嵢巰幏幎幊幍幋廅廌廆廋廇彀徯徭惷慉慊愫慅愶愲愮慆愯慏愩慀戠酨戣戥戤揅揱揫搐搒搉搠搤����������������������������������搳摃搟搕搘搹搷搢搣搌搦搰搨摁搵搯搊搚摀搥搧搋揧搛搮搡搎敯斒旓暆暌暕暐暋暊暙暔晸朠楦楟椸楎楢楱椿楅楪椹楂楗楙楺楈楉椵楬椳椽楥棰楸椴楩楀楯楄楶楘楁楴楌椻楋椷楜楏楑椲楒椯楻椼歆歅歃歂歈歁殛嗀毻毼�".split("");for(a=0;a!=t[221].length;++a)if(t[221][a].charCodeAt(0)!==65533){r[t[221][a]]=56576+a;e[56576+a]=t[221][a]}t[222]="����������������������������������������������������������������毹毷毸溛滖滈溏滀溟溓溔溠溱溹滆滒溽滁溞滉溷溰滍溦滏溲溾滃滜滘溙溒溎溍溤溡溿溳滐滊溗溮溣煇煔煒煣煠煁煝煢煲煸煪煡煂煘煃煋煰煟煐煓����������������������������������煄煍煚牏犍犌犑犐犎猼獂猻猺獀獊獉瑄瑊瑋瑒瑑瑗瑀瑏瑐瑎瑂瑆瑍瑔瓡瓿瓾瓽甝畹畷榃痯瘏瘃痷痾痼痹痸瘐痻痶痭痵痽皙皵盝睕睟睠睒睖睚睩睧睔睙睭矠碇碚碔碏碄碕碅碆碡碃硹碙碀碖硻祼禂祽祹稑稘稙稒稗稕稢稓�".split("");for(a=0;a!=t[222].length;++a)if(t[222][a].charCodeAt(0)!==65533){r[t[222][a]]=56832+a;e[56832+a]=t[222][a]}t[223]="����������������������������������������������������������������稛稐窣窢窞竫筦筤筭筴筩筲筥筳筱筰筡筸筶筣粲粴粯綈綆綀綍絿綅絺綎絻綃絼綌綔綄絽綒罭罫罧罨罬羦羥羧翛翜耡腤腠腷腜腩腛腢腲朡腞腶腧腯����������������������������������腄腡舝艉艄艀艂艅蓱萿葖葶葹蒏蒍葥葑葀蒆葧萰葍葽葚葙葴葳葝蔇葞萷萺萴葺葃葸萲葅萩菙葋萯葂萭葟葰萹葎葌葒葯蓅蒎萻葇萶萳葨葾葄萫葠葔葮葐蜋蜄蛷蜌蛺蛖蛵蝍蛸蜎蜉蜁蛶蜍蜅裖裋裍裎裞裛裚裌裐覅覛觟觥觤�".split("");for(a=0;a!=t[223].length;++a)if(t[223][a].charCodeAt(0)!==65533){r[t[223][a]]=57088+a;e[57088+a]=t[223][a]}t[224]="����������������������������������������������������������������觡觠觢觜触詶誆詿詡訿詷誂誄詵誃誁詴詺谼豋豊豥豤豦貆貄貅賌赨赩趑趌趎趏趍趓趔趐趒跰跠跬跱跮跐跩跣跢跧跲跫跴輆軿輁輀輅輇輈輂輋遒逿����������������������������������遄遉逽鄐鄍鄏鄑鄖鄔鄋鄎酮酯鉈鉒鈰鈺鉦鈳鉥鉞銃鈮鉊鉆鉭鉬鉏鉠鉧鉯鈶鉡鉰鈱鉔鉣鉐鉲鉎鉓鉌鉖鈲閟閜閞閛隒隓隑隗雎雺雽雸雵靳靷靸靲頏頍頎颬飶飹馯馲馰馵骭骫魛鳪鳭鳧麀黽僦僔僗僨僳僛僪僝僤僓僬僰僯僣僠�".split("");for(a=0;a!=t[224].length;++a)if(t[224][a].charCodeAt(0)!==65533){r[t[224][a]]=57344+a;e[57344+a]=t[224][a]}t[225]="����������������������������������������������������������������凘劀劁勩勫匰厬嘧嘕嘌嘒嗼嘏嘜嘁嘓嘂嗺嘝嘄嗿嗹墉塼墐墘墆墁塿塴墋塺墇墑墎塶墂墈塻墔墏壾奫嫜嫮嫥嫕嫪嫚嫭嫫嫳嫢嫠嫛嫬嫞嫝嫙嫨嫟孷寠����������������������������������寣屣嶂嶀嵽嶆嵺嶁嵷嶊嶉嶈嵾嵼嶍嵹嵿幘幙幓廘廑廗廎廜廕廙廒廔彄彃彯徶愬愨慁慞慱慳慒慓慲慬憀慴慔慺慛慥愻慪慡慖戩戧戫搫摍摛摝摴摶摲摳摽摵摦撦摎撂摞摜摋摓摠摐摿搿摬摫摙摥摷敳斠暡暠暟朅朄朢榱榶槉�".split("");for(a=0;a!=t[225].length;++a)if(t[225][a].charCodeAt(0)!==65533){r[t[225][a]]=57600+a;e[57600+a]=t[225][a]}t[226]="����������������������������������������������������������������榠槎榖榰榬榼榑榙榎榧榍榩榾榯榿槄榽榤槔榹槊榚槏榳榓榪榡榞槙榗榐槂榵榥槆歊歍歋殞殟殠毃毄毾滎滵滱漃漥滸漷滻漮漉潎漙漚漧漘漻漒滭漊����������������������������������漶潳滹滮漭潀漰漼漵滫漇漎潃漅滽滶漹漜滼漺漟漍漞漈漡熇熐熉熀熅熂熏煻熆熁熗牄牓犗犕犓獃獍獑獌瑢瑳瑱瑵瑲瑧瑮甀甂甃畽疐瘖瘈瘌瘕瘑瘊瘔皸瞁睼瞅瞂睮瞀睯睾瞃碲碪碴碭碨硾碫碞碥碠碬碢碤禘禊禋禖禕禔禓�".split("");for(a=0;a!=t[226].length;++a)if(t[226][a].charCodeAt(0)!==65533){r[t[226][a]]=57856+a;e[57856+a]=t[226][a]}t[227]="����������������������������������������������������������������禗禈禒禐稫穊稰稯稨稦窨窫窬竮箈箜箊箑箐箖箍箌箛箎箅箘劄箙箤箂粻粿粼粺綧綷緂綣綪緁緀緅綝緎緄緆緋緌綯綹綖綼綟綦綮綩綡緉罳翢翣翥翞����������������������������������耤聝聜膉膆膃膇膍膌膋舕蒗蒤蒡蒟蒺蓎蓂蒬蒮蒫蒹蒴蓁蓍蒪蒚蒱蓐蒝蒧蒻蒢蒔蓇蓌蒛蒩蒯蒨蓖蒘蒶蓏蒠蓗蓔蓒蓛蒰蒑虡蜳蜣蜨蝫蝀蜮蜞蜡蜙蜛蝃蜬蝁蜾蝆蜠蜲蜪蜭蜼蜒蜺蜱蜵蝂蜦蜧蜸蜤蜚蜰蜑裷裧裱裲裺裾裮裼裶裻�".split("");for(a=0;a!=t[227].length;++a)if(t[227][a].charCodeAt(0)!==65533){r[t[227][a]]=58112+a;e[58112+a]=t[227][a]}t[228]="����������������������������������������������������������������裰裬裫覝覡覟覞觩觫觨誫誙誋誒誏誖谽豨豩賕賏賗趖踉踂跿踍跽踊踃踇踆踅跾踀踄輐輑輎輍鄣鄜鄠鄢鄟鄝鄚鄤鄡鄛酺酲酹酳銥銤鉶銛鉺銠銔銪銍����������������������������������銦銚銫鉹銗鉿銣鋮銎銂銕銢鉽銈銡銊銆銌銙銧鉾銇銩銝銋鈭隞隡雿靘靽靺靾鞃鞀鞂靻鞄鞁靿韎韍頖颭颮餂餀餇馝馜駃馹馻馺駂馽駇骱髣髧鬾鬿魠魡魟鳱鳲鳵麧僿儃儰僸儆儇僶僾儋儌僽儊劋劌勱勯噈噂噌嘵噁噊噉噆噘�".split("");for(a=0;a!=t[228].length;++a)if(t[228][a].charCodeAt(0)!==65533){r[t[228][a]]=58368+a;e[58368+a]=t[228][a]}t[229]="����������������������������������������������������������������噚噀嘳嘽嘬嘾嘸嘪嘺圚墫墝墱墠墣墯墬墥墡壿嫿嫴嫽嫷嫶嬃嫸嬂嫹嬁嬇嬅嬏屧嶙嶗嶟嶒嶢嶓嶕嶠嶜嶡嶚嶞幩幝幠幜緳廛廞廡彉徲憋憃慹憱憰憢憉����������������������������������憛憓憯憭憟憒憪憡憍慦憳戭摮摰撖撠撅撗撜撏撋撊撌撣撟摨撱撘敶敺敹敻斲斳暵暰暩暲暷暪暯樀樆樗槥槸樕槱槤樠槿槬槢樛樝槾樧槲槮樔槷槧橀樈槦槻樍槼槫樉樄樘樥樏槶樦樇槴樖歑殥殣殢殦氁氀毿氂潁漦潾澇濆澒�".split("");for(a=0;a!=t[229].length;++a)if(t[229][a].charCodeAt(0)!==65533){r[t[229][a]]=58624+a;e[58624+a]=t[229][a]}t[230]="����������������������������������������������������������������澍澉澌潢潏澅潚澖潶潬澂潕潲潒潐潗澔澓潝漀潡潫潽潧澐潓澋潩潿澕潣潷潪潻熲熯熛熰熠熚熩熵熝熥熞熤熡熪熜熧熳犘犚獘獒獞獟獠獝獛獡獚獙����������������������������������獢璇璉璊璆璁瑽璅璈瑼瑹甈甇畾瘥瘞瘙瘝瘜瘣瘚瘨瘛皜皝皞皛瞍瞏瞉瞈磍碻磏磌磑磎磔磈磃磄磉禚禡禠禜禢禛歶稹窲窴窳箷篋箾箬篎箯箹篊箵糅糈糌糋緷緛緪緧緗緡縃緺緦緶緱緰緮緟罶羬羰羭翭翫翪翬翦翨聤聧膣膟�".split("");for(a=0;a!=t[230].length;++a)if(t[230][a].charCodeAt(0)!==65533){r[t[230][a]]=58880+a;e[58880+a]=t[230][a]}t[231]="����������������������������������������������������������������膞膕膢膙膗舖艏艓艒艐艎艑蔤蔻蔏蔀蔩蔎蔉蔍蔟蔊蔧蔜蓻蔫蓺蔈蔌蓴蔪蓲蔕蓷蓫蓳蓼蔒蓪蓩蔖蓾蔨蔝蔮蔂蓽蔞蓶蔱蔦蓧蓨蓰蓯蓹蔘蔠蔰蔋蔙蔯虢����������������������������������蝖蝣蝤蝷蟡蝳蝘蝔蝛蝒蝡蝚蝑蝞蝭蝪蝐蝎蝟蝝蝯蝬蝺蝮蝜蝥蝏蝻蝵蝢蝧蝩衚褅褌褔褋褗褘褙褆褖褑褎褉覢覤覣觭觰觬諏諆誸諓諑諔諕誻諗誾諀諅諘諃誺誽諙谾豍貏賥賟賙賨賚賝賧趠趜趡趛踠踣踥踤踮踕踛踖踑踙踦踧�".split("");for(a=0;a!=t[231].length;++a)if(t[231][a].charCodeAt(0)!==65533){r[t[231][a]]=59136+a;e[59136+a]=t[231][a]}t[232]="����������������������������������������������������������������踔踒踘踓踜踗踚輬輤輘輚輠輣輖輗遳遰遯遧遫鄯鄫鄩鄪鄲鄦鄮醅醆醊醁醂醄醀鋐鋃鋄鋀鋙銶鋏鋱鋟鋘鋩鋗鋝鋌鋯鋂鋨鋊鋈鋎鋦鋍鋕鋉鋠鋞鋧鋑鋓����������������������������������銵鋡鋆銴镼閬閫閮閰隤隢雓霅霈霂靚鞊鞎鞈韐韏頞頝頦頩頨頠頛頧颲餈飺餑餔餖餗餕駜駍駏駓駔駎駉駖駘駋駗駌骳髬髫髳髲髱魆魃魧魴魱魦魶魵魰魨魤魬鳼鳺鳽鳿鳷鴇鴀鳹鳻鴈鴅鴄麃黓鼏鼐儜儓儗儚儑凞匴叡噰噠噮�".split("");for(a=0;a!=t[232].length;++a)if(t[232][a].charCodeAt(0)!==65533){r[t[232][a]]=59392+a;e[59392+a]=t[232][a]}t[233]="����������������������������������������������������������������噳噦噣噭噲噞噷圜圛壈墽壉墿墺壂墼壆嬗嬙嬛嬡嬔嬓嬐嬖嬨嬚嬠嬞寯嶬嶱嶩嶧嶵嶰嶮嶪嶨嶲嶭嶯嶴幧幨幦幯廩廧廦廨廥彋徼憝憨憖懅憴懆懁懌憺����������������������������������憿憸憌擗擖擐擏擉撽撉擃擛擳擙攳敿敼斢曈暾曀曊曋曏暽暻暺曌朣樴橦橉橧樲橨樾橝橭橶橛橑樨橚樻樿橁橪橤橐橏橔橯橩橠樼橞橖橕橍橎橆歕歔歖殧殪殫毈毇氄氃氆澭濋澣濇澼濎濈潞濄澽澞濊澨瀄澥澮澺澬澪濏澿澸�".split("");for(a=0;a!=t[233].length;++a)if(t[233][a].charCodeAt(0)!==65533){r[t[233][a]]=59648+a;e[59648+a]=t[233][a]}t[234]="����������������������������������������������������������������澢濉澫濍澯澲澰燅燂熿熸燖燀燁燋燔燊燇燏熽燘熼燆燚燛犝犞獩獦獧獬獥獫獪瑿璚璠璔璒璕璡甋疀瘯瘭瘱瘽瘳瘼瘵瘲瘰皻盦瞚瞝瞡瞜瞛瞢瞣瞕瞙����������������������������������瞗磝磩磥磪磞磣磛磡磢磭磟磠禤穄穈穇窶窸窵窱窷篞篣篧篝篕篥篚篨篹篔篪篢篜篫篘篟糒糔糗糐糑縒縡縗縌縟縠縓縎縜縕縚縢縋縏縖縍縔縥縤罃罻罼罺羱翯耪耩聬膱膦膮膹膵膫膰膬膴膲膷膧臲艕艖艗蕖蕅蕫蕍蕓蕡蕘�".split("");for(a=0;a!=t[234].length;++a)if(t[234][a].charCodeAt(0)!==65533){r[t[234][a]]=59904+a;e[59904+a]=t[234][a]}t[235]="����������������������������������������������������������������蕀蕆蕤蕁蕢蕄蕑蕇蕣蔾蕛蕱蕎蕮蕵蕕蕧蕠薌蕦蕝蕔蕥蕬虣虥虤螛螏螗螓螒螈螁螖螘蝹螇螣螅螐螑螝螄螔螜螚螉褞褦褰褭褮褧褱褢褩褣褯褬褟觱諠����������������������������������諢諲諴諵諝謔諤諟諰諈諞諡諨諿諯諻貑貒貐賵賮賱賰賳赬赮趥趧踳踾踸蹀蹅踶踼踽蹁踰踿躽輶輮輵輲輹輷輴遶遹遻邆郺鄳鄵鄶醓醐醑醍醏錧錞錈錟錆錏鍺錸錼錛錣錒錁鍆錭錎錍鋋錝鋺錥錓鋹鋷錴錂錤鋿錩錹錵錪錔錌�".split("");for(a=0;a!=t[235].length;++a)if(t[235][a].charCodeAt(0)!==65533){r[t[235][a]]=60160+a;e[60160+a]=t[235][a]}t[236]="����������������������������������������������������������������錋鋾錉錀鋻錖閼闍閾閹閺閶閿閵閽隩雔霋霒霐鞙鞗鞔韰韸頵頯頲餤餟餧餩馞駮駬駥駤駰駣駪駩駧骹骿骴骻髶髺髹髷鬳鮀鮅鮇魼魾魻鮂鮓鮒鮐魺鮕����������������������������������魽鮈鴥鴗鴠鴞鴔鴩鴝鴘鴢鴐鴙鴟麈麆麇麮麭黕黖黺鼒鼽儦儥儢儤儠儩勴嚓嚌嚍嚆嚄嚃噾嚂噿嚁壖壔壏壒嬭嬥嬲嬣嬬嬧嬦嬯嬮孻寱寲嶷幬幪徾徻懃憵憼懧懠懥懤懨懞擯擩擣擫擤擨斁斀斶旚曒檍檖檁檥檉檟檛檡檞檇檓檎�".split("");for(a=0;a!=t[236].length;++a)if(t[236][a].charCodeAt(0)!==65533){r[t[236][a]]=60416+a;e[60416+a]=t[236][a]}t[237]="����������������������������������������������������������������檕檃檨檤檑橿檦檚檅檌檒歛殭氉濌澩濴濔濣濜濭濧濦濞濲濝濢濨燡燱燨燲燤燰燢獳獮獯璗璲璫璐璪璭璱璥璯甐甑甒甏疄癃癈癉癇皤盩瞵瞫瞲瞷瞶����������������������������������瞴瞱瞨矰磳磽礂磻磼磲礅磹磾礄禫禨穜穛穖穘穔穚窾竀竁簅簏篲簀篿篻簎篴簋篳簂簉簃簁篸篽簆篰篱簐簊糨縭縼繂縳顈縸縪繉繀繇縩繌縰縻縶繄縺罅罿罾罽翴翲耬膻臄臌臊臅臇膼臩艛艚艜薃薀薏薧薕薠薋薣蕻薤薚薞�".split("");for(a=0;a!=t[237].length;++a)if(t[237][a].charCodeAt(0)!==65533){r[t[237][a]]=60672+a;e[60672+a]=t[237][a]}t[238]="����������������������������������������������������������������蕷蕼薉薡蕺蕸蕗薎薖薆薍薙薝薁薢薂薈薅蕹蕶薘薐薟虨螾螪螭蟅螰螬螹螵螼螮蟉蟃蟂蟌螷螯蟄蟊螴螶螿螸螽蟞螲褵褳褼褾襁襒褷襂覭覯覮觲觳謞����������������������������������謘謖謑謅謋謢謏謒謕謇謍謈謆謜謓謚豏豰豲豱豯貕貔賹赯蹎蹍蹓蹐蹌蹇轃轀邅遾鄸醚醢醛醙醟醡醝醠鎡鎃鎯鍤鍖鍇鍼鍘鍜鍶鍉鍐鍑鍠鍭鎏鍌鍪鍹鍗鍕鍒鍏鍱鍷鍻鍡鍞鍣鍧鎀鍎鍙闇闀闉闃闅閷隮隰隬霠霟霘霝霙鞚鞡鞜�".split("");for(a=0;a!=t[238].length;++a)if(t[238][a].charCodeAt(0)!==65533){r[t[238][a]]=60928+a;e[60928+a]=t[238][a]}t[239]="����������������������������������������������������������������鞞鞝韕韔韱顁顄顊顉顅顃餥餫餬餪餳餲餯餭餱餰馘馣馡騂駺駴駷駹駸駶駻駽駾駼騃骾髾髽鬁髼魈鮚鮨鮞鮛鮦鮡鮥鮤鮆鮢鮠鮯鴳鵁鵧鴶鴮鴯鴱鴸鴰����������������������������������鵅鵂鵃鴾鴷鵀鴽翵鴭麊麉麍麰黈黚黻黿鼤鼣鼢齔龠儱儭儮嚘嚜嚗嚚嚝嚙奰嬼屩屪巀幭幮懘懟懭懮懱懪懰懫懖懩擿攄擽擸攁攃擼斔旛曚曛曘櫅檹檽櫡櫆檺檶檷櫇檴檭歞毉氋瀇瀌瀍瀁瀅瀔瀎濿瀀濻瀦濼濷瀊爁燿燹爃燽獶�".split("");for(a=0;a!=t[239].length;++a)if(t[239][a].charCodeAt(0)!==65533){r[t[239][a]]=61184+a;e[61184+a]=t[239][a]}t[240]="����������������������������������������������������������������璸瓀璵瓁璾璶璻瓂甔甓癜癤癙癐癓癗癚皦皽盬矂瞺磿礌礓礔礉礐礒礑禭禬穟簜簩簙簠簟簭簝簦簨簢簥簰繜繐繖繣繘繢繟繑繠繗繓羵羳翷翸聵臑臒����������������������������������臐艟艞薴藆藀藃藂薳薵薽藇藄薿藋藎藈藅薱薶藒蘤薸薷薾虩蟧蟦蟢蟛蟫蟪蟥蟟蟳蟤蟔蟜蟓蟭蟘蟣螤蟗蟙蠁蟴蟨蟝襓襋襏襌襆襐襑襉謪謧謣謳謰謵譇謯謼謾謱謥謷謦謶謮謤謻謽謺豂豵貙貘貗賾贄贂贀蹜蹢蹠蹗蹖蹞蹥蹧�".split("");for(a=0;a!=t[240].length;++a)if(t[240][a].charCodeAt(0)!==65533){r[t[240][a]]=61440+a;e[61440+a]=t[240][a]}t[241]="����������������������������������������������������������������蹛蹚蹡蹝蹩蹔轆轇轈轋鄨鄺鄻鄾醨醥醧醯醪鎵鎌鎒鎷鎛鎝鎉鎧鎎鎪鎞鎦鎕鎈鎙鎟鎍鎱鎑鎲鎤鎨鎴鎣鎥闒闓闑隳雗雚巂雟雘雝霣霢霥鞬鞮鞨鞫鞤鞪����������������������������������鞢鞥韗韙韖韘韺顐顑顒颸饁餼餺騏騋騉騍騄騑騊騅騇騆髀髜鬈鬄鬅鬩鬵魊魌魋鯇鯆鯃鮿鯁鮵鮸鯓鮶鯄鮹鮽鵜鵓鵏鵊鵛鵋鵙鵖鵌鵗鵒鵔鵟鵘鵚麎麌黟鼁鼀鼖鼥鼫鼪鼩鼨齌齕儴儵劖勷厴嚫嚭嚦嚧嚪嚬壚壝壛夒嬽嬾嬿巃幰�".split("");for(a=0;a!=t[241].length;++a)if(t[241][a].charCodeAt(0)!==65533){r[t[241][a]]=61696+a;e[61696+a]=t[241][a]}t[242]="����������������������������������������������������������������徿懻攇攐攍攉攌攎斄旞旝曞櫧櫠櫌櫑櫙櫋櫟櫜櫐櫫櫏櫍櫞歠殰氌瀙瀧瀠瀖瀫瀡瀢瀣瀩瀗瀤瀜瀪爌爊爇爂爅犥犦犤犣犡瓋瓅璷瓃甖癠矉矊矄矱礝礛����������������������������������礡礜礗礞禰穧穨簳簼簹簬簻糬糪繶繵繸繰繷繯繺繲繴繨罋罊羃羆羷翽翾聸臗臕艤艡艣藫藱藭藙藡藨藚藗藬藲藸藘藟藣藜藑藰藦藯藞藢蠀蟺蠃蟶蟷蠉蠌蠋蠆蟼蠈蟿蠊蠂襢襚襛襗襡襜襘襝襙覈覷覶觶譐譈譊譀譓譖譔譋譕�".split("");for(a=0;a!=t[242].length;++a)if(t[242][a].charCodeAt(0)!==65533){r[t[242][a]]=61952+a;e[61952+a]=t[242][a]}t[243]="����������������������������������������������������������������譑譂譒譗豃豷豶貚贆贇贉趬趪趭趫蹭蹸蹳蹪蹯蹻軂轒轑轏轐轓辴酀鄿醰醭鏞鏇鏏鏂鏚鏐鏹鏬鏌鏙鎩鏦鏊鏔鏮鏣鏕鏄鏎鏀鏒鏧镽闚闛雡霩霫霬霨霦����������������������������������鞳鞷鞶韝韞韟顜顙顝顗颿颽颻颾饈饇饃馦馧騚騕騥騝騤騛騢騠騧騣騞騜騔髂鬋鬊鬎鬌鬷鯪鯫鯠鯞鯤鯦鯢鯰鯔鯗鯬鯜鯙鯥鯕鯡鯚鵷鶁鶊鶄鶈鵱鶀鵸鶆鶋鶌鵽鵫鵴鵵鵰鵩鶅鵳鵻鶂鵯鵹鵿鶇鵨麔麑黀黼鼭齀齁齍齖齗齘匷嚲�".split("");for(a=0;a!=t[243].length;++a)if(t[243][a].charCodeAt(0)!==65533){r[t[243][a]]=62208+a;e[62208+a]=t[243][a]}t[244]="����������������������������������������������������������������嚵嚳壣孅巆巇廮廯忀忁懹攗攖攕攓旟曨曣曤櫳櫰櫪櫨櫹櫱櫮櫯瀼瀵瀯瀷瀴瀱灂瀸瀿瀺瀹灀瀻瀳灁爓爔犨獽獼璺皫皪皾盭矌矎矏矍矲礥礣礧礨礤礩����������������������������������禲穮穬穭竷籉籈籊籇籅糮繻繾纁纀羺翿聹臛臙舋艨艩蘢藿蘁藾蘛蘀藶蘄蘉蘅蘌藽蠙蠐蠑蠗蠓蠖襣襦覹觷譠譪譝譨譣譥譧譭趮躆躈躄轙轖轗轕轘轚邍酃酁醷醵醲醳鐋鐓鏻鐠鐏鐔鏾鐕鐐鐨鐙鐍鏵鐀鏷鐇鐎鐖鐒鏺鐉鏸鐊鏿�".split("");for(a=0;a!=t[244].length;++a)if(t[244][a].charCodeAt(0)!==65533){r[t[244][a]]=62464+a;e[62464+a]=t[244][a]}t[245]="����������������������������������������������������������������鏼鐌鏶鐑鐆闞闠闟霮霯鞹鞻韽韾顠顢顣顟飁飂饐饎饙饌饋饓騲騴騱騬騪騶騩騮騸騭髇髊髆鬐鬒鬑鰋鰈鯷鰅鰒鯸鱀鰇鰎鰆鰗鰔鰉鶟鶙鶤鶝鶒鶘鶐鶛����������������������������������鶠鶔鶜鶪鶗鶡鶚鶢鶨鶞鶣鶿鶩鶖鶦鶧麙麛麚黥黤黧黦鼰鼮齛齠齞齝齙龑儺儹劘劗囃嚽嚾孈孇巋巏廱懽攛欂櫼欃櫸欀灃灄灊灈灉灅灆爝爚爙獾甗癪矐礭礱礯籔籓糲纊纇纈纋纆纍罍羻耰臝蘘蘪蘦蘟蘣蘜蘙蘧蘮蘡蘠蘩蘞蘥�".split("");for(a=0;a!=t[245].length;++a)if(t[245][a].charCodeAt(0)!==65533){r[t[245][a]]=62720+a;e[62720+a]=t[245][a]}t[246]="����������������������������������������������������������������蠩蠝蠛蠠蠤蠜蠫衊襭襩襮襫觺譹譸譅譺譻贐贔趯躎躌轞轛轝酆酄酅醹鐿鐻鐶鐩鐽鐼鐰鐹鐪鐷鐬鑀鐱闥闤闣霵霺鞿韡顤飉飆飀饘饖騹騽驆驄驂驁騺����������������������������������騿髍鬕鬗鬘鬖鬺魒鰫鰝鰜鰬鰣鰨鰩鰤鰡鶷鶶鶼鷁鷇鷊鷏鶾鷅鷃鶻鶵鷎鶹鶺鶬鷈鶱鶭鷌鶳鷍鶲鹺麜黫黮黭鼛鼘鼚鼱齎齥齤龒亹囆囅囋奱孋孌巕巑廲攡攠攦攢欋欈欉氍灕灖灗灒爞爟犩獿瓘瓕瓙瓗癭皭礵禴穰穱籗籜籙籛籚�".split("");for(a=0;a!=t[246].length;++a)if(t[246][a].charCodeAt(0)!==65533){r[t[246][a]]=62976+a;e[62976+a]=t[246][a]}t[247]="����������������������������������������������������������������糴糱纑罏羇臞艫蘴蘵蘳蘬蘲蘶蠬蠨蠦蠪蠥襱覿覾觻譾讄讂讆讅譿贕躕躔躚躒躐躖躗轠轢酇鑌鑐鑊鑋鑏鑇鑅鑈鑉鑆霿韣顪顩飋饔饛驎驓驔驌驏驈驊����������������������������������驉驒驐髐鬙鬫鬻魖魕鱆鱈鰿鱄鰹鰳鱁鰼鰷鰴鰲鰽鰶鷛鷒鷞鷚鷋鷐鷜鷑鷟鷩鷙鷘鷖鷵鷕鷝麶黰鼵鼳鼲齂齫龕龢儽劙壨壧奲孍巘蠯彏戁戃戄攩攥斖曫欑欒欏毊灛灚爢玂玁玃癰矔籧籦纕艬蘺虀蘹蘼蘱蘻蘾蠰蠲蠮蠳襶襴襳觾�".split("");for(a=0;a!=t[247].length;++a)if(t[247][a].charCodeAt(0)!==65533){r[t[247][a]]=63232+a;e[63232+a]=t[247][a]}t[248]="����������������������������������������������������������������讌讎讋讈豅贙躘轤轣醼鑢鑕鑝鑗鑞韄韅頀驖驙鬞鬟鬠鱒鱘鱐鱊鱍鱋鱕鱙鱌鱎鷻鷷鷯鷣鷫鷸鷤鷶鷡鷮鷦鷲鷰鷢鷬鷴鷳鷨鷭黂黐黲黳鼆鼜鼸鼷鼶齃齏����������������������������������齱齰齮齯囓囍孎屭攭曭曮欓灟灡灝灠爣瓛瓥矕礸禷禶籪纗羉艭虃蠸蠷蠵衋讔讕躞躟躠躝醾醽釂鑫鑨鑩雥靆靃靇韇韥驞髕魙鱣鱧鱦鱢鱞鱠鸂鷾鸇鸃鸆鸅鸀鸁鸉鷿鷽鸄麠鼞齆齴齵齶囔攮斸欘欙欗欚灢爦犪矘矙礹籩籫糶纚�".split("");for(a=0;a!=t[248].length;++a)if(t[248][a].charCodeAt(0)!==65533){r[t[248][a]]=63488+a;e[63488+a]=t[248][a]}t[249]="����������������������������������������������������������������纘纛纙臠臡虆虇虈襹襺襼襻觿讘讙躥躤躣鑮鑭鑯鑱鑳靉顲饟鱨鱮鱭鸋鸍鸐鸏鸒鸑麡黵鼉齇齸齻齺齹圞灦籯蠼趲躦釃鑴鑸鑶鑵驠鱴鱳鱱鱵鸔鸓黶鼊����������������������������������龤灨灥糷虪蠾蠽蠿讞貜躩軉靋顳顴飌饡馫驤驦驧鬤鸕鸗齈戇欞爧虌躨钂钀钁驩驨鬮鸙爩虋讟钃鱹麷癵驫鱺鸝灩灪麤齾齉龘碁銹裏墻恒粧嫺╔╦╗╠╬╣╚╩╝╒╤╕╞╪╡╘╧╛╓╥╖╟╫╢╙╨╜║═╭╮╰╯▓�".split("");for(a=0;a!=t[249].length;++a)if(t[249][a].charCodeAt(0)!==65533){r[t[249][a]]=63744+a;e[63744+a]=t[249][a]}return{enc:r,dec:e}}();cptable[1250]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€�‚�„…†‡�‰Š‹ŚŤŽŹ�‘’“”•–—�™š›śťžź ˇ˘Ł¤Ą¦§¨©Ş«¬­®Ż°±˛ł´µ¶·¸ąş»Ľ˝ľżŔÁÂĂÄĹĆÇČÉĘËĚÍÎĎĐŃŇÓÔŐÖ×ŘŮÚŰÜÝŢßŕáâăäĺćçčéęëěíîďđńňóôőö÷řůúűüýţ˙",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[1251]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ЂЃ‚ѓ„…†‡€‰Љ‹ЊЌЋЏђ‘’“”•–—�™љ›њќћџ ЎўЈ¤Ґ¦§Ё©Є«¬­®Ї°±Ііґµ¶·ё№є»јЅѕїАБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюя",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[1252]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€�‚ƒ„…†‡ˆ‰Š‹Œ�Ž��‘’“”•–—˜™š›œ�žŸ ¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[1253]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€�‚ƒ„…†‡�‰�‹�����‘’“”•–—�™�›���� ΅Ά£¤¥¦§¨©�«¬­®―°±²³΄µ¶·ΈΉΊ»Ό½ΎΏΐΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡ�ΣΤΥΦΧΨΩΪΫάέήίΰαβγδεζηθικλμνξοπρςστυφχψωϊϋόύώ�",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[1254]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€�‚ƒ„…†‡ˆ‰Š‹Œ����‘’“”•–—˜™š›œ��Ÿ ¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏĞÑÒÓÔÕÖרÙÚÛÜİŞßàáâãäåæçèéêëìíîïğñòóôõö÷øùúûüışÿ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[1255]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€�‚ƒ„…†‡ˆ‰�‹�����‘’“”•–—˜™�›���� ¡¢£₪¥¦§¨©×«¬­®¯°±²³´µ¶·¸¹÷»¼½¾¿ְֱֲֳִֵֶַָֹ�ֻּֽ־ֿ׀ׁׂ׃װױײ׳״�������אבגדהוזחטיךכלםמןנסעףפץצקרשת��‎‏�",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[1256]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€پ‚ƒ„…†‡ˆ‰ٹ‹Œچژڈگ‘’“”•–—ک™ڑ›œ‌‍ں ،¢£¤¥¦§¨©ھ«¬­®¯°±²³´µ¶·¸¹؛»¼½¾؟ہءآأؤإئابةتثجحخدذرزسشصض×طظعغـفقكàلâمنهوçèéêëىيîïًٌٍَôُِ÷ّùْûü‎‏ے",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[1257]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€�‚�„…†‡�‰�‹�¨ˇ¸�‘’“”•–—�™�›�¯˛� �¢£¤�¦§Ø©Ŗ«¬­®Æ°±²³´µ¶·ø¹ŗ»¼½¾æĄĮĀĆÄÅĘĒČÉŹĖĢĶĪĻŠŃŅÓŌÕÖ×ŲŁŚŪÜŻŽßąįāćäåęēčéźėģķīļšńņóōõö÷ųłśūüżž˙",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[1258]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€�‚ƒ„…†‡ˆ‰�‹Œ����‘’“”•–—˜™�›œ��Ÿ ¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂĂÄÅÆÇÈÉÊË̀ÍÎÏĐÑ̉ÓÔƠÖרÙÚÛÜỮßàáâăäåæçèéêë́íîïđṇ̃óôơö÷øùúûüư₫ÿ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[1e4]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ÄÅÇÉÑÖÜáàâäãåçéèêëíìîïñóòôöõúùûü†°¢£§•¶ß®©™´¨≠ÆØ∞±≤≥¥µ∂∑∏π∫ªºΩæø¿¡¬√ƒ≈∆«»… ÀÃÕŒœ–—“”‘’÷◊ÿŸ⁄¤‹›fifl‡·‚„‰ÂÊÁËÈÍÎÏÌÓÔ�ÒÚÛÙıˆ˜¯˘˙˚¸˝˛ˇ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[10006]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~Ĺ²É³ÖÜ΅àâä΄¨çéèê룙î‰ôö¦­ùûü†ΓΔΘΛΞΠß®©ΣΪ§≠°·Α±≤≥¥ΒΕΖΗΙΚΜΦΫΨΩάΝ¬ΟΡ≈Τ«»… ΥΧΆΈœ–―“”‘’÷ΉΊΌΎέήίόΏύαβψδεφγηιξκλμνοπώρστθωςχυζϊϋΐΰ�",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[10007]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ†°¢£§•¶І®©™Ђђ≠Ѓѓ∞±≤≥іµ∂ЈЄєЇїЉљЊњјЅ¬√ƒ≈∆«»… ЋћЌќѕ–—“”‘’÷„ЎўЏџ№Ёёяабвгдежзийклмнопрстуфхцчшщъыьэю¤",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[10008]=function(){var e=[],r={},t=[],a;t[0]="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€���������������������������������������������������������������������������������������".split("");for(a=0;a!=t[0].length;++a)if(t[0][a].charCodeAt(0)!==65533){r[t[0][a]]=0+a;e[0+a]=t[0][a]}t[161]="����������������������������������������������������������������������������������������������������������������������������������������������������������������� 、。・ˉˇ¨〃々―~�…‘’“”〔〕〈〉《》「」『』〖〗【】±×÷∶∧∨∑∏∪∩∈∷√⊥∥∠⌒⊙∫∮≡≌≈∽∝≠≮≯≤≥∞∵∴♂♀°′″℃$¤¢£‰§№☆★○●◎◇◆□■△▲※→←↑↓〓�".split("");for(a=0;a!=t[161].length;++a)if(t[161][a].charCodeAt(0)!==65533){r[t[161][a]]=41216+a;e[41216+a]=t[161][a]}t[162]="���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������⒈⒉⒊⒋⒌⒍⒎⒏⒐⒑⒒⒓⒔⒕⒖⒗⒘⒙⒚⒛⑴⑵⑶⑷⑸⑹⑺⑻⑼⑽⑾⑿⒀⒁⒂⒃⒄⒅⒆⒇①②③④⑤⑥⑦⑧⑨⑩��㈠㈡㈢㈣㈤㈥㈦㈧㈨㈩��ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫ���".split("");for(a=0;a!=t[162].length;++a)if(t[162][a].charCodeAt(0)!==65533){r[t[162][a]]=41472+a;e[41472+a]=t[162][a]}t[163]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������!"#¥%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|} ̄�".split("");for(a=0;a!=t[163].length;++a)if(t[163][a].charCodeAt(0)!==65533){r[t[163][a]]=41728+a;e[41728+a]=t[163][a]}t[164]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������ぁあぃいぅうぇえぉおかがきぎくぐけげこごさざしじすずせぜそぞただちぢっつづてでとどなにぬねのはばぱひびぴふぶぷへべぺほぼぽまみむめもゃやゅゆょよらりるれろゎわゐゑをん������������".split("");for(a=0;a!=t[164].length;++a)if(t[164][a].charCodeAt(0)!==65533){r[t[164][a]]=41984+a;e[41984+a]=t[164][a]}t[165]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������ァアィイゥウェエォオカガキギクグケゲコゴサザシジスズセゼソゾタダチヂッツヅテデトドナニヌネノハバパヒビピフブプヘベペホボポマミムメモャヤュユョヨラリルレロヮワヰヱヲンヴヵヶ���������".split("");for(a=0;a!=t[165].length;++a)if(t[165][a].charCodeAt(0)!==65533){r[t[165][a]]=42240+a;e[42240+a]=t[165][a]}t[166]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ��������αβγδεζηθικλμνξοπρστυφχψω���������������������������������������".split("");for(a=0;a!=t[166].length;++a)if(t[166][a].charCodeAt(0)!==65533){ -r[t[166][a]]=42496+a;e[42496+a]=t[166][a]}t[167]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ���������������абвгдеёжзийклмнопрстуфхцчшщъыьэюя��������������".split("");for(a=0;a!=t[167].length;++a)if(t[167][a].charCodeAt(0)!==65533){r[t[167][a]]=42752+a;e[42752+a]=t[167][a]}t[168]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������āáǎàēéěèīíǐìōóǒòūúǔùǖǘǚǜüê����������ㄅㄆㄇㄈㄉㄊㄋㄌㄍㄎㄏㄐㄑㄒㄓㄔㄕㄖㄗㄘㄙㄚㄛㄜㄝㄞㄟㄠㄡㄢㄣㄤㄥㄦㄧㄨㄩ����������������������".split("");for(a=0;a!=t[168].length;++a)if(t[168][a].charCodeAt(0)!==65533){r[t[168][a]]=43008+a;e[43008+a]=t[168][a]}t[169]="��������������������������������������������������������������������������������������������������������������������������������������������������������������������─━│┃┄┅┆┇┈┉┊┋┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋����������������".split("");for(a=0;a!=t[169].length;++a)if(t[169][a].charCodeAt(0)!==65533){r[t[169][a]]=43264+a;e[43264+a]=t[169][a]}t[176]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������啊阿埃挨哎唉哀皑癌蔼矮艾碍爱隘鞍氨安俺按暗岸胺案肮昂盎凹敖熬翱袄傲奥懊澳芭捌扒叭吧笆八疤巴拔跋靶把耙坝霸罢爸白柏百摆佰败拜稗斑班搬扳般颁板版扮拌伴瓣半办绊邦帮梆榜膀绑棒磅蚌镑傍谤苞胞包褒剥�".split("");for(a=0;a!=t[176].length;++a)if(t[176][a].charCodeAt(0)!==65533){r[t[176][a]]=45056+a;e[45056+a]=t[176][a]}t[177]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������薄雹保堡饱宝抱报暴豹鲍爆杯碑悲卑北辈背贝钡倍狈备惫焙被奔苯本笨崩绷甭泵蹦迸逼鼻比鄙笔彼碧蓖蔽毕毙毖币庇痹闭敝弊必辟壁臂避陛鞭边编贬扁便变卞辨辩辫遍标彪膘表鳖憋别瘪彬斌濒滨宾摈兵冰柄丙秉饼炳�".split("");for(a=0;a!=t[177].length;++a)if(t[177][a].charCodeAt(0)!==65533){r[t[177][a]]=45312+a;e[45312+a]=t[177][a]}t[178]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������病并玻菠播拨钵波博勃搏铂箔伯帛舶脖膊渤泊驳捕卜哺补埠不布步簿部怖擦猜裁材才财睬踩采彩菜蔡餐参蚕残惭惨灿苍舱仓沧藏操糙槽曹草厕策侧册测层蹭插叉茬茶查碴搽察岔差诧拆柴豺搀掺蝉馋谗缠铲产阐颤昌猖�".split("");for(a=0;a!=t[178].length;++a)if(t[178][a].charCodeAt(0)!==65533){r[t[178][a]]=45568+a;e[45568+a]=t[178][a]}t[179]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������场尝常长偿肠厂敞畅唱倡超抄钞朝嘲潮巢吵炒车扯撤掣彻澈郴臣辰尘晨忱沉陈趁衬撑称城橙成呈乘程惩澄诚承逞骋秤吃痴持匙池迟弛驰耻齿侈尺赤翅斥炽充冲虫崇宠抽酬畴踌稠愁筹仇绸瞅丑臭初出橱厨躇锄雏滁除楚�".split("");for(a=0;a!=t[179].length;++a)if(t[179][a].charCodeAt(0)!==65533){r[t[179][a]]=45824+a;e[45824+a]=t[179][a]}t[180]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������础储矗搐触处揣川穿椽传船喘串疮窗幢床闯创吹炊捶锤垂春椿醇唇淳纯蠢戳绰疵茨磁雌辞慈瓷词此刺赐次聪葱囱匆从丛凑粗醋簇促蹿篡窜摧崔催脆瘁粹淬翠村存寸磋撮搓措挫错搭达答瘩打大呆歹傣戴带殆代贷袋待逮�".split("");for(a=0;a!=t[180].length;++a)if(t[180][a].charCodeAt(0)!==65533){r[t[180][a]]=46080+a;e[46080+a]=t[180][a]}t[181]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������怠耽担丹单郸掸胆旦氮但惮淡诞弹蛋当挡党荡档刀捣蹈倒岛祷导到稻悼道盗德得的蹬灯登等瞪凳邓堤低滴迪敌笛狄涤翟嫡抵底地蒂第帝弟递缔颠掂滇碘点典靛垫电佃甸店惦奠淀殿碉叼雕凋刁掉吊钓调跌爹碟蝶迭谍叠�".split("");for(a=0;a!=t[181].length;++a)if(t[181][a].charCodeAt(0)!==65533){r[t[181][a]]=46336+a;e[46336+a]=t[181][a]}t[182]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������丁盯叮钉顶鼎锭定订丢东冬董懂动栋侗恫冻洞兜抖斗陡豆逗痘都督毒犊独读堵睹赌杜镀肚度渡妒端短锻段断缎堆兑队对墩吨蹲敦顿囤钝盾遁掇哆多夺垛躲朵跺舵剁惰堕蛾峨鹅俄额讹娥恶厄扼遏鄂饿恩而儿耳尔饵洱二�".split("");for(a=0;a!=t[182].length;++a)if(t[182][a].charCodeAt(0)!==65533){r[t[182][a]]=46592+a;e[46592+a]=t[182][a]}t[183]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������贰发罚筏伐乏阀法珐藩帆番翻樊矾钒繁凡烦反返范贩犯饭泛坊芳方肪房防妨仿访纺放菲非啡飞肥匪诽吠肺废沸费芬酚吩氛分纷坟焚汾粉奋份忿愤粪丰封枫蜂峰锋风疯烽逢冯缝讽奉凤佛否夫敷肤孵扶拂辐幅氟符伏俘服�".split("");for(a=0;a!=t[183].length;++a)if(t[183][a].charCodeAt(0)!==65533){r[t[183][a]]=46848+a;e[46848+a]=t[183][a]}t[184]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������浮涪福袱弗甫抚辅俯釜斧脯腑府腐赴副覆赋复傅付阜父腹负富讣附妇缚咐噶嘎该改概钙盖溉干甘杆柑竿肝赶感秆敢赣冈刚钢缸肛纲岗港杠篙皋高膏羔糕搞镐稿告哥歌搁戈鸽胳疙割革葛格蛤阁隔铬个各给根跟耕更庚羹�".split("");for(a=0;a!=t[184].length;++a)if(t[184][a].charCodeAt(0)!==65533){r[t[184][a]]=47104+a;e[47104+a]=t[184][a]}t[185]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������埂耿梗工攻功恭龚供躬公宫弓巩汞拱贡共钩勾沟苟狗垢构购够辜菇咕箍估沽孤姑鼓古蛊骨谷股故顾固雇刮瓜剐寡挂褂乖拐怪棺关官冠观管馆罐惯灌贯光广逛瑰规圭硅归龟闺轨鬼诡癸桂柜跪贵刽辊滚棍锅郭国果裹过哈�".split("");for(a=0;a!=t[185].length;++a)if(t[185][a].charCodeAt(0)!==65533){r[t[185][a]]=47360+a;e[47360+a]=t[185][a]}t[186]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������骸孩海氦亥害骇酣憨邯韩含涵寒函喊罕翰撼捍旱憾悍焊汗汉夯杭航壕嚎豪毫郝好耗号浩呵喝荷菏核禾和何合盒貉阂河涸赫褐鹤贺嘿黑痕很狠恨哼亨横衡恒轰哄烘虹鸿洪宏弘红喉侯猴吼厚候后呼乎忽瑚壶葫胡蝴狐糊湖�".split("");for(a=0;a!=t[186].length;++a)if(t[186][a].charCodeAt(0)!==65533){r[t[186][a]]=47616+a;e[47616+a]=t[186][a]}t[187]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������弧虎唬护互沪户花哗华猾滑画划化话槐徊怀淮坏欢环桓还缓换患唤痪豢焕涣宦幻荒慌黄磺蝗簧皇凰惶煌晃幌恍谎灰挥辉徽恢蛔回毁悔慧卉惠晦贿秽会烩汇讳诲绘荤昏婚魂浑混豁活伙火获或惑霍货祸击圾基机畸稽积箕�".split("");for(a=0;a!=t[187].length;++a)if(t[187][a].charCodeAt(0)!==65533){r[t[187][a]]=47872+a;e[47872+a]=t[187][a]}t[188]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������肌饥迹激讥鸡姬绩缉吉极棘辑籍集及急疾汲即嫉级挤几脊己蓟技冀季伎祭剂悸济寄寂计记既忌际妓继纪嘉枷夹佳家加荚颊贾甲钾假稼价架驾嫁歼监坚尖笺间煎兼肩艰奸缄茧检柬碱硷拣捡简俭剪减荐槛鉴践贱见键箭件�".split("");for(a=0;a!=t[188].length;++a)if(t[188][a].charCodeAt(0)!==65533){r[t[188][a]]=48128+a;e[48128+a]=t[188][a]}t[189]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������健舰剑饯渐溅涧建僵姜将浆江疆蒋桨奖讲匠酱降蕉椒礁焦胶交郊浇骄娇嚼搅铰矫侥脚狡角饺缴绞剿教酵轿较叫窖揭接皆秸街阶截劫节桔杰捷睫竭洁结解姐戒藉芥界借介疥诫届巾筋斤金今津襟紧锦仅谨进靳晋禁近烬浸�".split("");for(a=0;a!=t[189].length;++a)if(t[189][a].charCodeAt(0)!==65533){r[t[189][a]]=48384+a;e[48384+a]=t[189][a]}t[190]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������尽劲荆兢茎睛晶鲸京惊精粳经井警景颈静境敬镜径痉靖竟竞净炯窘揪究纠玖韭久灸九酒厩救旧臼舅咎就疚鞠拘狙疽居驹菊局咀矩举沮聚拒据巨具距踞锯俱句惧炬剧捐鹃娟倦眷卷绢撅攫抉掘倔爵觉决诀绝均菌钧军君峻�".split("");for(a=0;a!=t[190].length;++a)if(t[190][a].charCodeAt(0)!==65533){r[t[190][a]]=48640+a;e[48640+a]=t[190][a]}t[191]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������俊竣浚郡骏喀咖卡咯开揩楷凯慨刊堪勘坎砍看康慷糠扛抗亢炕考拷烤靠坷苛柯棵磕颗科壳咳可渴克刻客课肯啃垦恳坑吭空恐孔控抠口扣寇枯哭窟苦酷库裤夸垮挎跨胯块筷侩快宽款匡筐狂框矿眶旷况亏盔岿窥葵奎魁傀�".split("");for(a=0;a!=t[191].length;++a)if(t[191][a].charCodeAt(0)!==65533){r[t[191][a]]=48896+a;e[48896+a]=t[191][a]}t[192]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������馈愧溃坤昆捆困括扩廓阔垃拉喇蜡腊辣啦莱来赖蓝婪栏拦篮阑兰澜谰揽览懒缆烂滥琅榔狼廊郎朗浪捞劳牢老佬姥酪烙涝勒乐雷镭蕾磊累儡垒擂肋类泪棱楞冷厘梨犁黎篱狸离漓理李里鲤礼莉荔吏栗丽厉励砾历利傈例俐�".split("");for(a=0;a!=t[192].length;++a)if(t[192][a].charCodeAt(0)!==65533){r[t[192][a]]=49152+a;e[49152+a]=t[192][a]}t[193]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������痢立粒沥隶力璃哩俩联莲连镰廉怜涟帘敛脸链恋炼练粮凉梁粱良两辆量晾亮谅撩聊僚疗燎寥辽潦了撂镣廖料列裂烈劣猎琳林磷霖临邻鳞淋凛赁吝拎玲菱零龄铃伶羚凌灵陵岭领另令溜琉榴硫馏留刘瘤流柳六龙聋咙笼窿�".split("");for(a=0;a!=t[193].length;++a)if(t[193][a].charCodeAt(0)!==65533){r[t[193][a]]=49408+a;e[49408+a]=t[193][a]}t[194]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������隆垄拢陇楼娄搂篓漏陋芦卢颅庐炉掳卤虏鲁麓碌露路赂鹿潞禄录陆戮驴吕铝侣旅履屡缕虑氯律率滤绿峦挛孪滦卵乱掠略抡轮伦仑沦纶论萝螺罗逻锣箩骡裸落洛骆络妈麻玛码蚂马骂嘛吗埋买麦卖迈脉瞒馒蛮满蔓曼慢漫�".split("");for(a=0;a!=t[194].length;++a)if(t[194][a].charCodeAt(0)!==65533){r[t[194][a]]=49664+a;e[49664+a]=t[194][a]}t[195]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������谩芒茫盲氓忙莽猫茅锚毛矛铆卯茂冒帽貌贸么玫枚梅酶霉煤没眉媒镁每美昧寐妹媚门闷们萌蒙檬盟锰猛梦孟眯醚靡糜迷谜弥米秘觅泌蜜密幂棉眠绵冕免勉娩缅面苗描瞄藐秒渺庙妙蔑灭民抿皿敏悯闽明螟鸣铭名命谬摸�".split("");for(a=0;a!=t[195].length;++a)if(t[195][a].charCodeAt(0)!==65533){r[t[195][a]]=49920+a;e[49920+a]=t[195][a]}t[196]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������摹蘑模膜磨摩魔抹末莫墨默沫漠寞陌谋牟某拇牡亩姆母墓暮幕募慕木目睦牧穆拿哪呐钠那娜纳氖乃奶耐奈南男难囊挠脑恼闹淖呢馁内嫩能妮霓倪泥尼拟你匿腻逆溺蔫拈年碾撵捻念娘酿鸟尿捏聂孽啮镊镍涅您柠狞凝宁�".split("");for(a=0;a!=t[196].length;++a)if(t[196][a].charCodeAt(0)!==65533){r[t[196][a]]=50176+a;e[50176+a]=t[196][a]}t[197]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������拧泞牛扭钮纽脓浓农弄奴努怒女暖虐疟挪懦糯诺哦欧鸥殴藕呕偶沤啪趴爬帕怕琶拍排牌徘湃派攀潘盘磐盼畔判叛乓庞旁耪胖抛咆刨炮袍跑泡呸胚培裴赔陪配佩沛喷盆砰抨烹澎彭蓬棚硼篷膨朋鹏捧碰坯砒霹批披劈琵毗�".split("");for(a=0;a!=t[197].length;++a)if(t[197][a].charCodeAt(0)!==65533){r[t[197][a]]=50432+a;e[50432+a]=t[197][a]}t[198]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������啤脾疲皮匹痞僻屁譬篇偏片骗飘漂瓢票撇瞥拼频贫品聘乒坪苹萍平凭瓶评屏坡泼颇婆破魄迫粕剖扑铺仆莆葡菩蒲埔朴圃普浦谱曝瀑期欺栖戚妻七凄漆柒沏其棋奇歧畦崎脐齐旗祈祁骑起岂乞企启契砌器气迄弃汽泣讫掐�".split("");for(a=0;a!=t[198].length;++a)if(t[198][a].charCodeAt(0)!==65533){r[t[198][a]]=50688+a;e[50688+a]=t[198][a]}t[199]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������恰洽牵扦钎铅千迁签仟谦乾黔钱钳前潜遣浅谴堑嵌欠歉枪呛腔羌墙蔷强抢橇锹敲悄桥瞧乔侨巧鞘撬翘峭俏窍切茄且怯窃钦侵亲秦琴勤芹擒禽寝沁青轻氢倾卿清擎晴氰情顷请庆琼穷秋丘邱球求囚酋泅趋区蛆曲躯屈驱渠�".split("");for(a=0;a!=t[199].length;++a)if(t[199][a].charCodeAt(0)!==65533){r[t[199][a]]=50944+a;e[50944+a]=t[199][a]}t[200]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������取娶龋趣去圈颧权醛泉全痊拳犬券劝缺炔瘸却鹊榷确雀裙群然燃冉染瓤壤攘嚷让饶扰绕惹热壬仁人忍韧任认刃妊纫扔仍日戎茸蓉荣融熔溶容绒冗揉柔肉茹蠕儒孺如辱乳汝入褥软阮蕊瑞锐闰润若弱撒洒萨腮鳃塞赛三叁�".split("");for(a=0;a!=t[200].length;++a)if(t[200][a].charCodeAt(0)!==65533){r[t[200][a]]=51200+a;e[51200+a]=t[200][a]}t[201]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������伞散桑嗓丧搔骚扫嫂瑟色涩森僧莎砂杀刹沙纱傻啥煞筛晒珊苫杉山删煽衫闪陕擅赡膳善汕扇缮墒伤商赏晌上尚裳梢捎稍烧芍勺韶少哨邵绍奢赊蛇舌舍赦摄射慑涉社设砷申呻伸身深娠绅神沈审婶甚肾慎渗声生甥牲升绳�".split("");for(a=0;a!=t[201].length;++a)if(t[201][a].charCodeAt(0)!==65533){r[t[201][a]]=51456+a;e[51456+a]=t[201][a]}t[202]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������省盛剩胜圣师失狮施湿诗尸虱十石拾时什食蚀实识史矢使屎驶始式示士世柿事拭誓逝势是嗜噬适仕侍释饰氏市恃室视试收手首守寿授售受瘦兽蔬枢梳殊抒输叔舒淑疏书赎孰熟薯暑曙署蜀黍鼠属术述树束戍竖墅庶数漱�".split("");for(a=0;a!=t[202].length;++a)if(t[202][a].charCodeAt(0)!==65533){r[t[202][a]]=51712+a;e[51712+a]=t[202][a]}t[203]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������恕刷耍摔衰甩帅栓拴霜双爽谁水睡税吮瞬顺舜说硕朔烁斯撕嘶思私司丝死肆寺嗣四伺似饲巳松耸怂颂送宋讼诵搜艘擞嗽苏酥俗素速粟僳塑溯宿诉肃酸蒜算虽隋随绥髓碎岁穗遂隧祟孙损笋蓑梭唆缩琐索锁所塌他它她塔�".split("");for(a=0;a!=t[203].length;++a)if(t[203][a].charCodeAt(0)!==65533){r[t[203][a]]=51968+a;e[51968+a]=t[203][a]}t[204]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������獭挞蹋踏胎苔抬台泰酞太态汰坍摊贪瘫滩坛檀痰潭谭谈坦毯袒碳探叹炭汤塘搪堂棠膛唐糖倘躺淌趟烫掏涛滔绦萄桃逃淘陶讨套特藤腾疼誊梯剔踢锑提题蹄啼体替嚏惕涕剃屉天添填田甜恬舔腆挑条迢眺跳贴铁帖厅听烃�".split("");for(a=0;a!=t[204].length;++a)if(t[204][a].charCodeAt(0)!==65533){r[t[204][a]]=52224+a;e[52224+a]=t[204][a]}t[205]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������汀廷停亭庭挺艇通桐酮瞳同铜彤童桶捅筒统痛偷投头透凸秃突图徒途涂屠土吐兔湍团推颓腿蜕褪退吞屯臀拖托脱鸵陀驮驼椭妥拓唾挖哇蛙洼娃瓦袜歪外豌弯湾玩顽丸烷完碗挽晚皖惋宛婉万腕汪王亡枉网往旺望忘妄威�".split("");for(a=0;a!=t[205].length;++a)if(t[205][a].charCodeAt(0)!==65533){r[t[205][a]]=52480+a;e[52480+a]=t[205][a]}t[206]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������巍微危韦违桅围唯惟为潍维苇萎委伟伪尾纬未蔚味畏胃喂魏位渭谓尉慰卫瘟温蚊文闻纹吻稳紊问嗡翁瓮挝蜗涡窝我斡卧握沃巫呜钨乌污诬屋无芜梧吾吴毋武五捂午舞伍侮坞戊雾晤物勿务悟误昔熙析西硒矽晰嘻吸锡牺�".split("");for(a=0;a!=t[206].length;++a)if(t[206][a].charCodeAt(0)!==65533){r[t[206][a]]=52736+a;e[52736+a]=t[206][a]}t[207]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������稀息希悉膝夕惜熄烯溪汐犀檄袭席习媳喜铣洗系隙戏细瞎虾匣霞辖暇峡侠狭下厦夏吓掀锨先仙鲜纤咸贤衔舷闲涎弦嫌显险现献县腺馅羡宪陷限线相厢镶香箱襄湘乡翔祥详想响享项巷橡像向象萧硝霄削哮嚣销消宵淆晓�".split("");for(a=0;a!=t[207].length;++a)if(t[207][a].charCodeAt(0)!==65533){r[t[207][a]]=52992+a;e[52992+a]=t[207][a]}t[208]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������小孝校肖啸笑效楔些歇蝎鞋协挟携邪斜胁谐写械卸蟹懈泄泻谢屑薪芯锌欣辛新忻心信衅星腥猩惺兴刑型形邢行醒幸杏性姓兄凶胸匈汹雄熊休修羞朽嗅锈秀袖绣墟戌需虚嘘须徐许蓄酗叙旭序畜恤絮婿绪续轩喧宣悬旋玄�".split("");for(a=0;a!=t[208].length;++a)if(t[208][a].charCodeAt(0)!==65533){r[t[208][a]]=53248+a;e[53248+a]=t[208][a]}t[209]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������选癣眩绚靴薛学穴雪血勋熏循旬询寻驯巡殉汛训讯逊迅压押鸦鸭呀丫芽牙蚜崖衙涯雅哑亚讶焉咽阉烟淹盐严研蜒岩延言颜阎炎沿奄掩眼衍演艳堰燕厌砚雁唁彦焰宴谚验殃央鸯秧杨扬佯疡羊洋阳氧仰痒养样漾邀腰妖瑶�".split("");for(a=0;a!=t[209].length;++a)if(t[209][a].charCodeAt(0)!==65533){r[t[209][a]]=53504+a;e[53504+a]=t[209][a]}t[210]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������摇尧遥窑谣姚咬舀药要耀椰噎耶爷野冶也页掖业叶曳腋夜液一壹医揖铱依伊衣颐夷遗移仪胰疑沂宜姨彝椅蚁倚已乙矣以艺抑易邑屹亿役臆逸肄疫亦裔意毅忆义益溢诣议谊译异翼翌绎茵荫因殷音阴姻吟银淫寅饮尹引隐�".split("");for(a=0;a!=t[210].length;++a)if(t[210][a].charCodeAt(0)!==65533){r[t[210][a]]=53760+a;e[53760+a]=t[210][a]}t[211]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������印英樱婴鹰应缨莹萤营荧蝇迎赢盈影颖硬映哟拥佣臃痈庸雍踊蛹咏泳涌永恿勇用幽优悠忧尤由邮铀犹油游酉有友右佑釉诱又幼迂淤于盂榆虞愚舆余俞逾鱼愉渝渔隅予娱雨与屿禹宇语羽玉域芋郁吁遇喻峪御愈欲狱育誉�".split("");for(a=0;a!=t[211].length;++a)if(t[211][a].charCodeAt(0)!==65533){r[t[211][a]]=54016+a;e[54016+a]=t[211][a]}t[212]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������浴寓裕预豫驭鸳渊冤元垣袁原援辕园员圆猿源缘远苑愿怨院曰约越跃钥岳粤月悦阅耘云郧匀陨允运蕴酝晕韵孕匝砸杂栽哉灾宰载再在咱攒暂赞赃脏葬遭糟凿藻枣早澡蚤躁噪造皂灶燥责择则泽贼怎增憎曾赠扎喳渣札轧�".split("");for(a=0;a!=t[212].length;++a)if(t[212][a].charCodeAt(0)!==65533){r[t[212][a]]=54272+a;e[54272+a]=t[212][a]}t[213]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������铡闸眨栅榨咋乍炸诈摘斋宅窄债寨瞻毡詹粘沾盏斩辗崭展蘸栈占战站湛绽樟章彰漳张掌涨杖丈帐账仗胀瘴障招昭找沼赵照罩兆肇召遮折哲蛰辙者锗蔗这浙珍斟真甄砧臻贞针侦枕疹诊震振镇阵蒸挣睁征狰争怔整拯正政�".split("");for(a=0;a!=t[213].length;++a)if(t[213][a].charCodeAt(0)!==65533){r[t[213][a]]=54528+a;e[54528+a]=t[213][a]}t[214]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������帧症郑证芝枝支吱蜘知肢脂汁之织职直植殖执值侄址指止趾只旨纸志挚掷至致置帜峙制智秩稚质炙痔滞治窒中盅忠钟衷终种肿重仲众舟周州洲诌粥轴肘帚咒皱宙昼骤珠株蛛朱猪诸诛逐竹烛煮拄瞩嘱主著柱助蛀贮铸筑�".split("");for(a=0;a!=t[214].length;++a)if(t[214][a].charCodeAt(0)!==65533){r[t[214][a]]=54784+a;e[54784+a]=t[214][a]}t[215]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������住注祝驻抓爪拽专砖转撰赚篆桩庄装妆撞壮状椎锥追赘坠缀谆准捉拙卓桌琢茁酌啄着灼浊兹咨资姿滋淄孜紫仔籽滓子自渍字鬃棕踪宗综总纵邹走奏揍租足卒族祖诅阻组钻纂嘴醉最罪尊遵昨左佐柞做作坐座������".split("");for(a=0;a!=t[215].length;++a)if(t[215][a].charCodeAt(0)!==65533){r[t[215][a]]=55040+a;e[55040+a]=t[215][a]}t[216]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������亍丌兀丐廿卅丕亘丞鬲孬噩丨禺丿匕乇夭爻卮氐囟胤馗毓睾鼗丶亟鼐乜乩亓芈孛啬嘏仄厍厝厣厥厮靥赝匚叵匦匮匾赜卦卣刂刈刎刭刳刿剀剌剞剡剜蒯剽劂劁劐劓冂罔亻仃仉仂仨仡仫仞伛仳伢佤仵伥伧伉伫佞佧攸佚佝�".split("");for(a=0;a!=t[216].length;++a)if(t[216][a].charCodeAt(0)!==65533){r[t[216][a]]=55296+a;e[55296+a]=t[216][a]}t[217]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������佟佗伲伽佶佴侑侉侃侏佾佻侪佼侬侔俦俨俪俅俚俣俜俑俟俸倩偌俳倬倏倮倭俾倜倌倥倨偾偃偕偈偎偬偻傥傧傩傺僖儆僭僬僦僮儇儋仝氽佘佥俎龠汆籴兮巽黉馘冁夔勹匍訇匐凫夙兕亠兖亳衮袤亵脔裒禀嬴蠃羸冫冱冽冼�".split("");for(a=0;a!=t[217].length;++a)if(t[217][a].charCodeAt(0)!==65533){r[t[217][a]]=55552+a;e[55552+a]=t[217][a]}t[218]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������凇冖冢冥讠讦讧讪讴讵讷诂诃诋诏诎诒诓诔诖诘诙诜诟诠诤诨诩诮诰诳诶诹诼诿谀谂谄谇谌谏谑谒谔谕谖谙谛谘谝谟谠谡谥谧谪谫谮谯谲谳谵谶卩卺阝阢阡阱阪阽阼陂陉陔陟陧陬陲陴隈隍隗隰邗邛邝邙邬邡邴邳邶邺�".split("");for(a=0;a!=t[218].length;++a)if(t[218][a].charCodeAt(0)!==65533){r[t[218][a]]=55808+a;e[55808+a]=t[218][a]}t[219]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������邸邰郏郅邾郐郄郇郓郦郢郜郗郛郫郯郾鄄鄢鄞鄣鄱鄯鄹酃酆刍奂劢劬劭劾哿勐勖勰叟燮矍廴凵凼鬯厶弁畚巯坌垩垡塾墼壅壑圩圬圪圳圹圮圯坜圻坂坩垅坫垆坼坻坨坭坶坳垭垤垌垲埏垧垴垓垠埕埘埚埙埒垸埴埯埸埤埝�".split("");for(a=0;a!=t[219].length;++a)if(t[219][a].charCodeAt(0)!==65533){r[t[219][a]]=56064+a;e[56064+a]=t[219][a]}t[220]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������堋堍埽埭堀堞堙塄堠塥塬墁墉墚墀馨鼙懿艹艽艿芏芊芨芄芎芑芗芙芫芸芾芰苈苊苣芘芷芮苋苌苁芩芴芡芪芟苄苎芤苡茉苷苤茏茇苜苴苒苘茌苻苓茑茚茆茔茕苠苕茜荑荛荜茈莒茼茴茱莛荞茯荏荇荃荟荀茗荠茭茺茳荦荥�".split("");for(a=0;a!=t[220].length;++a)if(t[220][a].charCodeAt(0)!==65533){r[t[220][a]]=56320+a;e[56320+a]=t[220][a]}t[221]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������荨茛荩荬荪荭荮莰荸莳莴莠莪莓莜莅荼莶莩荽莸荻莘莞莨莺莼菁萁菥菘堇萘萋菝菽菖萜萸萑萆菔菟萏萃菸菹菪菅菀萦菰菡葜葑葚葙葳蒇蒈葺蒉葸萼葆葩葶蒌蒎萱葭蓁蓍蓐蓦蒽蓓蓊蒿蒺蓠蒡蒹蒴蒗蓥蓣蔌甍蔸蓰蔹蔟蔺�".split("");for(a=0;a!=t[221].length;++a)if(t[221][a].charCodeAt(0)!==65533){r[t[221][a]]=56576+a;e[56576+a]=t[221][a]}t[222]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������蕖蔻蓿蓼蕙蕈蕨蕤蕞蕺瞢蕃蕲蕻薤薨薇薏蕹薮薜薅薹薷薰藓藁藜藿蘧蘅蘩蘖蘼廾弈夼奁耷奕奚奘匏尢尥尬尴扌扪抟抻拊拚拗拮挢拶挹捋捃掭揶捱捺掎掴捭掬掊捩掮掼揲揸揠揿揄揞揎摒揆掾摅摁搋搛搠搌搦搡摞撄摭撖�".split("");for(a=0;a!=t[222].length;++a)if(t[222][a].charCodeAt(0)!==65533){r[t[222][a]]=56832+a;e[56832+a]=t[222][a]}t[223]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������摺撷撸撙撺擀擐擗擤擢攉攥攮弋忒甙弑卟叱叽叩叨叻吒吖吆呋呒呓呔呖呃吡呗呙吣吲咂咔呷呱呤咚咛咄呶呦咝哐咭哂咴哒咧咦哓哔呲咣哕咻咿哌哙哚哜咩咪咤哝哏哞唛哧唠哽唔哳唢唣唏唑唧唪啧喏喵啉啭啁啕唿啐唼�".split("");for(a=0;a!=t[223].length;++a)if(t[223][a].charCodeAt(0)!==65533){r[t[223][a]]=57088+a;e[57088+a]=t[223][a]}t[224]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������唷啖啵啶啷唳唰啜喋嗒喃喱喹喈喁喟啾嗖喑啻嗟喽喾喔喙嗪嗷嗉嘟嗑嗫嗬嗔嗦嗝嗄嗯嗥嗲嗳嗌嗍嗨嗵嗤辔嘞嘈嘌嘁嘤嘣嗾嘀嘧嘭噘嘹噗嘬噍噢噙噜噌噔嚆噤噱噫噻噼嚅嚓嚯囔囗囝囡囵囫囹囿圄圊圉圜帏帙帔帑帱帻帼�".split("");for(a=0;a!=t[224].length;++a)if(t[224][a].charCodeAt(0)!==65533){r[t[224][a]]=57344+a;e[57344+a]=t[224][a]}t[225]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������帷幄幔幛幞幡岌屺岍岐岖岈岘岙岑岚岜岵岢岽岬岫岱岣峁岷峄峒峤峋峥崂崃崧崦崮崤崞崆崛嵘崾崴崽嵬嵛嵯嵝嵫嵋嵊嵩嵴嶂嶙嶝豳嶷巅彳彷徂徇徉後徕徙徜徨徭徵徼衢彡犭犰犴犷犸狃狁狎狍狒狨狯狩狲狴狷猁狳猃狺�".split("");for(a=0;a!=t[225].length;++a)if(t[225][a].charCodeAt(0)!==65533){r[t[225][a]]=57600+a;e[57600+a]=t[225][a]}t[226]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������狻猗猓猡猊猞猝猕猢猹猥猬猸猱獐獍獗獠獬獯獾舛夥飧夤夂饣饧饨饩饪饫饬饴饷饽馀馄馇馊馍馐馑馓馔馕庀庑庋庖庥庠庹庵庾庳赓廒廑廛廨廪膺忄忉忖忏怃忮怄忡忤忾怅怆忪忭忸怙怵怦怛怏怍怩怫怊怿怡恸恹恻恺恂�".split("");for(a=0;a!=t[226].length;++a)if(t[226][a].charCodeAt(0)!==65533){r[t[226][a]]=57856+a;e[57856+a]=t[226][a]}t[227]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������恪恽悖悚悭悝悃悒悌悛惬悻悱惝惘惆惚悴愠愦愕愣惴愀愎愫慊慵憬憔憧憷懔懵忝隳闩闫闱闳闵闶闼闾阃阄阆阈阊阋阌阍阏阒阕阖阗阙阚丬爿戕氵汔汜汊沣沅沐沔沌汨汩汴汶沆沩泐泔沭泷泸泱泗沲泠泖泺泫泮沱泓泯泾�".split("");for(a=0;a!=t[227].length;++a)if(t[227][a].charCodeAt(0)!==65533){r[t[227][a]]=58112+a;e[58112+a]=t[227][a]}t[228]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������洹洧洌浃浈洇洄洙洎洫浍洮洵洚浏浒浔洳涑浯涞涠浞涓涔浜浠浼浣渚淇淅淞渎涿淠渑淦淝淙渖涫渌涮渫湮湎湫溲湟溆湓湔渲渥湄滟溱溘滠漭滢溥溧溽溻溷滗溴滏溏滂溟潢潆潇漤漕滹漯漶潋潴漪漉漩澉澍澌潸潲潼潺濑�".split("");for(a=0;a!=t[228].length;++a)if(t[228][a].charCodeAt(0)!==65533){r[t[228][a]]=58368+a;e[58368+a]=t[228][a]}t[229]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������濉澧澹澶濂濡濮濞濠濯瀚瀣瀛瀹瀵灏灞宀宄宕宓宥宸甯骞搴寤寮褰寰蹇謇辶迓迕迥迮迤迩迦迳迨逅逄逋逦逑逍逖逡逵逶逭逯遄遑遒遐遨遘遢遛暹遴遽邂邈邃邋彐彗彖彘尻咫屐屙孱屣屦羼弪弩弭艴弼鬻屮妁妃妍妩妪妣�".split("");for(a=0;a!=t[229].length;++a)if(t[229][a].charCodeAt(0)!==65533){r[t[229][a]]=58624+a;e[58624+a]=t[229][a]}t[230]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������妗姊妫妞妤姒妲妯姗妾娅娆姝娈姣姘姹娌娉娲娴娑娣娓婀婧婊婕娼婢婵胬媪媛婷婺媾嫫媲嫒嫔媸嫠嫣嫱嫖嫦嫘嫜嬉嬗嬖嬲嬷孀尕尜孚孥孳孑孓孢驵驷驸驺驿驽骀骁骅骈骊骐骒骓骖骘骛骜骝骟骠骢骣骥骧纟纡纣纥纨纩�".split("");for(a=0;a!=t[230].length;++a)if(t[230][a].charCodeAt(0)!==65533){r[t[230][a]]=58880+a;e[58880+a]=t[230][a]}t[231]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������纭纰纾绀绁绂绉绋绌绐绔绗绛绠绡绨绫绮绯绱绲缍绶绺绻绾缁缂缃缇缈缋缌缏缑缒缗缙缜缛缟缡缢缣缤缥缦缧缪缫缬缭缯缰缱缲缳缵幺畿巛甾邕玎玑玮玢玟珏珂珑玷玳珀珉珈珥珙顼琊珩珧珞玺珲琏琪瑛琦琥琨琰琮琬�".split("");for(a=0;a!=t[231].length;++a)if(t[231][a].charCodeAt(0)!==65533){r[t[231][a]]=59136+a;e[59136+a]=t[231][a]}t[232]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������琛琚瑁瑜瑗瑕瑙瑷瑭瑾璜璎璀璁璇璋璞璨璩璐璧瓒璺韪韫韬杌杓杞杈杩枥枇杪杳枘枧杵枨枞枭枋杷杼柰栉柘栊柩枰栌柙枵柚枳柝栀柃枸柢栎柁柽栲栳桠桡桎桢桄桤梃栝桕桦桁桧桀栾桊桉栩梵梏桴桷梓桫棂楮棼椟椠棹�".split("");for(a=0;a!=t[232].length;++a)if(t[232][a].charCodeAt(0)!==65533){r[t[232][a]]=59392+a;e[59392+a]=t[232][a]}t[233]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������椤棰椋椁楗棣椐楱椹楠楂楝榄楫榀榘楸椴槌榇榈槎榉楦楣楹榛榧榻榫榭槔榱槁槊槟榕槠榍槿樯槭樗樘橥槲橄樾檠橐橛樵檎橹樽樨橘橼檑檐檩檗檫猷獒殁殂殇殄殒殓殍殚殛殡殪轫轭轱轲轳轵轶轸轷轹轺轼轾辁辂辄辇辋�".split("");for(a=0;a!=t[233].length;++a)if(t[233][a].charCodeAt(0)!==65533){r[t[233][a]]=59648+a;e[59648+a]=t[233][a]}t[234]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������辍辎辏辘辚軎戋戗戛戟戢戡戥戤戬臧瓯瓴瓿甏甑甓攴旮旯旰昊昙杲昃昕昀炅曷昝昴昱昶昵耆晟晔晁晏晖晡晗晷暄暌暧暝暾曛曜曦曩贲贳贶贻贽赀赅赆赈赉赇赍赕赙觇觊觋觌觎觏觐觑牮犟牝牦牯牾牿犄犋犍犏犒挈挲掰�".split("");for(a=0;a!=t[234].length;++a)if(t[234][a].charCodeAt(0)!==65533){r[t[234][a]]=59904+a;e[59904+a]=t[234][a]}t[235]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������搿擘耄毪毳毽毵毹氅氇氆氍氕氘氙氚氡氩氤氪氲攵敕敫牍牒牖爰虢刖肟肜肓肼朊肽肱肫肭肴肷胧胨胩胪胛胂胄胙胍胗朐胝胫胱胴胭脍脎胲胼朕脒豚脶脞脬脘脲腈腌腓腴腙腚腱腠腩腼腽腭腧塍媵膈膂膑滕膣膪臌朦臊膻�".split("");for(a=0;a!=t[235].length;++a)if(t[235][a].charCodeAt(0)!==65533){r[t[235][a]]=60160+a;e[60160+a]=t[235][a]}t[236]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������臁膦欤欷欹歃歆歙飑飒飓飕飙飚殳彀毂觳斐齑斓於旆旄旃旌旎旒旖炀炜炖炝炻烀炷炫炱烨烊焐焓焖焯焱煳煜煨煅煲煊煸煺熘熳熵熨熠燠燔燧燹爝爨灬焘煦熹戾戽扃扈扉礻祀祆祉祛祜祓祚祢祗祠祯祧祺禅禊禚禧禳忑忐�".split("");for(a=0;a!=t[236].length;++a)if(t[236][a].charCodeAt(0)!==65533){r[t[236][a]]=60416+a;e[60416+a]=t[236][a]}t[237]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������怼恝恚恧恁恙恣悫愆愍慝憩憝懋懑戆肀聿沓泶淼矶矸砀砉砗砘砑斫砭砜砝砹砺砻砟砼砥砬砣砩硎硭硖硗砦硐硇硌硪碛碓碚碇碜碡碣碲碹碥磔磙磉磬磲礅磴礓礤礞礴龛黹黻黼盱眄眍盹眇眈眚眢眙眭眦眵眸睐睑睇睃睚睨�".split("");for(a=0;a!=t[237].length;++a)if(t[237][a].charCodeAt(0)!==65533){r[t[237][a]]=60672+a;e[60672+a]=t[237][a]}t[238]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������睢睥睿瞍睽瞀瞌瞑瞟瞠瞰瞵瞽町畀畎畋畈畛畲畹疃罘罡罟詈罨罴罱罹羁罾盍盥蠲钅钆钇钋钊钌钍钏钐钔钗钕钚钛钜钣钤钫钪钭钬钯钰钲钴钶钷钸钹钺钼钽钿铄铈铉铊铋铌铍铎铐铑铒铕铖铗铙铘铛铞铟铠铢铤铥铧铨铪�".split("");for(a=0;a!=t[238].length;++a)if(t[238][a].charCodeAt(0)!==65533){r[t[238][a]]=60928+a;e[60928+a]=t[238][a]}t[239]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������铩铫铮铯铳铴铵铷铹铼铽铿锃锂锆锇锉锊锍锎锏锒锓锔锕锖锘锛锝锞锟锢锪锫锩锬锱锲锴锶锷锸锼锾锿镂锵镄镅镆镉镌镎镏镒镓镔镖镗镘镙镛镞镟镝镡镢镤镥镦镧镨镩镪镫镬镯镱镲镳锺矧矬雉秕秭秣秫稆嵇稃稂稞稔�".split("");for(a=0;a!=t[239].length;++a)if(t[239][a].charCodeAt(0)!==65533){r[t[239][a]]=61184+a;e[61184+a]=t[239][a]}t[240]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������稹稷穑黏馥穰皈皎皓皙皤瓞瓠甬鸠鸢鸨鸩鸪鸫鸬鸲鸱鸶鸸鸷鸹鸺鸾鹁鹂鹄鹆鹇鹈鹉鹋鹌鹎鹑鹕鹗鹚鹛鹜鹞鹣鹦鹧鹨鹩鹪鹫鹬鹱鹭鹳疒疔疖疠疝疬疣疳疴疸痄疱疰痃痂痖痍痣痨痦痤痫痧瘃痱痼痿瘐瘀瘅瘌瘗瘊瘥瘘瘕瘙�".split("");for(a=0;a!=t[240].length;++a)if(t[240][a].charCodeAt(0)!==65533){r[t[240][a]]=61440+a;e[61440+a]=t[240][a]}t[241]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������瘛瘼瘢瘠癀瘭瘰瘿瘵癃瘾瘳癍癞癔癜癖癫癯翊竦穸穹窀窆窈窕窦窠窬窨窭窳衤衩衲衽衿袂袢裆袷袼裉裢裎裣裥裱褚裼裨裾裰褡褙褓褛褊褴褫褶襁襦襻疋胥皲皴矜耒耔耖耜耠耢耥耦耧耩耨耱耋耵聃聆聍聒聩聱覃顸颀颃�".split("");for(a=0;a!=t[241].length;++a)if(t[241][a].charCodeAt(0)!==65533){r[t[241][a]]=61696+a;e[61696+a]=t[241][a]}t[242]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������颉颌颍颏颔颚颛颞颟颡颢颥颦虍虔虬虮虿虺虼虻蚨蚍蚋蚬蚝蚧蚣蚪蚓蚩蚶蛄蚵蛎蚰蚺蚱蚯蛉蛏蚴蛩蛱蛲蛭蛳蛐蜓蛞蛴蛟蛘蛑蜃蜇蛸蜈蜊蜍蜉蜣蜻蜞蜥蜮蜚蜾蝈蜴蜱蜩蜷蜿螂蜢蝽蝾蝻蝠蝰蝌蝮螋蝓蝣蝼蝤蝙蝥螓螯螨蟒�".split("");for(a=0;a!=t[242].length;++a)if(t[242][a].charCodeAt(0)!==65533){r[t[242][a]]=61952+a;e[61952+a]=t[242][a]}t[243]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������蟆螈螅螭螗螃螫蟥螬螵螳蟋蟓螽蟑蟀蟊蟛蟪蟠蟮蠖蠓蟾蠊蠛蠡蠹蠼缶罂罄罅舐竺竽笈笃笄笕笊笫笏筇笸笪笙笮笱笠笥笤笳笾笞筘筚筅筵筌筝筠筮筻筢筲筱箐箦箧箸箬箝箨箅箪箜箢箫箴篑篁篌篝篚篥篦篪簌篾篼簏簖簋�".split("");for(a=0;a!=t[243].length;++a)if(t[243][a].charCodeAt(0)!==65533){r[t[243][a]]=62208+a;e[62208+a]=t[243][a]}t[244]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������簟簪簦簸籁籀臾舁舂舄臬衄舡舢舣舭舯舨舫舸舻舳舴舾艄艉艋艏艚艟艨衾袅袈裘裟襞羝羟羧羯羰羲籼敉粑粝粜粞粢粲粼粽糁糇糌糍糈糅糗糨艮暨羿翎翕翥翡翦翩翮翳糸絷綦綮繇纛麸麴赳趄趔趑趱赧赭豇豉酊酐酎酏酤�".split("");for(a=0;a!=t[244].length;++a)if(t[244][a].charCodeAt(0)!==65533){r[t[244][a]]=62464+a;e[62464+a]=t[244][a]}t[245]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������酢酡酰酩酯酽酾酲酴酹醌醅醐醍醑醢醣醪醭醮醯醵醴醺豕鹾趸跫踅蹙蹩趵趿趼趺跄跖跗跚跞跎跏跛跆跬跷跸跣跹跻跤踉跽踔踝踟踬踮踣踯踺蹀踹踵踽踱蹉蹁蹂蹑蹒蹊蹰蹶蹼蹯蹴躅躏躔躐躜躞豸貂貊貅貘貔斛觖觞觚觜�".split("");for(a=0;a!=t[245].length;++a)if(t[245][a].charCodeAt(0)!==65533){r[t[245][a]]=62720+a;e[62720+a]=t[245][a]}t[246]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������觥觫觯訾謦靓雩雳雯霆霁霈霏霎霪霭霰霾龀龃龅龆龇龈龉龊龌黾鼋鼍隹隼隽雎雒瞿雠銎銮鋈錾鍪鏊鎏鐾鑫鱿鲂鲅鲆鲇鲈稣鲋鲎鲐鲑鲒鲔鲕鲚鲛鲞鲟鲠鲡鲢鲣鲥鲦鲧鲨鲩鲫鲭鲮鲰鲱鲲鲳鲴鲵鲶鲷鲺鲻鲼鲽鳄鳅鳆鳇鳊鳋�".split("");for(a=0;a!=t[246].length;++a)if(t[246][a].charCodeAt(0)!==65533){r[t[246][a]]=62976+a;e[62976+a]=t[246][a]}t[247]="�����������������������������������������������������������������������������������������������������������������������������������������������������������������鳌鳍鳎鳏鳐鳓鳔鳕鳗鳘鳙鳜鳝鳟鳢靼鞅鞑鞒鞔鞯鞫鞣鞲鞴骱骰骷鹘骶骺骼髁髀髅髂髋髌髑魅魃魇魉魈魍魑飨餍餮饕饔髟髡髦髯髫髻髭髹鬈鬏鬓鬟鬣麽麾縻麂麇麈麋麒鏖麝麟黛黜黝黠黟黢黩黧黥黪黯鼢鼬鼯鼹鼷鼽鼾齄�".split("");for(a=0;a!=t[247].length;++a)if(t[247][a].charCodeAt(0)!==65533){r[t[247][a]]=63232+a;e[63232+a]=t[247][a]}return{enc:r,dec:e}}();cptable[10029]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ÄĀāÉĄÖÜáąČäčĆć鏟ĎíďĒēĖóėôöõúĚěü†°Ę£§•¶ß®©™ę¨≠ģĮįĪ≤≥īĶ∂∑łĻļĽľĹĺŅņѬ√ńŇ∆«»… ňŐÕőŌ–—“”‘’÷◊ōŔŕŘ‹›řŖŗŠ‚„šŚśÁŤťÍŽžŪÓÔūŮÚůŰűŲųÝýķŻŁżĢˇ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[10079]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ÄÅÇÉÑÖÜáàâäãåçéèêëíìîïñóòôöõúùûüݰ¢£§•¶ß®©™´¨≠ÆØ∞±≤≥¥µ∂∑∏π∫ªºΩæø¿¡¬√ƒ≈∆«»… ÀÃÕŒœ–—“”‘’÷◊ÿŸ⁄¤ÐðÞþý·‚„‰ÂÊÁËÈÍÎÏÌÓÔ�ÒÚÛÙıˆ˜¯˘˙˚¸˝˛ˇ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[10081]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ÄÅÇÉÑÖÜáàâäãåçéèêëíìîïñóòôöõúùûü†°¢£§•¶ß®©™´¨≠ÆØ∞±≤≥¥µ∂∑∏π∫ªºΩæø¿¡¬√ƒ≈∆«»… ÀÃÕŒœ–—“”‘’÷◊ÿŸĞğİıŞş‡·‚„‰ÂÊÁËÈÍÎÏÌÓÔ�ÒÚÛÙ�ˆ˜¯˘˙˚¸˝˛ˇ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();cptable[28591]=function(){var e="\0\b\t\n\x0B\f\r !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ ¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ",r=[],t={};for(var a=0;a!=e.length;++a){if(e.charCodeAt(a)!==65533)t[e.charAt(a)]=a;r[a]=e.charAt(a)}return{enc:t,dec:r}}();if(typeof module!=="undefined"&&module.exports&&typeof DO_NOT_EXPORT_CODEPAGE==="undefined")module.exports=cptable;(function(e,r){"use strict";if(typeof cptable==="undefined"){if(typeof require!=="undefined"){var t=cptable;if(typeof module!=="undefined"&&module.exports&&typeof DO_NOT_EXPORT_CODEPAGE==="undefined")module.exports=r(t);else e.cptable=r(t)}else throw new Error("cptable not found")}else cptable=r(cptable)})(this,function(e){"use strict";var r={1200:"utf16le",1201:"utf16be",12000:"utf32le",12001:"utf32be",16969:"utf64le",20127:"ascii",65000:"utf7",65001:"utf8"};var t=[874,1250,1251,1252,1253,1254,1255,1256,1e4];var a=[932,936,949,950];var n=[65001];var i={};var s={};var f={};var o={};var l=function F(e){return String.fromCharCode(e)};var c=function P(e){return e.charCodeAt(0)};var h=typeof Buffer!=="undefined";var u=function(){};if(h){var d=!Buffer.from;if(!d)try{Buffer.from("foo","utf8")}catch(v){d=true}u=d?function(e,r){return r?new Buffer(e,r):new Buffer(e)}:Buffer.from.bind(Buffer);if(!Buffer.allocUnsafe)Buffer.allocUnsafe=function(e){return new Buffer(e)};var p=1024,g=Buffer.allocUnsafe(p);var m=function L(e){var r=Buffer.allocUnsafe(65536);for(var t=0;t<65536;++t)r[t]=0;var a=Object.keys(e),n=a.length;for(var i=0,s=a[i];i>10&1023)];n[s++]=t[56320+(o&1023)]}}}n=n.slice(0,s)}else{n=Buffer.allocUnsafe(a);for(i=0;i>8}return function f(e){var r=e.length,t=0,n=0;if(2*r>p){p=2*r;g=Buffer.allocUnsafe(p)}if(Buffer.isBuffer(e)){for(t=0;t>8}return function l(e,r){var t=e.length,n=Buffer.allocUnsafe(2*t),i=0,s=0,f=0,o=0,l=0;if(typeof e==="string"){for(i=o=0;i0)n[o++]=a[s]}n=n.slice(0,o)}else if(Buffer.isBuffer(e)){for(i=o=0;i0)n[o++]=a[s]}else{f=s-65536;s=2*(55296+(f>>10&1023));n[o++]=a[s+1]||a[s];if(a[s+1]>0)n[o++]=a[s];s=2*(56320+(f&1023));n[o++]=a[s+1]||a[s];if(a[s+1]>0)n[o++]=a[s]}}n=n.slice(0,o)}else{for(i=o=0;i0)n[o++]=a[s]}}if(!r||r==="buf")return n;if(r!=="arr")return n.toString("binary");return[].slice.call(n)}};var T=function W(r){var t=e[r].dec;var a=Buffer.allocUnsafe(131072),n=0,i,s=0,f=0,o=0;for(o=0;o<65536;++o){a[2*o]=255;a[2*o+1]=253}for(n=0;n>8}return function l(e){var r=e.length,t=Buffer.allocUnsafe(2*r),n=0,i=0,s=0;if(Buffer.isBuffer(e)){for(n=0;np){p=4*r;g=Buffer.allocUnsafe(p)}var n=0;if(r>=3&&e[0]==239)if(e[1]==187&&e[2]==191)n=3;for(var i=1,s=0,f=0;n>8}else{t-=65536;a=55296+(t>>10&1023);t=56320+(t&1023);g[s++]=a&255;g[s++]=a>>>8;g[s++]=t&255;g[s++]=t>>>8&255}}return g.slice(0,s).toString("ucs2")};s[65001]=function z(e,r){if(h&&Buffer.isBuffer(e)){if(!r||r==="buf")return e;if(r!=="arr")return e.toString("binary");return[].slice.call(e)}var t=e.length,a=0,n=0,i=0;var s=typeof e==="string";if(4*t>p){p=4*t;g=Buffer.allocUnsafe(p)}for(var f=0;f>6);g[i++]=128+(a&63)}else if(a>=55296&&a<=57343){a-=55296;++f;n=(s?e.charCodeAt(f):e[f].charCodeAt(0))-56320+(a<<10);g[i++]=240+(n>>>18&7);g[i++]=144+(n>>>12&63);g[i++]=128+(n>>>6&63);g[i++]=128+(n&63)}else{g[i++]=224+(a>>12);g[i++]=128+(a>>6&63);g[i++]=128+(a&63)}}if(!r||r==="buf")return g.slice(0,i);if(r!=="arr")return g.slice(0,i).toString("binary");return[].slice.call(g,0,i)}}var A=function V(){if(h){if(f[t[0]])return;var r=0,l=0;for(r=0;r255){s[d]=f>>8;s[++d]=f&255}else s[d]=f&255}else if(m=r[t])switch(m){case"utf8":if(h&&b){s=u(a,m);d=s.length;break}for(c=0;c>6);s[++d]=128+(f&63)}else if(f>=55296&&f<=57343){f-=55296;v=(b?a.charCodeAt(++c):a[++c].charCodeAt(0))-56320+(f<<10);s[d]=240+(v>>>18&7);s[++d]=144+(v>>>12&63);s[++d]=128+(v>>>6&63);s[++d]=128+(v&63)}else{s[d]=224+(f>>12);s[++d]=128+(f>>6&63);s[++d]=128+(f&63)}}break;case"ascii":if(h&&typeof a==="string"){s=u(a,m);d=s.length;break}for(c=0;c>8}break;case"utf16be":for(c=0;c>8;s[d++]=f&255}break;case"utf32le":for(c=0;c=55296&&f<=57343)f=65536+(f-55296<<10)+(a[++c].charCodeAt(0)-56320);s[d++]=f&255;f>>=8;s[d++]=f&255;f>>=8;s[d++]=f&255;f>>=8;s[d++]=f&255}break;case"utf32be":for(c=0;c=55296&&f<=57343)f=65536+(f-55296<<10)+(a[++c].charCodeAt(0)-56320);s[d+3]=f&255;f>>=8;s[d+2]=f&255;f>>=8;s[d+1]=f&255;f>>=8;s[d]=f&255;d+=4}break;case"utf7":for(c=0;c-1){s[d++]=w.charCodeAt(0);continue}var k=X(1201,w);s[d++]=43;s[d++]=_.charCodeAt(k[0]>>2);s[d++]=_.charCodeAt(((k[0]&3)<<4)+((k[1]||0)>>4));s[d++]=_.charCodeAt(((k[1]&15)<<2)+((k[2]||0)>>6));s[d++]=45}break;default:throw new Error("Unsupported magic: "+t+" "+r[t]);}else throw new Error("Unrecognized CP: "+t);s=s.slice(0,d);if(!h)return n=="str"?s.map(l).join(""):s;if(!n||n==="buf")return s;if(n!=="arr")return s.toString("binary");return[].slice.call(s)};var N=function $(t,a){var n;if(n=f[t])return n(a);if(typeof a==="string")return $(t,a.split("").map(c));var i=a.length,s=new Array(i),o="",l=0,u=0,d=1,v=0,p=0;var g=e[t],m,b="";if(g&&(m=g.dec)){for(u=0;u=3&&a[0]==239)if(a[1]==187&&a[2]==191)u=3;for(;u>10&1023);l=56320+(l&1023);s[v++]=String.fromCharCode(p);s[v++]=String.fromCharCode(l)}}break;case"ascii":if(h&&Buffer.isBuffer(a))return a.toString(b);for(u=0;u=2&&a[0]==255)if(a[1]==254)u=2;if(h&&Buffer.isBuffer(a))return a.toString(b);d=2;for(;u+1=2&&a[0]==254)if(a[1]==255)u=2;d=2;for(;u+1=4&&a[0]==255)if(a[1]==254&&a[2]===0&&a[3]===0)u=4;d=4;for(;u65535){l-=65536;s[v++]=String.fromCharCode(55296+(l>>10&1023));s[v++]=String.fromCharCode(56320+(l&1023))}else s[v++]=String.fromCharCode(l)}break;case"utf32be":if(i>=4&&a[3]==255)if(a[2]==254&&a[1]===0&&a[0]===0)u=4;d=4;for(;u65535){l-=65536;s[v++]=String.fromCharCode(55296+(l>>10&1023));s[v++]=String.fromCharCode(56320+(l&1023))}else s[v++]=String.fromCharCode(l)}break;case"utf7":if(i>=4&&a[0]==43&&a[1]==47&&a[2]==118){if(i>=5&&a[3]==56&&a[4]==45)u=5;else if(a[3]==56||a[3]==57||a[3]==43||a[3]==47)u=4}for(;u>4;k.push(A);x=_.indexOf(String.fromCharCode(a[u+R++]));if(x===-1)break;E=(S&15)<<4|x>>2;k.push(E);O=_.indexOf(String.fromCharCode(a[u+R++]));if(O===-1)break;C=(x&3)<<6|O;if(O<64)k.push(C)}T=$(1201,k);for(R=0;R>1;++t)r[t]=String.fromCharCode(e.charCodeAt(2*t)+(e.charCodeAt(2*t+1)<<8));return r.join("")}function u(e){var r=[];for(var t=0;t>1;++t)r[t]=String.fromCharCode(e.charCodeAt(2*t+1)+(e.charCodeAt(2*t)<<8));return r.join("")}var d=function(e){var r=e.charCodeAt(0),t=e.charCodeAt(1);if(r==255&&t==254)return h(e.slice(2));if(r==254&&t==255)return u(e.slice(2));if(r==65279)return e.slice(1);return e};var v=function Yw(e){return String.fromCharCode(e)};var p=function Kw(e){return String.fromCharCode(e)};if(typeof a!=="undefined"){o=function(e){r=e;s(e)};d=function(e){if(e.charCodeAt(0)===255&&e.charCodeAt(1)===254){return a.utils.decode(1200,c(e.slice(2)))}return e};v=function Jw(e){if(r===1200)return String.fromCharCode(e);return a.utils.decode(r,[e&255,e>>8])[0]};p=function qw(e){return a.utils.decode(t,[e])[0]}}var g=null;var m=true;var b="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";function w(e){var r="";var t=0,a=0,n=0,i=0,s=0,f=0,o=0;for(var l=0;l>2;a=e.charCodeAt(l++);s=(t&3)<<4|a>>4;n=e.charCodeAt(l++);f=(a&15)<<2|n>>6;o=n&63;if(isNaN(a)){f=o=64}else if(isNaN(n)){o=64}r+=b.charAt(i)+b.charAt(s)+b.charAt(f)+b.charAt(o)}return r}function k(e){var r="";var t=0,a=0,n=0,i=0,s=0,f=0,o=0;e=e.replace(/[^\w\+\/\=]/g,"");for(var l=0;l>4;r+=String.fromCharCode(t);f=b.indexOf(e.charAt(l++));a=(s&15)<<4|f>>2;if(f!==64){r+=String.fromCharCode(a)}o=b.indexOf(e.charAt(l++));n=(f&3)<<6|o;if(o!==64){r+=String.fromCharCode(n)}}return r}var T=function(){return typeof Buffer!=="undefined"&&typeof undefined!=="undefined"&&typeof{}!=="undefined"&&!!{}.node}();var A=function(){if(typeof Buffer!=="undefined"){var e=!Buffer.from;if(!e)try{Buffer.from("foo","utf8")}catch(r){e=true}return e?function(e,r){return r?new Buffer(e,r):new Buffer(e)}:Buffer.from.bind(Buffer)}return function(){}}();function E(e){if(T)return Buffer.alloc?Buffer.alloc(e):new Buffer(e);return typeof Uint8Array!="undefined"?new Uint8Array(e):new Array(e)}function C(e){if(T)return Buffer.allocUnsafe?Buffer.allocUnsafe(e):new Buffer(e);return typeof Uint8Array!="undefined"?new Uint8Array(e):new Array(e)}var y=function Zw(e){if(T)return A(e,"binary");return e.split("").map(function(e){return e.charCodeAt(0)&255})};function S(e){if(typeof ArrayBuffer==="undefined")return y(e);var r=new ArrayBuffer(e.length),t=new Uint8Array(r);for(var a=0;a!=e.length;++a)t[a]=e.charCodeAt(a)&255;return r}function _(e){if(Array.isArray(e))return e.map(function(e){return String.fromCharCode(e)}).join("");var r=[];for(var t=0;t>6&31;n[t++]=128|s&63}else if(s>=55296&&s<57344){s=(s&1023)+64;var f=e.charCodeAt(++i)&1023;n[t++]=240|s>>8&7;n[t++]=128|s>>2&63;n[t++]=128|f>>6&15|(s&3)<<4;n[t++]=128|f&63}else{n[t++]=224|s>>12&15;n[t++]=128|s>>6&63;n[t++]=128|s&63}if(t>a){r.push(n.slice(0,t));t=0;n=E(65535);a=65530}}r.push(n.slice(0,t));return R(r)}var N=/\u0000/g,D=/[\u0001-\u0006]/g;function F(e){var r="",t=e.length-1;while(t>=0)r+=e.charAt(t--);return r}function P(e,r){var t=""+e;return t.length>=r?t:wr("0",r-t.length)+t}function L(e,r){var t=""+e;return t.length>=r?t:wr(" ",r-t.length)+t}function M(e,r){var t=""+e;return t.length>=r?t:t+wr(" ",r-t.length)}function U(e,r){var t=""+Math.round(e);return t.length>=r?t:wr("0",r-t.length)+t}function B(e,r){var t=""+e;return t.length>=r?t:wr("0",r-t.length)+t}var W=Math.pow(2,32);function H(e,r){if(e>W||e<-W)return U(e,r);var t=Math.round(e);return B(t,r)}function z(e,r){r=r||0;return e.length>=7+r&&(e.charCodeAt(r)|32)===103&&(e.charCodeAt(r+1)|32)===101&&(e.charCodeAt(r+2)|32)===110&&(e.charCodeAt(r+3)|32)===101&&(e.charCodeAt(r+4)|32)===114&&(e.charCodeAt(r+5)|32)===97&&(e.charCodeAt(r+6)|32)===108}var V=[["Sun","Sunday"],["Mon","Monday"],["Tue","Tuesday"],["Wed","Wednesday"],["Thu","Thursday"],["Fri","Friday"],["Sat","Saturday"]];var G=[["J","Jan","January"],["F","Feb","February"],["M","Mar","March"],["A","Apr","April"],["M","May","May"],["J","Jun","June"],["J","Jul","July"],["A","Aug","August"],["S","Sep","September"],["O","Oct","October"],["N","Nov","November"],["D","Dec","December"]];function j(e){if(!e)e={};e[0]="General";e[1]="0";e[2]="0.00";e[3]="#,##0";e[4]="#,##0.00";e[9]="0%";e[10]="0.00%";e[11]="0.00E+00";e[12]="# ?/?";e[13]="# ??/??";e[14]="m/d/yy";e[15]="d-mmm-yy";e[16]="d-mmm";e[17]="mmm-yy";e[18]="h:mm AM/PM";e[19]="h:mm:ss AM/PM";e[20]="h:mm";e[21]="h:mm:ss";e[22]="m/d/yy h:mm";e[37]="#,##0 ;(#,##0)";e[38]="#,##0 ;[Red](#,##0)";e[39]="#,##0.00;(#,##0.00)";e[40]="#,##0.00;[Red](#,##0.00)";e[45]="mm:ss";e[46]="[h]:mm:ss";e[47]="mmss.0";e[48]="##0.0E+0";e[49]="@";e[56]='"上午/下午 "hh"時"mm"分"ss"秒 "';return e}var X={0:"General",1:"0",2:"0.00",3:"#,##0",4:"#,##0.00",9:"0%",10:"0.00%",11:"0.00E+00",12:"# ?/?",13:"# ??/??",14:"m/d/yy",15:"d-mmm-yy",16:"d-mmm",17:"mmm-yy",18:"h:mm AM/PM",19:"h:mm:ss AM/PM",20:"h:mm",21:"h:mm:ss",22:"m/d/yy h:mm",37:"#,##0 ;(#,##0)",38:"#,##0 ;[Red](#,##0)",39:"#,##0.00;(#,##0.00)",40:"#,##0.00;[Red](#,##0.00)",45:"mm:ss",46:"[h]:mm:ss",47:"mmss.0",48:"##0.0E+0",49:"@",56:'"上午/下午 "hh"時"mm"分"ss"秒 "'};var Y={5:37,6:38,7:39,8:40,23:0,24:0,25:0,26:0,27:14,28:14,29:14,30:14,31:14,50:14,51:14,52:14,53:14,54:14,55:14,56:14,57:14,58:14,59:1,60:2,61:3,62:4,67:9,68:10,69:12,70:13,71:14,72:14,73:15,74:16,75:17,76:20,77:21,78:22,79:45,80:46,81:47,82:0};var K={5:'"$"#,##0_);\\("$"#,##0\\)',63:'"$"#,##0_);\\("$"#,##0\\)',6:'"$"#,##0_);[Red]\\("$"#,##0\\)',64:'"$"#,##0_);[Red]\\("$"#,##0\\)',7:'"$"#,##0.00_);\\("$"#,##0.00\\)',65:'"$"#,##0.00_);\\("$"#,##0.00\\)',8:'"$"#,##0.00_);[Red]\\("$"#,##0.00\\)',66:'"$"#,##0.00_);[Red]\\("$"#,##0.00\\)',41:'_(* #,##0_);_(* \\(#,##0\\);_(* "-"_);_(@_)',42:'_("$"* #,##0_);_("$"* \\(#,##0\\);_("$"* "-"_);_(@_)',43:'_(* #,##0.00_);_(* \\(#,##0.00\\);_(* "-"??_);_(@_)',44:'_("$"* #,##0.00_);_("$"* \\(#,##0.00\\);_("$"* "-"??_);_(@_)'};function J(e,r,t){var a=e<0?-1:1;var n=e*a;var i=0,s=1,f=0;var o=1,l=0,c=0;var h=Math.floor(n);while(lr){if(l>r){c=o;f=i}else{c=l;f=s}}if(!t)return[0,a*f,c];var u=Math.floor(a*f/c);return[u,a*f-u*c,c]}function q(e,r,t){if(e>2958465||e<0)return null;var a=e|0,n=Math.floor(86400*(e-a)),i=0;var s=[];var f={D:a,T:n,u:86400*(e-a)-n,y:0,m:0,d:0,H:0,M:0,S:0,q:0};if(Math.abs(f.u)<1e-6)f.u=0;if(r&&r.date1904)a+=1462;if(f.u>.9999){f.u=0;if(++n==86400){f.T=n=0;++a;++f.D}}if(a===60){s=t?[1317,10,29]:[1900,2,29];i=3}else if(a===0){s=t?[1317,8,29]:[1900,1,0];i=6}else{if(a>60)--a;var o=new Date(1900,0,1);o.setDate(o.getDate()+a-1);s=[o.getFullYear(),o.getMonth()+1,o.getDate()];i=o.getDay();if(a<60)i=(i+6)%7;if(t)i=oe(o,s)}f.y=s[0];f.m=s[1];f.d=s[2];f.S=n%60;n=Math.floor(n/60);f.M=n%60;n=Math.floor(n/60);f.H=n;f.q=i;return f}var Z=new Date(1899,11,31,0,0,0);var Q=Z.getTime();var ee=new Date(1900,2,1,0,0,0);function re(e,r){var t=e.getTime();if(r)t-=1461*24*60*60*1e3;else if(e>=ee)t+=24*60*60*1e3;return(t-(Q+(e.getTimezoneOffset()-Z.getTimezoneOffset())*6e4))/(24*60*60*1e3)}function te(e){return e.indexOf(".")==-1?e:e.replace(/(?:\.0*|(\.\d*[1-9])0+)$/,"$1")}function ae(e){if(e.indexOf("E")==-1)return e;return e.replace(/(?:\.0*|(\.\d*[1-9])0+)[Ee]/,"$1E").replace(/(E[+-])(\d)$/,"$10$2")}function ne(e){var r=e<0?12:11;var t=te(e.toFixed(12));if(t.length<=r)return t;t=e.toPrecision(10);if(t.length<=r)return t;return e.toExponential(5)}function ie(e){var r=te(e.toFixed(11));return r.length>(e<0?12:11)||r==="0"||r==="-0"?e.toPrecision(6):r}function se(e){var r=Math.floor(Math.log(Math.abs(e))*Math.LOG10E),t;if(r>=-4&&r<=-1)t=e.toPrecision(10+r);else if(Math.abs(r)<=9)t=ne(e);else if(r===10)t=e.toFixed(10).substr(0,12);else t=ie(e);return te(ae(t.toUpperCase()))}function fe(e,r){switch(typeof e){case"string":return e;case"boolean":return e?"TRUE":"FALSE";case"number":return(e|0)===e?e.toString(10):se(e);case"undefined":return"";case"object":if(e==null)return"";if(e instanceof Date)return Be(14,re(e,r&&r.date1904),r);}throw new Error("unsupported value in General format: "+e)}function oe(e,r){r[0]-=581;var t=e.getDay();if(e<60)t=(t+6)%7;return t}function le(e,r,t,a){var n="",i=0,s=0,f=t.y,o,l=0;switch(e){case 98:f=t.y+543;case 121:switch(r.length){case 1:;case 2:o=f%100;l=2;break;default:o=f%1e4;l=4;break;}break;case 109:switch(r.length){case 1:;case 2:o=t.m;l=r.length;break;case 3:return G[t.m-1][1];case 5:return G[t.m-1][0];default:return G[t.m-1][2];}break;case 100:switch(r.length){case 1:;case 2:o=t.d;l=r.length;break;case 3:return V[t.q][0];default:return V[t.q][1];}break;case 104:switch(r.length){case 1:;case 2:o=1+(t.H+11)%12;l=r.length;break;default:throw"bad hour format: "+r;}break;case 72:switch(r.length){case 1:;case 2:o=t.H;l=r.length;break;default:throw"bad hour format: "+r;}break;case 77:switch(r.length){case 1:;case 2:o=t.M;l=r.length;break;default:throw"bad minute format: "+r;}break;case 115:if(r!="s"&&r!="ss"&&r!=".0"&&r!=".00"&&r!=".000")throw"bad second format: "+r;if(t.u===0&&(r=="s"||r=="ss"))return P(t.S,r.length);if(a>=2)s=a===3?1e3:100;else s=a===1?10:1;i=Math.round(s*(t.S+t.u));if(i>=60*s)i=0;if(r==="s")return i===0?"0":""+i/s;n=P(i,2+a);if(r==="ss")return n.substr(0,2);return"."+n.substr(2,r.length-1);case 90:switch(r){case"[h]":;case"[hh]":o=t.D*24+t.H;break;case"[m]":;case"[mm]":o=(t.D*24+t.H)*60+t.M;break;case"[s]":;case"[ss]":o=((t.D*24+t.H)*60+t.M)*60+Math.round(t.S+t.u);break;default:throw"bad abstime format: "+r;}l=r.length===3?1:2;break;case 101:o=f;l=1;break;}var c=l>0?P(o,l):"";return c}function ce(e){var r=3;if(e.length<=r)return e;var t=e.length%r,a=e.substr(0,t);for(;t!=e.length;t+=r)a+=(a.length>0?",":"")+e.substr(t,r);return a}var he=/%/g;function ue(e,r,t){var a=r.replace(he,""),n=r.length-a.length;return Ie(e,a,t*Math.pow(10,2*n))+wr("%",n)}function de(e,r,t){var a=r.length-1;while(r.charCodeAt(a-1)===44)--a;return Ie(e,r.substr(0,a),t/Math.pow(10,3*(r.length-a)))}function ve(e,r){var t;var a=e.indexOf("E")-e.indexOf(".")-1;if(e.match(/^#+0.0E\+0$/)){if(r==0)return"0.0E+0";else if(r<0)return"-"+ve(e,-r);var n=e.indexOf(".");if(n===-1)n=e.indexOf("E");var i=Math.floor(Math.log(r)*Math.LOG10E)%n;if(i<0)i+=n;t=(r/Math.pow(10,i)).toPrecision(a+1+(n+i)%n);if(t.indexOf("e")===-1){var s=Math.floor(Math.log(r)*Math.LOG10E);if(t.indexOf(".")===-1)t=t.charAt(0)+"."+t.substr(1)+"E+"+(s-t.length+i);else t+="E+"+(s-i);while(t.substr(0,2)==="0."){t=t.charAt(0)+t.substr(2,n)+"."+t.substr(2+n);t=t.replace(/^0+([1-9])/,"$1").replace(/^0+\./,"0.")}t=t.replace(/\+-/,"-")}t=t.replace(/^([+-]?)(\d*)\.(\d*)[Ee]/,function(e,r,t,a){return r+t+a.substr(0,(n+i)%n)+"."+a.substr(i)+"E"})}else t=r.toExponential(a);if(e.match(/E\+00$/)&&t.match(/e[+-]\d$/))t=t.substr(0,t.length-1)+"0"+t.charAt(t.length-1);if(e.match(/E\-/)&&t.match(/e\+/))t=t.replace(/e\+/,"e");return t.replace("e","E")}var pe=/# (\?+)( ?)\/( ?)(\d+)/;function ge(e,r,t){var a=parseInt(e[4],10),n=Math.round(r*a),i=Math.floor(n/a);var s=n-i*a,f=a;return t+(i===0?"":""+i)+" "+(s===0?wr(" ",e[1].length+1+e[4].length):L(s,e[1].length)+e[2]+"/"+e[3]+P(f,e[4].length))}function me(e,r,t){return t+(r===0?"":""+r)+wr(" ",e[1].length+2+e[4].length)}var be=/^#*0*\.([0#]+)/;var we=/\).*[0#]/;var ke=/\(###\) ###\\?-####/;function Te(e){var r="",t;for(var a=0;a!=e.length;++a)switch(t=e.charCodeAt(a)){case 35:break;case 63:r+=" ";break;case 48:r+="0";break;default:r+=String.fromCharCode(t);}return r}function Ae(e,r){var t=Math.pow(10,r);return""+Math.round(e*t)/t}function Ee(e,r){var t=e-Math.floor(e),a=Math.pow(10,r);if(r<(""+Math.round(t*a)).length)return 0;return Math.round(t*a)}function Ce(e,r){if(r<(""+Math.round((e-Math.floor(e))*Math.pow(10,r))).length){return 1}return 0}function ye(e){if(e<2147483647&&e>-2147483648)return""+(e>=0?e|0:e-1|0);return""+Math.floor(e)}function Se(e,r,t){if(e.charCodeAt(0)===40&&!r.match(we)){var a=r.replace(/\( */,"").replace(/ \)/,"").replace(/\)/,"");if(t>=0)return Se("n",a,t);return"("+Se("n",a,-t)+")"}if(r.charCodeAt(r.length-1)===44)return de(e,r,t);if(r.indexOf("%")!==-1)return ue(e,r,t);if(r.indexOf("E")!==-1)return ve(r,t);if(r.charCodeAt(0)===36)return"$"+Se(e,r.substr(r.charAt(1)==" "?2:1),t);var n;var i,s,f,o=Math.abs(t),l=t<0?"-":"";if(r.match(/^00+$/))return l+H(o,r.length);if(r.match(/^[#?]+$/)){n=H(t,0);if(n==="0")n="";return n.length>r.length?n:Te(r.substr(0,r.length-n.length))+n}if(i=r.match(pe))return ge(i,o,l);if(r.match(/^#+0+$/))return l+H(o,r.length-r.indexOf("0"));if(i=r.match(be)){n=Ae(t,i[1].length).replace(/^([^\.]+)$/,"$1."+Te(i[1])).replace(/\.$/,"."+Te(i[1])).replace(/\.(\d*)$/,function(e,r){return"."+r+wr("0",Te(i[1]).length-r.length)});return r.indexOf("0.")!==-1?n:n.replace(/^0\./,".")}r=r.replace(/^#+([0.])/,"$1");if(i=r.match(/^(0*)\.(#*)$/)){return l+Ae(o,i[2].length).replace(/\.(\d*[1-9])0*$/,".$1").replace(/^(-?\d*)$/,"$1.").replace(/^0\./,i[1].length?"0.":".")}if(i=r.match(/^#{1,3},##0(\.?)$/))return l+ce(H(o,0));if(i=r.match(/^#,##0\.([#0]*0)$/)){return t<0?"-"+Se(e,r,-t):ce(""+(Math.floor(t)+Ce(t,i[1].length)))+"."+P(Ee(t,i[1].length),i[1].length)}if(i=r.match(/^#,#*,#0/))return Se(e,r.replace(/^#,#*,/,""),t);if(i=r.match(/^([0#]+)(\\?-([0#]+))+$/)){n=F(Se(e,r.replace(/[\\-]/g,""),t));s=0;return F(F(r.replace(/\\/g,"")).replace(/[0#]/g,function(e){return s=0)return Re("n",a,t);return"("+Re("n",a,-t)+")"}if(r.charCodeAt(r.length-1)===44)return _e(e,r,t);if(r.indexOf("%")!==-1)return xe(e,r,t);if(r.indexOf("E")!==-1)return Oe(r,t);if(r.charCodeAt(0)===36)return"$"+Re(e,r.substr(r.charAt(1)==" "?2:1),t);var n;var i,s,f,o=Math.abs(t),l=t<0?"-":"";if(r.match(/^00+$/))return l+P(o,r.length);if(r.match(/^[#?]+$/)){n=""+t;if(t===0)n="";return n.length>r.length?n:Te(r.substr(0,r.length-n.length))+n}if(i=r.match(pe))return me(i,o,l);if(r.match(/^#+0+$/))return l+P(o,r.length-r.indexOf("0"));if(i=r.match(be)){n=(""+t).replace(/^([^\.]+)$/,"$1."+Te(i[1])).replace(/\.$/,"."+Te(i[1]));n=n.replace(/\.(\d*)$/,function(e,r){return"."+r+wr("0",Te(i[1]).length-r.length)});return r.indexOf("0.")!==-1?n:n.replace(/^0\./,".")}r=r.replace(/^#+([0.])/,"$1");if(i=r.match(/^(0*)\.(#*)$/)){return l+(""+o).replace(/\.(\d*[1-9])0*$/,".$1").replace(/^(-?\d*)$/,"$1.").replace(/^0\./,i[1].length?"0.":".")}if(i=r.match(/^#{1,3},##0(\.?)$/))return l+ce(""+o);if(i=r.match(/^#,##0\.([#0]*0)$/)){return t<0?"-"+Re(e,r,-t):ce(""+t)+"."+wr("0",i[1].length)}if(i=r.match(/^#,#*,#0/))return Re(e,r.replace(/^#,#*,/,""),t);if(i=r.match(/^([0#]+)(\\?-([0#]+))+$/)){n=F(Re(e,r.replace(/[\\-]/g,""),t));s=0;return F(F(r.replace(/\\/g,"")).replace(/[0#]/g,function(e){return s-1||t=="\\"&&e.charAt(r+1)=="-"&&"0#".indexOf(e.charAt(r+2))>-1)){}break;case"?":while(e.charAt(++r)===t){}break;case"*":++r;if(e.charAt(r)==" "||e.charAt(r)=="*")++r;break;case"(":;case")":++r;break;case"1":;case"2":;case"3":;case"4":;case"5":;case"6":;case"7":;case"8":;case"9":while(r-1){}break;case" ":++r;break;default:++r;break;}}return false}function Pe(e,r,t,a){ -var n=[],i="",s=0,f="",o="t",l,c,h;var u="H";while(s=12?"P":"A";p.t="T";u="h";s+=3}else if(e.substr(s,5).toUpperCase()==="AM/PM"){if(l!=null)p.v=l.H>=12?"PM":"AM";p.t="T";s+=5;u="h"}else if(e.substr(s,5).toUpperCase()==="上午/下午"){if(l!=null)p.v=l.H>=12?"下午":"上午";p.t="T";s+=5;u="h"}else{p.t="t";++s}if(l==null&&p.t==="T")return"";n[n.length]=p;o=f;break;case"[":i=f;while(e.charAt(s++)!=="]"&&s-1){i=(i.match(/\$([^-\[\]]*)/)||[])[1]||"$";if(!Fe(e))n[n.length]={t:"t",v:i}}break;case".":if(l!=null){i=f;while(++s-1)i+=f;n[n.length]={t:"n",v:i};break;case"?":i=f;while(e.charAt(++s)===f)i+=f;n[n.length]={t:f,v:i};o=f;break;case"*":++s;if(e.charAt(s)==" "||e.charAt(s)=="*")++s;break;case"(":;case")":n[n.length]={t:a===1?"t":f,v:f};++s;break;case"1":;case"2":;case"3":;case"4":;case"5":;case"6":;case"7":;case"8":;case"9":i=f;while(s-1)i+=e.charAt(s);n[n.length]={t:"D",v:i};break;case" ":n[n.length]={t:f,v:f};++s;break;case"$":n[n.length]={t:"t",v:"$"};++s;break;default:if(",$-+/():!^&'~{}<>=€acfijklopqrtuvwxzP".indexOf(f)===-1)throw new Error("unrecognized character "+f+" in "+e);n[n.length]={t:"t",v:f};++s;break;}}var g=0,m=0,b;for(s=n.length-1,o="t";s>=0;--s){switch(n[s].t){case"h":;case"H":n[s].t=u;o="h";if(g<1)g=1;break;case"s":if(b=n[s].v.match(/\.0+$/))m=Math.max(m,b[0].length-1);if(g<3)g=3;case"d":;case"y":;case"M":;case"e":o=n[s].t;break;case"m":if(o==="s"){n[s].t="M";if(g<2)g=2}break;case"X":break;case"Z":if(g<1&&n[s].v.match(/[Hh]/))g=1;if(g<2&&n[s].v.match(/[Mm]/))g=2;if(g<3&&n[s].v.match(/[Ss]/))g=3;}}switch(g){case 0:break;case 1:if(l.u>=.5){l.u=0;++l.S}if(l.S>=60){l.S=0;++l.M}if(l.M>=60){l.M=0;++l.H}break;case 2:if(l.u>=.5){l.u=0;++l.S}if(l.S>=60){l.S=0;++l.M}break;}var w="",k;for(s=0;s0){if(w.charCodeAt(0)==40){A=r<0&&w.charCodeAt(0)===45?-r:r;E=Ie("n",w,A)}else{A=r<0&&a>1?-r:r;E=Ie("n",w,A);if(A<0&&n[0]&&n[0].t=="t"){E=E.substr(1);n[0].v="-"+n[0].v}}k=E.length-1;var C=n.length;for(s=0;s-1){C=s;break}var y=n.length;if(C===n.length&&E.indexOf("E")===-1){for(s=n.length-1;s>=0;--s){if(n[s]==null||"n?".indexOf(n[s].t)===-1)continue;if(k>=n[s].v.length-1){k-=n[s].v.length;n[s].v=E.substr(k+1,n[s].v.length)}else if(k<0)n[s].v="";else{n[s].v=E.substr(0,k+1);k=-1}n[s].t="t";y=s}if(k>=0&&y=0;--s){if(n[s]==null||"n?".indexOf(n[s].t)===-1)continue;c=n[s].v.indexOf(".")>-1&&s===C?n[s].v.indexOf(".")-1:n[s].v.length-1;T=n[s].v.substr(c+1);for(;c>=0;--c){if(k>=0&&(n[s].v.charAt(c)==="0"||n[s].v.charAt(c)==="#"))T=E.charAt(k--)+T}n[s].v=T;n[s].t="t";y=s}if(k>=0&&y-1&&s===C?n[s].v.indexOf(".")+1:0;T=n[s].v.substr(0,c);for(;c-1){A=a>1&&r<0&&s>0&&n[s-1].v==="-"?-r:r;n[s].v=Ie(n[s].t,n[s].v,A);n[s].t="t"}var S="";for(s=0;s!==n.length;++s)if(n[s]!=null)S+=n[s].v;return S}var Le=/\[(=|>[=]?|<[>=]?)(-?\d+(?:\.\d*)?)\]/;function Me(e,r){if(r==null)return false;var t=parseFloat(r[2]);switch(r[1]){case"=":if(e==t)return true;break;case">":if(e>t)return true;break;case"<":if(e":if(e!=t)return true;break;case">=":if(e>=t)return true;break;case"<=":if(e<=t)return true;break;}return false}function Ue(e,r){var t=Ne(e);var a=t.length,n=t[a-1].indexOf("@");if(a<4&&n>-1)--a;if(t.length>4)throw new Error("cannot find right format for |"+t.join("|")+"|");if(typeof r!=="number")return[4,t.length===4||n>-1?t[t.length-1]:"@"];switch(t.length){case 1:t=n>-1?["General","General","General",t[0]]:[t[0],t[0],t[0],"@"];break;case 2:t=n>-1?[t[0],t[0],t[0],t[1]]:[t[0],t[1],t[0],"@"];break;case 3:t=n>-1?[t[0],t[1],t[0],t[2]]:[t[0],t[1],t[2],"@"];break;case 4:break;}var i=r>0?t[0]:r<0?t[1]:t[2];if(t[0].indexOf("[")===-1&&t[1].indexOf("[")===-1)return[a,i];if(t[0].match(/\[[=<>]/)!=null||t[1].match(/\[[=<>]/)!=null){var s=t[0].match(Le);var f=t[1].match(Le);return Me(r,s)?[a,t[0]]:Me(r,f)?[a,t[1]]:[a,t[s!=null&&f!=null?2:1]]}return[a,i]}function Be(e,r,t){if(t==null)t={};var a="";switch(typeof e){case"string":if(e=="m/d/yy"&&t.dateNF)a=t.dateNF;else a=e;break;case"number":if(e==14&&t.dateNF)a=t.dateNF;else a=(t.table!=null?t.table:X)[e];if(a==null)a=t.table&&t.table[Y[e]]||X[Y[e]];if(a==null)a=K[e]||"General";break;}if(z(a,0))return fe(r,t);if(r instanceof Date)r=re(r,t.date1904);var n=Ue(a,r);if(z(n[1]))return fe(r,t);if(r===true)r="TRUE";else if(r===false)r="FALSE";else if(r===""||r==null)return"";return Pe(n[1],r,t,n[0])}function We(e,r){if(typeof r!="number"){r=+r||-1;for(var t=0;t<392;++t){if(X[t]==undefined){if(r<0)r=t;continue}if(X[t]==e){r=t;break}}if(r<0)r=391}X[r]=e;return r}function He(e){for(var r=0;r!=392;++r)if(e[r]!==undefined)We(e[r],r)}function ze(){X=j()}var Ve={format:Be,load:We,_table:X,load_table:He,parse_date_code:q,is_date:Fe,get_table:function Qw(){return Ve._table=X}};var Ge={5:'"$"#,##0_);\\("$"#,##0\\)',6:'"$"#,##0_);[Red]\\("$"#,##0\\)',7:'"$"#,##0.00_);\\("$"#,##0.00\\)',8:'"$"#,##0.00_);[Red]\\("$"#,##0.00\\)',23:"General",24:"General",25:"General",26:"General",27:"m/d/yy",28:"m/d/yy",29:"m/d/yy",30:"m/d/yy",31:"m/d/yy",32:"h:mm:ss",33:"h:mm:ss",34:"h:mm:ss",35:"h:mm:ss",36:"m/d/yy",41:'_(* #,##0_);_(* (#,##0);_(* "-"_);_(@_)',42:'_("$"* #,##0_);_("$"* (#,##0);_("$"* "-"_);_(@_)',43:'_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)',44:'_("$"* #,##0.00_);_("$"* (#,##0.00);_("$"* "-"??_);_(@_)',50:"m/d/yy",51:"m/d/yy",52:"m/d/yy",53:"m/d/yy",54:"m/d/yy",55:"m/d/yy",56:"m/d/yy",57:"m/d/yy",58:"m/d/yy",59:"0",60:"0.00",61:"#,##0",62:"#,##0.00",63:'"$"#,##0_);\\("$"#,##0\\)',64:'"$"#,##0_);[Red]\\("$"#,##0\\)',65:'"$"#,##0.00_);\\("$"#,##0.00\\)',66:'"$"#,##0.00_);[Red]\\("$"#,##0.00\\)',67:"0%",68:"0.00%",69:"# ?/?",70:"# ??/??",71:"m/d/yy",72:"m/d/yy",73:"d-mmm-yy",74:"d-mmm",75:"mmm-yy",76:"h:mm",77:"h:mm:ss",78:"m/d/yy h:mm",79:"mm:ss",80:"[h]:mm:ss",81:"mmss.0"};var je=/[dD]+|[mM]+|[yYeE]+|[Hh]+|[Ss]+/g;function Xe(e){var r=typeof e=="number"?X[e]:e;r=r.replace(je,"(\\d+)");return new RegExp("^"+r+"$")}function $e(e,r,t){var a=-1,n=-1,i=-1,s=-1,f=-1,o=-1;(r.match(je)||[]).forEach(function(e,r){var l=parseInt(t[r+1],10);switch(e.toLowerCase().charAt(0)){case"y":a=l;break;case"d":i=l;break;case"h":s=l;break;case"s":o=l;break;case"m":if(s>=0)f=l;else n=l;break;}});if(o>=0&&f==-1&&n>=0){f=n;n=-1}var l=(""+(a>=0?a:(new Date).getFullYear())).slice(-4)+"-"+("00"+(n>=1?n:1)).slice(-2)+"-"+("00"+(i>=1?i:1)).slice(-2);if(l.length==7)l="0"+l;if(l.length==8)l="20"+l;var c=("00"+(s>=0?s:0)).slice(-2)+":"+("00"+(f>=0?f:0)).slice(-2)+":"+("00"+(o>=0?o:0)).slice(-2);if(s==-1&&f==-1&&o==-1)return l;if(a==-1&&n==-1&&i==-1)return c;return l+"T"+c}var Ye=function(){var e={};e.version="1.2.0";function r(){var e=0,r=new Array(256);for(var t=0;t!=256;++t){e=t;e=e&1?-306674912^e>>>1:e>>>1;e=e&1?-306674912^e>>>1:e>>>1;e=e&1?-306674912^e>>>1:e>>>1;e=e&1?-306674912^e>>>1:e>>>1;e=e&1?-306674912^e>>>1:e>>>1;e=e&1?-306674912^e>>>1:e>>>1;e=e&1?-306674912^e>>>1:e>>>1;e=e&1?-306674912^e>>>1:e>>>1;r[t]=e}return typeof Int32Array!=="undefined"?new Int32Array(r):r}var t=r();function a(e){var r=0,t=0,a=0,n=typeof Int32Array!=="undefined"?new Int32Array(4096):new Array(4096);for(a=0;a!=256;++a)n[a]=e[a];for(a=0;a!=256;++a){t=e[a];for(r=256+a;r<4096;r+=256)t=n[r]=t>>>8^e[t&255]}var i=[];for(a=1;a!=16;++a)i[a-1]=typeof Int32Array!=="undefined"?n.subarray(a*256,a*256+256):n.slice(a*256,a*256+256);return i}var n=a(t);var i=n[0],s=n[1],f=n[2],o=n[3],l=n[4];var c=n[5],h=n[6],u=n[7],d=n[8],v=n[9];var p=n[10],g=n[11],m=n[12],b=n[13],w=n[14];function k(e,r){var a=r^-1;for(var n=0,i=e.length;n>>8^t[(a^e.charCodeAt(n++))&255];return~a}function T(e,r){var a=r^-1,n=e.length-15,k=0;for(;k>8&255]^m[e[k++]^a>>16&255]^g[e[k++]^a>>>24]^p[e[k++]]^v[e[k++]]^d[e[k++]]^u[e[k++]]^h[e[k++]]^c[e[k++]]^l[e[k++]]^o[e[k++]]^f[e[k++]]^s[e[k++]]^i[e[k++]]^t[e[k++]];n+=15;while(k>>8^t[(a^e[k++])&255];return~a}function A(e,r){var a=r^-1;for(var n=0,i=e.length,s=0,f=0;n>>8^t[(a^s)&255]}else if(s<2048){a=a>>>8^t[(a^(192|s>>6&31))&255];a=a>>>8^t[(a^(128|s&63))&255]}else if(s>=55296&&s<57344){s=(s&1023)+64;f=e.charCodeAt(n++)&1023;a=a>>>8^t[(a^(240|s>>8&7))&255];a=a>>>8^t[(a^(128|s>>2&63))&255];a=a>>>8^t[(a^(128|f>>6&15|(s&3)<<4))&255];a=a>>>8^t[(a^(128|f&63))&255]}else{a=a>>>8^t[(a^(224|s>>12&15))&255];a=a>>>8^t[(a^(128|s>>6&63))&255];a=a>>>8^t[(a^(128|s&63))&255]}}return~a}e.table=t;e.bstr=k;e.buf=T;e.str=A;return e}();var Ke=function ek(){var e={};e.version="1.2.1";function r(e,r){var t=e.split("/"),a=r.split("/");for(var n=0,i=0,s=Math.min(t.length,a.length);n>>1;e._W(2,t);var a=r.getFullYear()-1980;a=a<<4|r.getMonth()+1;a=a<<5|r.getDate();e._W(2,a)}function i(e){var r=e._R(2)&65535;var t=e._R(2)&65535;var a=new Date;var n=t&31;t>>>=5;var i=t&15;t>>>=4;a.setMilliseconds(0);a.setFullYear(t+1980);a.setMonth(i-1);a.setDate(n);var s=r&31;r>>>=5;var f=r&63;r>>>=6;a.setHours(r);a.setMinutes(f);a.setSeconds(s<<1);return a}function s(e){ca(e,0);var r={};var t=0;while(e.l<=e.length-4){var a=e._R(2);var n=e._R(2),i=e.l+n;var s={};switch(a){case 21589:{t=e._R(1);if(t&1)s.mtime=e._R(4);if(n>5){if(t&2)s.atime=e._R(4);if(t&4)s.ctime=e._R(4)}if(s.mtime)s.mt=new Date(s.mtime*1e3)}break;}e.l=i;r[a]=s}return r}var f;function o(){return f||(f=undefined)}function l(e,r){if(e[0]==80&&e[1]==75)return Ie(e,r);if((e[0]|32)==109&&(e[1]|32)==105)return We(e,r);if(e.length<512)throw new Error("CFB file size "+e.length+" < 512");var t=3;var a=512;var n=0;var i=0;var s=0;var f=0;var o=0;var l=[];var v=e.slice(0,512);ca(v,0);var g=c(v);t=g[0];switch(t){case 3:a=512;break;case 4:a=4096;break;case 0:if(g[1]==0)return Ie(e,r);default:throw new Error("Major Version: Expected 3 or 4 saw "+t);}if(a!==512){v=e.slice(0,a);ca(v,28)}var w=e.slice(0,a);h(v,t);var k=v._R(4,"i");if(t===3&&k!==0)throw new Error("# Directory Sectors: Expected 0 saw "+k);v.l+=4;s=v._R(4,"i");v.l+=4;v.chk("00100000","Mini Stream Cutoff Size: ");f=v._R(4,"i");n=v._R(4,"i");o=v._R(4,"i");i=v._R(4,"i");for(var T=-1,A=0;A<109;++A){T=v._R(4,"i");if(T<0)break;l[A]=T}var E=u(e,a);p(o,i,E,a,l);var C=m(E,s,l,a);C[s].name="!Directory";if(n>0&&f!==U)C[f].name="!MiniFAT";C[l[0]].name="!FAT";C.fat_addrs=l;C.ssz=a;var y={},S=[],_=[],x=[];b(s,C,E,S,n,y,_,f);d(_,x,S);S.shift();var O={FileIndex:_,FullPaths:x};if(r&&r.raw)O.raw={header:w,sectors:E};return O}function c(e){if(e[e.l]==80&&e[e.l+1]==75)return[0,0];e.chk(B,"Header Signature: ");e.l+=16;var r=e._R(2,"u");return[e._R(2,"u"),r]}function h(e,r){var t=9;e.l+=2;switch(t=e._R(2)){case 9:if(r!=3)throw new Error("Sector Shift: Expected 9 saw "+t);break;case 12:if(r!=4)throw new Error("Sector Shift: Expected 12 saw "+t);break;default:throw new Error("Sector Shift: Expected 9 or 12 saw "+t);}e.chk("0600","Mini Sector Shift: ");e.chk("000000000000","Reserved: ")}function u(e,r){var t=Math.ceil(e.length/r)-1;var a=[];for(var n=1;n0&&s>=0){i.push(r.slice(s*M,s*M+M));n-=M;s=ta(t,s*4)}if(i.length===0)return ua(0);return R(i).slice(0,e.size)}function p(e,r,t,a,n){var i=U;if(e===U){if(r!==0)throw new Error("DIFAT chain shorter than expected")}else if(e!==-1){var s=t[e],f=(a>>>2)-1;if(!s)return;for(var o=0;o=0;){n[o]=true;i[i.length]=o;s.push(e[o]);var c=t[Math.floor(o*4/a)];l=o*4&f;if(a<4+l)throw new Error("FAT boundary crossed: "+o+" 4 "+a);if(!e[c])break;o=ta(e[c],l)}return{nodes:i,data:It([s])}}function m(e,r,t,a){var n=e.length,i=[];var s=[],f=[],o=[];var l=a-1,c=0,h=0,u=0,d=0;for(c=0;c=n)u-=n;if(s[u])continue;o=[];var v=[];for(h=u;h>=0;){v[h]=true;s[h]=true;f[f.length]=h;o.push(e[h]);var p=t[Math.floor(h*4/a)];d=h*4&l;if(a<4+d)throw new Error("FAT boundary crossed: "+h+" 4 "+a);if(!e[p])break;h=ta(e[p],d);if(v[h])break}i[u]={nodes:f,data:It([o])}}return i}function b(e,r,t,a,n,i,s,f){var o=0,l=a.length?2:0;var c=r[e].data;var h=0,u=0,d;for(;h0&&o!==U)r[o].name="!StreamData"}else if(m.size>=4096){m.storage="fat";if(r[m.start]===undefined)r[m.start]=g(t,m.start,r.fat_addrs,r.ssz);r[m.start].name=m.name;m.content=r[m.start].data.slice(0,m.size)}else{m.storage="minifat";if(m.size<0)m.size=0;else if(o!==U&&m.start!==U&&r[o]){m.content=v(m,r[o].data,(r[f]||{}).data)}}if(m.content)ca(m.content,0);i[d]=m;s.push(m)}}function S(e,r){return new Date((ra(e,r+4)/1e7*Math.pow(2,32)+ra(e,r)/1e7-11644473600)*1e3)}function _(e,r){o();return l(f.readFileSync(e),r)}function x(e,r){var t=r&&r.type;if(!t){if(T&&Buffer.isBuffer(e))t="buffer"}switch(t||"base64"){case"file":return _(e,r);case"base64":return l(y(k(e)),r);case"binary":return l(y(e),r);}return l(e,r)}function O(e,r){var t=r||{},a=t.root||"Root Entry";if(!e.FullPaths)e.FullPaths=[];if(!e.FileIndex)e.FileIndex=[];if(e.FullPaths.length!==e.FileIndex.length)throw new Error("inconsistent CFB structure");if(e.FullPaths.length===0){e.FullPaths[0]=a+"/";e.FileIndex[0]={name:a,type:5}}if(t.CLSID)e.FileIndex[0].clsid=t.CLSID;I(e)}function I(e){var r="Sh33tJ5";if(Ke.find(e,"/"+r))return;var t=ua(4);t[0]=55;t[1]=t[3]=50;t[2]=54;e.FileIndex.push({name:r,type:2,content:t,size:4,L:69,R:69,C:69});e.FullPaths.push(e.FullPaths[0]+r);F(e)}function F(e,n){O(e);var i=false,s=false;for(var f=e.FullPaths.length-1;f>=0;--f){var o=e.FileIndex[f];switch(o.type){case 0:if(s)i=true;else{e.FileIndex.pop();e.FullPaths.pop()}break;case 1:;case 2:;case 5:s=true;if(isNaN(o.R*o.L*o.C))i=true;if(o.R>-1&&o.L>-1&&o.R==o.L)i=true;break;default:i=true;break;}}if(!i&&!n)return;var l=new Date(1987,1,19),c=0;var h=Object.create?Object.create(null):{};var u=[];for(f=0;f1?1:-1;v.size=0;v.type=5}else if(p.slice(-1)=="/"){for(c=f+1;c=u.length?-1:c;for(c=f+1;c=u.length?-1:c;v.type=1}else{if(t(e.FullPaths[f+1]||"")==t(p))v.R=f+1;v.type=2}}}function P(e,r){var t=r||{};if(t.fileType=="mad")return He(e,t);F(e);switch(t.fileType){case"zip":return De(e,t);}var a=function(e){var r=0,t=0;for(var a=0;a0){if(i<4096)r+=i+63>>6;else t+=i+511>>9}}var s=e.FullPaths.length+3>>2;var f=r+7>>3;var o=r+127>>7;var l=f+t+s+o;var c=l+127>>7;var h=c<=109?0:Math.ceil((c-109)/127);while(l+c+h+127>>7>c)h=++c<=109?0:Math.ceil((c-109)/127);var u=[1,h,c,o,s,t,r,0];e.FileIndex[0].size=r<<6;u[7]=(e.FileIndex[0].start=u[0]+u[1]+u[2]+u[3]+u[4]+u[5])+(u[6]+7>>3);return u}(e);var n=ua(a[7]<<9);var i=0,s=0;{for(i=0;i<8;++i)n._W(1,W[i]);for(i=0;i<8;++i)n._W(2,0);n._W(2,62);n._W(2,3);n._W(2,65534);n._W(2,9);n._W(2,6);for(i=0;i<3;++i)n._W(2,0);n._W(4,0);n._W(4,a[2]);n._W(4,a[0]+a[1]+a[2]+a[3]-1);n._W(4,0);n._W(4,1<<12);n._W(4,a[3]?a[0]+a[1]+a[2]-1:U);n._W(4,a[3]);n._W(-4,a[1]?a[0]-1:U);n._W(4,a[1]);for(i=0;i<109;++i)n._W(-4,i>9)}f(a[6]+7>>3);while(n.l&511)n._W(-4,z.ENDOFCHAIN);s=i=0;for(o=0;o=4096)continue;c.start=s;f(l+63>>6)}while(n.l&511)n._W(-4,z.ENDOFCHAIN);for(i=0;i=4096){n.l=c.start+1<<9;if(T&&Buffer.isBuffer(c.content)){c.content.copy(n,n.l,0,c.size);n.l+=c.size+511&-512}else{for(o=0;o0&&c.size<4096){if(T&&Buffer.isBuffer(c.content)){c.content.copy(n,n.l,0,c.size);n.l+=c.size+63&-64}else{for(o=0;o>16|r>>8|r)&255}var ee=typeof Uint8Array!=="undefined";var re=ee?new Uint8Array(1<<8):[];for(var te=0;te<1<<8;++te)re[te]=Q(te);function ae(e,r){var t=re[e&255];if(r<=8)return t>>>8-r;t=t<<8|re[e>>8&255];if(r<=16)return t>>>16-r;t=t<<8|re[e>>16&255];return t>>>24-r}function ne(e,r){var t=r&7,a=r>>>3;return(e[a]|(t<=6?0:e[a+1]<<8))>>>t&3}function ie(e,r){var t=r&7,a=r>>>3;return(e[a]|(t<=5?0:e[a+1]<<8))>>>t&7}function se(e,r){var t=r&7,a=r>>>3;return(e[a]|(t<=4?0:e[a+1]<<8))>>>t&15}function fe(e,r){var t=r&7,a=r>>>3;return(e[a]|(t<=3?0:e[a+1]<<8))>>>t&31}function oe(e,r){var t=r&7,a=r>>>3;return(e[a]|(t<=1?0:e[a+1]<<8))>>>t&127}function le(e,r,t){var a=r&7,n=r>>>3,i=(1<>>a;if(t<8-a)return s&i;s|=e[n+1]<<8-a;if(t<16-a)return s&i;s|=e[n+2]<<16-a;if(t<24-a)return s&i;s|=e[n+3]<<24-a;return s&i}function ce(e,r,t){var a=r&7,n=r>>>3;if(a<=5)e[n]|=(t&7)<>8-a}return r+3}function he(e,r,t){var a=r&7,n=r>>>3;t=(t&1)<>>3;t<<=a;e[n]|=t&255;t>>>=8;e[n+1]=t;return r+8}function de(e,r,t){var a=r&7,n=r>>>3;t<<=a;e[n]|=t&255;t>>>=8;e[n+1]=t&255;e[n+2]=t>>>8;return r+16}function ve(e,r){var t=e.length,a=2*t>r?2*t:r+5,n=0;if(t>=r)return e;if(T){var i=C(a);if(e.copy)e.copy(i);else for(;n>a-h;for(s=(1<=0;--s)r[f|s<0)r[r.l++]=e[t++]}return r.l}function i(r,t){var n=0;var i=0;var s=ee?new Uint16Array(32768):[];while(i0)t[t.l++]=r[i++];n=t.l*8;continue}n=ce(t,n,+!!(i+f==r.length)+2);var o=0;while(f-- >0){var l=r[i];o=(o<<5^l)&32767;var c=-1,h=0;if(c=s[o]){c|=i&~32767;if(c>i)c-=32768;if(c2){l=a[h];if(l<=22)n=ue(t,n,re[l+1]>>1)-1;else{ue(t,n,3);n+=5;ue(t,n,re[l-23]>>5);n+=3}var u=l<8?0:l-4>>2;if(u>0){de(t,n,h-q[l]);n+=u}l=e[i-c];n=ue(t,n,re[l]>>3);n-=3;var d=l<4?0:l-2>>1;if(d>0){de(t,n,i-c-Z[l]);n+=d}for(var v=0;v>8-v;for(var p=(1<<7-v)-1;p>=0;--p)Ce[d|p<>>=3){case 16:i=3+ne(e,r);r+=2;d=g[g.length-1];while(i-- >0)g.push(d);break;case 17:i=3+ie(e,r);r+=3;while(i-- >0)g.push(0);break;case 18:i=11+oe(e,r);r+=7;while(i-- >0)g.push(0);break;default:g.push(d);if(o>>0;var f=0,o=0;while((a&1)==0){a=ie(e,t);t+=3;if(a>>>1==0){if(t&7)t+=8-(t&7);var l=e[t>>>3]|e[(t>>>3)+1]<<8;t+=32;if(l>0){if(!r&&s0){n[i++]=e[t>>>3];t+=8}}continue}else if(a>>1==1){f=9;o=5}else{t=_e(e,t);f=ye;o=Se}for(;;){if(!r&&s>>1==1?me[c]:Ae[c];t+=h&15;h>>>=4;if((h>>>8&255)===0)n[i++]=h;else if(h==256)break;else{h-=257;var u=h<8?0:h-4>>2;if(u>5)u=0;var d=i+q[h];if(u>0){d+=le(e,t,u);t+=u}c=le(e,t,o);h=a>>>1==1?be[c]:Ee[c];t+=h&15;h>>>=4;var v=h<4?0:h-2>>1;var p=Z[h];if(v>0){p+=le(e,t,v);t+=v}if(!r&&s>>3];return[n.slice(0,i),t+7>>>3]}function Oe(e,r){var t=e.slice(e.l||0);var a=xe(t,r);e.l+=a[1];return a[0]}function Re(e,r){if(e){if(typeof console!=="undefined")console.error(r)}else throw new Error(r)}function Ie(e,r){var t=e;ca(t,0);var a=[],n=[];var i={FileIndex:a,FullPaths:n};O(i,{root:r.root});var f=t.length-4;while((t[f]!=80||t[f+1]!=75||t[f+2]!=5||t[f+3]!=6)&&f>=0)--f;t.l=f+4;t.l+=4;var o=t._R(2);t.l+=6;var l=t._R(4);t.l=l;for(f=0;f0){t=t.slice(0,t.length-1);t=t.slice(0,t.lastIndexOf("/")+1);if(i.slice(0,t.length)==t)break}}var s=(a[1]||"").match(/boundary="(.*?)"/);if(!s)throw new Error("MAD cannot find boundary");var f="--"+(s[1]||"");var o=[],l=[];var c={FileIndex:o,FullPaths:l};O(c);var h,u=0;for(n=0;n=32&&d<128)++h;var p=h>=u*4/5;n.push(a);n.push("Content-Location: "+(t.root||"file:///C:/SheetJS/")+s);n.push("Content-Transfer-Encoding: "+(p?"quoted-printable":"base64"));n.push("Content-Type: "+Pe(f,s));n.push("");n.push(p?Me(c):Le(c))}n.push(a+"--\r\n");return n.join("\r\n")}function ze(e){var r={};O(r,e);return r}function Ve(e,r,t,n){var i=n&&n.unsafe;if(!i)O(e);var s=!i&&Ke.find(e,r);if(!s){var f=e.FullPaths[0];if(r.slice(0,f.length)==f)f=r;else{if(f.slice(-1)!="/")f+="/";f=(f+r).replace("//","/")}s={name:a(r),type:2};e.FileIndex.push(s);e.FullPaths.push(f);if(!i)Ke.utils.cfb_gc(e)}s.content=t;s.size=t?t.length:0;if(n){if(n.CLSID)s.clsid=n.CLSID;if(n.mt)s.mt=n.mt;if(n.ct)s.ct=n.ct}return s}function Ge(e,r){O(e);var t=Ke.find(e,r);if(t)for(var a=0;a3)a=true;switch(n[i].slice(n[i].length-1)){case"Y":throw new Error("Unsupported ISO Duration Field: "+n[i].slice(n[i].length-1));case"D":t*=24;case"H":t*=60;case"M":if(!a)throw new Error("Unsupported ISO Duration Field: M");else t*=60;case"S":break;}r+=t*parseInt(n[i],10)}return r}var dr=new Date("2017-02-19T19:06:09.000Z");var vr=isNaN(dr.getFullYear())?new Date("2/19/17"):dr;var pr=vr.getFullYear()==2017;function gr(e,r){var t=new Date(e);if(pr){if(r>0)t.setTime(t.getTime()+t.getTimezoneOffset()*60*1e3);else if(r<0)t.setTime(t.getTime()-t.getTimezoneOffset()*60*1e3);return t}if(e instanceof Date)return e;if(vr.getFullYear()==1917&&!isNaN(t.getFullYear())){var a=t.getFullYear();if(e.indexOf(""+a)>-1)return t;t.setFullYear(t.getFullYear()+100);return t}var n=e.match(/\d+/g)||["2017","2","19","0","0","0"];var i=new Date(+n[0],+n[1]-1,+n[2],+n[3]||0,+n[4]||0,+n[5]||0);if(e.indexOf("Z")>-1)i=new Date(i.getTime()-i.getTimezoneOffset()*60*1e3);return i}function mr(e,r){if(T&&Buffer.isBuffer(e)){if(r){if(e[0]==255&&e[1]==254)return ct(e.slice(2).toString("utf16le"));if(e[1]==254&&e[2]==255)return ct(u(e.slice(2).toString("binary")))}return e.toString("binary")}if(typeof TextDecoder!=="undefined")try{if(r){if(e[0]==255&&e[1]==254)return ct(new TextDecoder("utf-16le").decode(e.slice(2)));if(e[0]==254&&e[1]==255)return ct(new TextDecoder("utf-16be").decode(e.slice(2)))}var t={"€":"€","‚":"‚","ƒ":"ƒ","„":"„","…":"…","†":"†","‡":"‡","ˆ":"ˆ","‰":"‰","Š":"Š","‹":"‹","Œ":"Œ","Ž":"Ž","‘":"‘","’":"’","“":"“","”":"”","•":"•","–":"–","—":"—","˜":"˜","™":"™","š":"š","›":"›","œ":"œ","ž":"ž","Ÿ":"Ÿ"};if(Array.isArray(e))e=new Uint8Array(e);return new TextDecoder("latin1").decode(e).replace(/[€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ]/g,function(e){return t[e]||e})}catch(a){}var n=[];for(var i=0;i!=e.length;++i)n.push(String.fromCharCode(e[i]));return n.join("")}function br(e){if(typeof JSON!="undefined"&&!Array.isArray(e))return JSON.parse(JSON.stringify(e));if(typeof e!="object"||e==null)return e;if(e instanceof Date)return new Date(e.getTime());var r={};for(var t in e)if(Object.prototype.hasOwnProperty.call(e,t))r[t]=br(e[t]);return r}function wr(e,r){var t="";while(t.length3&&Tr.indexOf(s)==-1)return t}else if(s.match(/[a-z]/))return t;if(a<0||a>8099)return t;if((n>0||i>1)&&a!=101)return r;if(e.match(/[^-0-9:,\/\\]/))return t;return r}var Er=function(){var e="abacaba".split(/(:?b)/i).length==5;return function r(t,a,n){if(e||typeof a=="string")return t.split(a);var i=t.split(a),s=[i[0]];for(var f=1;f\/]+)\s*=\s*((?:")([^"]*)(?:")|(?:')([^']*)(?:')|([^'">\s]+))/g;var Br=/<[\/\?]?[a-zA-Z0-9:_-]+(?:\s+[^"\s?>\/]+\s*=\s*(?:"[^"]*"|'[^']*'|[^'">\s=]+))*\s*[\/\?]?>/gm,Wr=/<[^>]*>/g;var Hr=Mr.match(Br)?Br:Wr;var zr=/<\w*:/,Vr=/<(\/?)\w+:/;function Gr(e,r,t){var a={};var n=0,i=0;for(;n!==e.length;++n)if((i=e.charCodeAt(n))===32||i===10||i===13)break;if(!r)a[0]=e.slice(0,n);if(n===e.length)return a;var s=e.match(Ur),f=0,o="",l=0,c="",h="",u=1;if(s)for(l=0;l!=s.length;++l){h=s[l];for(i=0;i!=h.length;++i)if(h.charCodeAt(i)===61)break;c=h.slice(0,i).trim();while(h.charCodeAt(i+1)==32)++i;u=(n=h.charCodeAt(i+1))==34||n==39?1:0;o=h.slice(i+1+u,h.length-u);for(f=0;f!=c.length;++f)if(c.charCodeAt(f)===58)break;if(f===c.length){if(c.indexOf("_")>0)c=c.slice(0,c.indexOf("_"));a[c]=o;if(!t)a[c.toLowerCase()]=o}else{var d=(f===5&&c.slice(0,5)==="xmlns"?"xmlns":"")+c.slice(f+1);if(a[d]&&c.slice(f-3,f)=="ext")continue;a[d]=o;if(!t)a[d.toLowerCase()]=o}}return a}function jr(e){return e.replace(Vr,"<$1")}var Xr={""":'"',"'":"'",">":">","<":"<","&":"&"};var $r=ar(Xr);var Yr=function(){var e=/&(?:quot|apos|gt|lt|amp|#x?([\da-fA-F]+));/gi,r=/_x([\da-fA-F]{4})_/gi;return function t(a){var n=a+"",i=n.indexOf("-1?16:10))||e}).replace(r,function(e,r){return String.fromCharCode(parseInt(r,16))});var s=n.indexOf("]]>");return t(n.slice(0,i))+n.slice(i+9,s)+t(n.slice(s+3))}}();var Kr=/[&<>'"]/g,Jr=/[\u0000-\u0008\u000b-\u001f]/g;function qr(e){var r=e+"";return r.replace(Kr,function(e){return $r[e]}).replace(Jr,function(e){return"_x"+("000"+e.charCodeAt(0).toString(16)).slice(-4)+"_"})}function Zr(e){return qr(e).replace(/ /g,"_x0020_")}var Qr=/[\u0000-\u001f]/g;function et(e){var r=e+"";return r.replace(Kr,function(e){return $r[e]}).replace(/\n/g,"
    ").replace(Qr,function(e){return"&#x"+("000"+e.charCodeAt(0).toString(16)).slice(-4)+";"})}function rt(e){var r=e+"";return r.replace(Kr,function(e){return $r[e]}).replace(Qr,function(e){return"&#x"+e.charCodeAt(0).toString(16).toUpperCase()+";"})}var tt=function(){var e=/&#(\d+);/g;function r(e,r){return String.fromCharCode(parseInt(r,10))}return function t(a){return a.replace(e,r)}}();function at(e){return e.replace(/(\r\n|[\r\n])/g," ")}function nt(e){switch(e){case 1:;case true:;case"1":;case"true":;case"TRUE":return true;default:return false;}}function it(e){var r="",t=0,a=0,n=0,i=0,s=0,f=0;while(t191&&a<224){s=(a&31)<<6;s|=n&63;r+=String.fromCharCode(s);continue}i=e.charCodeAt(t++);if(a<240){r+=String.fromCharCode((a&15)<<12|(n&63)<<6|i&63);continue}s=e.charCodeAt(t++);f=((a&7)<<18|(n&63)<<12|(i&63)<<6|s&63)-65536;r+=String.fromCharCode(55296+(f>>>10&1023));r+=String.fromCharCode(56320+(f&1023))}return r}function st(e){var r=E(2*e.length),t,a,n=1,i=0,s=0,f;for(a=0;a>>10&1023);t=56320+(t&1023)}if(s!==0){r[i++]=s&255;r[i++]=s>>>8;s=0}r[i++]=t%256;r[i++]=t>>>8}return r.slice(0,i).toString("ucs2")}function ft(e){return A(e,"binary").toString("utf8")}var ot="foo bar baz☃🍣";var lt=T&&(ft(ot)==it(ot)&&ft||st(ot)==it(ot)&&st)||it;var ct=T?function(e){return A(e,"utf8").toString("binary")}:function(e){var r=[],t=0,a=0,n=0;while(t>6)));r.push(String.fromCharCode(128+(a&63)));break;case a>=55296&&a<57344:a-=55296;n=e.charCodeAt(t++)-56320+(a<<10);r.push(String.fromCharCode(240+(n>>18&7)));r.push(String.fromCharCode(144+(n>>12&63)));r.push(String.fromCharCode(128+(n>>6&63)));r.push(String.fromCharCode(128+(n&63)));break;default:r.push(String.fromCharCode(224+(a>>12)));r.push(String.fromCharCode(128+(a>>6&63)));r.push(String.fromCharCode(128+(a&63)));}}return r.join("")};var ht=function(){var e={};return function r(t,a){var n=t+"|"+(a||"");if(e[n])return e[n];return e[n]=new RegExp("<(?:\\w+:)?"+t+'(?: xml:space="preserve")?(?:[^>]*)>([\\s\\S]*?)",a||"")}}();var ut=function(){var e=[["nbsp"," "],["middot","·"],["quot",'"'],["apos","'"],["gt",">"],["lt","<"],["amp","&"]].map(function(e){return[new RegExp("&"+e[0]+";","ig"),e[1]]});return function r(t){var a=t.replace(/^[\t\n\r ]+/,"").replace(/[\t\n\r ]+$/,"").replace(/>\s+/g,">").replace(/\s+/g,"\n").replace(/<[^>]*>/g,"");for(var n=0;n([\\s\\S]*?)","g")}}();var vt=/<\/?(?:vt:)?variant>/g,pt=/<(?:vt:)([^>]*)>([\s\S]*)"+r+""}function wt(e){return rr(e).map(function(r){return" "+r+'="'+e[r]+'"'}).join("")}function kt(e,r,t){return"<"+e+(t!=null?wt(t):"")+(r!=null?(r.match(mt)?' xml:space="preserve"':"")+">"+r+""}function Tt(e,r){try{return e.toISOString().replace(/\.\d*/,"")}catch(t){if(r)throw t}return""}function At(e,r){switch(typeof e){case"string":var t=kt("vt:lpwstr",qr(e));if(r)t=t.replace(/"/g,"_x0022_");return t;case"number":return kt((e|0)==e?"vt:i4":"vt:r8",qr(String(e)));case"boolean":return kt("vt:bool",e?"true":"false");}if(e instanceof Date)return kt("vt:filetime",Tt(e));throw new Error("Unable to serialize "+e)}function Et(e){if(T&&Buffer.isBuffer(e))return e.toString("utf8");if(typeof e==="string")return e;if(typeof Uint8Array!=="undefined"&&e instanceof Uint8Array)return lt(_(O(e)));throw new Error("Bad input format: expected Buffer or string")}var Ct=/<(\/?)([^\s?>:\/]+)(?:[\s?:\/][^>]*)?>/gm;var yt={CORE_PROPS:"http://schemas.openxmlformats.org/package/2006/metadata/core-properties",CUST_PROPS:"http://schemas.openxmlformats.org/officeDocument/2006/custom-properties",EXT_PROPS:"http://schemas.openxmlformats.org/officeDocument/2006/extended-properties",CT:"http://schemas.openxmlformats.org/package/2006/content-types",RELS:"http://schemas.openxmlformats.org/package/2006/relationships",TCMNT:"http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments",dc:"http://purl.org/dc/elements/1.1/",dcterms:"http://purl.org/dc/terms/",dcmitype:"http://purl.org/dc/dcmitype/",mx:"http://schemas.microsoft.com/office/mac/excel/2008/main",r:"http://schemas.openxmlformats.org/officeDocument/2006/relationships",sjs:"http://schemas.openxmlformats.org/package/2006/sheetjs/core-properties",vt:"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes",xsi:"http://www.w3.org/2001/XMLSchema-instance",xsd:"http://www.w3.org/2001/XMLSchema"};var St=["http://schemas.openxmlformats.org/spreadsheetml/2006/main","http://purl.oclc.org/ooxml/spreadsheetml/main","http://schemas.microsoft.com/office/excel/2006/main","http://schemas.microsoft.com/office/excel/2006/2"];var _t={o:"urn:schemas-microsoft-com:office:office",x:"urn:schemas-microsoft-com:office:excel",ss:"urn:schemas-microsoft-com:office:spreadsheet",dt:"uuid:C2F41010-65B3-11d1-A29F-00AA00C14882",mv:"http://macVmlSchemaUri",v:"urn:schemas-microsoft-com:vml",html:"http://www.w3.org/TR/REC-html40"};function xt(e,r){var t=1-2*(e[r+7]>>>7);var a=((e[r+7]&127)<<4)+(e[r+6]>>>4&15);var n=e[r+6]&15;for(var i=5;i>=0;--i)n=n*256+e[r+i];if(a==2047)return n==0?t*Infinity:NaN;if(a==0)a=-1022;else{a-=1023;n+=Math.pow(2,52)}return t*Math.pow(2,a-52)*n}function Ot(e,r,t){var a=(r<0||1/r==-Infinity?1:0)<<7,n=0,i=0;var s=a?-r:r;if(!isFinite(s)){n=2047;i=isNaN(r)?26985:0}else if(s==0)n=i=0;else{n=Math.floor(Math.log(s)/Math.LN2);i=s*Math.pow(2,52-n);if(n<=-1023&&(!isFinite(i)||i>4|a}var Rt=function(e){var r=[],t=10240;for(var a=0;a0&&Buffer.isBuffer(e[0][0])?Buffer.concat(e[0].map(function(e){return Buffer.isBuffer(e)?e:A(e)})):Rt(e)}:Rt;var Nt=function(e,r,t){var a=[];for(var n=r;n0?Mt(e,r+4,r+4+t-1):""};var Bt=Ut;var Wt=function(e,r){var t=ra(e,r);return t>0?Mt(e,r+4,r+4+t-1):""};var Ht=Wt;var zt=function(e,r){var t=2*ra(e,r);return t>0?Mt(e,r+4,r+4+t-1):""};var Vt=zt;var Gt=function tk(e,r){var t=ra(e,r);return t>0?Dt(e,r+4,r+4+t):""};var jt=Gt;var Xt=function(e,r){var t=ra(e,r);return t>0?Mt(e,r+4,r+4+t):""};var $t=Xt;var Yt=function(e,r){return xt(e,r)};var Kt=Yt;var Jt=function ak(e){return Array.isArray(e)||typeof Uint8Array!=="undefined"&&e instanceof Uint8Array};if(T){Bt=function nk(e,r){if(!Buffer.isBuffer(e))return Ut(e,r);var t=e.readUInt32LE(r);return t>0?e.toString("utf8",r+4,r+4+t-1):""};Ht=function ik(e,r){if(!Buffer.isBuffer(e))return Wt(e,r);var t=e.readUInt32LE(r);return t>0?e.toString("utf8",r+4,r+4+t-1):""};Vt=function sk(e,r){if(!Buffer.isBuffer(e))return zt(e,r);var t=2*e.readUInt32LE(r);return e.toString("utf16le",r+4,r+4+t-1)};jt=function fk(e,r){if(!Buffer.isBuffer(e))return Gt(e,r);var t=e.readUInt32LE(r);return e.toString("utf16le",r+4,r+4+t)};$t=function ok(e,r){if(!Buffer.isBuffer(e))return Xt(e,r);var t=e.readUInt32LE(r);return e.toString("utf8",r+4,r+4+t)};Kt=function lk(e,r){if(Buffer.isBuffer(e))return e.readDoubleLE(r);return Yt(e,r)};Jt=function ck(e){return Buffer.isBuffer(e)||Array.isArray(e)||typeof Uint8Array!=="undefined"&&e instanceof Uint8Array}}function qt(){Dt=function(e,r,t){return a.utils.decode(1200,e.slice(r,t)).replace(N,"")};Mt=function(e,r,t){return a.utils.decode(65001,e.slice(r,t))};Bt=function(e,r){var n=ra(e,r);return n>0?a.utils.decode(t,e.slice(r+4,r+4+n-1)):""};Ht=function(e,t){var n=ra(e,t);return n>0?a.utils.decode(r,e.slice(t+4,t+4+n-1)):""};Vt=function(e,r){var t=2*ra(e,r);return t>0?a.utils.decode(1200,e.slice(r+4,r+4+t-1)):""};jt=function(e,r){var t=ra(e,r);return t>0?a.utils.decode(1200,e.slice(r+4,r+4+t)):""};$t=function(e,r){var t=ra(e,r);return t>0?a.utils.decode(65001,e.slice(r+4,r+4+t)):""}}if(typeof a!=="undefined")qt();var Zt=function(e,r){return e[r]};var Qt=function(e,r){return e[r+1]*(1<<8)+e[r]};var ea=function(e,r){var t=e[r+1]*(1<<8)+e[r];return t<32768?t:(65535-t+1)*-1};var ra=function(e,r){return e[r+3]*(1<<24)+(e[r+2]<<16)+(e[r+1]<<8)+e[r]};var ta=function(e,r){return e[r+3]<<24|e[r+2]<<16|e[r+1]<<8|e[r]};var aa=function(e,r){return e[r]<<24|e[r+1]<<16|e[r+2]<<8|e[r+3]};function na(e,t){var n="",i,s,f=[],o,l,c,h;switch(t){case"dbcs":h=this.l;if(T&&Buffer.isBuffer(this))n=this.slice(this.l,this.l+2*e).toString("utf16le");else for(c=0;c0?ta:aa)(this,this.l);this.l+=4;return i}else{s=ra(this,this.l);this.l+=4}return s;case 8:;case-8:if(t==="f"){if(e==8)s=Kt(this,this.l);else s=Kt([this[this.l+7],this[this.l+6],this[this.l+5],this[this.l+4],this[this.l+3],this[this.l+2],this[this.l+1],this[this.l+0]],0);this.l+=8;return s}else e=8;case 16:n=Pt(this,this.l,e);break;};}this.l+=e;return n}var ia=function(e,r,t){e[t]=r&255;e[t+1]=r>>>8&255;e[t+2]=r>>>16&255;e[t+3]=r>>>24&255};var sa=function(e,r,t){e[t]=r&255;e[t+1]=r>>8&255;e[t+2]=r>>16&255;e[t+3]=r>>24&255};var fa=function(e,r,t){e[t]=r&255;e[t+1]=r>>>8&255};function oa(e,r,n){var i=0,s=0;if(n==="dbcs"){for(s=0;s!=r.length;++s)fa(this,r.charCodeAt(s),this.l+2*s);i=2*r.length}else if(n==="sbcs"){if(typeof a!=="undefined"&&t==874){for(s=0;s!=r.length;++s){var f=a.utils.encode(t,r.charAt(s));this[this.l+s]=f[0]}}else{r=r.replace(/[^\x00-\x7F]/g,"_");for(s=0;s!=r.length;++s)this[this.l+s]=r.charCodeAt(s)&255}i=r.length}else if(n==="hex"){for(;s>8}while(this.l>>=8;this[this.l+1]=r&255;break;case 3:i=3;this[this.l]=r&255;r>>>=8;this[this.l+1]=r&255;r>>>=8;this[this.l+2]=r&255;break;case 4:i=4;ia(this,r,this.l);break;case 8:i=8;if(n==="f"){Ot(this,r,this.l);break};case 16:break;case-4:i=4;sa(this,r,this.l);break;}this.l+=i;return this}function la(e,r){var t=Pt(this,this.l,e.length>>1);if(t!==e)throw new Error(r+"Expected "+e+" saw "+t);this.l+=e.length>>1}function ca(e,r){e.l=r;e._R=na;e.chk=la;e._W=oa}function ha(e,r){e.l+=r}function ua(e){var r=E(e);ca(r,0);return r}function da(e,r,t){if(!e)return;var a,n,i;ca(e,e.l||0);var s=e.length,f=0,o=0;while(e.la.l){a=a.slice(0,a.l);a.l=a.length}if(a.length>0)e.push(a);a=null};var i=function c(e){if(a&&e=128?1:0)+1;if(a>=128)++i;if(a>=16384)++i;if(a>=2097152)++i;var s=e.next(i);if(n<=127)s._W(1,n);else{s._W(1,(n&127)+128);s._W(1,n>>7)}for(var f=0;f!=4;++f){if(a>=128){s._W(1,(a&127)+128);a>>=7}else{s._W(1,a);break}}if(a>0&&Jt(t))e.push(t)}function ga(e,r,t){var a=br(e);if(r.s){if(a.cRel)a.c+=r.s.c;if(a.rRel)a.r+=r.s.r}else{if(a.cRel)a.c+=r.c;if(a.rRel)a.r+=r.r}if(!t||t.biff<12){while(a.c>=256)a.c-=256;while(a.r>=65536)a.r-=65536}return a}function ma(e,r,t){var a=br(e);a.s=ga(a.s,r.s,t);a.e=ga(a.e,r.s,t);return a}function ba(e,r){if(e.cRel&&e.c<0){e=br(e);while(e.c<0)e.c+=r>8?16384:256}if(e.rRel&&e.r<0){e=br(e);while(e.r<0)e.r+=r>8?1048576:r>5?65536:16384}var t=Ra(e);if(!e.cRel&&e.cRel!=null)t=Sa(t);if(!e.rRel&&e.rRel!=null)t=Aa(t);return t}function wa(e,r){if(e.s.r==0&&!e.s.rRel){if(e.e.r==(r.biff>=12?1048575:r.biff>=8?65536:16384)&&!e.e.rRel){return(e.s.cRel?"":"$")+ya(e.s.c)+":"+(e.e.cRel?"":"$")+ya(e.e.c); -}}if(e.s.c==0&&!e.s.cRel){if(e.e.c==(r.biff>=12?16383:255)&&!e.e.cRel){return(e.s.rRel?"":"$")+Ta(e.s.r)+":"+(e.e.rRel?"":"$")+Ta(e.e.r)}}return ba(e.s,r.biff)+":"+ba(e.e,r.biff)}function ka(e){return parseInt(Ea(e),10)-1}function Ta(e){return""+(e+1)}function Aa(e){return e.replace(/([A-Z]|^)(\d+)$/,"$1$$$2")}function Ea(e){return e.replace(/\$(\d+)$/,"$1")}function Ca(e){var r=_a(e),t=0,a=0;for(;a!==r.length;++a)t=26*t+r.charCodeAt(a)-64;return t-1}function ya(e){if(e<0)throw new Error("invalid column "+e);var r="";for(++e;e;e=Math.floor((e-1)/26))r=String.fromCharCode((e-1)%26+65)+r;return r}function Sa(e){return e.replace(/^([A-Z])/,"$$$1")}function _a(e){return e.replace(/^\$([A-Z])/,"$1")}function xa(e){return e.replace(/(\$?[A-Z]*)(\$?\d*)/,"$1,$2").split(",")}function Oa(e){var r=0,t=0;for(var a=0;a=48&&n<=57)r=10*r+(n-48);else if(n>=65&&n<=90)t=26*t+(n-64)}return{c:t-1,r:r-1}}function Ra(e){var r=e.c+1;var t="";for(;r;r=(r-1)/26|0)t=String.fromCharCode((r-1)%26+65)+t;return t+(e.r+1)}function Ia(e){var r=e.indexOf(":");if(r==-1)return{s:Oa(e),e:Oa(e)};return{s:Oa(e.slice(0,r)),e:Oa(e.slice(r+1))}}function Na(e,r){if(typeof r==="undefined"||typeof r==="number"){return Na(e.s,e.e)}if(typeof e!=="string")e=Ra(e);if(typeof r!=="string")r=Ra(r);return e==r?e:e+":"+r}function Da(e){var r={s:{c:0,r:0},e:{c:0,r:0}};var t=0,a=0,n=0;var i=e.length;for(t=0;a26)break;t=26*t+n}r.s.c=--t;for(t=0;a9)break;t=10*t+n}r.s.r=--t;if(a===i||n!=10){r.e.c=r.s.c;r.e.r=r.s.r;return r}++a;for(t=0;a!=i;++a){if((n=e.charCodeAt(a)-64)<1||n>26)break;t=26*t+n}r.e.c=--t;for(t=0;a!=i;++a){if((n=e.charCodeAt(a)-48)<0||n>9)break;t=10*t+n}r.e.r=--t;return r}function Fa(e,r){var t=e.t=="d"&&r instanceof Date;if(e.z!=null)try{return e.w=Be(e.z,t?fr(r):r)}catch(a){}try{return e.w=Be((e.XF||{}).numFmtId||(t?14:0),t?fr(r):r)}catch(a){return""+r}}function Pa(e,r,t){if(e==null||e.t==null||e.t=="z")return"";if(e.w!==undefined)return e.w;if(e.t=="d"&&!e.z&&t&&t.dateNF)e.z=t.dateNF;if(e.t=="e")return Gn[e.v]||e.v;if(r==undefined)return Fa(e,e.v);return Fa(e,r)}function La(e,r){var t=r&&r.sheet?r.sheet:"Sheet1";var a={};a[t]=e;return{SheetNames:[t],Sheets:a}}function Ma(e,r,t){var a=t||{};var n=e?Array.isArray(e):a.dense;if(g!=null&&n==null)n=g;var i=e||(n?[]:{});var s=0,f=0;if(i&&a.origin!=null){if(typeof a.origin=="number")s=a.origin;else{var o=typeof a.origin=="string"?Oa(a.origin):a.origin;s=o.r;f=o.c}if(!i["!ref"])i["!ref"]="A1:A1"}var l={s:{c:1e7,r:1e7},e:{c:0,r:0}};if(i["!ref"]){var c=Da(i["!ref"]);l.s.c=c.s.c;l.s.r=c.s.r;l.e.c=Math.max(l.e.c,c.e.c);l.e.r=Math.max(l.e.r,c.e.r);if(s==-1)l.e.r=s=c.e.r+1}for(var h=0;h!=r.length;++h){if(!r[h])continue;if(!Array.isArray(r[h]))throw new Error("aoa_to_sheet expects an array of arrays");for(var u=0;u!=r[h].length;++u){if(typeof r[h][u]==="undefined")continue;var d={v:r[h][u]};var v=s+h,p=f+u;if(l.s.r>v)l.s.r=v;if(l.s.c>p)l.s.c=p;if(l.e.r0)r._W(0,e,"dbcs");return t?r.slice(0,r.l):r}function Va(e){return{ich:e._R(2),ifnt:e._R(2)}}function Ga(e,r){if(!r)r=ua(4);r._W(2,e.ich||0);r._W(2,e.ifnt||0);return r}function ja(e,r){var t=e.l;var a=e._R(1);var n=Ha(e);var i=[];var s={t:n,h:n};if((a&1)!==0){var f=e._R(4);for(var o=0;o!=f;++o)i.push(Va(e));s.r=i}else s.r=[{ich:0,ifnt:0}];e.l=t+r;return s}function Xa(e,r){var t=false;if(r==null){t=true;r=ua(15+4*e.t.length)}r._W(1,0);za(e.t,r);return t?r.slice(0,r.l):r}var $a=ja;function Ya(e,r){var t=false;if(r==null){t=true;r=ua(23+4*e.t.length)}r._W(1,1);za(e.t,r);r._W(4,1);Ga({ich:0,ifnt:0},r);return t?r.slice(0,r.l):r}function Ka(e){var r=e._R(4);var t=e._R(2);t+=e._R(1)<<16;e.l++;return{c:r,iStyleRef:t}}function Ja(e,r){if(r==null)r=ua(8);r._W(-4,e.c);r._W(3,e.iStyleRef||e.s);r._W(1,0);return r}function qa(e){var r=e._R(2);r+=e._R(1)<<16;e.l++;return{c:-1,iStyleRef:r}}function Za(e,r){if(r==null)r=ua(4);r._W(3,e.iStyleRef||e.s);r._W(1,0);return r}var Qa=Ha;var en=za;function rn(e){var r=e._R(4);return r===0||r===4294967295?"":e._R(r,"dbcs")}function tn(e,r){var t=false;if(r==null){t=true;r=ua(127)}r._W(4,e.length>0?e.length:4294967295);if(e.length>0)r._W(0,e,"dbcs");return t?r.slice(0,r.l):r}var an=Ha;var nn=rn;var sn=tn;function fn(e){var r=e.slice(e.l,e.l+4);var t=r[0]&1,a=r[0]&2;e.l+=4;var n=a===0?Kt([0,0,0,0,r[0]&252,r[1],r[2],r[3]],0):ta(r,0)>>2;return t?n/100:n}function on(e,r){if(r==null)r=ua(4);var t=0,a=0,n=e*100;if(e==(e|0)&&e>=-(1<<29)&&e<1<<29){a=1}else if(n==(n|0)&&n>=-(1<<29)&&n<1<<29){a=1;t=1}if(a)r._W(-4,((t?n:e)<<2)+(t+2));else throw new Error("unsupported RkNumber "+e)}function ln(e){var r={s:{},e:{}};r.s.r=e._R(4);r.e.r=e._R(4);r.s.c=e._R(4);r.e.c=e._R(4);return r}function cn(e,r){if(!r)r=ua(16);r._W(4,e.s.r);r._W(4,e.e.r);r._W(4,e.s.c);r._W(4,e.e.c);return r}var hn=ln;var un=cn;function dn(e){if(e.length-e.l<8)throw"XLS Xnum Buffer underflow";return e._R(8,"f")}function vn(e,r){return(r||ua(8))._W(8,e,"f")}function pn(e){var r={};var t=e._R(1);var a=t>>>1;var n=e._R(1);var i=e._R(2,"i");var s=e._R(1);var f=e._R(1);var o=e._R(1);e.l++;switch(a){case 0:r.auto=1;break;case 1:r.index=n;var l=Vn[n];if(l)r.rgb=bl(l);break;case 2:r.rgb=bl([s,f,o]);break;case 3:r.theme=n;break;}if(i!=0)r.tint=i>0?i/32767:i/32768;return r}function gn(e,r){if(!r)r=ua(8);if(!e||e.auto){r._W(4,0);r._W(4,0);return r}if(e.index!=null){r._W(1,2);r._W(1,e.index)}else if(e.theme!=null){r._W(1,6);r._W(1,e.theme)}else{r._W(1,5);r._W(1,0)}var t=e.tint||0;if(t>0)t*=32767;else if(t<0)t*=32768;r._W(2,t);if(!e.rgb||e.theme!=null){r._W(2,0);r._W(1,0);r._W(1,0)}else{var a=e.rgb||"FFFFFF";if(typeof a=="number")a=("000000"+a.toString(16)).slice(-6);r._W(1,parseInt(a.slice(0,2),16));r._W(1,parseInt(a.slice(2,4),16));r._W(1,parseInt(a.slice(4,6),16));r._W(1,255)}return r}function mn(e){var r=e._R(1);e.l++;var t={fBold:r&1,fItalic:r&2,fUnderline:r&4,fStrikeout:r&8,fOutline:r&16,fShadow:r&32,fCondense:r&64,fExtend:r&128};return t}function bn(e,r){if(!r)r=ua(2);var t=(e.italic?2:0)|(e.strike?8:0)|(e.outline?16:0)|(e.shadow?32:0)|(e.condense?64:0)|(e.extend?128:0);r._W(1,t);r._W(1,0);return r}function wn(e,r){var t={2:"BITMAP",3:"METAFILEPICT",8:"DIB",14:"ENHMETAFILE"};var a=e._R(4);switch(a){case 0:return"";case 4294967295:;case 4294967294:return t[e._R(4)]||"";}if(a>400)throw new Error("Unsupported Clipboard: "+a.toString(16));e.l-=4;return e._R(0,r==1?"lpstr":"lpwstr")}function kn(e){return wn(e,1)}function Tn(e){return wn(e,2)}var An=2;var En=3;var Cn=11;var yn=12;var Sn=19;var _n=30;var xn=64;var On=65;var Rn=71;var In=4096;var Nn=4108;var Dn=4126;var Fn=80;var Pn=81;var Ln=[Fn,Pn];var Mn={1:{n:"CodePage",t:An},2:{n:"Category",t:Fn},3:{n:"PresentationFormat",t:Fn},4:{n:"ByteCount",t:En},5:{n:"LineCount",t:En},6:{n:"ParagraphCount",t:En},7:{n:"SlideCount",t:En},8:{n:"NoteCount",t:En},9:{n:"HiddenCount",t:En},10:{n:"MultimediaClipCount",t:En},11:{n:"ScaleCrop",t:Cn},12:{n:"HeadingPairs",t:Nn},13:{n:"TitlesOfParts",t:Dn},14:{n:"Manager",t:Fn},15:{n:"Company",t:Fn},16:{n:"LinksUpToDate",t:Cn},17:{n:"CharacterCount",t:En},19:{n:"SharedDoc",t:Cn},22:{n:"HyperlinksChanged",t:Cn},23:{n:"AppVersion",t:En,p:"version"},24:{n:"DigSig",t:On},26:{n:"ContentType",t:Fn},27:{n:"ContentStatus",t:Fn},28:{n:"Language",t:Fn},29:{n:"Version",t:Fn},255:{},2147483648:{n:"Locale",t:Sn},2147483651:{n:"Behavior",t:Sn},1919054434:{}};var Un={1:{n:"CodePage",t:An},2:{n:"Title",t:Fn},3:{n:"Subject",t:Fn},4:{n:"Author",t:Fn},5:{n:"Keywords",t:Fn},6:{n:"Comments",t:Fn},7:{n:"Template",t:Fn},8:{n:"LastAuthor",t:Fn},9:{n:"RevNumber",t:Fn},10:{n:"EditTime",t:xn},11:{n:"LastPrinted",t:xn},12:{n:"CreatedDate",t:xn},13:{n:"ModifiedDate",t:xn},14:{n:"PageCount",t:En},15:{n:"WordCount",t:En},16:{n:"CharCount",t:En},17:{n:"Thumbnail",t:Rn},18:{n:"Application",t:Fn},19:{n:"DocSecurity",t:En},255:{},2147483648:{n:"Locale",t:Sn},2147483651:{n:"Behavior",t:Sn},1919054434:{}};var Bn={1:"US",2:"CA",3:"",7:"RU",20:"EG",30:"GR",31:"NL",32:"BE",33:"FR",34:"ES",36:"HU",39:"IT",41:"CH",43:"AT",44:"GB",45:"DK",46:"SE",47:"NO",48:"PL",49:"DE",52:"MX",55:"BR",61:"AU",64:"NZ",66:"TH",81:"JP",82:"KR",84:"VN",86:"CN",90:"TR",105:"JS",213:"DZ",216:"MA",218:"LY",351:"PT",354:"IS",358:"FI",420:"CZ",886:"TW",961:"LB",962:"JO",963:"SY",964:"IQ",965:"KW",966:"SA",971:"AE",972:"IL",974:"QA",981:"IR",65535:"US"};var Wn=[null,"solid","mediumGray","darkGray","lightGray","darkHorizontal","darkVertical","darkDown","darkUp","darkGrid","darkTrellis","lightHorizontal","lightVertical","lightDown","lightUp","lightGrid","lightTrellis","gray125","gray0625"];function Hn(e){return e.map(function(e){return[e>>16&255,e>>8&255,e&255]})}var zn=Hn([0,16777215,16711680,65280,255,16776960,16711935,65535,0,16777215,16711680,65280,255,16776960,16711935,65535,8388608,32768,128,8421376,8388736,32896,12632256,8421504,10066431,10040166,16777164,13434879,6684774,16744576,26316,13421823,128,16711935,16776960,65535,8388736,8388608,32896,255,52479,13434879,13434828,16777113,10079487,16751052,13408767,16764057,3368703,3394764,10079232,16763904,16750848,16737792,6710937,9868950,13158,3381606,13056,3355392,10040064,10040166,3355545,3355443,16777215,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]);var Vn=br(zn);var Gn={0:"#NULL!",7:"#DIV/0!",15:"#VALUE!",23:"#REF!",29:"#NAME?",36:"#NUM!",42:"#N/A",43:"#GETTING_DATA",255:"#WTF?"};var jn={"#NULL!":0,"#DIV/0!":7,"#VALUE!":15,"#REF!":23,"#NAME?":29,"#NUM!":36,"#N/A":42,"#GETTING_DATA":43,"#WTF?":255};var Xn={"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml":"workbooks","application/vnd.ms-excel.sheet.macroEnabled.main+xml":"workbooks","application/vnd.ms-excel.sheet.binary.macroEnabled.main":"workbooks","application/vnd.ms-excel.addin.macroEnabled.main+xml":"workbooks","application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml":"workbooks","application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml":"sheets","application/vnd.ms-excel.worksheet":"sheets","application/vnd.ms-excel.binIndexWs":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml":"charts","application/vnd.ms-excel.chartsheet":"charts","application/vnd.ms-excel.macrosheet+xml":"macros","application/vnd.ms-excel.macrosheet":"macros","application/vnd.ms-excel.intlmacrosheet":"TODO","application/vnd.ms-excel.binIndexMs":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml":"dialogs","application/vnd.ms-excel.dialogsheet":"dialogs","application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml":"strs","application/vnd.ms-excel.sharedStrings":"strs","application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml":"styles","application/vnd.ms-excel.styles":"styles","application/vnd.openxmlformats-package.core-properties+xml":"coreprops","application/vnd.openxmlformats-officedocument.custom-properties+xml":"custprops","application/vnd.openxmlformats-officedocument.extended-properties+xml":"extprops","application/vnd.openxmlformats-officedocument.customXmlProperties+xml":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.customProperty":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml":"comments","application/vnd.ms-excel.comments":"comments","application/vnd.ms-excel.threadedcomments+xml":"threadedcomments","application/vnd.ms-excel.person+xml":"people","application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml":"metadata","application/vnd.ms-excel.sheetMetadata":"metadata","application/vnd.ms-excel.pivotTable":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml":"TODO","application/vnd.openxmlformats-officedocument.drawingml.chart+xml":"TODO","application/vnd.ms-office.chartcolorstyle+xml":"TODO","application/vnd.ms-office.chartstyle+xml":"TODO","application/vnd.ms-office.chartex+xml":"TODO","application/vnd.ms-excel.calcChain":"calcchains","application/vnd.openxmlformats-officedocument.spreadsheetml.calcChain+xml":"calcchains","application/vnd.openxmlformats-officedocument.spreadsheetml.printerSettings":"TODO","application/vnd.ms-office.activeX":"TODO","application/vnd.ms-office.activeX+xml":"TODO","application/vnd.ms-excel.attachedToolbars":"TODO","application/vnd.ms-excel.connections":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml":"TODO","application/vnd.ms-excel.externalLink":"links","application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml":"links","application/vnd.ms-excel.pivotCacheDefinition":"TODO","application/vnd.ms-excel.pivotCacheRecords":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml":"TODO","application/vnd.ms-excel.queryTable":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.queryTable+xml":"TODO","application/vnd.ms-excel.userNames":"TODO","application/vnd.ms-excel.revisionHeaders":"TODO","application/vnd.ms-excel.revisionLog":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.revisionHeaders+xml":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.revisionLog+xml":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.userNames+xml":"TODO","application/vnd.ms-excel.tableSingleCells":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.tableSingleCells+xml":"TODO","application/vnd.ms-excel.slicer":"TODO","application/vnd.ms-excel.slicerCache":"TODO","application/vnd.ms-excel.slicer+xml":"TODO","application/vnd.ms-excel.slicerCache+xml":"TODO","application/vnd.ms-excel.wsSortMap":"TODO","application/vnd.ms-excel.table":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml":"TODO","application/vnd.openxmlformats-officedocument.theme+xml":"themes","application/vnd.openxmlformats-officedocument.themeOverride+xml":"TODO","application/vnd.ms-excel.Timeline+xml":"TODO","application/vnd.ms-excel.TimelineCache+xml":"TODO","application/vnd.ms-office.vbaProject":"vba","application/vnd.ms-office.vbaProjectSignature":"TODO","application/vnd.ms-office.volatileDependencies":"TODO","application/vnd.openxmlformats-officedocument.spreadsheetml.volatileDependencies+xml":"TODO","application/vnd.ms-excel.controlproperties+xml":"TODO","application/vnd.openxmlformats-officedocument.model+data":"TODO","application/vnd.ms-excel.Survey+xml":"TODO","application/vnd.openxmlformats-officedocument.drawing+xml":"drawings","application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml":"TODO","application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml":"TODO","application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml":"TODO","application/vnd.openxmlformats-officedocument.drawingml.diagramLayout+xml":"TODO","application/vnd.openxmlformats-officedocument.drawingml.diagramStyle+xml":"TODO","application/vnd.openxmlformats-officedocument.vmlDrawing":"TODO","application/vnd.openxmlformats-package.relationships+xml":"rels","application/vnd.openxmlformats-officedocument.oleObject":"TODO","image/png":"TODO",sheet:"js"};var $n={workbooks:{xlsx:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml",xlsm:"application/vnd.ms-excel.sheet.macroEnabled.main+xml",xlsb:"application/vnd.ms-excel.sheet.binary.macroEnabled.main",xlam:"application/vnd.ms-excel.addin.macroEnabled.main+xml",xltx:"application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml"},strs:{xlsx:"application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml",xlsb:"application/vnd.ms-excel.sharedStrings"},comments:{xlsx:"application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml",xlsb:"application/vnd.ms-excel.comments"},sheets:{xlsx:"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml",xlsb:"application/vnd.ms-excel.worksheet"},charts:{xlsx:"application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml",xlsb:"application/vnd.ms-excel.chartsheet"},dialogs:{xlsx:"application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml",xlsb:"application/vnd.ms-excel.dialogsheet"},macros:{xlsx:"application/vnd.ms-excel.macrosheet+xml",xlsb:"application/vnd.ms-excel.macrosheet"},metadata:{xlsx:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml",xlsb:"application/vnd.ms-excel.sheetMetadata"},styles:{xlsx:"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml",xlsb:"application/vnd.ms-excel.styles"}};function Yn(){return{workbooks:[],sheets:[],charts:[],dialogs:[],macros:[],rels:[],strs:[],comments:[],threadedcomments:[],links:[],coreprops:[],extprops:[],custprops:[],themes:[],styles:[],calcchains:[],vba:[],drawings:[],metadata:[],people:[],TODO:[],xmlns:""}}function Kn(e){var r=Yn();if(!e||!e.match)return r;var t={};(e.match(Hr)||[]).forEach(function(e){var a=Gr(e);switch(a[0].replace(zr,"<")){case"0?r.calcchains[0]:"";r.sst=r.strs.length>0?r.strs[0]:"";r.style=r.styles.length>0?r.styles[0]:"";r.defaults=t;delete r.calcchains;return r}function Jn(e,r){var t=ir(Xn);var a=[],n;a[a.length]=Mr;a[a.length]=kt("Types",null,{xmlns:yt.CT,"xmlns:xsd":yt.xsd,"xmlns:xsi":yt.xsi});a=a.concat([["xml","application/xml"],["bin","application/vnd.ms-excel.sheet.binary.macroEnabled.main"],["vml","application/vnd.openxmlformats-officedocument.vmlDrawing"],["data","application/vnd.openxmlformats-officedocument.model+data"],["bmp","image/bmp"],["png","image/png"],["gif","image/gif"],["emf","image/x-emf"],["wmf","image/x-wmf"],["jpg","image/jpeg"],["jpeg","image/jpeg"],["tif","image/tiff"],["tiff","image/tiff"],["pdf","application/pdf"],["rels","application/vnd.openxmlformats-package.relationships+xml"]].map(function(e){return kt("Default",null,{Extension:e[0],ContentType:e[1]})}));var i=function(t){if(e[t]&&e[t].length>0){n=e[t][0];a[a.length]=kt("Override",null,{PartName:(n[0]=="/"?"":"/")+n,ContentType:$n[t][r.bookType]||$n[t]["xlsx"]})}};var s=function(t){(e[t]||[]).forEach(function(e){a[a.length]=kt("Override",null,{PartName:(e[0]=="/"?"":"/")+e,ContentType:$n[t][r.bookType]||$n[t]["xlsx"]})})};var f=function(r){(e[r]||[]).forEach(function(e){a[a.length]=kt("Override",null,{PartName:(e[0]=="/"?"":"/")+e,ContentType:t[r][0]})})};i("workbooks");s("sheets");s("charts");f("themes");["strs","styles"].forEach(i);["coreprops","extprops","custprops"].forEach(f);f("vba");f("comments");f("threadedcomments");f("drawings");s("metadata");f("people");if(a.length>2){a[a.length]="";a[1]=a[1].replace("/>",">")}return a.join("")}var qn={WB:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",SHEET:"http://sheetjs.openxmlformats.org/officeDocument/2006/relationships/officeDocument",HLINK:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",VML:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing",XPATH:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/externalLinkPath",XMISS:"http://schemas.microsoft.com/office/2006/relationships/xlExternalLinkPath/xlPathMissing",XLINK:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/externalLink",CXML:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml",CXMLP:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps",CMNT:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",CORE_PROPS:"http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties",EXT_PROPS:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties",CUST_PROPS:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties",SST:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings",STY:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles",THEME:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",CHART:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart",CHARTEX:"http://schemas.microsoft.com/office/2014/relationships/chartEx",CS:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet",WS:["http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet","http://purl.oclc.org/ooxml/officeDocument/relationships/worksheet"],DS:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/dialogsheet",MS:"http://schemas.microsoft.com/office/2006/relationships/xlMacrosheet",IMG:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",DRAW:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing",XLMETA:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/sheetMetadata",TCMNT:"http://schemas.microsoft.com/office/2017/10/relationships/threadedComment",PEOPLE:"http://schemas.microsoft.com/office/2017/10/relationships/person",VBA:"http://schemas.microsoft.com/office/2006/relationships/vbaProject"};function Zn(e){var r=e.lastIndexOf("/");return e.slice(0,r+1)+"_rels/"+e.slice(r+1)+".rels"}function Qn(e,r){var t={"!id":{}};if(!e)return t;if(r.charAt(0)!=="/"){r="/"+r}var a={};(e.match(Hr)||[]).forEach(function(e){var n=Gr(e);if(n[0]==="2){r[r.length]="";r[1]=r[1].replace("/>",">")}return r.join("")}function ri(e,r,t,a,n,i){if(!n)n={};if(!e["!id"])e["!id"]={};if(!e["!idx"])e["!idx"]=1;if(r<0)for(r=e["!idx"];e["!id"]["rId"+r];++r){}e["!idx"]=r+1;n.Id="rId"+r;n.Type=a;n.Target=t;if(i)n.TargetMode=i;else if([qn.HLINK,qn.XPATH,qn.XMISS].indexOf(n.Type)>-1)n.TargetMode="External";if(e["!id"][n.Id])throw new Error("Cannot rewrite rId "+r);e["!id"][n.Id]=n;e[("/"+n.Target).replace("//","/")]=n;return r}var ti="application/vnd.oasis.opendocument.spreadsheet";function ai(e,r){var t=Et(e);var a;var n;while(a=Ct.exec(t))switch(a[3]){case"manifest":break;case"file-entry":n=Gr(a[0],false);if(n.path=="/"&&n.type!==ti)throw new Error("This OpenDocument is not a spreadsheet");break;case"encryption-data":;case"algorithm":;case"start-key-generation":;case"key-derivation":throw new Error("Unsupported ODS Encryption");default:if(r&&r.WTF)throw a;}}function ni(e){var r=[Mr];r.push('\n');r.push(' \n');for(var t=0;t\n');r.push("");return r.join("")}function ii(e,r,t){return[' \n',' \n'," \n"].join("")}function si(e,r){return[' \n',' \n'," \n"].join("")}function fi(e){var r=[Mr];r.push('\n');for(var t=0;t!=e.length;++t){r.push(ii(e[t][0],e[t][1]));r.push(si("",e[t][0]))}r.push(ii("","Document","pkg"));r.push("");return r.join("")}function oi(){return'Sheet'+"JS "+e.version+""}var li=[["cp:category","Category"],["cp:contentStatus","ContentStatus"],["cp:keywords","Keywords"],["cp:lastModifiedBy","LastAuthor"],["cp:lastPrinted","LastPrinted"],["cp:revision","RevNumber"],["cp:version","Version"],["dc:creator","Author"],["dc:description","Comments"],["dc:identifier","Identifier"],["dc:language","Language"],["dc:subject","Subject"],["dc:title","Title"],["dcterms:created","CreatedDate","date"],["dcterms:modified","ModifiedDate","date"]];var ci=function(){var e=new Array(li.length);for(var r=0;r]*>([\\s\\S]*?)")}return e}();function hi(e){var r={};e=lt(e);for(var t=0;t0)r[a[1]]=Yr(n[1]);if(a[2]==="date"&&r[a[1]])r[a[1]]=gr(r[a[1]])}return r}function ui(e,r,t,a,n){if(n[e]!=null||r==null||r==="")return;n[e]=r;r=qr(r);a[a.length]=t?kt(e,r,t):bt(e,r)}function di(e,r){var t=r||{};var a=[Mr,kt("cp:coreProperties",null,{"xmlns:cp":yt.CORE_PROPS,"xmlns:dc":yt.dc,"xmlns:dcterms":yt.dcterms,"xmlns:dcmitype":yt.dcmitype,"xmlns:xsi":yt.xsi})],n={};if(!e&&!t.Props)return a.join("");if(e){if(e.CreatedDate!=null)ui("dcterms:created",typeof e.CreatedDate==="string"?e.CreatedDate:Tt(e.CreatedDate,t.WTF),{"xsi:type":"dcterms:W3CDTF"},a,n);if(e.ModifiedDate!=null)ui("dcterms:modified",typeof e.ModifiedDate==="string"?e.ModifiedDate:Tt(e.ModifiedDate,t.WTF),{"xsi:type":"dcterms:W3CDTF"},a,n)}for(var i=0;i!=li.length;++i){var s=li[i];var f=t.Props&&t.Props[s[1]]!=null?t.Props[s[1]]:e?e[s[1]]:null;if(f===true)f="1";else if(f===false)f="0";else if(typeof f=="number")f=String(f);if(f!=null)ui(s[0],f,null,a,n)}if(a.length>2){a[a.length]="";a[1]=a[1].replace("/>",">")}return a.join("")}var vi=[["Application","Application","string"],["AppVersion","AppVersion","string"],["Company","Company","string"],["DocSecurity","DocSecurity","string"],["Manager","Manager","string"],["HyperlinksChanged","HyperlinksChanged","bool"],["SharedDoc","SharedDoc","bool"],["LinksUpToDate","LinksUpToDate","bool"],["ScaleCrop","ScaleCrop","bool"],["HeadingPairs","HeadingPairs","raw"],["TitlesOfParts","TitlesOfParts","raw"]];var pi=["Worksheets","SheetNames","NamedRanges","DefinedNames","Chartsheets","ChartNames"];function gi(e,r,t,a){var n=[];if(typeof e=="string")n=gt(e,a);else for(var i=0;i0)for(var l=0;l!==n.length;l+=2){o=+n[l+1].v;switch(n[l].v){case"Worksheets":;case"工作表":;case"Листы":;case"أوراق العمل":;case"ワークシート":;case"גליונות עבודה":;case"Arbeitsblätter":;case"Çalışma Sayfaları":;case"Feuilles de calcul":;case"Fogli di lavoro":;case"Folhas de cálculo":;case"Planilhas":;case"Regneark":;case"Hojas de cálculo":;case"Werkbladen":t.Worksheets=o;t.SheetNames=s.slice(f,f+o);break;case"Named Ranges":;case"Rangos con nombre":;case"名前付き一覧":;case"Benannte Bereiche":;case"Navngivne områder":t.NamedRanges=o;t.DefinedNames=s.slice(f,f+o);break;case"Charts":;case"Diagramme":t.Chartsheets=o;t.ChartNames=s.slice(f,f+o);break;}f+=o}}function mi(e,r,t){var a={};if(!r)r={};e=lt(e);vi.forEach(function(t){var n=(e.match(ht(t[0]))||[])[1];switch(t[2]){case"string":if(n)r[t[1]]=Yr(n);break;case"bool":r[t[1]]=n==="true";break;case"raw":var i=e.match(new RegExp("<"+t[0]+"[^>]*>([\\s\\S]*?)"));if(i&&i.length>0)a[t[1]]=i[1];break;}});if(a.HeadingPairs&&a.TitlesOfParts)gi(a.HeadingPairs,a.TitlesOfParts,r,t);return r}function bi(e){var r=[],t=kt;if(!e)e={};e.Application="SheetJS";r[r.length]=Mr;r[r.length]=kt("Properties",null,{xmlns:yt.EXT_PROPS,"xmlns:vt":yt.vt});vi.forEach(function(a){if(e[a[1]]===undefined)return;var n;switch(a[2]){case"string":n=qr(String(e[a[1]]));break;case"bool":n=e[a[1]]?"true":"false";break;}if(n!==undefined)r[r.length]=t(a[0],n)});r[r.length]=t("HeadingPairs",t("vt:vector",t("vt:variant","Worksheets")+t("vt:variant",t("vt:i4",String(e.Worksheets))),{size:2,baseType:"variant"}));r[r.length]=t("TitlesOfParts",t("vt:vector",e.SheetNames.map(function(e){return""+qr(e)+""}).join(""),{size:e.Worksheets,baseType:"lpstr"}));if(r.length>2){r[r.length]="";r[1]=r[1].replace("/>",">")}return r.join("")}var wi=/<[^>]+>[^<]*/g;function ki(e,r){var t={},a="";var n=e.match(wi);if(n)for(var i=0;i!=n.length;++i){var s=n[i],f=Gr(s);switch(f[0]){case"":a=null;break;default:if(s.indexOf("");var l=o[0].slice(4),c=o[1];switch(l){case"lpstr":;case"bstr":;case"lpwstr":t[a]=Yr(c);break;case"bool":t[a]=nt(c);break;case"i1":;case"i2":;case"i4":;case"i8":;case"int":;case"uint":t[a]=parseInt(c,10);break;case"r4":;case"r8":;case"decimal":t[a]=parseFloat(c);break;case"filetime":;case"date":t[a]=gr(c);break;case"cy":;case"error":t[a]=Yr(c);break;default:if(l.slice(-1)=="/")break;if(r.WTF&&typeof console!=="undefined")console.warn("Unexpected",s,l,o);}}else if(s.slice(0,2)==="2){r[r.length]="";r[1]=r[1].replace("/>",">")}return r.join("")}var Ai={Title:"Title",Subject:"Subject",Author:"Author",Keywords:"Keywords",Comments:"Description",LastAuthor:"LastAuthor",RevNumber:"Revision",Application:"AppName",LastPrinted:"LastPrinted",CreatedDate:"Created",ModifiedDate:"LastSaved",Category:"Category",Manager:"Manager",Company:"Company",AppVersion:"Version",ContentStatus:"ContentStatus",Identifier:"Identifier", -Language:"Language"};var Ei;function Ci(e,r,t){if(!Ei)Ei=ar(Ai);r=Ei[r]||r;e[r]=t}function yi(e,r){var t=[];rr(Ai).map(function(e){for(var r=0;r'+n.join("")+""}function _i(e){var r=e._R(4),t=e._R(4);return new Date((t/1e7*Math.pow(2,32)+r/1e7-11644473600)*1e3).toISOString().replace(/\.000/,"")}function xi(e){var r=typeof e=="string"?new Date(Date.parse(e)):e;var t=r.getTime()/1e3+11644473600;var a=t%Math.pow(2,32),n=(t-a)/Math.pow(2,32);a*=1e7;n*=1e7;var i=a/Math.pow(2,32)|0;if(i>0){a=a%Math.pow(2,32);n+=i}var s=ua(8);s._W(4,a);s._W(4,n);return s}function Oi(e,r,t){var a=e.l;var n=e._R(0,"lpstr-cp");if(t)while(e.l-a&3)++e.l;return n}function Ri(e,r,t){var a=e._R(0,"lpwstr");if(t)e.l+=4-(a.length+1&3)&3;return a}function Ii(e,r,t){if(r===31)return Ri(e);return Oi(e,r,t)}function Ni(e,r,t){return Ii(e,r,t===false?0:4)}function Di(e,r){if(!r)throw new Error("VtUnalignedString must have positive length");return Ii(e,r,0)}function Fi(e){var r=e._R(4);var t=[];for(var a=0;a!=r;++a){var n=e.l;t[a]=e._R(0,"lpwstr").replace(N,"");if(e.l-n&2)e.l+=2}return t}function Pi(e){var r=e._R(4);var t=[];for(var a=0;a!=r;++a)t[a]=e._R(0,"lpstr-cp").replace(N,"");return t}function Li(e){var r=e.l;var t=Hi(e,Pn);if(e[e.l]==0&&e[e.l+1]==0&&e.l-r&2)e.l+=2;var a=Hi(e,En);return[t,a]}function Mi(e){var r=e._R(4);var t=[];for(var a=0;a>2+1<<2;return a}function Bi(e){var r=e._R(4);var t=e.slice(e.l,e.l+r);e.l+=r;if((r&3)>0)e.l+=4-(r&3)&3;return t}function Wi(e){var r={};r.Size=e._R(4);e.l+=r.Size+3-(r.Size-1)%4;return r}function Hi(e,r,t){var a=e._R(2),n,i=t||{};e.l+=2;if(r!==yn)if(a!==r&&Ln.indexOf(r)===-1&&!((r&65534)==4126&&(a&65534)==4126))throw new Error("Expected type "+r+" saw "+a);switch(r===yn?a:r){case 2:n=e._R(2,"i");if(!i.raw)e.l+=2;return n;case 3:n=e._R(4,"i");return n;case 11:return e._R(4)!==0;case 19:n=e._R(4);return n;case 30:return Oi(e,a,4).replace(N,"");case 31:return Ri(e);case 64:return _i(e);case 65:return Bi(e);case 71:return Wi(e);case 80:return Ni(e,a,!i.raw).replace(N,"");case 81:return Di(e,a).replace(N,"");case 4108:return Mi(e);case 4126:;case 4127:return a==4127?Fi(e):Pi(e);default:throw new Error("TypedPropertyValue unrecognized type "+r+" "+a);}}function zi(e,r){var t=ua(4),a=ua(4);t._W(4,e==80?31:e);switch(e){case 3:a._W(-4,r);break;case 5:a=ua(8);a._W(8,r,"f");break;case 11:a._W(4,r?1:0);break;case 64:a=xi(r);break;case 31:;case 80:a=ua(4+2*(r.length+1)+(r.length%2?0:2));a._W(4,r.length+1);a._W(0,r,"dbcs");while(a.l!=a.length)a._W(1,0);break;default:throw new Error("TypedPropertyValue unrecognized type "+e+" "+r);}return R([t,a])}function Vi(e,r){var t=e.l;var a=e._R(4);var n=e._R(4);var i=[],s=0;var f=0;var l=-1,c={};for(s=0;s!=n;++s){var h=e._R(4);var u=e._R(4);i[s]=[h,u+t]}i.sort(function(e,r){return e[1]-r[1]});var d={};for(s=0;s!=n;++s){if(e.l!==i[s][1]){var v=true;if(s>0&&r)switch(r[i[s-1][0]].t){case 2:if(e.l+2===i[s][1]){e.l+=2;v=false}break;case 80:if(e.l<=i[s][1]){e.l=i[s][1];v=false}break;case 4108:if(e.l<=i[s][1]){e.l=i[s][1];v=false}break;}if((!r||s==0)&&e.l<=i[s][1]){v=false;e.l=i[s][1]}if(v)throw new Error("Read Error: Expected address "+i[s][1]+" at "+e.l+" :"+s)}if(r){var p=r[i[s][0]];d[p.n]=Hi(e,p.t,{raw:true});if(p.p==="version")d[p.n]=String(d[p.n]>>16)+"."+("0000"+String(d[p.n]&65535)).slice(-4);if(p.n=="CodePage")switch(d[p.n]){case 0:d[p.n]=1252;case 874:;case 932:;case 936:;case 949:;case 950:;case 1250:;case 1251:;case 1253:;case 1254:;case 1255:;case 1256:;case 1257:;case 1258:;case 1e4:;case 1200:;case 1201:;case 1252:;case 65e3:;case-536:;case 65001:;case-535:o(f=d[p.n]>>>0&65535);break;default:throw new Error("Unsupported CodePage: "+d[p.n]);}}else{if(i[s][0]===1){f=d.CodePage=Hi(e,An);o(f);if(l!==-1){var g=e.l;e.l=i[l][1];c=Ui(e,f);e.l=g}}else if(i[s][0]===0){if(f===0){l=s;e.l=i[s+1][1];continue}c=Ui(e,f)}else{var m=c[i[s][0]];var b;switch(e[e.l]){case 65:e.l+=4;b=Bi(e);break;case 30:e.l+=4;b=Ni(e,e[e.l-4]).replace(/\u0000+$/,"");break;case 31:e.l+=4;b=Ni(e,e[e.l-4]).replace(/\u0000+$/,"");break;case 3:e.l+=4;b=e._R(4,"i");break;case 19:e.l+=4;b=e._R(4);break;case 5:e.l+=4;b=e._R(8,"f");break;case 11:e.l+=4;b=Zi(e,4);break;case 64:e.l+=4;b=gr(_i(e));break;default:throw new Error("unparsed value: "+e[e.l]);}d[m]=b}}}e.l=t+a;return d}var Gi=["CodePage","Thumbnail","_PID_LINKBASE","_PID_HLINKS","SystemIdentifier","FMTID"];function ji(e){switch(typeof e){case"boolean":return 11;case"number":return(e|0)==e?3:5;case"string":return 31;case"object":if(e instanceof Date)return 64;break;}return-1}function Xi(e,r,t){var a=ua(8),n=[],i=[];var s=8,f=0;var o=ua(8),l=ua(8);o._W(4,2);o._W(4,1200);l._W(4,1);i.push(o);n.push(l);s+=8+o.length;if(!r){l=ua(8);l._W(4,0);n.unshift(l);var c=[ua(4)];c[0]._W(4,e.length);for(f=0;f-1||pi.indexOf(e[f][0])>-1)continue;if(e[f][1]==null)continue;var u=e[f][1],d=0;if(r){d=+r[e[f][0]];var v=t[d];if(v.p=="version"&&typeof u=="string"){var p=u.split(".");u=(+p[0]<<16)+(+p[1]||0)}o=zi(v.t,u)}else{var g=ji(u);if(g==-1){g=31;u=String(u)}o=zi(g,u)}i.push(o);l=ua(8);l._W(4,!r?2+f:d);n.push(l);s+=8+o.length}var m=8*(i.length+1);for(f=0;f=12?2:1);var i="sbcs-cont";var s=r;if(a&&a.biff>=8)r=1200;if(!a||a.biff==8){var f=e._R(1);if(f){i="dbcs-cont"}}else if(a.biff==12){i="wstr"}if(a.biff>=2&&a.biff<=5)i="cpstr";var o=n?e._R(n,i):"";r=s;return o}function ss(e){var t=r;r=1200;var a=e._R(2),n=e._R(1);var i=n&4,s=n&8;var f=1+(n&1);var o=0,l;var c={};if(s)o=e._R(2);if(i)l=e._R(4);var h=f==2?"dbcs-cont":"sbcs-cont";var u=a===0?"":e._R(a,h);if(s)e.l+=4*o;if(i)e.l+=l;c.t=u;if(!s){c.raw=""+c.t+"";c.r=c.t}r=t;return c}function fs(e){var r=e.t||"",t=1;var a=ua(3+(t>1?2:0));a._W(2,r.length);a._W(1,(t>1?8:0)|1);if(t>1)a._W(2,t);var n=ua(2*r.length);n._W(2*r.length,r,"utf16le");var i=[a,n];return R(i)}function os(e,r,t){var a;if(t){if(t.biff>=2&&t.biff<=5)return e._R(r,"cpstr");if(t.biff>=12)return e._R(r,"dbcs-cont")}var n=e._R(1);if(n===0){a=e._R(r,"sbcs-cont")}else{a=e._R(r,"dbcs-cont")}return a}function ls(e,r,t){var a=e._R(t&&t.biff==2?1:2);if(a===0){e.l++;return""}return os(e,a,t)}function cs(e,r,t){if(t.biff>5)return ls(e,r,t);var a=e._R(1);if(a===0){e.l++;return""}return e._R(a,t.biff<=4||!e.lens?"cpstr":"sbcs-cont")}function hs(e,r,t){if(!t)t=ua(3+2*e.length);t._W(2,e.length);t._W(1,1);t._W(31,e,"utf16le");return t}function us(e){var r=e._R(1);e.l++;var t=e._R(2);e.l+=2;return[r,t]}function ds(e){var r=e._R(4),t=e.l;var a=false;if(r>24){e.l+=r-24;if(e._R(16)==="795881f43b1d7f48af2c825dc4852763")a=true;e.l=t}var n=e._R((a?r-24:r)>>1,"utf16le").replace(N,"");if(a)e.l+=24;return n}function vs(e){var r=e._R(2);var t="";while(r-- >0)t+="../";var a=e._R(0,"lpstr-ansi");e.l+=2;if(e._R(2)!=57005)throw new Error("Bad FileMoniker");var n=e._R(4);if(n===0)return t+a.replace(/\\/g,"/");var i=e._R(4);if(e._R(2)!=3)throw new Error("Bad FileMoniker");var s=e._R(i>>1,"utf16le").replace(N,"");return t+s}function ps(e,r){var t=e._R(16);r-=16;switch(t){case"e0c9ea79f9bace118c8200aa004ba90b":return ds(e,r);case"0303000000000000c000000000000046":return vs(e,r);default:throw new Error("Unsupported Moniker "+t);}}function gs(e){var r=e._R(4);var t=r>0?e._R(r,"utf16le").replace(N,""):"";return t}function ms(e,r){if(!r)r=ua(6+e.length*2);r._W(4,1+e.length);for(var t=0;t-1?31:23;switch(a.charAt(0)){case"#":i=28;break;case".":i&=~2;break;}r._W(4,2);r._W(4,i);var s=[8,6815827,6619237,4849780,83];for(t=0;t-1?a.slice(0,n):a;r._W(4,2*(f.length+1));for(t=0;t-1?a.slice(n+1):"",r)}else{s="03 03 00 00 00 00 00 00 c0 00 00 00 00 00 00 46".split(" ");for(t=0;t8?4:2;var n=e._R(a),i=e._R(a,"i"),s=e._R(a,"i");return[n,i,s]}function _s(e){var r=e._R(2);var t=fn(e);return[r,t]}function xs(e,r,t){e.l+=4;r-=4;var a=e.l+r;var n=is(e,r,t);var i=e._R(2);a-=e.l;if(i!==a)throw new Error("Malformed AddinUdf: padding = "+a+" != "+i);e.l+=i;return n}function Os(e){var r=e._R(2);var t=e._R(2);var a=e._R(2);var n=e._R(2);return{s:{c:a,r:r},e:{c:n,r:t}}}function Rs(e,r){if(!r)r=ua(8);r._W(2,e.s.r);r._W(2,e.e.r);r._W(2,e.s.c);r._W(2,e.e.c);return r}function Is(e){var r=e._R(2);var t=e._R(2);var a=e._R(1);var n=e._R(1);return{s:{c:a,r:r},e:{c:n,r:t}}}var Ns=Is;function Ds(e){e.l+=4;var r=e._R(2);var t=e._R(2);var a=e._R(2);e.l+=12;return[t,r,a]}function Fs(e){var r={};e.l+=4;e.l+=16;r.fSharedNote=e._R(2);e.l+=4;return r}function Ps(e){var r={};e.l+=4;e.cf=e._R(2);return r}function Ls(e){e.l+=2;e.l+=e._R(2)}var Ms={0:Ls,4:Ls,5:Ls,6:Ls,7:Ps,8:Ls,9:Ls,10:Ls,11:Ls,12:Ls,13:Fs,14:Ls,15:Ls,16:Ls,17:Ls,18:Ls,19:Ls,20:Ls,21:Ds};function Us(e,r){var t=e.l+r;var a=[];while(e.l=2){t.dt=e._R(2);e.l-=2}switch(t.BIFFVer){case 1536:;case 1280:;case 1024:;case 768:;case 512:;case 2:;case 7:break;default:if(r>6)throw new Error("Unexpected BIFF Ver "+t.BIFFVer);}e._R(r);return t}function Ws(e,r,t){var a=1536,n=16;switch(t.bookType){case"biff8":break;case"biff5":a=1280;n=8;break;case"biff4":a=4;n=6;break;case"biff3":a=3;n=6;break;case"biff2":a=2;n=4;break;case"xla":break;default:throw new Error("unsupported BIFF version");}var i=ua(n);i._W(2,a);i._W(2,r);if(n>4)i._W(2,29282);if(n>6)i._W(2,1997);if(n>8){i._W(2,49161);i._W(2,1);i._W(2,1798);i._W(2,0)}return i}function Hs(e,r){if(r===0)return 1200;if(e._R(2)!==1200){}return 1200}function zs(e,r,t){if(t.enc){e.l+=r;return""}var a=e.l;var n=cs(e,0,t);e._R(r+a-e.l);return n}function Vs(e,r){var t=!r||r.biff==8;var a=ua(t?112:54);a._W(r.biff==8?2:1,7);if(t)a._W(1,0);a._W(4,859007059);a._W(4,5458548|(t?0:536870912));while(a.l=8?2:1;var a=ua(8+t*e.name.length);a._W(4,e.pos);a._W(1,e.hs||0);a._W(1,e.dt);a._W(1,e.name.length);if(r.biff>=8)a._W(1,1);a._W(t*e.name.length,e.name,r.biff<8?"sbcs":"utf16le");var n=a.slice(0,a.l);n.l=a.l;return n}function $s(e,r){var t=e.l+r;var a=e._R(4);var n=e._R(4);var i=[];for(var s=0;s!=n&&e.l>15);n&=32767}var i={Unsynced:a&1,DyZero:(a&2)>>1,ExAsc:(a&4)>>2,ExDsc:(a&8)>>3};return[i,n]}function ef(e){var r=e._R(2),t=e._R(2),a=e._R(2),n=e._R(2);var i=e._R(2),s=e._R(2),f=e._R(2);var o=e._R(2),l=e._R(2);return{Pos:[r,t],Dim:[a,n],Flags:i,CurTab:s,FirstTab:f,Selected:o,TabRatio:l}}function rf(){var e=ua(18);e._W(2,0);e._W(2,0);e._W(2,29280);e._W(2,17600);e._W(2,56);e._W(2,0);e._W(2,0);e._W(2,1);e._W(2,500);return e}function tf(e,r,t){if(t&&t.biff>=2&&t.biff<5)return{};var a=e._R(2);return{RTL:a&64}}function af(e){var r=ua(18),t=1718;if(e&&e.RTL)t|=64;r._W(2,t);r._W(4,0);r._W(4,64);r._W(4,0);r._W(4,0);return r}function nf(){}function sf(e,r,t){var a={dyHeight:e._R(2),fl:e._R(2)};switch(t&&t.biff||8){case 2:break;case 3:;case 4:e.l+=2;break;default:e.l+=10;break;}a.name=is(e,0,t);return a}function ff(e,r){var t=e.name||"Arial";var a=r&&r.biff==5,n=a?15+t.length:16+2*t.length;var i=ua(n);i._W(2,(e.sz||12)*20);i._W(4,0);i._W(2,400);i._W(4,0);i._W(2,0);i._W(1,t.length);if(!a)i._W(1,1);i._W((a?1:2)*t.length,t,a?"sbcs":"utf16le");return i}function of(e){var r=As(e);r.isst=e._R(4);return r}function lf(e,r,t,a){var n=ua(10);Es(e,r,a,n);n._W(4,t);return n}function cf(e,r,t){if(t.biffguess&&t.biff==2)t.biff=5;var a=e.l+r;var n=As(e,6);if(t.biff==2)e.l++;var i=ls(e,a-e.l,t);n.val=i;return n}function hf(e,r,t,a,n){var i=!n||n.biff==8;var s=ua(6+2+ +i+(1+i)*t.length);Es(e,r,a,s);s._W(2,t.length);if(i)s._W(1,1);s._W((1+i)*t.length,t,i?"utf16le":"sbcs");return s}function uf(e,r,t){var a=e._R(2);var n=cs(e,0,t);return[a,n]}function df(e,r,t,a){var n=t&&t.biff==5;if(!a)a=ua(n?3+r.length:5+2*r.length);a._W(2,e);a._W(n?1:2,r.length);if(!n)a._W(1,1);a._W((n?1:2)*r.length,r,n?"sbcs":"utf16le");var i=a.length>a.l?a.slice(0,a.l):a;if(i.l==null)i.l=i.length;return i}var vf=cs;function pf(e,r,t){var a=e.l+r;var n=t.biff==8||!t.biff?4:2;var i=e._R(n),s=e._R(n);var f=e._R(2),o=e._R(2);e.l=a;return{s:{r:i,c:f},e:{r:s,c:o}}}function gf(e,r){var t=r.biff==8||!r.biff?4:2;var a=ua(2*t+6);a._W(t,e.s.r);a._W(t,e.e.r+1);a._W(2,e.s.c);a._W(2,e.e.c+1);a._W(2,0);return a}function mf(e){var r=e._R(2),t=e._R(2);var a=_s(e);return{r:r,c:t,ixfe:a[0],rknum:a[1]}}function bf(e,r){var t=e.l+r-2;var a=e._R(2),n=e._R(2);var i=[];while(e.l>26];if(!a.cellStyles)return n;n.alc=i&7;n.fWrap=i>>3&1;n.alcV=i>>4&7;n.fJustLast=i>>7&1;n.trot=i>>8&255;n.cIndent=i>>16&15;n.fShrinkToFit=i>>20&1;n.iReadOrder=i>>22&2;n.fAtrNum=i>>26&1;n.fAtrFnt=i>>27&1;n.fAtrAlc=i>>28&1;n.fAtrBdr=i>>29&1;n.fAtrPat=i>>30&1;n.fAtrProt=i>>31&1;n.dgLeft=s&15;n.dgRight=s>>4&15;n.dgTop=s>>8&15;n.dgBottom=s>>12&15;n.icvLeft=s>>16&127;n.icvRight=s>>23&127;n.grbitDiag=s>>30&3;n.icvTop=f&127;n.icvBottom=f>>7&127;n.icvDiag=f>>14&127;n.dgDiag=f>>21&15;n.icvFore=o&127;n.icvBack=o>>7&127;n.fsxButton=o>>14&1;return n}function Tf(e,r,t){var a={};a.ifnt=e._R(2);a.numFmtId=e._R(2);a.flags=e._R(2);a.fStyle=a.flags>>2&1;r-=6;a.data=kf(e,r,a.fStyle,t);return a}function Af(e,r,t,a){var n=t&&t.biff==5;if(!a)a=ua(n?16:20);a._W(2,0);if(e.style){a._W(2,e.numFmtId||0);a._W(2,65524)}else{a._W(2,e.numFmtId||0);a._W(2,r<<4)}var i=0;if(e.numFmtId>0&&n)i|=1024;a._W(4,i);a._W(4,0);if(!n)a._W(4,0);a._W(2,0);return a}function Ef(e){e.l+=4;var r=[e._R(2),e._R(2)];if(r[0]!==0)r[0]--;if(r[1]!==0)r[1]--;if(r[0]>7||r[1]>7)throw new Error("Bad Gutters: "+r.join("|"));return r}function Cf(e){var r=ua(8);r._W(4,0);r._W(2,e[0]?e[0]+1:0);r._W(2,e[1]?e[1]+1:0);return r}function yf(e,r,t){var a=As(e,6);if(t.biff==2||r==9)++e.l;var n=as(e,2);a.val=n;a.t=n===true||n===false?"b":"e";return a}function Sf(e,r,t,a,n,i){var s=ua(8);Es(e,r,a,s);ns(t,i,s);return s}function _f(e,r,t){if(t.biffguess&&t.biff==2)t.biff=5;var a=As(e,6);var n=dn(e,8);a.val=n;return a}function xf(e,r,t,a){var n=ua(14);Es(e,r,a,n);vn(t,n);return n}var Of=ys;function Rf(e,r,t){var a=e.l+r;var n=e._R(2);var i=e._R(2);t.sbcch=i;if(i==1025||i==14849)return[i,n];if(i<1||i>255)throw new Error("Unexpected SupBook type: "+i);var s=os(e,i);var f=[];while(a>e.l)f.push(ls(e));return[i,n,s,f]}function If(e,r,t){var a=e._R(2);var n;var i={fBuiltIn:a&1,fWantAdvise:a>>>1&1,fWantPict:a>>>2&1,fOle:a>>>3&1,fOleLink:a>>>4&1,cf:a>>>5&1023,fIcon:a>>>15&1};if(t.sbcch===14849)n=xs(e,r-2,t);i.body=n||e._R(r-2);if(typeof n==="string")i.Name=n;return i}var Nf=["_xlnm.Consolidate_Area","_xlnm.Auto_Open","_xlnm.Auto_Close","_xlnm.Extract","_xlnm.Database","_xlnm.Criteria","_xlnm.Print_Area","_xlnm.Print_Titles","_xlnm.Recorder","_xlnm.Data_Form","_xlnm.Auto_Activate","_xlnm.Auto_Deactivate","_xlnm.Sheet_Title","_xlnm._FilterDatabase"];function Df(e,r,t){var a=e.l+r;var n=e._R(2);var i=e._R(1);var s=e._R(1);var f=e._R(t&&t.biff==2?1:2);var o=0;if(!t||t.biff>=5){if(t.biff!=5)e.l+=2;o=e._R(2);if(t.biff==5)e.l+=2;e.l+=4}var l=os(e,s,t);if(n&32)l=Nf[l.charCodeAt(0)];var c=a-e.l;if(t&&t.biff==2)--c;var h=a==e.l||f===0||!(c>0)?[]:cd(e,c,t,f);return{chKey:i,Name:l,itab:o,rgce:h}}function Ff(e,r,t){if(t.biff<8)return Pf(e,r,t);var a=[],n=e.l+r,i=e._R(t.biff>8?4:2);while(i--!==0)a.push(Ss(e,t.biff>8?12:6,t));if(e.l!=n)throw new Error("Bad ExternSheet: "+e.l+" != "+n);return a}function Pf(e,r,t){if(e[e.l+1]==3)e[e.l]++;var a=is(e,r,t);return a.charCodeAt(0)==3?a.slice(1):a}function Lf(e,r,t){if(t.biff<8){e.l+=r;return}var a=e._R(2);var n=e._R(2);var i=os(e,a,t);var s=os(e,n,t);return[i,s]}function Mf(e,r,t){var a=Is(e,6);e.l++;var n=e._R(1);r-=8;return[hd(e,r,t),n,a]}function Uf(e,r,t){var a=Ns(e,6);switch(t.biff){case 2:e.l++;r-=7;break;case 3:;case 4:e.l+=2;r-=8;break;default:e.l+=6;r-=12;}return[a,od(e,r,t,a)]}function Bf(e){var r=e._R(4)!==0;var t=e._R(4)!==0;var a=e._R(4);return[r,t,a]}function Wf(e,r,t){if(t.biff<8)return;var a=e._R(2),n=e._R(2);var i=e._R(2),s=e._R(2);var f=cs(e,0,t);if(t.biff<8)e._R(1);return[{r:a,c:n},f,s,i]}function Hf(e,r,t){return Wf(e,r,t)}function zf(e,r){var t=[];var a=e._R(2);while(a--)t.push(Os(e,r));return t}function Vf(e){var r=ua(2+e.length*8);r._W(2,e.length);for(var t=0;t=(c?f:2*f))break}if(n.length!==f&&n.length!==f*2){throw new Error("cchText: "+f+" != "+n.length)}e.l=a+r;return{t:n}}catch(u){e.l=a+r;return{t:n}}}function Yf(e,r){var t=Os(e,8);e.l+=16;var a=bs(e,r-24);return[t,a]}function Kf(e){var r=ua(24);var t=Oa(e[0]);r._W(2,t.r);r._W(2,t.r);r._W(2,t.c);r._W(2,t.c);var a="d0 c9 ea 79 f9 ba ce 11 8c 82 00 aa 00 4b a9 0b".split(" ");for(var n=0;n<16;++n)r._W(1,parseInt(a[n],16));return R([r,ws(e[1])])}function Jf(e,r){e._R(2);var t=Os(e,8);var a=e._R((r-10)/2,"dbcs-cont");a=a.replace(N,"");return[t,a]}function qf(e){var r=e[1].Tooltip;var t=ua(10+2*(r.length+1));t._W(2,2048);var a=Oa(e[0]);t._W(2,a.r);t._W(2,a.r);t._W(2,a.c);t._W(2,a.c);for(var n=0;n0)t.push(Ts(e,8));return t}function ro(e){var r=e._R(2);var t=[];while(r-- >0)t.push(Ts(e,8));return t}function to(e){e.l+=2;var r={cxfs:0,crc:0};r.cxfs=e._R(2);r.crc=e._R(4);return r}function ao(e,r,t){if(!t.cellStyles)return ha(e,r);var a=t&&t.biff>=12?4:2;var n=e._R(a);var i=e._R(a);var s=e._R(a);var f=e._R(a);var o=e._R(2);if(a==2)e.l+=2;var l={s:n,e:i,w:s,ixfe:f,flags:o};if(t.biff>=5||!t.biff)l.level=o>>8&7;return l}function no(e,r){var t=ua(12);t._W(2,r);t._W(2,r);t._W(2,e.width*256);t._W(2,0);var a=0;if(e.hidden)a|=1;t._W(1,a);a=e.level||0;t._W(1,a);t._W(2,0);return t}function io(e,r){var t={};if(r<32)return t;e.l+=16;t.header=dn(e,8);t.footer=dn(e,8);e.l+=2;return t}function so(e,r,t){var a={area:false};if(t.biff!=5){e.l+=r;return a}var n=e._R(1);e.l+=3;if(n&16)a.area=true;return a}function fo(e){var r=ua(2*e);for(var t=0;t1048576)c=1e6;if(s!=2)h=i._R(2);var u=i._R(2);var d=t.codepage||1252;if(s!=2){i.l+=16;i._R(1);if(i[i.l]!==0)d=e[i[i.l]];i.l+=1;i.l+=2}if(l)i.l+=36;var v=[],p={};var g=Math.min(i.length,s==2?521:h-10-(o?264:0));var m=l?32:11;while(i.l0){if(i[i.l]===42){i.l+=u;continue}++i.l;n[++b]=[];w=0;for(w=0;w!=v.length;++w){var T=i.slice(i.l,i.l+v[w].len);i.l+=v[w].len;ca(T,0);var A=a.utils.decode(d,T);switch(v[w].type){case"C":if(A.trim().length)n[b][w]=A.replace(/\s+$/,""); -break;case"D":if(A.length===8)n[b][w]=new Date(+A.slice(0,4),+A.slice(4,6)-1,+A.slice(6,8));else n[b][w]=A;break;case"F":n[b][w]=parseFloat(A.trim());break;case"+":;case"I":n[b][w]=l?T._R(-4,"i")^2147483648:T._R(4,"i");break;case"L":switch(A.trim().toUpperCase()){case"Y":;case"T":n[b][w]=true;break;case"N":;case"F":n[b][w]=false;break;case"":;case"?":break;default:throw new Error("DBF Unrecognized L:|"+A+"|");}break;case"M":if(!f)throw new Error("DBF Unexpected MEMO for type "+s.toString(16));n[b][w]="##MEMO##"+(l?parseInt(A.trim(),10):T._R(4));break;case"N":A=A.replace(/\u0000/g,"").trim();if(A&&A!=".")n[b][w]=+A||0;break;case"@":n[b][w]=new Date(T._R(-8,"f")-621356832e5);break;case"T":n[b][w]=new Date((T._R(4)-2440588)*864e5+T._R(4));break;case"Y":n[b][w]=T._R(4,"i")/1e4+T._R(4,"i")/1e4*Math.pow(2,32);break;case"O":n[b][w]=-T._R(-8,"f");break;case"B":if(o&&v[w].len==8){n[b][w]=T._R(8,"f");break};case"G":;case"P":T.l+=v[w].len;break;case"0":if(v[w].name==="_NullFlags")break;default:throw new Error("DBF Unsupported data type "+v[w].type);}}}if(s!=2)if(i.l=0)o(+n.codepage);if(n.type=="string")throw new Error("Cannot write DBF to JS string");var i=va();var s=Aw(e,{header:1,raw:true,cellDates:true});var l=s[0],c=s.slice(1),h=e["!cols"]||[];var u=0,d=0,v=0,p=1;for(u=0;u250)A=250;T=((h[u]||{}).DBF||{}).type;if(T=="C"){if(h[u].DBF.len>A)A=h[u].DBF.len}if(k=="B"&&T=="N"){k="N";w[u]=h[u].DBF.dec;A=h[u].DBF.len}b[u]=k=="C"||T=="N"?A:f[k]||0;p+=b[u];m[u]=k}var C=i.next(32);C._W(4,318902576);C._W(4,c.length);C._W(2,296+32*v);C._W(2,p);for(u=0;u<4;++u)C._W(4,0);C._W(4,0|(+r[t]||3)<<8);for(u=0,d=0;u":190,"?":191,"{":223};var r=new RegExp("N("+rr(e).join("|").replace(/\|\|\|/,"|\\||").replace(/([?()+])/g,"\\$1")+"|\\|)","gm");var t=function(r,t){var a=e[t];return typeof a=="number"?p(a):a};var n=function(e,r,t){var a=r.charCodeAt(0)-32<<4|t.charCodeAt(0)-48;return a==59?e:p(a)};e["|"]=254;function i(e,r){switch(r.type){case"base64":return s(k(e),r);case"binary":return s(e,r);case"buffer":return s(T&&Buffer.isBuffer(e)?e.toString("binary"):_(e),r);case"array":return s(mr(e),r);}throw new Error("Unrecognized type "+r.type)}function s(e,i){var s=e.split(/[\n\r]+/),f=-1,l=-1,c=0,h=0,u=[];var d=[];var v=null;var p={},g=[],m=[],b=[];var w=0,k;if(+i.codepage>=0)o(+i.codepage);for(;c!==s.length;++c){w=0;var T=s[c].trim().replace(/\x1B([\x20-\x2F])([\x30-\x3F])/g,n).replace(r,t);var A=T.replace(/;;/g,"\0").split(";").map(function(e){return e.replace(/\u0000/g,";")});var E=A[0],C;if(T.length>0)switch(E){case"ID":break;case"E":break;case"B":break;case"O":break;case"W":break;case"P":if(A[1].charAt(0)=="P")d.push(T.slice(3).replace(/;;/g,";"));break;case"C":var y=false,S=false,_=false,x=false,O=-1,R=-1;for(h=1;h-1&&u[O][R];if(!N||!N[1])throw new Error("SYLK shared formula cannot find base");u[f][l][1]=Oh(N[1],{r:f-O,c:l-R})}break;case"F":var D=0;for(h=1;h0){g[f].hpt=w;g[f].hpx=Pl(w)}else if(w===0)g[f].hidden=true;break;default:if(i&&i.WTF)throw new Error("SYLK bad record "+T);}if(D<1)v=null;break;default:if(i&&i.WTF)throw new Error("SYLK bad record "+T);}}if(g.length>0)p["!rows"]=g;if(m.length>0)p["!cols"]=m;if(i&&i.sheetRows)u=u.slice(0,i.sheetRows);return[u,p]}function f(e,r){var t=i(e,r);var a=t[0],n=t[1];var s=Ua(a,r);rr(n).forEach(function(e){s[e]=n[e]});return s}function l(e,r){return La(f(e,r),r)}function c(e,r,t,a){var n="C;Y"+(t+1)+";X"+(a+1)+";K";switch(e.t){case"n":n+=e.v||0;if(e.f&&!e.F)n+=";E"+xh(e.f,{r:t,c:a});break;case"b":n+=e.v?"TRUE":"FALSE";break;case"e":n+=e.w||e.v;break;case"d":n+='"'+(e.w||e.v)+'"';break;case"s":n+='"'+e.v.replace(/"/g,"").replace(/;/g,";;")+'"';break;}return n}function h(e,r){r.forEach(function(r,t){var a="F;W"+(t+1)+" "+(t+1)+" ";if(r.hidden)a+="0";else{if(typeof r.width=="number"&&!r.wpx)r.wpx=Sl(r.width);if(typeof r.wpx=="number"&&!r.wch)r.wch=_l(r.wpx);if(typeof r.wch=="number")a+=Math.round(r.wch)}if(a.charAt(a.length-1)!=" ")e.push(a)})}function u(e,r){r.forEach(function(r,t){var a="F;";if(r.hidden)a+="M0;";else if(r.hpt)a+="M"+20*r.hpt+";";else if(r.hpx)a+="M"+20*Fl(r.hpx)+";";if(a.length>2)e.push(a+"R"+(t+1))})}function d(e,r){var t=["ID;PWXL;N;E"],a=[];var n=Da(e["!ref"]),i;var s=Array.isArray(e);var f="\r\n";t.push("P;PGeneral");t.push("F;P0;DG0G8;M255");if(e["!cols"])h(t,e["!cols"]);if(e["!rows"])u(t,e["!rows"]);t.push("B;Y"+(n.e.r-n.s.r+1)+";X"+(n.e.c-n.s.c+1)+";D"+[n.s.c,n.s.r,n.e.c,n.e.r].join(" "));for(var o=n.s.r;o<=n.e.r;++o){for(var l=n.s.c;l<=n.e.c;++l){var d=Ra({r:o,c:l});i=s?(e[o]||[])[l]:e[d];if(!i||i.v==null&&(!i.f||i.F))continue;a.push(c(i,e,o,l,r))}}return t.join(f)+f+a.join(f)+f+"E"+f}return{to_workbook:l,to_sheet:f,from_sheet:d}}();var Co=function(){function e(e,t){switch(t.type){case"base64":return r(k(e),t);case"binary":return r(e,t);case"buffer":return r(T&&Buffer.isBuffer(e)?e.toString("binary"):_(e),t);case"array":return r(mr(e),t);}throw new Error("Unrecognized type "+t.type)}function r(e,r){var t=e.split("\n"),a=-1,n=-1,i=0,s=[];for(;i!==t.length;++i){if(t[i].trim()==="BOT"){s[++a]=[];n=0;continue}if(a<0)continue;var f=t[i].trim().split(",");var o=f[0],l=f[1];++i;var c=t[i]||"";while((c.match(/["]/g)||[]).length&1&&i=0&&i[s].length===0)--s;var f=10,o=0;var l=0;for(;l<=s;++l){o=i[l].indexOf(" ");if(o==-1)o=i[l].length;else o++;f=Math.max(f,o)}for(l=0;l<=s;++l){n[l]=[];var c=0;e(i[l].slice(0,f).trim(),n,l,c,a);for(c=1;c<=(i[l].length-f)/10+1;++c)e(i[l].slice(f+(c-1)*10,f+c*10).trim(),n,l,c,a)}if(a.sheetRows)n=n.slice(0,a.sheetRows);return n}var t={44:",",9:"\t",59:";",124:"|"};var n={44:3,9:2,59:1,124:0};function i(e){var r={},a=false,i=0,s=0;for(;i0)b();n["!ref"]=Na(s);return n}function f(e,t){if(!(t&&t.PRN))return s(e,t);if(t.FS)return s(e,t);if(e.slice(0,4)=="sep=")return s(e,t);if(e.indexOf("\t")>=0||e.indexOf(",")>=0||e.indexOf(";")>=0)return s(e,t);return Ua(r(e,t),t)}function o(e,r){var t="",n=r.type=="string"?[0,0,0,0]:qb(e,r);switch(r.type){case"base64":t=k(e);break;case"binary":t=e;break;case"buffer":if(r.codepage==65001)t=e.toString("utf8");else if(r.codepage&&typeof a!=="undefined")t=a.utils.decode(r.codepage,e);else t=T&&Buffer.isBuffer(e)?e.toString("binary"):_(e);break;case"array":t=mr(e);break;case"string":t=e;break;default:throw new Error("Unrecognized type "+r.type);}if(n[0]==239&&n[1]==187&&n[2]==191)t=lt(t.slice(3));else if(r.type!="string"&&r.type!="buffer"&&r.codepage==65001)t=lt(t);else if(r.type=="binary"&&typeof a!=="undefined"&&r.codepage)t=a.utils.decode(r.codepage,a.utils.encode(28591,t));if(t.slice(0,19)=="socialcalc:version:")return yo.to_sheet(r.type=="string"?t:lt(t),r);return f(t,r)}function l(e,r){return La(o(e,r),r)}function c(e){var r=[];var t=Da(e["!ref"]),a;var n=Array.isArray(e);for(var i=t.s.r;i<=t.e.r;++i){var s=[];for(var f=t.s.c;f<=t.e.c;++f){var o=Ra({r:i,c:f});a=n?(e[i]||[])[f]:e[o];if(!a||a.v==null){s.push(" ");continue}var l=(a.w||(Pa(a),a.w)||"").slice(0,10);while(l.length<10)l+=" ";s.push(l+(f===0?" ":""))}r.push(s.join(""))}return r.join("\n")}return{to_workbook:l,to_sheet:o,from_sheet:c}}();function _o(e,r){var t=r||{},a=!!t.WTF;t.WTF=true;try{var n=Eo.to_workbook(e,t);t.WTF=a;return n}catch(i){t.WTF=a;if(!i.message.match(/SYLK bad record ID/)&&a)throw i;return So.to_workbook(e,r)}}var xo=function(){function e(e,r,t){if(!e)return;ca(e,e.l||0);var a=t.Enum||W;while(e.l=16&&r[14]==5&&r[15]===108)throw new Error("Unsupported Works 3 for Mac file")}}if(r[2]==2){a.Enum=W;e(r,function(e,r,t){switch(t){case 0:a.vers=e;if(e>=4096)a.qpro=true;break;case 6:h=e;break;case 204:if(e)s=e;break;case 222:s=e;break;case 15:;case 51:if(!a.qpro)e[1].v=e[1].v.slice(1);case 13:;case 14:;case 16:if(t==14&&(e[2]&112)==112&&(e[2]&15)>1&&(e[2]&15)<15){e[1].z=a.dateNF||X[14];if(a.cellDates){e[1].t="d";e[1].v=hr(e[1].v)}}if(a.qpro){if(e[3]>f){n["!ref"]=Na(h);o[i]=n;l.push(i);n=a.dense?[]:{};h={s:{r:0,c:0},e:{r:0,c:0}};f=e[3];i=s||"Sheet"+(f+1);s=""}}var c=a.dense?(n[e[0].r]||[])[e[0].c]:n[Ra(e[0])];if(c){c.t=e[1].t;c.v=e[1].v;if(e[1].z!=null)c.z=e[1].z;if(e[1].f!=null)c.f=e[1].f;break}if(a.dense){if(!n[e[0].r])n[e[0].r]=[];n[e[0].r][e[0].c]=e[1]}else n[Ra(e[0])]=e[1];break;default:;}},a)}else if(r[2]==26||r[2]==14){a.Enum=H;if(r[2]==14){a.qpro=true;r.l=0}e(r,function(e,r,t){switch(t){case 204:i=e;break;case 22:e[1].v=e[1].v.slice(1);case 23:;case 24:;case 25:;case 37:;case 39:;case 40:if(e[3]>f){n["!ref"]=Na(h);o[i]=n;l.push(i);n=a.dense?[]:{};h={s:{r:0,c:0},e:{r:0,c:0}};f=e[3];i="Sheet"+(f+1)}if(u>0&&e[0].r>=u)break;if(a.dense){if(!n[e[0].r])n[e[0].r]=[];n[e[0].r][e[0].c]=e[1]}else n[Ra(e[0])]=e[1];if(h.e.c=0)o(+t.codepage);if(t.type=="string")throw new Error("Cannot write WK1 to JS string");var a=va();var n=Da(e["!ref"]);var s=Array.isArray(e);var f=[];bm(a,0,i(1030));bm(a,6,l(n));var c=Math.min(n.e.r,8191);for(var h=n.s.r;h<=c;++h){var d=Ta(h);for(var p=n.s.c;p<=n.e.c;++p){if(h===n.s.r)f[p]=ya(p);var g=f[p]+d;var b=s?(e[h]||[])[p]:e[g];if(!b||b.t=="z")continue;if(b.t=="n"){if((b.v|0)==b.v&&b.v>=-32768&&b.v<=32767)bm(a,13,v(h,p,b.v));else bm(a,14,m(h,p,b.v))}else{var w=Pa(b);bm(a,15,u(h,p,w.slice(0,239)))}}}bm(a,1);return a.end()}function n(e,r){var t=r||{};if(+t.codepage>=0)o(+t.codepage);if(t.type=="string")throw new Error("Cannot write WK3 to JS string");var a=va();bm(a,0,s(e));for(var n=0,i=0;n8191)t=8191;r._W(2,t);r._W(1,n);r._W(1,a);r._W(2,0);r._W(2,0);r._W(1,1);r._W(1,2);r._W(4,0);r._W(4,0);return r}function f(e,r,t){var a={s:{c:0,r:0},e:{c:0,r:0}};if(r==8&&t.qpro){a.s.c=e._R(1);e.l++;a.s.r=e._R(2);a.e.c=e._R(1);e.l++;a.e.r=e._R(2);return a}a.s.c=e._R(2);a.s.r=e._R(2);if(r==12&&t.qpro)e.l+=2;a.e.c=e._R(2);a.e.r=e._R(2);if(r==12&&t.qpro)e.l+=2;if(a.s.c==65535)a.s.c=a.e.c=a.s.r=a.e.r=0;return a}function l(e){var r=ua(8);r._W(2,e.s.c);r._W(2,e.s.r);r._W(2,e.e.c);r._W(2,e.e.r);return r}function c(e,r,t){var a=[{c:0,r:0},{t:"n",v:0},0,0];if(t.qpro&&t.vers!=20768){a[0].c=e._R(1);a[3]=e._R(1);a[0].r=e._R(2);e.l+=2}else{a[2]=e._R(1);a[0].c=e._R(2);a[0].r=e._R(2)}return a}function h(e,r,t){var a=e.l+r;var n=c(e,r,t);n[1].t="s";if(t.vers==20768){e.l++;var i=e._R(1);n[1].v=e._R(i,"utf8");return n}if(t.qpro)e.l++;n[1].v=e._R(a-e.l,"cstr");return n}function u(e,r,t){var a=ua(7+t.length);a._W(1,255);a._W(2,r);a._W(2,e);a._W(1,39);for(var n=0;n=128?95:i)}a._W(1,0);return a}function d(e,r,t){var a=c(e,r,t);a[1].v=e._R(2,"i");return a}function v(e,r,t){var a=ua(7);a._W(1,255);a._W(2,r);a._W(2,e);a._W(2,t,"i");return a}function p(e,r,t){var a=c(e,r,t);a[1].v=e._R(8,"f");return a}function m(e,r,t){var a=ua(13);a._W(1,255);a._W(2,r);a._W(2,e);a._W(8,t,"f");return a}function b(e,r,t){var a=e.l+r;var n=c(e,r,t);n[1].v=e._R(8,"f");if(t.qpro)e.l=a;else{var i=e._R(2);E(e.slice(e.l,e.l+i),n);e.l+=i}return n}function w(e,r,t){var a=r&32768;r&=~32768;r=(a?e:0)+(r>=8192?r-16384:r);return(a?"":"$")+(t?ya(r):Ta(r))}var T={51:["FALSE",0],52:["TRUE",0],70:["LEN",1],80:["SUM",69],81:["AVERAGEA",69],82:["COUNTA",69],83:["MINA",69],84:["MAXA",69],111:["T",1]};var A=["","","","","","","","","","+","-","*","/","^","=","<>","<=",">=","<",">","","","","","&","","","","","","",""];function E(e,r){ca(e,0);var t=[],a=0,n="",i="",s="",f="";while(e.lt.length){console.error("WK1 bad formula parse 0x"+o.toString(16)+":|"+t.join("|")+"|");return}var u=t.slice(-a);t.length-=a;t.push(T[o][0]+"("+u.join(",")+")")}else if(o<=7)return console.error("WK1 invalid opcode "+o.toString(16));else if(o<=24)return console.error("WK1 unsupported op "+o.toString(16));else if(o<=30)return console.error("WK1 invalid opcode "+o.toString(16));else if(o<=115)return console.error("WK1 unsupported function opcode "+o.toString(16));else return console.error("WK1 unrecognized opcode "+o.toString(16));}}if(t.length==1)r[1].f=""+t[0];else console.error("WK1 bad formula parse |"+t.join("|")+"|")}function C(e){var r=[{c:0,r:0},{t:"n",v:0},0];r[0].r=e._R(2);r[3]=e[e.l++];r[0].c=e[e.l++];return r}function S(e,r){var t=C(e,r);t[1].t="s";t[1].v=e._R(r-4,"cstr");return t}function _(e,r,t,a){var n=ua(6+a.length);n._W(2,e);n._W(1,t);n._W(1,r);n._W(1,39);for(var i=0;i=128?95:s)}n._W(1,0);return n}function x(e,r){var t=C(e,r);t[1].v=e._R(2);var a=t[1].v>>1;if(t[1].v&1){switch(a&7){case 0:a=(a>>3)*5e3;break;case 1:a=(a>>3)*500;break;case 2:a=(a>>3)/20;break;case 3:a=(a>>3)/200;break;case 4:a=(a>>3)/2e3;break;case 5:a=(a>>3)/2e4;break;case 6:a=(a>>3)/16;break;case 7:a=(a>>3)/64;break;}}t[1].v=a;return t}function O(e,r){var t=C(e,r);var a=e._R(4);var n=e._R(4);var i=e._R(2);if(i==65535){if(a===0&&n===3221225472){t[1].t="e";t[1].v=15}else if(a===0&&n===3489660928){t[1].t="e";t[1].v=42}else t[1].v=0;return t}var s=i&32768;i=(i&32767)-16446;t[1].v=(1-s*2)*(n*Math.pow(2,i+32)+a*Math.pow(2,i));return t}function R(e,r,t,a){var n=ua(14);n._W(2,e);n._W(1,t);n._W(1,r);if(a==0){n._W(4,0);n._W(4,0);n._W(2,65535);return n}var i=0,s=0,f=0,o=0;if(a<0){i=1;a=-a}s=Math.log2(a)|0;a/=Math.pow(2,s-31);o=a>>>0;if((o&2147483648)==0){a/=2;++s;o=a>>>0}a-=o;o|=2147483648;o>>>=0;a*=Math.pow(2,32);f=a>>>0;n._W(4,f);n._W(4,o);s+=16383+(i?32768:0);n._W(2,s);return n}function I(e,r){var t=O(e,14);e.l+=r-14;return t}function N(e,r){var t=C(e,r);var a=e._R(4);t[1].v=a>>6;return t}function D(e,r){var t=C(e,r);var a=e._R(8,"f");t[1].v=a;return t}function F(e,r){var t=D(e,14);e.l+=r-10;return t}function P(e,r){return e[e.l+r-1]==0?e._R(r,"cstr"):""}function L(e,r){var t=e[e.l++];if(t>r-1)t=r-1;var a="";while(a.length127?95:n}t[t.l++]=0;return t}var W={0:{n:"BOF",f:es},1:{n:"EOF"},2:{n:"CALCMODE"},3:{n:"CALCORDER"},4:{n:"SPLIT"},5:{n:"SYNC"},6:{n:"RANGE",f:f},7:{n:"WINDOW1"},8:{n:"COLW1"},9:{n:"WINTWO"},10:{n:"COLW2"},11:{n:"NAME"},12:{n:"BLANK"},13:{n:"INTEGER",f:d},14:{n:"NUMBER",f:p},15:{n:"LABEL",f:h},16:{n:"FORMULA",f:b},24:{n:"TABLE"},25:{n:"ORANGE"},26:{n:"PRANGE"},27:{n:"SRANGE"},28:{n:"FRANGE"},29:{n:"KRANGE1"},32:{n:"HRANGE"},35:{n:"KRANGE2"},36:{n:"PROTEC"},37:{n:"FOOTER"},38:{n:"HEADER"},39:{n:"SETUP"},40:{n:"MARGINS"},41:{n:"LABELFMT"},42:{n:"TITLES"},43:{n:"SHEETJS"},45:{n:"GRAPH"},46:{n:"NGRAPH"},47:{n:"CALCCOUNT"},48:{n:"UNFORMATTED"},49:{n:"CURSORW12"},50:{n:"WINDOW"},51:{n:"STRING",f:h},55:{n:"PASSWORD"},56:{n:"LOCKED"},60:{n:"QUERY"},61:{n:"QUERYNAME"},62:{n:"PRINT"},63:{n:"PRINTNAME"},64:{n:"GRAPH2"},65:{n:"GRAPHNAME"},66:{n:"ZOOM"},67:{n:"SYMSPLIT"},68:{n:"NSROWS"},69:{n:"NSCOLS"},70:{n:"RULER"},71:{n:"NNAME"},72:{n:"ACOMM"},73:{n:"AMACRO"},74:{n:"PARSE"},102:{n:"PRANGES??"},103:{n:"RRANGES??"},104:{n:"FNAME??"},105:{n:"MRANGES??"},204:{n:"SHEETNAMECS",f:P},222:{n:"SHEETNAMELP",f:L},65535:{n:""}};var H={0:{n:"BOF"},1:{n:"EOF"},2:{n:"PASSWORD"},3:{n:"CALCSET"},4:{n:"WINDOWSET"},5:{n:"SHEETCELLPTR"},6:{n:"SHEETLAYOUT"},7:{n:"COLUMNWIDTH"},8:{n:"HIDDENCOLUMN"},9:{n:"USERRANGE"},10:{n:"SYSTEMRANGE"},11:{n:"ZEROFORCE"},12:{n:"SORTKEYDIR"},13:{n:"FILESEAL"},14:{n:"DATAFILLNUMS"},15:{n:"PRINTMAIN"},16:{n:"PRINTSTRING"},17:{n:"GRAPHMAIN"},18:{n:"GRAPHSTRING"},19:{n:"??"},20:{n:"ERRCELL"},21:{n:"NACELL"},22:{n:"LABEL16",f:S},23:{n:"NUMBER17",f:O},24:{n:"NUMBER18",f:x},25:{n:"FORMULA19",f:I},26:{n:"FORMULA1A"},27:{n:"XFORMAT",f:U},28:{n:"DTLABELMISC"},29:{n:"DTLABELCELL"},30:{n:"GRAPHWINDOW"},31:{n:"CPA"},32:{n:"LPLAUTO"},33:{n:"QUERY"},34:{n:"HIDDENSHEET"},35:{n:"??"},37:{n:"NUMBER25",f:N},38:{n:"??"},39:{n:"NUMBER27",f:D},40:{n:"FORMULA28",f:F},142:{n:"??"},147:{n:"??"},150:{n:"??"},151:{n:"??"},152:{n:"??"},153:{n:"??"},154:{n:"??"},155:{n:"??"},156:{n:"??"},163:{n:"??"},174:{n:"??"},175:{n:"??"},176:{n:"??"},177:{n:"??"},184:{n:"??"},185:{n:"??"},186:{n:"??"},187:{n:"??"},188:{n:"??"},195:{n:"??"},201:{n:"??"},204:{n:"SHEETNAMECS",f:P},205:{n:"??"},206:{n:"??"},207:{n:"??"},208:{n:"??"},256:{n:"??"},259:{n:"??"},260:{n:"??"},261:{n:"??"},262:{n:"??"},263:{n:"??"},265:{n:"??"},266:{n:"??"},267:{n:"??"},268:{n:"??"},270:{n:"??"},271:{n:"??"},384:{n:"??"},389:{n:"??"},390:{n:"??"},393:{n:"??"},396:{n:"??"},512:{n:"??"},514:{n:"??"},513:{n:"??"},516:{n:"??"},517:{n:"??"},640:{n:"??"},641:{n:"??"},642:{n:"??"},643:{n:"??"},644:{n:"??"},645:{n:"??"},646:{n:"??"},647:{n:"??"},648:{n:"??"},658:{n:"??"},659:{n:"??"},660:{n:"??"},661:{n:"??"},662:{n:"??"},665:{n:"??"},666:{n:"??"},768:{n:"??"},772:{n:"??"},1537:{n:"SHEETINFOQP",f:M},1600:{n:"??"},1602:{n:"??"},1793:{n:"??"},1794:{n:"??"},1795:{n:"??"},1796:{n:"??"},1920:{n:"??"},2048:{n:"??"},2049:{n:"??"},2052:{n:"??"},2688:{n:"??"},10998:{n:"??"},12849:{n:"??"},28233:{n:"??"},28484:{n:"??"},65535:{n:""}};return{sheet_to_wk1:a,book_to_wk3:n,to_workbook:r}}();function Oo(e){var r={},t=e.match(Hr),a=0;var n=false;if(t)for(;a!=t.length;++a){var s=Gr(t[a]);switch(s[0].replace(/\w*:/g,"")){case"":;case"":r.shadow=1;break;case"":break;case"":;case"":r.outline=1;break;case"":break;case"":;case"":r.strike=1;break;case"":break;case"":;case"":r.u=1;break;case"":break;case"":;case"":r.b=1;break;case"":break;case"":;case"":r.i=1;break;case"":break;case"":;case"":;case"":break;case"":;case"":;case"":break;case"":;case"":;case"":break;case"":;case"":;case"":break;case"":;case"":break;case"":n=false;break;default:if(s[0].charCodeAt(1)!==47&&!n)throw new Error("Unrecognized rich format "+s[0]);}}return r}var Ro=function(){var e=ht("t"),r=ht("rPr");function t(t){var a=t.match(e);if(!a)return{t:"s",v:""};var n={t:"s",v:Yr(a[1])};var i=t.match(r);if(i)n.s=Oo(i[1]);return n}var a=/<(?:\w+:)?r>/g,n=/<\/(?:\w+:)?r>/;return function i(e){return e.replace(a,"").split(n).map(t).filter(function(e){return e.v})}}();var Io=function hk(){var e=/(\r\n|\n)/g;function r(e,r,t){var a=[];if(e.u)a.push("text-decoration: underline;");if(e.uval)a.push("text-underline-style:"+e.uval+";");if(e.sz)a.push("font-size:"+e.sz+"pt;");if(e.outline)a.push("text-effect: outline;");if(e.shadow)a.push("text-shadow: auto;");r.push('');if(e.b){r.push("");t.push("")}if(e.i){r.push("");t.push("")}if(e.strike){r.push("");t.push("")}var n=e.valign||"";if(n=="superscript"||n=="super")n="sup";else if(n=="subscript")n="sub";if(n!=""){r.push("<"+n+">");t.push("")}t.push("");return e}function t(t){var a=[[],t.v,[]];if(!t.v)return"";if(t.s)r(t.s,a[0],a[2]);return a[0].join("")+a[1].replace(e,"
    ")+a[2].join("")}return function a(e){return e.map(t).join("")}}();var No=/<(?:\w+:)?t[^>]*>([^<]*)<\/(?:\w+:)?t>/g,Do=/<(?:\w+:)?r>/;var Fo=/<(?:\w+:)?rPh.*?>([\s\S]*?)<\/(?:\w+:)?rPh>/g;function Po(e,r){var t=r?r.cellHTML:true;var a={};if(!e)return{t:""};if(e.match(/^\s*<(?:\w+:)?t[^>]*>/)){a.t=Yr(lt(e.slice(e.indexOf(">")+1).split(/<\/(?:\w+:)?t>/)[0]||""));a.r=lt(e);if(t)a.h=et(a.t)}else if(e.match(Do)){a.r=lt(e);a.t=Yr(lt((e.replace(Fo,"").match(No)||[]).join("").replace(Hr,"")));if(t)a.h=Io(Ro(a.r))}return a}var Lo=/<(?:\w+:)?sst([^>]*)>([\s\S]*)<\/(?:\w+:)?sst>/;var Mo=/<(?:\w+:)?(?:si|sstItem)>/g;var Uo=/<\/(?:\w+:)?(?:si|sstItem)>/;function Bo(e,r){var t=[],a="";if(!e)return t;var n=e.match(Lo);if(n){a=n[2].replace(Mo,"").split(Uo);for(var i=0;i!=a.length;++i){var s=Po(a[i].trim(),r);if(s!=null)t[t.length]=s}n=Gr(n[1]);t.Count=n.count;t.Unique=n.uniqueCount}return t}var Wo=/^\s|\s$|[\t\n\r]/;function Ho(e,r){if(!r.bookSST)return"";var t=[Mr];t[t.length]=kt("sst",null,{xmlns:St[0],count:e.Count,uniqueCount:e.Unique});for(var a=0;a!=e.length;++a){if(e[a]==null)continue;var n=e[a];var i="";if(n.r)i+=n.r;else{i+=""}i+="";t[t.length]=i}if(t.length>2){t[t.length]="";t[1]=t[1].replace("/>",">")}return t.join("")}function zo(e){return[e._R(4),e._R(4)]}function Vo(e,r){var t=[];var a=false;da(e,function n(e,i,s){switch(s){case 159:t.Count=e[0];t.Unique=e[1];break;case 19:t.push(e);break;case 160:return true;case 35:a=true;break;case 36:a=false;break;default:if(i.T){}if(!a||r.WTF)throw new Error("Unexpected record 0x"+s.toString(16));}});return t}function Go(e,r){if(!r)r=ua(8);r._W(4,e.Count);r._W(4,e.Unique);return r}var jo=Xa;function Xo(e){var r=va();pa(r,159,Go(e));for(var t=0;t=4)e.l+=r-4;return t}function Ko(e){var r={};r.id=e._R(0,"lpp4");r.R=Yo(e,4);r.U=Yo(e,4);r.W=Yo(e,4);return r}function Jo(e){var r=e._R(4);var t=e.l+r-4;var a={};var n=e._R(4);var i=[];while(n-- >0)i.push({t:e._R(4),v:e._R(0,"lpp4")});a.name=e._R(0,"lpp4");a.comps=i;if(e.l!=t)throw new Error("Bad DataSpaceMapEntry: "+e.l+" != "+t);return a}function qo(e){var r=[];e.l+=4;var t=e._R(4);while(t-- >0)r.push(Jo(e));return r}function Zo(e){var r=[];e.l+=4;var t=e._R(4);while(t-- >0)r.push(e._R(0,"lpp4"));return r}function Qo(e){var r={};e._R(4);e.l+=4;r.id=e._R(0,"lpp4");r.name=e._R(0,"lpp4");r.R=Yo(e,4);r.U=Yo(e,4);r.W=Yo(e,4);return r}function el(e){var r=Qo(e);r.ename=e._R(0,"8lpp4");r.blksz=e._R(4);r.cmode=e._R(4);if(e._R(4)!=4)throw new Error("Bad !Primary record");return r}function rl(e,r){var t=e.l+r;var a={};a.Flags=e._R(4)&63;e.l+=4;a.AlgID=e._R(4);var n=false;switch(a.AlgID){case 26126:;case 26127:;case 26128:n=a.Flags==36;break;case 26625:n=a.Flags==4;break;case 0:n=a.Flags==16||a.Flags==4||a.Flags==36;break;default:throw"Unrecognized encryption algorithm: "+a.AlgID;}if(!n)throw new Error("Encryption Flags/AlgID mismatch");a.AlgIDHash=e._R(4);a.KeySize=e._R(4);a.ProviderType=e._R(4);e.l+=8;a.CSPName=e._R(t-e.l>>1,"utf16le");e.l=t;return a}function tl(e,r){var t={},a=e.l+r;e.l+=4;t.Salt=e.slice(e.l,e.l+16);e.l+=16;t.Verifier=e.slice(e.l,e.l+16);e.l+=16;e._R(4);t.VerifierHash=e.slice(e.l,a);e.l=a;return t}function al(e){var r=Yo(e);switch(r.Minor){case 2:return[r.Minor,nl(e,r)];case 3:return[r.Minor,il(e,r)];case 4:return[r.Minor,sl(e,r)];}throw new Error("ECMA-376 Encrypted file unrecognized Version: "+r.Minor)}function nl(e){var r=e._R(4);if((r&63)!=36)throw new Error("EncryptionInfo mismatch");var t=e._R(4);var a=rl(e,t);var n=tl(e,e.length-e.l);return{t:"Std",h:a,v:n}}function il(){throw new Error("File is password-protected: ECMA-376 Extensible")}function sl(e){var r=["saltSize","blockSize","keyBits","hashSize","cipherAlgorithm","cipherChaining","hashAlgorithm","saltValue"];e.l+=4;var t=e._R(e.length-e.l,"utf8");var a={};t.replace(Hr,function n(e){var t=Gr(e);switch(jr(t[0])){case"":break;case"":;case"":break;case"":break;case"4||a.Major<2)throw new Error("unrecognized major version code: "+a.Major);t.Flags=e._R(4);r-=4;var n=e._R(4);r-=4;t.EncryptionHeader=rl(e,n);r-=n;t.EncryptionVerifier=tl(e,r);return t}function ol(e){var r={};var t=r.EncryptionVersionInfo=Yo(e,4);if(t.Major!=1||t.Minor!=1)throw"unrecognized version code "+t.Major+" : "+t.Minor;r.Salt=e._R(16);r.EncryptedVerifier=e._R(16);r.EncryptedVerifierHash=e._R(16);return r}function ll(e){var r=0,t;var a=$o(e);var n=a.length+1,i,s;var f,o,l;t=E(n);t[0]=a.length;for(i=1;i!=n;++i)t[i]=a[i-1];for(i=n-1;i>=0;--i){s=t[i];f=(r&16384)===0?0:1;o=r<<1&32767;l=f|o;r=l^s}return r^52811}var cl=function(){var e=[187,255,255,186,255,255,185,128,0,190,15,0,191,15,0];var r=[57840,7439,52380,33984,4364,3600,61902,12606,6258,57657,54287,34041,10252,43370,20163];var t=[44796,19929,39858,10053,20106,40212,10761,31585,63170,64933,60267,50935,40399,11199,17763,35526,1453,2906,5812,11624,23248,885,1770,3540,7080,14160,28320,56640,55369,41139,20807,41614,21821,43642,17621,28485,56970,44341,19019,38038,14605,29210,60195,50791,40175,10751,21502,43004,24537,18387,36774,3949,7898,15796,31592,63184,47201,24803,49606,37805,14203,28406,56812,17824,35648,1697,3394,6788,13576,27152,43601,17539,35078,557,1114,2228,4456,30388,60776,51953,34243,7079,14158,28316,14128,28256,56512,43425,17251,34502,7597,13105,26210,52420,35241,883,1766,3532,4129,8258,16516,33032,4657,9314,18628];var a=function(e){return(e/2|e*128)&255};var n=function(e,r){return a(e^r)};var i=function(e){var a=r[e.length-1];var n=104;for(var i=e.length-1;i>=0;--i){var s=e[i];for(var f=0;f!=7;++f){if(s&64)a^=t[n];s*=2;--n}}return a};return function(r){var t=$o(r);var a=i(t);var s=t.length;var f=E(16);for(var o=0;o!=16;++o)f[o]=0;var l,c,h;if((s&1)===1){l=a>>8;f[s]=n(e[0],l);--s;l=a&255;c=t[t.length-1];f[s]=n(c,l)}while(s>0){--s;l=a>>8;f[s]=n(t[s],l);--s;l=a&255;f[s]=n(t[s],l)}s=15;h=15-t.length;while(h>0){l=a>>8;f[s]=n(e[h],l);--s;--h;l=a&255;f[s]=n(t[s],l);--s;--h}return f}}();var hl=function(e,r,t,a,n){if(!n)n=r;if(!a)a=cl(e);var i,s;for(i=0;i!=r.length;++i){s=r[i];s^=a[t];s=(s>>5|s<<3)&255;n[i]=s;++t}return[n,t,a]};var ul=function(e){var r=0,t=cl(e);return function(e){var a=hl("",e,r,t);r=a[1];return a[0]}};function dl(e,r,t,a){var n={key:es(e),verificationBytes:es(e)};if(t.password)n.verifier=ll(t.password);a.valid=n.verificationBytes===n.verifier;if(a.valid)a.insitu=ul(t.password);return n}function vl(e,r,t){var a=t||{};a.Info=e._R(2);e.l-=2;if(a.Info===1)a.Data=ol(e,r);else a.Data=fl(e,r);return a}function pl(e,r,t){var a={Type:t.biff>=8?e._R(2):0};if(a.Type)vl(e,r-2,a);else dl(e,t.biff>=8?r:r-2,t,a);return a}var gl=function(){function e(e,t){switch(t.type){case"base64":return r(k(e),t);case"binary":return r(e,t);case"buffer":return r(T&&Buffer.isBuffer(e)?e.toString("binary"):_(e),t);case"array":return r(mr(e),t);}throw new Error("Unrecognized type "+t.type)}function r(e,r){var t=r||{};var a=t.dense?[]:{};var n=e.match(/\\trowd.*?\\row\b/g);if(!n.length)throw new Error("RTF missing table");var i={s:{c:0,r:0},e:{c:0,r:n.length-1}};n.forEach(function(e,r){if(Array.isArray(a))a[r]=[];var t=/\\\w+\b/g;var n=0;var s;var f=-1;while(s=t.exec(e)){switch(s[0]){case"\\cell":var o=e.slice(n,t.lastIndex-s[0].length);if(o[0]==" ")o=o.slice(1);++f;if(o.length){var l={v:o,t:"s"};if(Array.isArray(a))a[r][f]=l;else a[Ra({r:r,c:f})]=l}break;}n=t.lastIndex}if(f>i.e.c)i.e.c=f});a["!ref"]=Na(i);return a}function t(r,t){return La(e(r,t),t)}function a(e){var r=["{\\rtf1\\ansi"];var t=Da(e["!ref"]),a;var n=Array.isArray(e);for(var i=t.s.r;i<=t.e.r;++i){r.push("\\trowd\\trautofit1");for(var s=t.s.c;s<=t.e.c;++s)r.push("\\cellx"+(s+1));r.push("\\pard\\intbl");for(s=t.s.c;s<=t.e.c;++s){var f=Ra({r:i,c:s});a=n?(e[i]||[])[s]:e[f];if(!a||a.v==null&&(!a.f||a.F))continue;r.push(" "+(a.w||(Pa(a),a.w)));r.push("\\cell")}r.push("\\pard\\intbl\\row")}return r.join("")+"}"}return{to_workbook:t,to_sheet:e,from_sheet:a}}();function ml(e){var r=e.slice(e[0]==="#"?1:0).slice(0,6);return[parseInt(r.slice(0,2),16),parseInt(r.slice(2,4),16),parseInt(r.slice(4,6),16)]}function bl(e){for(var r=0,t=1;r!=3;++r)t=t*256+(e[r]>255?255:e[r]<0?0:e[r]);return t.toString(16).toUpperCase().slice(1)}function wl(e){var r=e[0]/255,t=e[1]/255,a=e[2]/255;var n=Math.max(r,t,a),i=Math.min(r,t,a),s=n-i;if(s===0)return[0,0,r];var f=0,o=0,l=n+i;o=s/(l>1?2-l:l);switch(n){case r:f=((t-a)/s+6)%6;break;case t:f=(a-r)/s+2;break;case a:f=(r-t)/s+4;break;}return[f/6,o,l/2]}function kl(e){var r=e[0],t=e[1],a=e[2];var n=t*2*(a<.5?a:1-a),i=a-n/2;var s=[i,i,i],f=6*r;var o;if(t!==0)switch(f|0){case 0:;case 6:o=n*f;s[0]+=n;s[1]+=o;break;case 1:o=n*(2-f);s[0]+=o;s[1]+=n;break;case 2:o=n*(f-2);s[1]+=n;s[2]+=o;break;case 3:o=n*(4-f);s[1]+=o;s[2]+=n;break;case 4:o=n*(f-4);s[2]+=n;s[0]+=o;break;case 5:o=n*(6-f);s[2]+=o;s[0]+=n;break;}for(var l=0;l!=3;++l)s[l]=Math.round(s[l]*255);return s}function Tl(e,r){if(r===0)return e;var t=wl(ml(e));if(r<0)t[2]=t[2]*(1+r);else t[2]=1-(1-t[2])*(1-r);return bl(kl(t))}var Al=6,El=15,Cl=1,yl=Al;function Sl(e){return Math.floor((e+Math.round(128/yl)/256)*yl)}function _l(e){return Math.floor((e-5)/yl*100+.5)/100}function xl(e){return Math.round((e*yl+5)/yl*256)/256}function Ol(e){return xl(_l(Sl(e)))}function Rl(e){var r=Math.abs(e-Ol(e)),t=yl;if(r>.005)for(yl=Cl;yl":;case"":break;case"":;case"":n={};if(t.diagonalUp)n.diagonalUp=nt(t.diagonalUp);if(t.diagonalDown)n.diagonalDown=nt(t.diagonalDown);r.Borders.push(n);break;case"":break;case"":break;case"":break;case"":break;case"":break;case"":break;case"":break;case"":break;case"":break;case"":break;case"":break;case"":break;case"":break;case"":;case"":break;case"":break;case"":;case"":break;case"":break;case"":;case"":break;case"":break;case"":;case"":break;case"":break;case"":;case"":break;case"":break;case"":break;case"":;case"":break;case"":;case"":break;case"":i=false;break;default:if(a&&a.WTF){if(!i)throw new Error("unrecognized "+t[0]+" in borders")};}})}function Ul(e,r,t,a){r.Fills=[];var n={};var i=false;(e[0].match(Hr)||[]).forEach(function(e){var t=Gr(e);switch(jr(t[0])){case"":;case"":break;case"":;case"":n={};r.Fills.push(n);break;case"":break;case"":break;case"":r.Fills.push(n);n={};break;case"":if(t.patternType)n.patternType=t.patternType;break;case"":;case"":break;case"":;case"":break;case"":;case"":break;case"":break;case"":break;case"":break;case"":break;case"":;case"":break;case"":i=false;break;default:if(a&&a.WTF){if(!i)throw new Error("unrecognized "+t[0]+" in fills")};}})}function Bl(e,r,t,a){r.Fonts=[];var n={};var s=false;(e[0].match(Hr)||[]).forEach(function(e){var f=Gr(e);switch(jr(f[0])){case"":;case"":break;case"":break;case"":;case"":r.Fonts.push(n);n={};break;case"":;case"":break;case"":n.bold=1;break;case"":n.italic=1;break;case"":n.underline=1;break;case"":n.strike=1;break;case"":n.outline=1;break;case"":n.shadow=1;break;case"":n.condense=1;break;case"":n.extend=1;break;case"":;case"":break;case"":;case"":break;case"":;case"":break;case"":;case"":break;case"":;case"":break;case"":s=false;break;case"":;case"":break;case"":s=false;break;default:if(a&&a.WTF){if(!s)throw new Error("unrecognized "+f[0]+" in fonts")};}})}function Wl(e,r,t){r.NumberFmt=[];var a=rr(X);for(var n=0;n":;case"":;case"":break;case"0){if(o>392){for(o=392;o>60;--o)if(r.NumberFmt[o]==null)break;r.NumberFmt[o]=f}We(f,o)}}break;case"":break;default:if(t.WTF)throw new Error("unrecognized "+s[0]+" in numFmts");}}}function Hl(e){var r=[""];[[5,8],[23,26],[41,44],[50,392]].forEach(function(t){for(var a=t[0];a<=t[1];++a)if(e[a]!=null)r[r.length]=kt("numFmt",null,{numFmtId:a,formatCode:qr(e[a])})});if(r.length===1)return"";r[r.length]="";r[0]=kt("numFmts",null,{count:r.length-2}).replace("/>",">");return r.join("")}var zl=["numFmtId","fillId","fontId","borderId","xfId"];var Vl=["applyAlignment","applyBorder","applyFill","applyFont","applyNumberFormat","applyProtection","pivotButton","quotePrefix"];function Gl(e,r,t){r.CellXf=[];var a;var n=false;(e[0].match(Hr)||[]).forEach(function(e){var i=Gr(e),s=0;switch(jr(i[0])){case"":;case"":;case"":break;case"":a=i;delete a[0];for(s=0;s392){for(s=392;s>60;--s)if(r.NumberFmt[a.numFmtId]==r.NumberFmt[s]){a.numFmtId=s;break}}r.CellXf.push(a);break;case"":break;case"":var f={};if(i.vertical)f.vertical=i.vertical;if(i.horizontal)f.horizontal=i.horizontal;if(i.textRotation!=null)f.textRotation=i.textRotation;if(i.indent)f.indent=i.indent;if(i.wrapText)f.wrapText=nt(i.wrapText);a.alignment=f;break;case"":break;case"":;case"":break;case"":n=false;break;case"":;case"":break;case"":n=false;break;default:if(t&&t.WTF){if(!n)throw new Error("unrecognized "+i[0]+" in cellXfs")};}})}function jl(e){var r=[];r[r.length]=kt("cellXfs",null);e.forEach(function(e){r[r.length]=kt("xf",null,e)});r[r.length]="";if(r.length===2)return"";r[0]=kt("cellXfs",null,{count:r.length-2}).replace("/>",">");return r.join("")}var Xl=function uk(){var e=/<(?:\w+:)?numFmts([^>]*)>[\S\s]*?<\/(?:\w+:)?numFmts>/;var r=/<(?:\w+:)?cellXfs([^>]*)>[\S\s]*?<\/(?:\w+:)?cellXfs>/;var t=/<(?:\w+:)?fills([^>]*)>[\S\s]*?<\/(?:\w+:)?fills>/;var a=/<(?:\w+:)?fonts([^>]*)>[\S\s]*?<\/(?:\w+:)?fonts>/;var n=/<(?:\w+:)?borders([^>]*)>[\S\s]*?<\/(?:\w+:)?borders>/;return function i(s,f,o){var l={};if(!s)return l;s=s.replace(//gm,"").replace(//gm,"");var c;if(c=s.match(e))Wl(c,l,o);if(c=s.match(a))Bl(c,l,f,o);if(c=s.match(t))Ul(c,l,f,o);if(c=s.match(n))Ml(c,l,f,o);if(c=s.match(r))Gl(c,l,o);return l}}();function $l(e,r){var t=[Mr,kt("styleSheet",null,{xmlns:St[0],"xmlns:vt":yt.vt})],a;if(e.SSF&&(a=Hl(e.SSF))!=null)t[t.length]=a;t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';if(a=jl(r.cellXfs))t[t.length]=a;t[t.length]='';t[t.length]='';t[t.length]='';if(t.length>2){t[t.length]="";t[1]=t[1].replace("/>",">")}return t.join("")}function Yl(e,r){var t=e._R(2);var a=Ha(e,r-2);return[t,a]}function Kl(e,r,t){if(!t)t=ua(6+4*r.length);t._W(2,e);za(r,t);var a=t.length>t.l?t.slice(0,t.l):t;if(t.l==null)t.l=t.length;return a}function Jl(e,r,t){var a={};a.sz=e._R(2)/20;var n=mn(e,2,t);if(n.fItalic)a.italic=1;if(n.fCondense)a.condense=1;if(n.fExtend)a.extend=1;if(n.fShadow)a.shadow=1;if(n.fOutline)a.outline=1;if(n.fStrikeout)a.strike=1;var i=e._R(2);if(i===700)a.bold=1;switch(e._R(2)){case 1:a.vertAlign="superscript";break;case 2:a.vertAlign="subscript";break;}var s=e._R(1);if(s!=0)a.underline=s;var f=e._R(1);if(f>0)a.family=f;var o=e._R(1);if(o>0)a.charset=o;e.l++;a.color=pn(e,8);switch(e._R(1)){case 1:a.scheme="major";break;case 2:a.scheme="minor";break;}a.name=Ha(e,r-21);return a}function ql(e,r){if(!r)r=ua(25+4*32);r._W(2,e.sz*20);bn(e,r);r._W(2,e.bold?700:400);var t=0;if(e.vertAlign=="superscript")t=1;else if(e.vertAlign=="subscript")t=2;r._W(2,t);r._W(1,e.underline||0);r._W(1,e.family||0);r._W(1,e.charset||0);r._W(1,0);gn(e.color,r);var a=0;if(e.scheme=="major")a=1;if(e.scheme=="minor")a=2;r._W(1,a);za(e.name,r);return r.length>r.l?r.slice(0,r.l):r}var Zl=["none","solid","mediumGray","darkGray","lightGray","darkHorizontal","darkVertical","darkDown","darkUp","darkGrid","darkTrellis","lightHorizontal","lightVertical","lightDown","lightUp","lightGrid","lightTrellis","gray125","gray0625"];var Ql;var ec=ha;function rc(e,r){if(!r)r=ua(4*3+8*7+16*1);if(!Ql)Ql=ar(Zl);var t=Ql[e.patternType];if(t==null)t=40;r._W(4,t);var a=0;if(t!=40){gn({auto:1},r);gn({auto:1},r);for(;a<12;++a)r._W(4,0)}else{for(;a<4;++a)r._W(4,0);for(;a<12;++a)r._W(4,0)}return r.length>r.l?r.slice(0,r.l):r}function tc(e,r){var t=e.l+r;var a=e._R(2);var n=e._R(2);e.l=t;return{ixfe:a,numFmtId:n}}function ac(e,r,t){if(!t)t=ua(16);t._W(2,r||0);t._W(2,e.numFmtId||0);t._W(2,0);t._W(2,0);t._W(2,0);t._W(1,0);t._W(1,0);var a=0;t._W(1,a);t._W(1,0);t._W(1,0);t._W(1,0);return t}function nc(e,r){if(!r)r=ua(10);r._W(1,0);r._W(1,0);r._W(4,0);r._W(4,0);return r}var ic=ha;function sc(e,r){if(!r)r=ua(51);r._W(1,0);nc(null,r);nc(null,r);nc(null,r);nc(null,r);nc(null,r);return r.length>r.l?r.slice(0,r.l):r}function fc(e,r){if(!r)r=ua(12+4*10);r._W(4,e.xfId);r._W(2,1);r._W(1,+e.builtinId);r._W(1,0);tn(e.name||"",r);return r.length>r.l?r.slice(0,r.l):r}function oc(e,r,t){var a=ua(4+256*2*4);a._W(4,e);tn(r,a);tn(t,a);return a.length>a.l?a.slice(0,a.l):a}function lc(e,r,t){var a={};a.NumberFmt=[];for(var n in X)a.NumberFmt[n]=X[n];a.CellXf=[];a.Fonts=[];var i=[];var s=false;da(e,function f(e,n,o){switch(o){case 44:a.NumberFmt[e[0]]=e[1];We(e[1],e[0]);break;case 43:a.Fonts.push(e);if(e.color.theme!=null&&r&&r.themeElements&&r.themeElements.clrScheme){e.color.rgb=Tl(r.themeElements.clrScheme[e.color.theme].rgb,e.color.tint||0)}break;case 1025:break;case 45:break;case 46:break;case 47:if(i[i.length-1]==617){a.CellXf.push(e)}break;case 48:;case 507:;case 572:;case 475:break;case 1171:;case 2102:;case 1130:;case 512:;case 2095:;case 3072:break;case 35:s=true;break;case 36:s=false;break;case 37:i.push(o);s=true;break;case 38:i.pop();s=false;break;default:if(n.T>0)i.push(o);else if(n.T<0)i.pop();else if(!s||t.WTF&&i[i.length-1]!=37)throw new Error("Unexpected record 0x"+o.toString(16));}});return a}function cc(e,r){if(!r)return;var t=0;[[5,8],[23,26],[41,44],[50,392]].forEach(function(e){for(var a=e[0];a<=e[1];++a)if(r[a]!=null)++t});if(t==0)return;pa(e,615,Wa(t));[[5,8],[23,26],[41,44],[50,392]].forEach(function(t){for(var a=t[0];a<=t[1];++a)if(r[a]!=null)pa(e,44,Kl(a,r[a]))});pa(e,616)}function hc(e){var r=1;if(r==0)return;pa(e,611,Wa(r));pa(e,43,ql({sz:12,color:{theme:1},name:"Calibri",family:2,scheme:"minor"}));pa(e,612)}function uc(e){var r=2;if(r==0)return;pa(e,603,Wa(r));pa(e,45,rc({patternType:"none"}));pa(e,45,rc({patternType:"gray125"}));pa(e,604)}function dc(e){var r=1;if(r==0)return;pa(e,613,Wa(r));pa(e,46,sc({}));pa(e,614)}function vc(e){var r=1;pa(e,626,Wa(r));pa(e,47,ac({numFmtId:0,fontId:0,fillId:0,borderId:0},65535));pa(e,627)}function pc(e,r){pa(e,617,Wa(r.length));r.forEach(function(r){pa(e,47,ac(r,0))});pa(e,618)}function gc(e){var r=1;pa(e,619,Wa(r));pa(e,48,fc({xfId:0,builtinId:0,name:"Normal"}));pa(e,620)}function mc(e){var r=0;pa(e,505,Wa(r));pa(e,506)}function bc(e){var r=0;pa(e,508,oc(r,"TableStyleMedium9","PivotStyleMedium4"));pa(e,509)}function wc(){return}function kc(e,r){var t=va();pa(t,278);cc(t,e.SSF);hc(t,e);uc(t,e);dc(t,e);vc(t,e);pc(t,r.cellXfs);gc(t,e);mc(t,e);bc(t,e);wc(t,e);pa(t,279);return t.end()}var Tc=["","","","","","","","","","","",""];function Ac(e,r,t){r.themeElements.clrScheme=[];var a={};(e[0].match(Hr)||[]).forEach(function(e){var n=Gr(e);switch(n[0]){case"":break;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":;case"":if(n[0].charAt(1)==="/"){r.themeElements.clrScheme[Tc.indexOf(n[0])]=a;a={}}else{a.name=n[0].slice(3,n[0].length-1)}break;default:if(t&&t.WTF)throw new Error("Unrecognized "+n[0]+" in clrScheme");}})}function Ec(){}function Cc(){}var yc=/]*)>[\s\S]*<\/a:clrScheme>/;var Sc=/]*)>[\s\S]*<\/a:fontScheme>/;var _c=/]*)>[\s\S]*<\/a:fmtScheme>/;function xc(e,r,t){r.themeElements={};var a;[["clrScheme",yc,Ac],["fontScheme",Sc,Ec],["fmtScheme",_c,Cc]].forEach(function(n){if(!(a=e.match(n[1])))throw new Error(n[0]+" not found in themeElements");n[2](a,r,t)})}var Oc=/]*)>[\s\S]*<\/a:themeElements>/;function Rc(e,r){if(!e||e.length===0)e=Ic();var t;var a={};if(!(t=e.match(Oc)))throw new Error("themeElements not found in theme");xc(t[0],a,r);a.raw=e;return a}function Ic(e,r){if(r&&r.themeXLSX)return r.themeXLSX;if(e&&typeof e.raw=="string")return e.raw;var t=[Mr];t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]="";t[t.length]="";t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]=''; -t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]="";t[t.length]="";t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]="";t[t.length]="";t[t.length]="";t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]="";t[t.length]="";t[t.length]="";t[t.length]="";t[t.length]='';t[t.length]="";t[t.length]="";t[t.length]="";t[t.length]="";t[t.length]='';t[t.length]="";t[t.length]="";t[t.length]="";t[t.length]="";t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]='';t[t.length]="";t[t.length]="";t[t.length]="";t[t.length]='';t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]='';t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]='';t[t.length]="";t[t.length]='';t[t.length]="";t[t.length]="";t[t.length]="";t[t.length]="";t[t.length]="";t[t.length]="";t[t.length]='';t[t.length]="";t[t.length]="";t[t.length]='';t[t.length]="";t[t.length]="";t[t.length]="";t[t.length]="";return t.join("")}function Nc(e,r,t){var a=e.l+r;var n=e._R(4);if(n===124226)return;if(!t.cellStyles){e.l=a;return}var i=e.slice(e.l);e.l=a;var s;try{s=Pr(i,{type:"array"})}catch(f){return}var o=Rr(s,"theme/theme/theme1.xml",true);if(!o)return;return Rc(o,t)}function Dc(e){return e._R(4)}function Fc(e){var r={};r.xclrType=e._R(2);r.nTintShade=e._R(2);switch(r.xclrType){case 0:e.l+=4;break;case 1:r.xclrValue=Pc(e,4);break;case 2:r.xclrValue=ks(e,4);break;case 3:r.xclrValue=Dc(e,4);break;case 4:e.l+=4;break;}e.l+=8;return r}function Pc(e,r){return ha(e,r)}function Lc(e,r){return ha(e,r)}function Mc(e){var r=e._R(2);var t=e._R(2)-4;var a=[r];switch(r){case 4:;case 5:;case 7:;case 8:;case 9:;case 10:;case 11:;case 13:a[1]=Fc(e,t);break;case 6:a[1]=Lc(e,t);break;case 14:;case 15:a[1]=e._R(t===1?1:2);break;default:throw new Error("Unrecognized ExtProp type: "+r+" "+t);}return a}function Uc(e,r){var t=e.l+r;e.l+=2;var a=e._R(2);e.l+=2;var n=e._R(2);var i=[];while(n-- >0)i.push(Mc(e,t-e.l));return{ixfe:a,ext:i}}function Bc(e,r){r.forEach(function(e){switch(e[0]){case 4:break;case 5:break;case 6:break;case 7:break;case 8:break;case 9:break;case 10:break;case 11:break;case 13:break;case 14:break;case 15:break;}})}function Wc(e,r){return{flags:e._R(4),version:e._R(4),name:Ha(e,r-8)}}function Hc(e){var r=ua(12+2*e.name.length);r._W(4,e.flags);r._W(4,e.version);za(e.name,r);return r.slice(0,r.l)}function zc(e){var r=[];var t=e._R(4);while(t-- >0)r.push([e._R(4),e._R(4)]);return r}function Vc(e){var r=ua(4+8*e.length);r._W(4,e.length);for(var t=0;t":break;case"":break;case"":break;case"":break;case"":break;case"":break;case"":break;case"":i=2;break;case"":i=2;break;case"":;case"":;case"":break;case"":n=false;break;case"\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n');return e.join("")}function qc(e){var r=[];if(!e)return r;var t=1;(e.match(Hr)||[]).forEach(function(e){var a=Gr(e);switch(a[0]){case"":;case"":break;case"]*r:id="([^"]*)"/)||["",""])[1];return r["!id"][t].Target}var ah=1024;function nh(e,r){var t=[21600,21600];var a=["m0,0l0",t[1],t[0],t[1],t[0],"0xe"].join(",");var n=[kt("xml",null,{"xmlns:v":_t.v,"xmlns:o":_t.o,"xmlns:x":_t.x,"xmlns:mv":_t.mv}).replace(/\/>/,">"),kt("o:shapelayout",kt("o:idmap",null,{"v:ext":"edit",data:e}),{"v:ext":"edit"}),kt("v:shapetype",[kt("v:stroke",null,{joinstyle:"miter"}),kt("v:path",null,{gradientshapeok:"t","o:connecttype":"rect"})].join(""),{id:"_x0000_t202","o:spt":202,coordsize:t.join(","),path:a})];while(ah",i,kt("v:shadow",null,s),kt("v:path",null,{"o:connecttype":"none"}),'
    ','',"","",bt("x:Anchor",[r.c+1,0,r.r+1,0,r.c+3,20,r.r+5,20].join(",")),bt("x:AutoFill","False"),bt("x:Row",String(r.r)),bt("x:Column",String(r.c)),e[1].hidden?"":"","",""])});n.push("");return n.join("")}function ih(e,r,t,a){var n=Array.isArray(e);var i;r.forEach(function(r){var s=Oa(r.ref);if(n){if(!e[s.r])e[s.r]=[];i=e[s.r][s.c]}else i=e[r.ref];if(!i){i={t:"z"};if(n)e[s.r][s.c]=i;else e[r.ref]=i;var f=Da(e["!ref"]||"BDWGO1000001:A1");if(f.s.r>s.r)f.s.r=s.r;if(f.e.rs.c)f.s.c=s.c;if(f.e.c=0;--c){if(!t&&i.c[c].T)return;if(t&&!i.c[c].T)i.c.splice(c,1)}if(t&&a)for(c=0;c/))return[];var t=[];var a=[];var n=e.match(/<(?:\w+:)?authors>([\s\S]*)<\/(?:\w+:)?authors>/);if(n&&n[1])n[1].split(/<\/\w*:?author>/).forEach(function(e){if(e===""||e.trim()==="")return;var r=e.match(/<(?:\w+:)?author[^>]*>(.*)/);if(r)t.push(r[1])});var i=e.match(/<(?:\w+:)?commentList>([\s\S]*)<\/(?:\w+:)?commentList>/);if(i&&i[1])i[1].split(/<\/\w*:?comment>/).forEach(function(e){if(e===""||e.trim()==="")return;var n=e.match(/<(?:\w+:)?comment[^>]*>/);if(!n)return;var i=Gr(n[0]);var s={author:i.authorId&&t[i.authorId]||"sheetjsghost",ref:i.ref,guid:i.guid};var f=Oa(i.ref);if(r.sheetRows&&r.sheetRows<=f.r)return;var o=e.match(/<(?:\w+:)?text>([\s\S]*)<\/(?:\w+:)?text>/);var l=!!o&&!!o[1]&&Po(o[1])||{r:"",t:"",h:""};s.r=l.r;if(l.r=="")l.t=l.h="";s.t=(l.t||"").replace(/\r\n/g,"\n").replace(/\r/g,"\n");if(r.cellHTML)s.h=l.h;a.push(s)});return a}function fh(e){var r=[Mr,kt("comments",null,{xmlns:St[0]})];var t=[];r.push("");e.forEach(function(e){e[1].forEach(function(e){var a=qr(e.a);if(t.indexOf(a)==-1){t.push(a);r.push(""+a+"")}if(e.T&&e.ID&&t.indexOf("tc="+e.ID)==-1){t.push("tc="+e.ID);r.push(""+"tc="+e.ID+"")}})});if(t.length==0){t.push("SheetJ5");r.push("SheetJ5")}r.push("");r.push("");e.forEach(function(e){var a=0,n=[];if(e[1][0]&&e[1][0].T&&e[1][0].ID)a=t.indexOf("tc="+e[1][0].ID);else e[1].forEach(function(e){if(e.a)a=t.indexOf(qr(e.a));n.push(e.t||"")});r.push('');if(n.length<=1)r.push(bt("t",qr(n[0]||"")));else{var i="Comment:\n "+n[0]+"\n";for(var s=1;s")});r.push("");if(r.length>2){r[r.length]="";r[1]=r[1].replace("/>",">")}return r.join("")}function oh(e,r){var t=[];var a=false,n={},i=0;e.replace(Hr,function s(f,o){var l=Gr(f);switch(jr(l[0])){case"":break;case"":if(n.t!=null)t.push(n);break;case"":;case"":n.t=e.slice(i,o).replace(/\r\n/g,"\n").replace(/\r/g,"\n");break;case"":a=true;break;case"":a=false;break;case"":;case"
    ":;case"":break;case"":a=false;break;default:if(!a&&r.WTF)throw new Error("unrecognized "+l[0]+" in threaded comments");}return f});return t}function lh(e,r,t){var a=[Mr,kt("ThreadedComments",null,{xmlns:yt.TCMNT}).replace(/[\/]>/,">")];e.forEach(function(e){var n="";(e[1]||[]).forEach(function(i,s){if(!i.T){delete i.ID;return}if(i.a&&r.indexOf(i.a)==-1)r.push(i.a);var f={ref:e[0],id:"{54EE7951-7262-4200-6969-"+("000000000000"+t.tcid++).slice(-12)+"}"};if(s==0)n=f.id;else f.parentId=n;i.ID=f.id;if(i.a)f.personId="{54EE7950-7262-4200-6969-"+("000000000000"+r.indexOf(i.a)).slice(-12)+"}";a.push(kt("threadedComment",bt("text",i.t||""),f))})});a.push("");return a.join("")}function ch(e,r){var t=[];var a=false;e.replace(Hr,function n(e){var n=Gr(e);switch(jr(n[0])){case"":break;case"":break;case"":;case"":;case"":break;case"":a=false;break;default:if(!a&&r.WTF)throw new Error("unrecognized "+n[0]+" in threaded comments");}return e});return t}function hh(e){var r=[Mr,kt("personList",null,{xmlns:yt.TCMNT,"xmlns:x":St[0]}).replace(/[\/]>/,">")];e.forEach(function(e,t){r.push(kt("person",null,{displayName:e,id:"{54EE7950-7262-4200-6969-"+("000000000000"+t).slice(-12)+"}",userId:e,providerId:"None"}))});r.push("");return r.join("")}function uh(e){var r={};r.iauthor=e._R(4);var t=hn(e,16);r.rfx=t.s;r.ref=Ra(t.s);e.l+=16;return r}function dh(e,r){if(r==null)r=ua(36);r._W(4,e[1].iauthor);un(e[0],r);r._W(4,0);r._W(4,0);r._W(4,0);r._W(4,0);return r}var vh=Ha;function ph(e){return za(e.slice(0,54))}function gh(e,r){var t=[];var a=[];var n={};var i=false;da(e,function s(e,f,o){switch(o){case 632:a.push(e);break;case 635:n=e;break;case 637:n.t=e.t;n.h=e.h;n.r=e.r;break;case 636:n.author=a[n.iauthor];delete n.iauthor;if(r.sheetRows&&n.rfx&&r.sheetRows<=n.rfx.r)break;if(!n.t)n.t="";delete n.rfx;t.push(n);break;case 3072:break;case 35:i=true;break;case 36:i=false;break;case 37:break;case 38:break;default:if(f.T){}else if(!i||r.WTF)throw new Error("Unexpected record 0x"+o.toString(16));}});return t}function mh(e){var r=va();var t=[];pa(r,628);pa(r,630);e.forEach(function(e){e[1].forEach(function(e){if(t.indexOf(e.a)>-1)return;t.push(e.a.slice(0,54));pa(r,632,ph(e.a))})});pa(r,631);pa(r,633);e.forEach(function(e){e[1].forEach(function(a){a.iauthor=t.indexOf(a.a);var n={s:Oa(e[0]),e:Oa(e[0])};pa(r,635,dh([n,a]));if(a.t&&a.t.length>0)pa(r,637,Ya(a));pa(r,636);delete a.iauthor})});pa(r,634);pa(r,629);return r.end()}var bh="application/vnd.ms-office.vbaProject";function wh(e){var r=Ke.utils.cfb_new({root:"R"});e.FullPaths.forEach(function(t,a){if(t.slice(-1)==="/"||!t.match(/_VBA_PROJECT_CUR/))return;var n=t.replace(/^[^\/]*/,"R").replace(/\/_VBA_PROJECT_CUR\u0000*/,"");Ke.utils.cfb_add(r,n,e.FileIndex[a].content)});return Ke.write(r)}function kh(e,r){r.FullPaths.forEach(function(t,a){if(a==0)return;var n=t.replace(/[^\/]*[\/]/,"/_VBA_PROJECT_CUR/");if(n.slice(-1)!=="/")Ke.utils.cfb_add(e,n,r.FileIndex[a].content)})}var Th=["xlsb","xlsm","xlam","biff8","xla"];function Ah(){return{"!type":"dialog"}}function Eh(){return{"!type":"dialog"}}function Ch(){return{"!type":"macro"}}function yh(){return{"!type":"macro"}}var Sh=function(){var e=/(^|[^A-Za-z_])R(\[?-?\d+\]|[1-9]\d*|)C(\[?-?\d+\]|[1-9]\d*|)(?![A-Za-z0-9_])/g;var r={r:0,c:0};function t(e,t,a,n){var i=false,s=false;if(a.length==0)s=true;else if(a.charAt(0)=="["){s=true;a=a.slice(1,-1)}if(n.length==0)i=true;else if(n.charAt(0)=="["){i=true;n=n.slice(1,-1)}var f=a.length>0?parseInt(a,10)|0:0,o=n.length>0?parseInt(n,10)|0:0;if(i)o+=r.c;else--o;if(s)f+=r.r;else--f;return t+(i?"":"$")+ya(o)+(s?"":"$")+Ta(f)}return function a(n,i){r=i;return n.replace(e,t)}}();var _h=/(^|[^._A-Z0-9])([$]?)([A-Z]{1,2}|[A-W][A-Z]{2}|X[A-E][A-Z]|XF[A-D])([$]?)(10[0-3]\d{4}|104[0-7]\d{3}|1048[0-4]\d{2}|10485[0-6]\d|104857[0-6]|[1-9]\d{0,5})(?![_.\(A-Za-z0-9])/g;var xh=function(){return function e(r,t){return r.replace(_h,function(e,r,a,n,i,s){var f=Ca(n)-(a?0:t.c);var o=ka(s)-(i?0:t.r);var l=o==0?"":!i?"["+o+"]":o+1;var c=f==0?"":!a?"["+f+"]":f+1;return r+"R"+l+"C"+c})}}();function Oh(e,r){return e.replace(_h,function(e,t,a,n,i,s){return t+(a=="$"?a+n:ya(Ca(n)+r.c))+(i=="$"?i+s:Ta(ka(s)+r.r))})}function Rh(e,r,t){var a=Ia(r),n=a.s,i=Oa(t);var s={r:i.r-n.r,c:i.c-n.c};return Oh(e,s)}function Ih(e){if(e.length==1)return false;return true}function Nh(e){return e.replace(/_xlfn\./g,"")}function Dh(e){e.l+=1;return}function Fh(e,r){var t=e._R(r==1?1:2);return[t&16383,t>>14&1,t>>15&1]}function Ph(e,r,t){var a=2;if(t){if(t.biff>=2&&t.biff<=5)return Lh(e,r,t);else if(t.biff==12)a=4}var n=e._R(a),i=e._R(a);var s=Fh(e,2);var f=Fh(e,2);return{s:{r:n,c:s[0],cRel:s[1],rRel:s[2]},e:{r:i,c:f[0],cRel:f[1],rRel:f[2]}}}function Lh(e){var r=Fh(e,2),t=Fh(e,2);var a=e._R(1);var n=e._R(1);return{s:{r:r[0],c:a,cRel:r[1],rRel:r[2]},e:{r:t[0],c:n,cRel:t[1],rRel:t[2]}}}function Mh(e,r,t){if(t.biff<8)return Lh(e,r,t);var a=e._R(t.biff==12?4:2),n=e._R(t.biff==12?4:2);var i=Fh(e,2);var s=Fh(e,2);return{s:{r:a,c:i[0],cRel:i[1],rRel:i[2]},e:{r:n,c:s[0],cRel:s[1],rRel:s[2]}}}function Uh(e,r,t){if(t&&t.biff>=2&&t.biff<=5)return Bh(e,r,t);var a=e._R(t&&t.biff==12?4:2);var n=Fh(e,2);return{r:a,c:n[0],cRel:n[1],rRel:n[2]}}function Bh(e){var r=Fh(e,2);var t=e._R(1);return{r:r[0],c:t,cRel:r[1],rRel:r[2]}}function Wh(e){var r=e._R(2);var t=e._R(2);return{r:r,c:t&255,fQuoted:!!(t&16384),cRel:t>>15,rRel:t>>15}}function Hh(e,r,t){var a=t&&t.biff?t.biff:8;if(a>=2&&a<=5)return zh(e,r,t);var n=e._R(a>=12?4:2);var i=e._R(2);var s=(i&16384)>>14,f=(i&32768)>>15;i&=16383;if(f==1)while(n>524287)n-=1048576;if(s==1)while(i>8191)i=i-16384;return{r:n,c:i,cRel:s,rRel:f}}function zh(e){var r=e._R(2);var t=e._R(1);var a=(r&32768)>>15,n=(r&16384)>>14;r&=16383;if(a==1&&r>=8192)r=r-16384;if(n==1&&t>=128)t=t-256;return{r:r,c:t,cRel:n,rRel:a}}function Vh(e,r,t){var a=(e[e.l++]&96)>>5;var n=Ph(e,t.biff>=2&&t.biff<=5?6:8,t);return[a,n]}function Gh(e,r,t){var a=(e[e.l++]&96)>>5;var n=e._R(2,"i");var i=8;if(t)switch(t.biff){case 5:e.l+=12;i=6;break;case 12:i=12;break;}var s=Ph(e,i,t);return[a,n,s]}function jh(e,r,t){var a=(e[e.l++]&96)>>5;e.l+=t&&t.biff>8?12:t.biff<8?6:8;return[a]}function Xh(e,r,t){var a=(e[e.l++]&96)>>5;var n=e._R(2);var i=8;if(t)switch(t.biff){case 5:e.l+=12;i=6;break;case 12:i=12;break;}e.l+=i;return[a,n]}function $h(e,r,t){var a=(e[e.l++]&96)>>5;var n=Mh(e,r-1,t);return[a,n]}function Yh(e,r,t){var a=(e[e.l++]&96)>>5;e.l+=t.biff==2?6:t.biff==12?14:7;return[a]}function Kh(e){var r=e[e.l+1]&1;var t=1;e.l+=4;return[r,t]}function Jh(e,r,t){e.l+=2;var a=e._R(t&&t.biff==2?1:2);var n=[];for(var i=0;i<=a;++i)n.push(e._R(t&&t.biff==2?1:2));return n}function qh(e,r,t){var a=e[e.l+1]&255?1:0;e.l+=2;return[a,e._R(t&&t.biff==2?1:2)]}function Zh(e,r,t){var a=e[e.l+1]&255?1:0;e.l+=2;return[a,e._R(t&&t.biff==2?1:2)]}function Qh(e){var r=e[e.l+1]&255?1:0;e.l+=2;return[r,e._R(2)]}function eu(e,r,t){var a=e[e.l+1]&255?1:0;e.l+=t&&t.biff==2?3:4;return[a]}function ru(e){var r=e._R(1),t=e._R(1);return[r,t]}function tu(e){e._R(2);return ru(e,2)}function au(e){e._R(2);return ru(e,2)}function nu(e,r,t){var a=(e[e.l]&96)>>5;e.l+=1;var n=Uh(e,0,t);return[a,n]}function iu(e,r,t){var a=(e[e.l]&96)>>5;e.l+=1;var n=Hh(e,0,t);return[a,n]}function su(e,r,t){var a=(e[e.l]&96)>>5;e.l+=1;var n=e._R(2);if(t&&t.biff==5)e.l+=12;var i=Uh(e,0,t);return[a,n,i]}function fu(e,r,t){var a=(e[e.l]&96)>>5;e.l+=1;var n=e._R(t&&t.biff<=3?1:2);return[Ed[n],Ad[n],a]}function ou(e,r,t){var a=e[e.l++];var n=e._R(1),i=t&&t.biff<=3?[a==88?-1:0,e._R(1)]:lu(e);return[n,(i[0]===0?Ad:Td)[i[1]]]}function lu(e){return[e[e.l+1]>>7,e._R(2)&32767]}function cu(e,r,t){e.l+=t&&t.biff==2?3:4;return}function hu(e,r,t){e.l++;if(t&&t.biff==12)return[e._R(4,"i"),0];var a=e._R(2);var n=e._R(t&&t.biff==2?1:2);return[a,n]}function uu(e){e.l++;return Gn[e._R(1)]}function du(e){e.l++;return e._R(2)}function vu(e){e.l++;return e._R(1)!==0}function pu(e){e.l++;return dn(e,8)}function gu(e,r,t){e.l++;return is(e,r-1,t)}function mu(e,r){var t=[e._R(1)];if(r==12)switch(t[0]){case 2:t[0]=4;break;case 4:t[0]=16;break;case 0:t[0]=1;break;case 1:t[0]=2;break;}switch(t[0]){case 4:t[1]=Zi(e,1)?"TRUE":"FALSE";if(r!=12)e.l+=7;break;case 37:;case 16:t[1]=Gn[e[e.l]];e.l+=r==12?4:8;break;case 0:e.l+=8;break;case 1:t[1]=dn(e,8);break;case 2:t[1]=cs(e,0,{biff:r>0&&r<8?2:r});break;default:throw new Error("Bad SerAr: "+t[0]);}return t}function bu(e,r,t){var a=e._R(t.biff==12?4:2);var n=[];for(var i=0;i!=a;++i)n.push((t.biff==12?hn:Os)(e,8));return n}function wu(e,r,t){var a=0,n=0;if(t.biff==12){a=e._R(4);n=e._R(4)}else{n=1+e._R(1);a=1+e._R(2)}if(t.biff>=2&&t.biff<8){--a;if(--n==0)n=256}for(var i=0,s=[];i!=a&&(s[i]=[]);++i)for(var f=0;f!=n;++f)s[i][f]=mu(e,t.biff);return s}function ku(e,r,t){var a=e._R(1)>>>5&3;var n=!t||t.biff>=8?4:2;var i=e._R(n);switch(t.biff){case 2:e.l+=5;break;case 3:;case 4:e.l+=8;break;case 5:e.l+=12;break;}return[a,0,i]}function Tu(e,r,t){if(t.biff==5)return Au(e,r,t);var a=e._R(1)>>>5&3;var n=e._R(2);var i=e._R(4);return[a,n,i]}function Au(e){var r=e._R(1)>>>5&3;var t=e._R(2,"i");e.l+=8;var a=e._R(2);e.l+=12;return[r,t,a]}function Eu(e,r,t){var a=e._R(1)>>>5&3;e.l+=t&&t.biff==2?3:4;var n=e._R(t&&t.biff==2?1:2);return[a,n]}function Cu(e,r,t){var a=e._R(1)>>>5&3;var n=e._R(t&&t.biff==2?1:2);return[a,n]}function yu(e,r,t){var a=e._R(1)>>>5&3;e.l+=4;if(t.biff<8)e.l--;if(t.biff==12)e.l+=2;return[a]}function Su(e,r,t){var a=(e[e.l++]&96)>>5;var n=e._R(2);var i=4;if(t)switch(t.biff){case 5:i=15;break;case 12:i=6;break;}e.l+=i;return[a,n]}var _u=ha;var xu=ha;var Ou=ha;function Ru(e,r,t){e.l+=2;return[Wh(e,4,t)]}function Iu(e){e.l+=6;return[]}var Nu=Ru;var Du=Iu;var Fu=Iu;var Pu=Ru;function Lu(e){e.l+=2;return[es(e),e._R(2)&1]}var Mu=Ru;var Uu=Lu;var Bu=Iu;var Wu=Ru;var Hu=Ru;var zu=["Data","All","Headers","??","?Data2","??","?DataHeaders","??","Totals","??","??","??","?DataTotals","??","??","??","?Current"];function Vu(e){e.l+=2;var r=e._R(2);var t=e._R(2);var a=e._R(4);var n=e._R(2);var i=e._R(2);var s=zu[t>>2&31];return{ixti:r,coltype:t&3,rt:s,idx:a,c:n,C:i}}function Gu(e){e.l+=2;return[e._R(4)]}function ju(e,r,t){e.l+=5;e.l+=2;e.l+=t.biff==2?1:4;return["PTGSHEET"]}function Xu(e,r,t){e.l+=t.biff==2?4:5;return["PTGENDSHEET"]}function $u(e){var r=e._R(1)>>>5&3;var t=e._R(2);return[r,t]}function Yu(e){var r=e._R(1)>>>5&3;var t=e._R(2);return[r,t]}function Ku(e){e.l+=4;return[0,0]}var Ju={1:{n:"PtgExp",f:hu},2:{n:"PtgTbl",f:Ou},3:{n:"PtgAdd",f:Dh},4:{n:"PtgSub",f:Dh},5:{n:"PtgMul",f:Dh},6:{n:"PtgDiv",f:Dh},7:{n:"PtgPower",f:Dh},8:{n:"PtgConcat",f:Dh},9:{n:"PtgLt",f:Dh},10:{n:"PtgLe",f:Dh},11:{n:"PtgEq",f:Dh},12:{n:"PtgGe",f:Dh},13:{n:"PtgGt",f:Dh},14:{n:"PtgNe",f:Dh},15:{n:"PtgIsect",f:Dh},16:{n:"PtgUnion",f:Dh},17:{n:"PtgRange",f:Dh},18:{n:"PtgUplus",f:Dh},19:{n:"PtgUminus",f:Dh},20:{n:"PtgPercent",f:Dh},21:{n:"PtgParen",f:Dh},22:{n:"PtgMissArg",f:Dh},23:{n:"PtgStr",f:gu},26:{n:"PtgSheet",f:ju},27:{n:"PtgEndSheet",f:Xu},28:{n:"PtgErr",f:uu},29:{n:"PtgBool",f:vu},30:{n:"PtgInt",f:du},31:{n:"PtgNum",f:pu},32:{n:"PtgArray",f:Yh},33:{n:"PtgFunc",f:fu},34:{n:"PtgFuncVar",f:ou},35:{n:"PtgName",f:ku},36:{n:"PtgRef",f:nu},37:{n:"PtgArea",f:Vh},38:{n:"PtgMemArea",f:Eu},39:{n:"PtgMemErr",f:_u},40:{n:"PtgMemNoMem",f:xu},41:{n:"PtgMemFunc",f:Cu},42:{n:"PtgRefErr",f:yu},43:{n:"PtgAreaErr",f:jh},44:{n:"PtgRefN",f:iu},45:{n:"PtgAreaN",f:$h},46:{n:"PtgMemAreaN",f:$u},47:{n:"PtgMemNoMemN",f:Yu},57:{n:"PtgNameX",f:Tu},58:{n:"PtgRef3d",f:su},59:{n:"PtgArea3d",f:Gh},60:{n:"PtgRefErr3d",f:Su},61:{n:"PtgAreaErr3d",f:Xh},255:{}};var qu={64:32,96:32,65:33,97:33,66:34,98:34,67:35,99:35,68:36,100:36,69:37,101:37,70:38,102:38,71:39,103:39,72:40,104:40,73:41,105:41,74:42,106:42,75:43,107:43,76:44,108:44,77:45,109:45,78:46,110:46,79:47,111:47,88:34,120:34,89:57,121:57,90:58,122:58,91:59,123:59,92:60,124:60,93:61,125:61};var Zu={1:{n:"PtgElfLel",f:Lu},2:{n:"PtgElfRw",f:Wu},3:{n:"PtgElfCol",f:Nu},6:{n:"PtgElfRwV",f:Hu},7:{n:"PtgElfColV",f:Pu},10:{n:"PtgElfRadical",f:Mu},11:{n:"PtgElfRadicalS",f:Bu},13:{n:"PtgElfColS",f:Du},15:{n:"PtgElfColSV",f:Fu},16:{n:"PtgElfRadicalLel",f:Uu},25:{n:"PtgList",f:Vu},29:{n:"PtgSxName",f:Gu},255:{}};var Qu={0:{n:"PtgAttrNoop",f:Ku},1:{n:"PtgAttrSemi",f:eu},2:{n:"PtgAttrIf",f:Zh},4:{n:"PtgAttrChoose",f:Jh},8:{n:"PtgAttrGoto",f:qh},16:{n:"PtgAttrSum",f:cu},32:{n:"PtgAttrBaxcel",f:Kh},33:{n:"PtgAttrBaxcel",f:Kh},64:{n:"PtgAttrSpace",f:tu},65:{n:"PtgAttrSpaceSemi",f:au},128:{n:"PtgAttrIfError",f:Qh},255:{}};function ed(e,r,t,a){if(a.biff<8)return ha(e,r);var n=e.l+r;var i=[];for(var s=0;s!==t.length;++s){switch(t[s][0]){case"PtgArray":t[s][1]=wu(e,0,a);i.push(t[s][1]);break;case"PtgMemArea":t[s][2]=bu(e,t[s][1],a);i.push(t[s][2]);break;case"PtgExp":if(a&&a.biff==12){t[s][1][1]=e._R(4);i.push(t[s][1])}break;case"PtgList":;case"PtgElfRadicalS":;case"PtgElfColS":;case"PtgElfColSV":throw"Unsupported "+t[s][0];default:break;}}r=n-e.l;if(r!==0)i.push(ha(e,r));return i}function rd(e,r,t){var a=e.l+r;var n,i,s=[];while(a!=e.l){r=a-e.l;i=e[e.l];n=Ju[i]||Ju[qu[i]];if(i===24||i===25)n=(i===24?Zu:Qu)[e[e.l+1]];if(!n||!n.f){ha(e,r)}else{s.push([n.n,n.f(e,r,t)])}}return s}function td(e){var r=[];for(var t=0;t=",PtgGt:">",PtgLe:"<=",PtgLt:"<",PtgMul:"*",PtgNe:"<>",PtgPower:"^",PtgSub:"-"};function nd(e,r){if(!e&&!(r&&r.biff<=5&&r.biff>=2))throw new Error("empty sheet name");if(/[^\w\u4E00-\u9FFF\u3040-\u30FF]/.test(e))return"'"+e+"'";return e}function id(e,r,t){if(!e)return"SH33TJSERR0";if(t.biff>8&&(!e.XTI||!e.XTI[r]))return e.SheetNames[r];if(!e.XTI)return"SH33TJSERR6"; -var a=e.XTI[r];if(t.biff<8){if(r>1e4)r-=65536;if(r<0)r=-r;return r==0?"":e.XTI[r-1]}if(!a)return"SH33TJSERR1";var n="";if(t.biff>8)switch(e[a[0]][0]){case 357:n=a[1]==-1?"#REF":e.SheetNames[a[1]];return a[1]==a[2]?n:n+":"+e.SheetNames[a[2]];case 358:if(t.SID!=null)return e.SheetNames[t.SID];return"SH33TJSSAME"+e[a[0]][0];case 355:;default:return"SH33TJSSRC"+e[a[0]][0];}switch(e[a[0]][0][0]){case 1025:n=a[1]==-1?"#REF":e.SheetNames[a[1]]||"SH33TJSERR3";return a[1]==a[2]?n:n+":"+e.SheetNames[a[2]];case 14849:return e[a[0]].slice(1).map(function(e){return e.Name}).join(";;");default:if(!e[a[0]][0][3])return"SH33TJSERR2";n=a[1]==-1?"#REF":e[a[0]][0][3][a[1]]||"SH33TJSERR4";return a[1]==a[2]?n:n+":"+e[a[0]][0][3][a[2]];}}function sd(e,r,t){var a=id(e,r,t);return a=="#REF"?a:nd(a,t)}function fd(e,r,t,a,n){var i=n&&n.biff||8;var s={s:{c:0,r:0},e:{c:0,r:0}};var f=[],o,l,c,h=0,u=0,d,v="";if(!e[0]||!e[0][0])return"";var p=-1,g="";for(var m=0,b=e[0].length;m=0){switch(e[0][p][1][0]){case 0:g=wr(" ",e[0][p][1][1]);break;case 1:g=wr("\r",e[0][p][1][1]);break;default:g="";if(n.WTF)throw new Error("Unexpected PtgAttrSpaceType "+e[0][p][1][0]);}l=l+g;p=-1}f.push(l+ad[w[0]]+o);break;case"PtgIsect":o=f.pop();l=f.pop();f.push(l+" "+o);break;case"PtgUnion":o=f.pop();l=f.pop();f.push(l+","+o);break;case"PtgRange":o=f.pop();l=f.pop();f.push(l+":"+o);break;case"PtgAttrChoose":break;case"PtgAttrGoto":break;case"PtgAttrIf":break;case"PtgAttrIfError":break;case"PtgRef":c=ga(w[1][1],s,n);f.push(ba(c,i));break;case"PtgRefN":c=t?ga(w[1][1],t,n):w[1][1];f.push(ba(c,i));break;case"PtgRef3d":h=w[1][1];c=ga(w[1][2],s,n);v=sd(a,h,n);var k=v;f.push(v+"!"+ba(c,i));break;case"PtgFunc":;case"PtgFuncVar":var T=w[1][0],A=w[1][1];if(!T)T=0;T&=127;var E=T==0?[]:f.slice(-T);f.length-=T;if(A==="User")A=E.shift();f.push(A+"("+E.join(",")+")");break;case"PtgBool":f.push(w[1]?"TRUE":"FALSE");break;case"PtgInt":f.push(w[1]);break;case"PtgNum":f.push(String(w[1]));break;case"PtgStr":f.push('"'+w[1].replace(/"/g,'""')+'"');break;case"PtgErr":f.push(w[1]);break;case"PtgAreaN":d=ma(w[1][1],t?{s:t}:s,n);f.push(wa(d,n));break;case"PtgArea":d=ma(w[1][1],s,n);f.push(wa(d,n));break;case"PtgArea3d":h=w[1][1];d=w[1][2];v=sd(a,h,n);f.push(v+"!"+wa(d,n));break;case"PtgAttrSum":f.push("SUM("+f.pop()+")");break;case"PtgAttrBaxcel":;case"PtgAttrSemi":break;case"PtgName":u=w[1][2];var C=(a.names||[])[u-1]||(a[0]||[])[u];var y=C?C.Name:"SH33TJSNAME"+String(u);if(y&&y.slice(0,6)=="_xlfn."&&!n.xlfn)y=y.slice(6);f.push(y);break;case"PtgNameX":var S=w[1][1];u=w[1][2];var _;if(n.biff<=5){if(S<0)S=-S;if(a[S])_=a[S][u]}else{var x="";if(((a[S]||[])[0]||[])[0]==14849){}else if(((a[S]||[])[0]||[])[0]==1025){if(a[S][u]&&a[S][u].itab>0){x=a.SheetNames[a[S][u].itab-1]+"!"}}else x=a.SheetNames[u-1]+"!";if(a[S]&&a[S][u])x+=a[S][u].Name;else if(a[0]&&a[0][u])x+=a[0][u].Name;else{var O=(id(a,S,n)||"").split(";;");if(O[u-1])x=O[u-1];else x+="SH33TJSERRX"}f.push(x);break}if(!_)_={Name:"SH33TJSERRY"};f.push(_.Name);break;case"PtgParen":var R="(",I=")";if(p>=0){g="";switch(e[0][p][1][0]){case 2:R=wr(" ",e[0][p][1][1])+R;break;case 3:R=wr("\r",e[0][p][1][1])+R;break;case 4:I=wr(" ",e[0][p][1][1])+I;break;case 5:I=wr("\r",e[0][p][1][1])+I;break;default:if(n.WTF)throw new Error("Unexpected PtgAttrSpaceType "+e[0][p][1][0]);}p=-1}f.push(R+f.pop()+I);break;case"PtgRefErr":f.push("#REF!");break;case"PtgRefErr3d":f.push("#REF!");break;case"PtgExp":c={c:w[1][1],r:w[1][0]};var N={c:t.c,r:t.r};if(a.sharedf[Ra(c)]){var D=a.sharedf[Ra(c)];f.push(fd(D,s,N,a,n))}else{var F=false;for(o=0;o!=a.arrayf.length;++o){l=a.arrayf[o];if(c.cl[0].e.c)continue;if(c.rl[0].e.r)continue;f.push(fd(l[1],s,N,a,n));F=true;break}if(!F)f.push(w[1])}break;case"PtgArray":f.push("{"+td(w[1])+"}");break;case"PtgMemArea":break;case"PtgAttrSpace":;case"PtgAttrSpaceSemi":p=m;break;case"PtgTbl":break;case"PtgMemErr":break;case"PtgMissArg":f.push("");break;case"PtgAreaErr":f.push("#REF!");break;case"PtgAreaErr3d":f.push("#REF!");break;case"PtgList":f.push("Table"+w[1].idx+"[#"+w[1].rt+"]");break;case"PtgMemAreaN":;case"PtgMemNoMemN":;case"PtgAttrNoop":;case"PtgSheet":;case"PtgEndSheet":break;case"PtgMemFunc":break;case"PtgMemNoMem":break;case"PtgElfCol":;case"PtgElfColS":;case"PtgElfColSV":;case"PtgElfColV":;case"PtgElfLel":;case"PtgElfRadical":;case"PtgElfRadicalLel":;case"PtgElfRadicalS":;case"PtgElfRw":;case"PtgElfRwV":throw new Error("Unsupported ELFs");case"PtgSxName":throw new Error("Unrecognized Formula Token: "+String(w));default:throw new Error("Unrecognized Formula Token: "+String(w));}var P=["PtgAttrSpace","PtgAttrSpaceSemi","PtgAttrGoto"];if(n.biff!=3)if(p>=0&&P.indexOf(e[0][m][0])==-1){w=e[0][p];var L=true;switch(w[1][0]){case 4:L=false;case 0:g=wr(" ",w[1][1]);break;case 5:L=false;case 1:g=wr("\r",w[1][1]);break;default:g="";if(n.WTF)throw new Error("Unexpected PtgAttrSpaceType "+w[1][0]);}f.push((L?g:"")+f.pop()+(L?"":g));p=-1}}if(f.length>1&&n.WTF)throw new Error("bad formula stack");return f[0]}function od(e,r,t){var a=e.l+r,n=t.biff==2?1:2;var i,s=e._R(n);if(s==65535)return[[],ha(e,r-2)];var f=rd(e,s,t);if(r!==s+n)i=ed(e,r-s-n,f,t);e.l=a;return[f,i]}function ld(e,r,t){var a=e.l+r,n=t.biff==2?1:2;var i,s=e._R(n);if(s==65535)return[[],ha(e,r-2)];var f=rd(e,s,t);if(r!==s+n)i=ed(e,r-s-n,f,t);e.l=a;return[f,i]}function cd(e,r,t,a){var n=e.l+r;var i=rd(e,a,t);var s;if(n!==e.l)s=ed(e,n-e.l,i,t);return[i,s]}function hd(e,r,t){var a=e.l+r;var n,i=e._R(2);var s=rd(e,i,t);if(i==65535)return[[],ha(e,r-2)];if(r!==i+2)n=ed(e,a-i-2,s,t);return[s,n]}function ud(e){var r;if(Qt(e,e.l+6)!==65535)return[dn(e),"n"];switch(e[e.l]){case 0:e.l+=8;return["String","s"];case 1:r=e[e.l+2]===1;e.l+=8;return[r,"b"];case 2:r=e[e.l+2];e.l+=8;return[r,"e"];case 3:e.l+=8;return["","s"];}return[]}function dd(e){if(e==null){var r=ua(8);r._W(1,3);r._W(1,0);r._W(2,0);r._W(2,0);r._W(2,65535);return r}else if(typeof e=="number")return vn(e);return vn(0)}function vd(e,r,t){var a=e.l+r;var n=As(e,6);if(t.biff==2)++e.l;var i=ud(e,8);var s=e._R(1);if(t.biff!=2){e._R(1);if(t.biff>=5){e._R(4)}}var f=ld(e,a-e.l,t);return{cell:n,val:i[0],formula:f,shared:s>>3&1,tt:i[1]}}function pd(e,r,t,a,n){var i=Es(r,t,n);var s=dd(e.v);var f=ua(6);var o=1|32;f._W(2,o);f._W(4,0);var l=ua(e.bf.length);for(var c=0;c0?ed(e,i,n,t):null;return[n,s]}var md=gd;var bd=gd;var wd=gd;var kd=gd;var Td={0:"BEEP",1:"OPEN",2:"OPEN.LINKS",3:"CLOSE.ALL",4:"SAVE",5:"SAVE.AS",6:"FILE.DELETE",7:"PAGE.SETUP",8:"PRINT",9:"PRINTER.SETUP",10:"QUIT",11:"NEW.WINDOW",12:"ARRANGE.ALL",13:"WINDOW.SIZE",14:"WINDOW.MOVE",15:"FULL",16:"CLOSE",17:"RUN",22:"SET.PRINT.AREA",23:"SET.PRINT.TITLES",24:"SET.PAGE.BREAK",25:"REMOVE.PAGE.BREAK",26:"FONT",27:"DISPLAY",28:"PROTECT.DOCUMENT",29:"PRECISION",30:"A1.R1C1",31:"CALCULATE.NOW",32:"CALCULATION",34:"DATA.FIND",35:"EXTRACT",36:"DATA.DELETE",37:"SET.DATABASE",38:"SET.CRITERIA",39:"SORT",40:"DATA.SERIES",41:"TABLE",42:"FORMAT.NUMBER",43:"ALIGNMENT",44:"STYLE",45:"BORDER",46:"CELL.PROTECTION",47:"COLUMN.WIDTH",48:"UNDO",49:"CUT",50:"COPY",51:"PASTE",52:"CLEAR",53:"PASTE.SPECIAL",54:"EDIT.DELETE",55:"INSERT",56:"FILL.RIGHT",57:"FILL.DOWN",61:"DEFINE.NAME",62:"CREATE.NAMES",63:"FORMULA.GOTO",64:"FORMULA.FIND",65:"SELECT.LAST.CELL",66:"SHOW.ACTIVE.CELL",67:"GALLERY.AREA",68:"GALLERY.BAR",69:"GALLERY.COLUMN",70:"GALLERY.LINE",71:"GALLERY.PIE",72:"GALLERY.SCATTER",73:"COMBINATION",74:"PREFERRED",75:"ADD.OVERLAY",76:"GRIDLINES",77:"SET.PREFERRED",78:"AXES",79:"LEGEND",80:"ATTACH.TEXT",81:"ADD.ARROW",82:"SELECT.CHART",83:"SELECT.PLOT.AREA",84:"PATTERNS",85:"MAIN.CHART",86:"OVERLAY",87:"SCALE",88:"FORMAT.LEGEND",89:"FORMAT.TEXT",90:"EDIT.REPEAT",91:"PARSE",92:"JUSTIFY",93:"HIDE",94:"UNHIDE",95:"WORKSPACE",96:"FORMULA",97:"FORMULA.FILL",98:"FORMULA.ARRAY",99:"DATA.FIND.NEXT",100:"DATA.FIND.PREV",101:"FORMULA.FIND.NEXT",102:"FORMULA.FIND.PREV",103:"ACTIVATE",104:"ACTIVATE.NEXT",105:"ACTIVATE.PREV",106:"UNLOCKED.NEXT",107:"UNLOCKED.PREV",108:"COPY.PICTURE",109:"SELECT",110:"DELETE.NAME",111:"DELETE.FORMAT",112:"VLINE",113:"HLINE",114:"VPAGE",115:"HPAGE",116:"VSCROLL",117:"HSCROLL",118:"ALERT",119:"NEW",120:"CANCEL.COPY",121:"SHOW.CLIPBOARD",122:"MESSAGE",124:"PASTE.LINK",125:"APP.ACTIVATE",126:"DELETE.ARROW",127:"ROW.HEIGHT",128:"FORMAT.MOVE",129:"FORMAT.SIZE",130:"FORMULA.REPLACE",131:"SEND.KEYS",132:"SELECT.SPECIAL",133:"APPLY.NAMES",134:"REPLACE.FONT",135:"FREEZE.PANES",136:"SHOW.INFO",137:"SPLIT",138:"ON.WINDOW",139:"ON.DATA",140:"DISABLE.INPUT",142:"OUTLINE",143:"LIST.NAMES",144:"FILE.CLOSE",145:"SAVE.WORKBOOK",146:"DATA.FORM",147:"COPY.CHART",148:"ON.TIME",149:"WAIT",150:"FORMAT.FONT",151:"FILL.UP",152:"FILL.LEFT",153:"DELETE.OVERLAY",155:"SHORT.MENUS",159:"SET.UPDATE.STATUS",161:"COLOR.PALETTE",162:"DELETE.STYLE",163:"WINDOW.RESTORE",164:"WINDOW.MAXIMIZE",166:"CHANGE.LINK",167:"CALCULATE.DOCUMENT",168:"ON.KEY",169:"APP.RESTORE",170:"APP.MOVE",171:"APP.SIZE",172:"APP.MINIMIZE",173:"APP.MAXIMIZE",174:"BRING.TO.FRONT",175:"SEND.TO.BACK",185:"MAIN.CHART.TYPE",186:"OVERLAY.CHART.TYPE",187:"SELECT.END",188:"OPEN.MAIL",189:"SEND.MAIL",190:"STANDARD.FONT",191:"CONSOLIDATE",192:"SORT.SPECIAL",193:"GALLERY.3D.AREA",194:"GALLERY.3D.COLUMN",195:"GALLERY.3D.LINE",196:"GALLERY.3D.PIE",197:"VIEW.3D",198:"GOAL.SEEK",199:"WORKGROUP",200:"FILL.GROUP",201:"UPDATE.LINK",202:"PROMOTE",203:"DEMOTE",204:"SHOW.DETAIL",206:"UNGROUP",207:"OBJECT.PROPERTIES",208:"SAVE.NEW.OBJECT",209:"SHARE",210:"SHARE.NAME",211:"DUPLICATE",212:"APPLY.STYLE",213:"ASSIGN.TO.OBJECT",214:"OBJECT.PROTECTION",215:"HIDE.OBJECT",216:"SET.EXTRACT",217:"CREATE.PUBLISHER",218:"SUBSCRIBE.TO",219:"ATTRIBUTES",220:"SHOW.TOOLBAR",222:"PRINT.PREVIEW",223:"EDIT.COLOR",224:"SHOW.LEVELS",225:"FORMAT.MAIN",226:"FORMAT.OVERLAY",227:"ON.RECALC",228:"EDIT.SERIES",229:"DEFINE.STYLE",240:"LINE.PRINT",243:"ENTER.DATA",249:"GALLERY.RADAR",250:"MERGE.STYLES",251:"EDITION.OPTIONS",252:"PASTE.PICTURE",253:"PASTE.PICTURE.LINK",254:"SPELLING",256:"ZOOM",259:"INSERT.OBJECT",260:"WINDOW.MINIMIZE",265:"SOUND.NOTE",266:"SOUND.PLAY",267:"FORMAT.SHAPE",268:"EXTEND.POLYGON",269:"FORMAT.AUTO",272:"GALLERY.3D.BAR",273:"GALLERY.3D.SURFACE",274:"FILL.AUTO",276:"CUSTOMIZE.TOOLBAR",277:"ADD.TOOL",278:"EDIT.OBJECT",279:"ON.DOUBLECLICK",280:"ON.ENTRY",281:"WORKBOOK.ADD",282:"WORKBOOK.MOVE",283:"WORKBOOK.COPY",284:"WORKBOOK.OPTIONS",285:"SAVE.WORKSPACE",288:"CHART.WIZARD",289:"DELETE.TOOL",290:"MOVE.TOOL",291:"WORKBOOK.SELECT",292:"WORKBOOK.ACTIVATE",293:"ASSIGN.TO.TOOL",295:"COPY.TOOL",296:"RESET.TOOL",297:"CONSTRAIN.NUMERIC",298:"PASTE.TOOL",302:"WORKBOOK.NEW",305:"SCENARIO.CELLS",306:"SCENARIO.DELETE",307:"SCENARIO.ADD",308:"SCENARIO.EDIT",309:"SCENARIO.SHOW",310:"SCENARIO.SHOW.NEXT",311:"SCENARIO.SUMMARY",312:"PIVOT.TABLE.WIZARD",313:"PIVOT.FIELD.PROPERTIES",314:"PIVOT.FIELD",315:"PIVOT.ITEM",316:"PIVOT.ADD.FIELDS",318:"OPTIONS.CALCULATION",319:"OPTIONS.EDIT",320:"OPTIONS.VIEW",321:"ADDIN.MANAGER",322:"MENU.EDITOR",323:"ATTACH.TOOLBARS",324:"VBAActivate",325:"OPTIONS.CHART",328:"VBA.INSERT.FILE",330:"VBA.PROCEDURE.DEFINITION",336:"ROUTING.SLIP",338:"ROUTE.DOCUMENT",339:"MAIL.LOGON",342:"INSERT.PICTURE",343:"EDIT.TOOL",344:"GALLERY.DOUGHNUT",350:"CHART.TREND",352:"PIVOT.ITEM.PROPERTIES",354:"WORKBOOK.INSERT",355:"OPTIONS.TRANSITION",356:"OPTIONS.GENERAL",370:"FILTER.ADVANCED",373:"MAIL.ADD.MAILER",374:"MAIL.DELETE.MAILER",375:"MAIL.REPLY",376:"MAIL.REPLY.ALL",377:"MAIL.FORWARD",378:"MAIL.NEXT.LETTER",379:"DATA.LABEL",380:"INSERT.TITLE",381:"FONT.PROPERTIES",382:"MACRO.OPTIONS",383:"WORKBOOK.HIDE",384:"WORKBOOK.UNHIDE",385:"WORKBOOK.DELETE",386:"WORKBOOK.NAME",388:"GALLERY.CUSTOM",390:"ADD.CHART.AUTOFORMAT",391:"DELETE.CHART.AUTOFORMAT",392:"CHART.ADD.DATA",393:"AUTO.OUTLINE",394:"TAB.ORDER",395:"SHOW.DIALOG",396:"SELECT.ALL",397:"UNGROUP.SHEETS",398:"SUBTOTAL.CREATE",399:"SUBTOTAL.REMOVE",400:"RENAME.OBJECT",412:"WORKBOOK.SCROLL",413:"WORKBOOK.NEXT",414:"WORKBOOK.PREV",415:"WORKBOOK.TAB.SPLIT",416:"FULL.SCREEN",417:"WORKBOOK.PROTECT",420:"SCROLLBAR.PROPERTIES",421:"PIVOT.SHOW.PAGES",422:"TEXT.TO.COLUMNS",423:"FORMAT.CHARTTYPE",424:"LINK.FORMAT",425:"TRACER.DISPLAY",430:"TRACER.NAVIGATE",431:"TRACER.CLEAR",432:"TRACER.ERROR",433:"PIVOT.FIELD.GROUP",434:"PIVOT.FIELD.UNGROUP",435:"CHECKBOX.PROPERTIES",436:"LABEL.PROPERTIES",437:"LISTBOX.PROPERTIES",438:"EDITBOX.PROPERTIES",439:"PIVOT.REFRESH",440:"LINK.COMBO",441:"OPEN.TEXT",442:"HIDE.DIALOG",443:"SET.DIALOG.FOCUS",444:"ENABLE.OBJECT",445:"PUSHBUTTON.PROPERTIES",446:"SET.DIALOG.DEFAULT",447:"FILTER",448:"FILTER.SHOW.ALL",449:"CLEAR.OUTLINE",450:"FUNCTION.WIZARD",451:"ADD.LIST.ITEM",452:"SET.LIST.ITEM",453:"REMOVE.LIST.ITEM",454:"SELECT.LIST.ITEM",455:"SET.CONTROL.VALUE",456:"SAVE.COPY.AS",458:"OPTIONS.LISTS.ADD",459:"OPTIONS.LISTS.DELETE",460:"SERIES.AXES",461:"SERIES.X",462:"SERIES.Y",463:"ERRORBAR.X",464:"ERRORBAR.Y",465:"FORMAT.CHART",466:"SERIES.ORDER",467:"MAIL.LOGOFF",468:"CLEAR.ROUTING.SLIP",469:"APP.ACTIVATE.MICROSOFT",470:"MAIL.EDIT.MAILER",471:"ON.SHEET",472:"STANDARD.WIDTH",473:"SCENARIO.MERGE",474:"SUMMARY.INFO",475:"FIND.FILE",476:"ACTIVE.CELL.FONT",477:"ENABLE.TIPWIZARD",478:"VBA.MAKE.ADDIN",480:"INSERTDATATABLE",481:"WORKGROUP.OPTIONS",482:"MAIL.SEND.MAILER",485:"AUTOCORRECT",489:"POST.DOCUMENT",491:"PICKLIST",493:"VIEW.SHOW",494:"VIEW.DEFINE",495:"VIEW.DELETE",509:"SHEET.BACKGROUND",510:"INSERT.MAP.OBJECT",511:"OPTIONS.MENONO",517:"MSOCHECKS",518:"NORMAL",519:"LAYOUT",520:"RM.PRINT.AREA",521:"CLEAR.PRINT.AREA",522:"ADD.PRINT.AREA",523:"MOVE.BRK",545:"HIDECURR.NOTE",546:"HIDEALL.NOTES",547:"DELETE.NOTE",548:"TRAVERSE.NOTES",549:"ACTIVATE.NOTES",620:"PROTECT.REVISIONS",621:"UNPROTECT.REVISIONS",647:"OPTIONS.ME",653:"WEB.PUBLISH",667:"NEWWEBQUERY",673:"PIVOT.TABLE.CHART",753:"OPTIONS.SAVE",755:"OPTIONS.SPELL",808:"HIDEALL.INKANNOTS"};var Ad={0:"COUNT",1:"IF",2:"ISNA",3:"ISERROR",4:"SUM",5:"AVERAGE",6:"MIN",7:"MAX",8:"ROW",9:"COLUMN",10:"NA",11:"NPV",12:"STDEV",13:"DOLLAR",14:"FIXED",15:"SIN",16:"COS",17:"TAN",18:"ATAN",19:"PI",20:"SQRT",21:"EXP",22:"LN",23:"LOG10",24:"ABS",25:"INT",26:"SIGN",27:"ROUND",28:"LOOKUP",29:"INDEX",30:"REPT",31:"MID",32:"LEN",33:"VALUE",34:"TRUE",35:"FALSE",36:"AND",37:"OR",38:"NOT",39:"MOD",40:"DCOUNT",41:"DSUM",42:"DAVERAGE",43:"DMIN",44:"DMAX",45:"DSTDEV",46:"VAR",47:"DVAR",48:"TEXT",49:"LINEST",50:"TREND",51:"LOGEST",52:"GROWTH",53:"GOTO",54:"HALT",55:"RETURN",56:"PV",57:"FV",58:"NPER",59:"PMT",60:"RATE",61:"MIRR",62:"IRR",63:"RAND",64:"MATCH",65:"DATE",66:"TIME",67:"DAY",68:"MONTH",69:"YEAR",70:"WEEKDAY",71:"HOUR",72:"MINUTE",73:"SECOND",74:"NOW",75:"AREAS",76:"ROWS",77:"COLUMNS",78:"OFFSET",79:"ABSREF",80:"RELREF",81:"ARGUMENT",82:"SEARCH",83:"TRANSPOSE",84:"ERROR",85:"STEP",86:"TYPE",87:"ECHO",88:"SET.NAME",89:"CALLER",90:"DEREF",91:"WINDOWS",92:"SERIES",93:"DOCUMENTS",94:"ACTIVE.CELL",95:"SELECTION",96:"RESULT",97:"ATAN2",98:"ASIN",99:"ACOS",100:"CHOOSE",101:"HLOOKUP",102:"VLOOKUP",103:"LINKS",104:"INPUT",105:"ISREF",106:"GET.FORMULA",107:"GET.NAME",108:"SET.VALUE",109:"LOG",110:"EXEC",111:"CHAR",112:"LOWER",113:"UPPER",114:"PROPER",115:"LEFT",116:"RIGHT",117:"EXACT",118:"TRIM",119:"REPLACE",120:"SUBSTITUTE",121:"CODE",122:"NAMES",123:"DIRECTORY",124:"FIND",125:"CELL",126:"ISERR",127:"ISTEXT",128:"ISNUMBER",129:"ISBLANK",130:"T",131:"N",132:"FOPEN",133:"FCLOSE",134:"FSIZE",135:"FREADLN",136:"FREAD",137:"FWRITELN",138:"FWRITE",139:"FPOS",140:"DATEVALUE",141:"TIMEVALUE",142:"SLN",143:"SYD",144:"DDB",145:"GET.DEF",146:"REFTEXT",147:"TEXTREF",148:"INDIRECT",149:"REGISTER",150:"CALL",151:"ADD.BAR",152:"ADD.MENU",153:"ADD.COMMAND",154:"ENABLE.COMMAND",155:"CHECK.COMMAND",156:"RENAME.COMMAND",157:"SHOW.BAR",158:"DELETE.MENU",159:"DELETE.COMMAND",160:"GET.CHART.ITEM",161:"DIALOG.BOX",162:"CLEAN",163:"MDETERM",164:"MINVERSE",165:"MMULT",166:"FILES",167:"IPMT",168:"PPMT",169:"COUNTA",170:"CANCEL.KEY",171:"FOR",172:"WHILE",173:"BREAK",174:"NEXT",175:"INITIATE",176:"REQUEST",177:"POKE",178:"EXECUTE",179:"TERMINATE",180:"RESTART",181:"HELP",182:"GET.BAR",183:"PRODUCT",184:"FACT",185:"GET.CELL",186:"GET.WORKSPACE",187:"GET.WINDOW",188:"GET.DOCUMENT",189:"DPRODUCT",190:"ISNONTEXT",191:"GET.NOTE",192:"NOTE",193:"STDEVP",194:"VARP",195:"DSTDEVP",196:"DVARP",197:"TRUNC",198:"ISLOGICAL",199:"DCOUNTA",200:"DELETE.BAR",201:"UNREGISTER",204:"USDOLLAR",205:"FINDB",206:"SEARCHB",207:"REPLACEB",208:"LEFTB",209:"RIGHTB",210:"MIDB",211:"LENB",212:"ROUNDUP",213:"ROUNDDOWN",214:"ASC",215:"DBCS",216:"RANK",219:"ADDRESS",220:"DAYS360",221:"TODAY",222:"VDB",223:"ELSE",224:"ELSE.IF",225:"END.IF",226:"FOR.CELL",227:"MEDIAN",228:"SUMPRODUCT",229:"SINH",230:"COSH",231:"TANH",232:"ASINH",233:"ACOSH",234:"ATANH",235:"DGET",236:"CREATE.OBJECT",237:"VOLATILE",238:"LAST.ERROR",239:"CUSTOM.UNDO",240:"CUSTOM.REPEAT",241:"FORMULA.CONVERT",242:"GET.LINK.INFO",243:"TEXT.BOX",244:"INFO",245:"GROUP",246:"GET.OBJECT",247:"DB",248:"PAUSE",251:"RESUME",252:"FREQUENCY",253:"ADD.TOOLBAR",254:"DELETE.TOOLBAR",255:"User",256:"RESET.TOOLBAR",257:"EVALUATE",258:"GET.TOOLBAR",259:"GET.TOOL",260:"SPELLING.CHECK",261:"ERROR.TYPE",262:"APP.TITLE",263:"WINDOW.TITLE",264:"SAVE.TOOLBAR",265:"ENABLE.TOOL",266:"PRESS.TOOL",267:"REGISTER.ID",268:"GET.WORKBOOK",269:"AVEDEV",270:"BETADIST",271:"GAMMALN",272:"BETAINV",273:"BINOMDIST",274:"CHIDIST",275:"CHIINV",276:"COMBIN",277:"CONFIDENCE",278:"CRITBINOM",279:"EVEN",280:"EXPONDIST",281:"FDIST",282:"FINV",283:"FISHER",284:"FISHERINV",285:"FLOOR",286:"GAMMADIST",287:"GAMMAINV",288:"CEILING",289:"HYPGEOMDIST",290:"LOGNORMDIST",291:"LOGINV",292:"NEGBINOMDIST",293:"NORMDIST",294:"NORMSDIST",295:"NORMINV",296:"NORMSINV",297:"STANDARDIZE",298:"ODD",299:"PERMUT",300:"POISSON",301:"TDIST",302:"WEIBULL",303:"SUMXMY2",304:"SUMX2MY2",305:"SUMX2PY2",306:"CHITEST",307:"CORREL",308:"COVAR",309:"FORECAST",310:"FTEST",311:"INTERCEPT",312:"PEARSON",313:"RSQ",314:"STEYX",315:"SLOPE",316:"TTEST",317:"PROB",318:"DEVSQ",319:"GEOMEAN",320:"HARMEAN",321:"SUMSQ",322:"KURT",323:"SKEW",324:"ZTEST",325:"LARGE",326:"SMALL",327:"QUARTILE",328:"PERCENTILE",329:"PERCENTRANK",330:"MODE",331:"TRIMMEAN",332:"TINV",334:"MOVIE.COMMAND",335:"GET.MOVIE",336:"CONCATENATE",337:"POWER",338:"PIVOT.ADD.DATA",339:"GET.PIVOT.TABLE",340:"GET.PIVOT.FIELD",341:"GET.PIVOT.ITEM",342:"RADIANS",343:"DEGREES",344:"SUBTOTAL",345:"SUMIF",346:"COUNTIF",347:"COUNTBLANK",348:"SCENARIO.GET",349:"OPTIONS.LISTS.GET",350:"ISPMT",351:"DATEDIF",352:"DATESTRING",353:"NUMBERSTRING",354:"ROMAN",355:"OPEN.DIALOG",356:"SAVE.DIALOG",357:"VIEW.GET",358:"GETPIVOTDATA",359:"HYPERLINK",360:"PHONETIC",361:"AVERAGEA",362:"MAXA",363:"MINA",364:"STDEVPA",365:"VARPA",366:"STDEVA",367:"VARA",368:"BAHTTEXT",369:"THAIDAYOFWEEK",370:"THAIDIGIT",371:"THAIMONTHOFYEAR",372:"THAINUMSOUND",373:"THAINUMSTRING",374:"THAISTRINGLENGTH",375:"ISTHAIDIGIT",376:"ROUNDBAHTDOWN",377:"ROUNDBAHTUP",378:"THAIYEAR",379:"RTD",380:"CUBEVALUE",381:"CUBEMEMBER",382:"CUBEMEMBERPROPERTY",383:"CUBERANKEDMEMBER",384:"HEX2BIN",385:"HEX2DEC",386:"HEX2OCT",387:"DEC2BIN",388:"DEC2HEX",389:"DEC2OCT",390:"OCT2BIN",391:"OCT2HEX",392:"OCT2DEC",393:"BIN2DEC",394:"BIN2OCT",395:"BIN2HEX",396:"IMSUB",397:"IMDIV",398:"IMPOWER",399:"IMABS",400:"IMSQRT",401:"IMLN",402:"IMLOG2",403:"IMLOG10",404:"IMSIN",405:"IMCOS",406:"IMEXP",407:"IMARGUMENT",408:"IMCONJUGATE",409:"IMAGINARY",410:"IMREAL",411:"COMPLEX",412:"IMSUM",413:"IMPRODUCT",414:"SERIESSUM",415:"FACTDOUBLE",416:"SQRTPI",417:"QUOTIENT",418:"DELTA",419:"GESTEP",420:"ISEVEN",421:"ISODD",422:"MROUND",423:"ERF",424:"ERFC",425:"BESSELJ",426:"BESSELK",427:"BESSELY",428:"BESSELI",429:"XIRR",430:"XNPV",431:"PRICEMAT",432:"YIELDMAT",433:"INTRATE",434:"RECEIVED",435:"DISC",436:"PRICEDISC",437:"YIELDDISC",438:"TBILLEQ",439:"TBILLPRICE",440:"TBILLYIELD",441:"PRICE",442:"YIELD",443:"DOLLARDE",444:"DOLLARFR",445:"NOMINAL",446:"EFFECT",447:"CUMPRINC",448:"CUMIPMT",449:"EDATE",450:"EOMONTH",451:"YEARFRAC",452:"COUPDAYBS",453:"COUPDAYS",454:"COUPDAYSNC",455:"COUPNCD",456:"COUPNUM",457:"COUPPCD",458:"DURATION",459:"MDURATION",460:"ODDLPRICE",461:"ODDLYIELD",462:"ODDFPRICE",463:"ODDFYIELD",464:"RANDBETWEEN",465:"WEEKNUM",466:"AMORDEGRC",467:"AMORLINC",468:"CONVERT",724:"SHEETJS",469:"ACCRINT",470:"ACCRINTM",471:"WORKDAY",472:"NETWORKDAYS",473:"GCD",474:"MULTINOMIAL",475:"LCM",476:"FVSCHEDULE",477:"CUBEKPIMEMBER",478:"CUBESET",479:"CUBESETCOUNT",480:"IFERROR",481:"COUNTIFS",482:"SUMIFS",483:"AVERAGEIF",484:"AVERAGEIFS"};var Ed={2:1,3:1,10:0,15:1,16:1,17:1,18:1,19:0,20:1,21:1,22:1,23:1,24:1,25:1,26:1,27:2,30:2,31:3,32:1,33:1,34:0,35:0,38:1,39:2,40:3,41:3,42:3,43:3,44:3,45:3,47:3,48:2,53:1,61:3,63:0,65:3,66:3,67:1,68:1,69:1,70:1,71:1,72:1,73:1,74:0,75:1,76:1,77:1,79:2,80:2,83:1,85:0,86:1,89:0,90:1,94:0,95:0,97:2,98:1,99:1,101:3,102:3,105:1,106:1,108:2,111:1,112:1,113:1,114:1,117:2,118:1,119:4,121:1,126:1,127:1,128:1,129:1,130:1,131:1,133:1,134:1,135:1,136:2,137:2,138:2,140:1,141:1,142:3,143:4,144:4,161:1,162:1,163:1,164:1,165:2,172:1,175:2,176:2,177:3,178:2,179:1,184:1,186:1,189:3,190:1,195:3,196:3,197:1,198:1,199:3,201:1,207:4,210:3,211:1,212:2,213:2,214:1,215:1,225:0,229:1,230:1,231:1,232:1,233:1,234:1,235:3,244:1,247:4,252:2,257:1,261:1,271:1,273:4,274:2,275:2,276:2,277:3,278:3,279:1,280:3,281:3,282:3,283:1,284:1,285:2,286:4,287:3,288:2,289:4,290:3,291:3,292:3,293:4,294:1,295:3,296:1,297:3,298:1,299:2,300:3,301:3,302:4,303:2,304:2,305:2,306:2,307:2,308:2,309:3,310:2,311:2,312:2,313:2,314:2,315:2,316:4,325:2,326:2,327:2,328:2,331:2,332:2,337:2,342:1,343:1,346:2,347:1,350:4,351:3,352:1,353:2,360:1,368:1,369:1,370:1,371:1,372:1,373:1,374:1,375:1,376:1,377:1,378:1,382:3,385:1,392:1,393:1,396:2,397:2,398:2,399:1,400:1,401:1,402:1,403:1,404:1,405:1,406:1,407:1,408:1,409:1,410:1,414:4,415:1,416:1,417:2,420:1,421:1,422:2,424:1,425:2,426:2,427:2,428:2,430:3,438:3,439:3,440:3,443:2,444:2,445:2,446:2,447:6,448:6,449:2,450:2,464:2,468:3,476:2,479:1,480:2,65535:0};function Cd(e){if(e.slice(0,3)=="of:")e=e.slice(3);if(e.charCodeAt(0)==61){e=e.slice(1);if(e.charCodeAt(0)==61)e=e.slice(1)}e=e.replace(/COM\.MICROSOFT\./g,"");e=e.replace(/\[((?:\.[A-Z]+[0-9]+)(?::\.[A-Z]+[0-9]+)?)\]/g,function(e,r){return r.replace(/\./g,"")});e=e.replace(/\[.(#[A-Z]*[?!])\]/g,"$1");return e.replace(/[;~]/g,",").replace(/\|/g,";")}function yd(e){var r="of:="+e.replace(_h,"$1[.$2$3$4$5]").replace(/\]:\[/g,":");return r.replace(/;/g,"|").replace(/,/g,";")}function Sd(e){var r=e.split(":");var t=r[0].split(".")[0];return[t,r[0].split(".")[1]+(r.length>1?":"+(r[1].split(".")[1]||r[1].split(".")[0]):"")]}function _d(e){return e.replace(/\./,"!")}var xd={};var Od={};var Rd=typeof Map!=="undefined";function Id(e,r,t){var a=0,n=e.length;if(t){if(Rd?t.has(r):Object.prototype.hasOwnProperty.call(t,r)){var i=Rd?t.get(r):t[r];for(;a-1){t.width=xl(a);t.customWidth=1}else if(r.width!=null)t.width=r.width;if(r.hidden)t.hidden=true;if(r.level!=null){t.outlineLevel=t.level=r.level}return t}function Dd(e,r){if(!e)return;var t=[.7,.7,.75,.75,.3,.3];if(r=="xlml")t=[1,1,1,1,.5,.5];if(e.left==null)e.left=t[0];if(e.right==null)e.right=t[1];if(e.top==null)e.top=t[2];if(e.bottom==null)e.bottom=t[3];if(e.header==null)e.header=t[4];if(e.footer==null)e.footer=t[5]}function Fd(e,r,t){var a=t.revssf[r.z!=null?r.z:"General"];var n=60,i=e.length;if(a==null&&t.ssf){for(;n<392;++n)if(t.ssf[n]==null){We(r.z,n);t.ssf[n]=r.z;t.revssf[r.z]=a=n;break}}for(n=0;n!=i;++n)if(e[n].numFmtId===a)return n;e[i]={numFmtId:a,fontId:0,fillId:0,borderId:0,xfId:0,applyNumberFormat:1};return i}function Pd(e,r,t,a,n,i){try{if(a.cellNF)e.z=X[r]}catch(s){if(a.WTF)throw s}if(e.t==="z"&&!a.cellStyles)return;if(e.t==="d"&&typeof e.v==="string")e.v=gr(e.v);if((!a||a.cellText!==false)&&e.t!=="z")try{if(X[r]==null)We(Ge[r]||"General",r);if(e.t==="e")e.w=e.w||Gn[e.v];else if(r===0){if(e.t==="n"){if((e.v|0)===e.v)e.w=e.v.toString(10);else e.w=se(e.v)}else if(e.t==="d"){var f=fr(e.v);if((f|0)===f)e.w=f.toString(10);else e.w=se(f)}else if(e.v===undefined)return"";else e.w=fe(e.v,Od)}else if(e.t==="d")e.w=Be(r,fr(e.v),Od);else e.w=Be(r,e.v,Od)}catch(s){if(a.WTF)throw s}if(!a.cellStyles)return;if(t!=null)try{e.s=i.Fills[t];if(e.s.fgColor&&e.s.fgColor.theme&&!e.s.fgColor.rgb){e.s.fgColor.rgb=Tl(n.themeElements.clrScheme[e.s.fgColor.theme].rgb,e.s.fgColor.tint||0);if(a.WTF)e.s.fgColor.raw_rgb=n.themeElements.clrScheme[e.s.fgColor.theme].rgb}if(e.s.bgColor&&e.s.bgColor.theme){e.s.bgColor.rgb=Tl(n.themeElements.clrScheme[e.s.bgColor.theme].rgb,e.s.bgColor.tint||0);if(a.WTF)e.s.bgColor.raw_rgb=n.themeElements.clrScheme[e.s.bgColor.theme].rgb}}catch(s){if(a.WTF&&i.Fills)throw s}}function Ld(e,r,t){if(e&&e["!ref"]){var a=Da(e["!ref"]);if(a.e.c=0&&t.s.c>=0)e["!ref"]=Na(t)}var Ud=/<(?:\w:)?mergeCell ref="[A-Z0-9:]+"\s*[\/]?>/g;var Bd=/<(?:\w+:)?sheetData[^>]*>([\s\S]*)<\/(?:\w+:)?sheetData>/;var Wd=/<(?:\w:)?hyperlink [^>]*>/gm;var Hd=/"(\w*:\w*)"/;var zd=/<(?:\w:)?col\b[^>]*[\/]?>/g;var Vd=/<(?:\w:)?autoFilter[^>]*([\/]|>([\s\S]*)<\/(?:\w:)?autoFilter)>/g;var Gd=/<(?:\w:)?pageMargins[^>]*\/>/g;var jd=/<(?:\w:)?sheetPr\b(?:[^>a-z][^>]*)?\/>/;var Xd=/<(?:\w:)?sheetPr[^>]*(?:[\/]|>([\s\S]*)<\/(?:\w:)?sheetPr)>/;var $d=/<(?:\w:)?sheetViews[^>]*(?:[\/]|>([\s\S]*)<\/(?:\w:)?sheetViews)>/;function Yd(e,r,t,a,n,i,s){if(!e)return e;if(!a)a={"!id":{}};if(g!=null&&r.dense==null)r.dense=g;var f=r.dense?[]:{};var o={s:{r:2e6,c:2e6},e:{r:0,c:0}};var l="",c="";var h=e.match(Bd);if(h){l=e.slice(0,h.index);c=e.slice(h.index+h[0].length)}else l=c=e;var u=l.match(jd);if(u)Jd(u[0],f,n,t);else if(u=l.match(Xd))qd(u[0],u[1]||"",f,n,t,s,i);var d=(l.match(/<(?:\w*:)?dimension/)||{index:-1}).index;if(d>0){var v=l.slice(d,d+50).match(Hd);if(v)Md(f,v[1])}var p=l.match($d);if(p&&p[1])cv(p[1],n);var m=[];if(r.cellStyles){var b=l.match(zd);if(b)iv(m,b)}if(h)dv(h[1],f,r,o,i,s);var w=c.match(Vd);if(w)f["!autofilter"]=fv(w[0]);var k=[];var T=c.match(Ud);if(T)for(d=0;d!=T.length;++d)k[d]=Da(T[d].slice(T[d].indexOf('"')+1));var A=c.match(Wd);if(A)tv(f,A,a);var E=c.match(Gd);if(E)f["!margins"]=av(Gr(E[0]));if(!f["!ref"]&&o.e.c>=o.s.c&&o.e.r>=o.s.r)f["!ref"]=Na(o);if(r.sheetRows>0&&f["!ref"]){var C=Da(f["!ref"]);if(r.sheetRows<=+C.e.r){C.e.r=r.sheetRows-1;if(C.e.r>o.e.r)C.e.r=o.e.r;if(C.e.ro.e.c)C.e.c=o.e.c;if(C.e.c0)f["!cols"]=m;if(k.length>0)f["!merges"]=k;return f}function Kd(e){if(e.length===0)return"";var r='';for(var t=0;t!=e.length;++t)r+='';return r+""}function Jd(e,r,t,a){var n=Gr(e);if(!t.Sheets[a])t.Sheets[a]={};if(n.codeName)t.Sheets[a].CodeName=Yr(lt(n.codeName))}function qd(e,r,t,a,n){Jd(e.slice(0,e.indexOf(">")),t,a,n)}function Zd(e,r,t,a,n){var i=false;var s={},f=null;if(a.bookType!=="xlsx"&&r.vbaraw){var o=r.SheetNames[t];try{if(r.Workbook)o=r.Workbook.Sheets[t].CodeName||o}catch(l){}i=true;s.codeName=ct(qr(o))}if(e&&e["!outline"]){var c={summaryBelow:1,summaryRight:1};if(e["!outline"].above)c.summaryBelow=0;if(e["!outline"].left)c.summaryRight=0;f=(f||"")+kt("outlinePr",null,c)}if(!i&&!f)return;n[n.length]=kt("sheetPr",f,s)}var Qd=["objects","scenarios","selectLockedCells","selectUnlockedCells"];var ev=["formatColumns","formatRows","formatCells","insertColumns","insertRows","insertHyperlinks","deleteColumns","deleteRows","sort","autoFilter","pivotTables"];function rv(e){var r={sheet:1};Qd.forEach(function(t){if(e[t]!=null&&e[t])r[t]="1"});ev.forEach(function(t){if(e[t]!=null&&!e[t])r[t]="0"});if(e.password)r.password=ll(e.password).toString(16).toUpperCase();return kt("sheetProtection",null,r)}function tv(e,r,t){var a=Array.isArray(e);for(var n=0;n!=r.length;++n){var i=Gr(lt(r[n]),true);if(!i.ref)return;var s=((t||{})["!id"]||[])[i.id];if(s){i.Target=s.Target;if(i.location)i.Target+="#"+Yr(i.location)}else{i.Target="#"+Yr(i.location);s={Target:i.Target,TargetMode:"Internal"}}i.Rel=s;if(i.tooltip){i.Tooltip=i.tooltip;delete i.tooltip}var f=Da(i.ref);for(var o=f.s.r;o<=f.e.r;++o)for(var l=f.s.c;l<=f.e.c;++l){var c=Ra({c:l,r:o});if(a){if(!e[o])e[o]=[];if(!e[o][l])e[o][l]={t:"z",v:undefined};e[o][l].l=i}else{if(!e[c])e[c]={t:"z",v:undefined};e[c].l=i}}}}function av(e){var r={};["left","right","top","bottom","header","footer"].forEach(function(t){if(e[t])r[t]=parseFloat(e[t])});return r}function nv(e){Dd(e);return kt("pageMargins",null,e)}function iv(e,r){var t=false;for(var a=0;a!=r.length;++a){var n=Gr(r[a],true);if(n.hidden)n.hidden=nt(n.hidden);var i=parseInt(n.min,10)-1,s=parseInt(n.max,10)-1;if(n.outlineLevel)n.level=+n.outlineLevel||0;delete n.min;delete n.max;n.width=+n.width;if(!t&&n.width){t=true;Rl(n.width)}Il(n);while(i<=s)e[i++]=br(n)}}function sv(e,r){var t=[""],a;for(var n=0;n!=r.length;++n){if(!(a=r[n]))continue;t[t.length]=kt("col",null,Nd(n,a))}t[t.length]="";return t.join("")}function fv(e){var r={ref:(e.match(/ref="([^"]*)"/)||[])[1]};return r}function ov(e,r,t,a){var n=typeof e.ref=="string"?e.ref:Na(e.ref);if(!t.Workbook)t.Workbook={Sheets:[]};if(!t.Workbook.Names)t.Workbook.Names=[];var i=t.Workbook.Names;var s=Ia(n);if(s.s.r==s.e.r){s.e.r=Ia(r["!ref"]).e.r;n=Na(s)}for(var f=0;fa-z][^>]*)?\/?>/;function cv(e,r){if(!r.Views)r.Views=[{}];(e.match(lv)||[]).forEach(function(e,t){var a=Gr(e);if(!r.Views[t])r.Views[t]={};if(+a.zoomScale)r.Views[t].zoom=+a.zoomScale;if(nt(a.rightToLeft))r.Views[t].RTL=true})}function hv(e,r,t,a){var n={workbookViewId:"0"};if((((a||{}).Workbook||{}).Views||[])[0])n.rightToLeft=a.Workbook.Views[0].RTL?"1":"0";return kt("sheetViews",kt("sheetView",null,n),{})}function uv(e,r,t,a){if(e.c)t["!comments"].push([r,e.c]);if(e.v===undefined&&typeof e.f!=="string"||e.t==="z"&&!e.f)return"";var n="";var i=e.t,s=e.v;if(e.t!=="z")switch(e.t){case"b":n=e.v?"1":"0";break;case"n":n=""+e.v;break;case"e":n=Gn[e.v];break;case"d":if(a&&a.cellDates)n=gr(e.v,-1).toISOString();else{e=br(e);e.t="n";n=""+(e.v=fr(gr(e.v)))}if(typeof e.z==="undefined")e.z=X[14];break;default:n=e.v;break;}var f=bt("v",qr(n)),o={r:r};var l=Fd(a.cellXfs,e,a);if(l!==0)o.s=l;switch(e.t){case"n":break;case"d":o.t="d";break;case"b":o.t="b";break;case"e":o.t="e";break;case"z":break;default:if(e.v==null){delete e.t;break}if(e.v.length>32767)throw new Error("Text length must not exceed 32767 characters");if(a&&a.bookSST){f=bt("v",""+Id(a.Strings,e.v,a.revStrings));o.t="s";break}o.t="str";break;}if(e.t!=i){e.t=i;e.v=s}if(typeof e.f=="string"&&e.f){var c=e.F&&e.F.slice(0,r.length)==r?{t:"array",ref:e.F}:null;f=kt("f",qr(e.f),c)+(e.v!=null?f:"")}if(e.l)t["!links"].push([r,e.l]);if(e.D)o.cm=1;return kt("c",f,o)}var dv=function(){var e=/<(?:\w+:)?c[ \/>]/,r=/<\/(?:\w+:)?row>/;var t=/r=["']([^"']*)["']/,a=/<(?:\w+:)?is>([\S\s]*?)<\/(?:\w+:)?is>/; -var n=/ref=["']([^"']*)["']/;var i=ht("v"),s=ht("f");return function f(o,l,c,h,u,d){var v=0,p="",g=[],m=[],b=0,w=0,k=0,T="",A;var E,C=0,y=0;var S,_;var x=0,O=0;var R=Array.isArray(d.CellXf),I;var N=[];var D=[];var F=Array.isArray(l);var P=[],L={},M=false;var U=!!c.sheetStubs;for(var B=o.split(r),W=0,H=B.length;W!=H;++W){p=B[W].trim();var z=p.length;if(z===0)continue;var V=0;e:for(v=0;v":if(p[v-1]!="/"){++v;break e}if(c&&c.cellStyles){E=Gr(p.slice(V,v),true);C=E.r!=null?parseInt(E.r,10):C+1;y=-1;if(c.sheetRows&&c.sheetRows=v)break;E=Gr(p.slice(V,v),true);C=E.r!=null?parseInt(E.r,10):C+1;y=-1;if(c.sheetRows&&c.sheetRowsC-1)h.s.r=C-1;if(h.e.r":"")+p;if(m!=null&&m.length===2){b=0;T=m[1];for(w=0;w!=T.length;++w){if((k=T.charCodeAt(w)-64)<1||k>26)break;b=26*b+k}--b;y=b}else++y;for(w=0;w!=p.length;++w)if(p.charCodeAt(w)===62)break;++w;E=Gr(p.slice(0,w),true);if(!E.r)E.r=Ra({r:C-1,c:y});T=p.slice(w);A={t:""};if((m=T.match(i))!=null&&m[1]!=="")A.v=Yr(m[1]);if(c.cellFormula){if((m=T.match(s))!=null&&m[1]!==""){A.f=Yr(lt(m[1])).replace(/\r\n/g,"\n");if(!c.xlfn)A.f=Nh(A.f);if(m[0].indexOf('t="array"')>-1){A.F=(T.match(n)||[])[1];if(A.F.indexOf(":")>-1)N.push([Da(A.F),A.F])}else if(m[0].indexOf('t="shared"')>-1){_=Gr(m[0]);var j=Yr(lt(m[1]));if(!c.xlfn)j=Nh(j);D[parseInt(_.si,10)]=[_,j,E.r]}}else if(m=T.match(/]*\/>/)){_=Gr(m[0]);if(D[_.si])A.f=Rh(D[_.si][1],D[_.si][2],E.r)}var $=Oa(E.r);for(w=0;w=N[w][0].s.r&&$.r<=N[w][0].e.r)if($.c>=N[w][0].s.c&&$.c<=N[w][0].e.c)A.F=N[w][1]}if(E.t==null&&A.v===undefined){if(A.f||A.F){A.v=0;A.t="n"}else if(!U)continue;else A.t="z"}else A.t=E.t||"n";if(h.s.c>y)h.s.c=y;if(h.e.c0)l["!rows"]=P}}();function vv(e,r,t,a){var n=[],i=[],s=Da(e["!ref"]),f="",o,l="",c=[],h=0,u=0,d=e["!rows"];var v=Array.isArray(e);var p={r:l},g,m=-1;for(u=s.s.c;u<=s.e.c;++u)c[u]=ya(u);for(h=s.s.r;h<=s.e.r;++h){i=[];l=Ta(h);for(u=s.s.c;u<=s.e.c;++u){o=c[u]+l;var b=v?(e[h]||[])[u]:e[o];if(b===undefined)continue;if((f=uv(b,o,e,r,t,a))!=null)i.push(f)}if(i.length>0||d&&d[h]){p={r:l};if(d&&d[h]){g=d[h];if(g.hidden)p.hidden=1;m=-1;if(g.hpx)m=Fl(g.hpx);else if(g.hpt)m=g.hpt;if(m>-1){p.ht=m;p.customHeight=1}if(g.level){p.outlineLevel=g.level}}n[n.length]=kt("row",i.join(""),p)}}if(d)for(;h-1){p.ht=m;p.customHeight=1}if(g.level){p.outlineLevel=g.level}n[n.length]=kt("row","",p)}}return n.join("")}function pv(e,r,t,a){var n=[Mr,kt("worksheet",null,{xmlns:St[0],"xmlns:r":yt.r})];var i=t.SheetNames[e],s=0,f="";var o=t.Sheets[i];if(o==null)o={};var l=o["!ref"]||"A1";var c=Da(l);if(c.e.c>16383||c.e.r>1048575){if(r.WTF)throw new Error("Range "+l+" exceeds format limit A1:XFD1048576");c.e.c=Math.min(c.e.c,16383);c.e.r=Math.min(c.e.c,1048575);l=Na(c)}if(!a)a={};o["!comments"]=[];var h=[];Zd(o,t,e,r,n);n[n.length]=kt("dimension",null,{ref:l});n[n.length]=hv(o,r,e,t);if(r.sheetFormat)n[n.length]=kt("sheetFormatPr",null,{defaultRowHeight:r.sheetFormat.defaultRowHeight||"16",baseColWidth:r.sheetFormat.baseColWidth||"10",outlineLevelRow:r.sheetFormat.outlineLevelRow||"7"});if(o["!cols"]!=null&&o["!cols"].length>0)n[n.length]=sv(o,o["!cols"]);n[s=n.length]="";o["!links"]=[];if(o["!ref"]!=null){f=vv(o,r,e,t,a);if(f.length>0)n[n.length]=f}if(n.length>s+1){n[n.length]="";n[s]=n[s].replace("/>",">")}if(o["!protect"])n[n.length]=rv(o["!protect"]);if(o["!autofilter"]!=null)n[n.length]=ov(o["!autofilter"],o,t,e);if(o["!merges"]!=null&&o["!merges"].length>0)n[n.length]=Kd(o["!merges"]);var u=-1,d,v=-1;if(o["!links"].length>0){n[n.length]="";o["!links"].forEach(function(e){if(!e[1].Target)return;d={ref:e[0]};if(e[1].Target.charAt(0)!="#"){v=ri(a,-1,qr(e[1].Target).replace(/#.*$/,""),qn.HLINK);d["r:id"]="rId"+v}if((u=e[1].Target.indexOf("#"))>-1)d.location=qr(e[1].Target.slice(u+1));if(e[1].Tooltip)d.tooltip=qr(e[1].Tooltip);n[n.length]=kt("hyperlink",null,d)});n[n.length]=""}delete o["!links"];if(o["!margins"]!=null)n[n.length]=nv(o["!margins"]);if(!r||r.ignoreEC||r.ignoreEC==void 0)n[n.length]=bt("ignoredErrors",kt("ignoredError",null,{numberStoredAsText:1,sqref:l}));if(h.length>0){v=ri(a,-1,"../drawings/drawing"+(e+1)+".xml",qn.DRAW);n[n.length]=kt("drawing",null,{"r:id":"rId"+v});o["!drawing"]=h}if(o["!comments"].length>0){v=ri(a,-1,"../drawings/vmlDrawing"+(e+1)+".vml",qn.VML);n[n.length]=kt("legacyDrawing",null,{"r:id":"rId"+v});o["!legacy"]=v}if(n.length>1){n[n.length]="";n[1]=n[1].replace("/>",">")}return n.join("")}function gv(e,r){var t={};var a=e.l+r;t.r=e._R(4);e.l+=4;var n=e._R(2);e.l+=1;var i=e._R(1);e.l=a;if(i&7)t.level=i&7;if(i&16)t.hidden=true;if(i&32)t.hpt=n/20;return t}function mv(e,r,t){var a=ua(17+8*16);var n=(t["!rows"]||[])[e]||{};a._W(4,e);a._W(4,0);var i=320;if(n.hpx)i=Fl(n.hpx)*20;else if(n.hpt)i=n.hpt*20;a._W(2,i);a._W(1,0);var s=0;if(n.level)s|=n.level;if(n.hidden)s|=16;if(n.hpx||n.hpt)s|=32;a._W(1,s);a._W(1,0);var f=0,o=a.l;a.l+=4;var l={r:e,c:0};for(var c=0;c<16;++c){if(r.s.c>c+1<<10||r.e.ca.l?a.slice(0,a.l):a}function bv(e,r,t,a){var n=mv(a,t,r);if(n.length>17||(r["!rows"]||[])[a])pa(e,0,n)}var wv=hn;var kv=un;function Tv(){}function Av(e,r){var t={};var a=e[e.l];++e.l;t.above=!(a&64);t.left=!(a&128);e.l+=18;t.name=Qa(e,r-19);return t}function Ev(e,r,t){if(t==null)t=ua(84+4*e.length);var a=192;if(r){if(r.above)a&=~64;if(r.left)a&=~128}t._W(1,a);for(var n=1;n<3;++n)t._W(1,0);gn({auto:1},t);t._W(-4,-1);t._W(-4,-1);en(e,t);return t.slice(0,t.l)}function Cv(e){var r=Ka(e);return[r]}function yv(e,r,t){if(t==null)t=ua(8);return Ja(r,t)}function Sv(e){var r=qa(e);return[r]}function _v(e,r,t){if(t==null)t=ua(4);return Za(r,t)}function xv(e){var r=Ka(e);var t=e._R(1);return[r,t,"b"]}function Ov(e,r,t){if(t==null)t=ua(9);Ja(r,t);t._W(1,e.v?1:0);return t}function Rv(e){var r=qa(e);var t=e._R(1);return[r,t,"b"]}function Iv(e,r,t){if(t==null)t=ua(5);Za(r,t);t._W(1,e.v?1:0);return t}function Nv(e){var r=Ka(e);var t=e._R(1);return[r,t,"e"]}function Dv(e,r,t){if(t==null)t=ua(9);Ja(r,t);t._W(1,e.v);return t}function Fv(e){var r=qa(e);var t=e._R(1);return[r,t,"e"]}function Pv(e,r,t){if(t==null)t=ua(8);Za(r,t);t._W(1,e.v);t._W(2,0);t._W(1,0);return t}function Lv(e){var r=Ka(e);var t=e._R(4);return[r,t,"s"]}function Mv(e,r,t){if(t==null)t=ua(12);Ja(r,t);t._W(4,r.v);return t}function Uv(e){var r=qa(e);var t=e._R(4);return[r,t,"s"]}function Bv(e,r,t){if(t==null)t=ua(8);Za(r,t);t._W(4,r.v);return t}function Wv(e){var r=Ka(e);var t=dn(e);return[r,t,"n"]}function Hv(e,r,t){if(t==null)t=ua(16);Ja(r,t);vn(e.v,t);return t}function zv(e){var r=qa(e);var t=dn(e);return[r,t,"n"]}function Vv(e,r,t){if(t==null)t=ua(12);Za(r,t);vn(e.v,t);return t}function Gv(e){var r=Ka(e);var t=fn(e);return[r,t,"n"]}function jv(e,r,t){if(t==null)t=ua(12);Ja(r,t);on(e.v,t);return t}function Xv(e){var r=qa(e);var t=fn(e);return[r,t,"n"]}function $v(e,r,t){if(t==null)t=ua(8);Za(r,t);on(e.v,t);return t}function Yv(e){var r=Ka(e);var t=ja(e);return[r,t,"is"]}function Kv(e){var r=Ka(e);var t=Ha(e);return[r,t,"str"]}function Jv(e,r,t){if(t==null)t=ua(12+4*e.v.length);Ja(r,t);za(e.v,t);return t.length>t.l?t.slice(0,t.l):t}function qv(e){var r=qa(e);var t=Ha(e);return[r,t,"str"]}function Zv(e,r,t){if(t==null)t=ua(8+4*e.v.length);Za(r,t);za(e.v,t);return t.length>t.l?t.slice(0,t.l):t}function Qv(e,r,t){var a=e.l+r;var n=Ka(e);n.r=t["!row"];var i=e._R(1);var s=[n,i,"b"];if(t.cellFormula){e.l+=2;var f=bd(e,a-e.l,t);s[3]=fd(f,null,n,t.supbooks,t)}else e.l=a;return s}function ep(e,r,t){var a=e.l+r;var n=Ka(e);n.r=t["!row"];var i=e._R(1);var s=[n,i,"e"];if(t.cellFormula){e.l+=2;var f=bd(e,a-e.l,t);s[3]=fd(f,null,n,t.supbooks,t)}else e.l=a;return s}function rp(e,r,t){var a=e.l+r;var n=Ka(e);n.r=t["!row"];var i=dn(e);var s=[n,i,"n"];if(t.cellFormula){e.l+=2;var f=bd(e,a-e.l,t);s[3]=fd(f,null,n,t.supbooks,t)}else e.l=a;return s}function tp(e,r,t){var a=e.l+r;var n=Ka(e);n.r=t["!row"];var i=Ha(e);var s=[n,i,"str"];if(t.cellFormula){e.l+=2;var f=bd(e,a-e.l,t);s[3]=fd(f,null,n,t.supbooks,t)}else e.l=a;return s}var ap=hn;var np=un;function ip(e,r){if(r==null)r=ua(4);r._W(4,e);return r}function sp(e,r){var t=e.l+r;var a=hn(e,16);var n=rn(e);var i=Ha(e);var s=Ha(e);var f=Ha(e);e.l=t;var o={rfx:a,relId:n,loc:i,display:f};if(s)o.Tooltip=s;return o}function fp(e,r){var t=ua(50+4*(e[1].Target.length+(e[1].Tooltip||"").length));un({s:Oa(e[0]),e:Oa(e[0])},t);sn("rId"+r,t);var a=e[1].Target.indexOf("#");var n=a==-1?"":e[1].Target.slice(a+1);za(n||"",t);za(e[1].Tooltip||"",t);za("",t);return t.slice(0,t.l)}function op(){}function lp(e,r,t){var a=e.l+r;var n=ln(e,16);var i=e._R(1);var s=[n];s[2]=i;if(t.cellFormula){var f=md(e,a-e.l,t);s[1]=f}else e.l=a;return s}function cp(e,r,t){var a=e.l+r;var n=hn(e,16);var i=[n];if(t.cellFormula){var s=kd(e,a-e.l,t);i[1]=s;e.l=a}else e.l=a;return i}function hp(e,r,t){if(t==null)t=ua(18);var a=Nd(e,r);t._W(-4,e);t._W(-4,e);t._W(4,(a.width||10)*256);t._W(4,0);var n=0;if(r.hidden)n|=1;if(typeof a.width=="number")n|=2;if(r.level)n|=r.level<<8;t._W(2,n);return t}var up=["left","right","top","bottom","header","footer"];function dp(e){var r={};up.forEach(function(t){r[t]=dn(e,8)});return r}function vp(e,r){if(r==null)r=ua(6*8);Dd(e);up.forEach(function(t){vn(e[t],r)});return r}function pp(e){var r=e._R(2);e.l+=28;return{RTL:r&32}}function gp(e,r,t){if(t==null)t=ua(30);var a=924;if((((r||{}).Views||[])[0]||{}).RTL)a|=32;t._W(2,a);t._W(4,0);t._W(4,0);t._W(4,0);t._W(1,0);t._W(1,0);t._W(2,0);t._W(2,100);t._W(2,0);t._W(2,0);t._W(2,0);t._W(4,0);return t}function mp(e){var r=ua(24);r._W(4,4);r._W(4,1);un(e,r);return r}function bp(e,r){if(r==null)r=ua(16*4+2);r._W(2,e.password?ll(e.password):0);r._W(4,1);[["objects",false],["scenarios",false],["formatCells",true],["formatColumns",true],["formatRows",true],["insertColumns",true],["insertRows",true],["insertHyperlinks",true],["deleteColumns",true],["deleteRows",true],["selectLockedCells",false],["sort",true],["autoFilter",true],["pivotTables",true],["selectUnlockedCells",false]].forEach(function(t){if(t[1])r._W(4,e[t[0]]!=null&&!e[t[0]]?1:0);else r._W(4,e[t[0]]!=null&&e[t[0]]?0:1)});return r}function wp(){}function kp(){}function Tp(e,r,t,a,n,i,s){if(!e)return e;var f=r||{};if(!a)a={"!id":{}};if(g!=null&&f.dense==null)f.dense=g;var o=f.dense?[]:{};var l;var c={s:{r:2e6,c:2e6},e:{r:0,c:0}};var h=[];var u=false,d=false;var v,p,m,b,w,k,T,A,E;var C=[];f.biff=12;f["!row"]=0;var y=0,S=false;var _=[];var x={};var O=f.supbooks||n.supbooks||[[]];O.sharedf=x;O.arrayf=_;O.SheetNames=n.SheetNames||n.Sheets.map(function(e){return e.name});if(!f.supbooks){f.supbooks=O;if(n.Names)for(var R=0;R=R[0].s.r&&v.r<=R[0].e.r)if(w>=R[0].s.c&&w<=R[0].e.c){p.F=Na(R[0]);S=true}}if(!S&&e.length>3)p.f=e[3]}if(c.s.r>v.r)c.s.r=v.r;if(c.s.c>w)c.s.c=w;if(c.e.rv.r)c.s.r=v.r;if(c.s.c>w)c.s.c=w;if(c.e.r=e.s){I[e.e--]={width:e.w/256,hidden:!!(e.flags&1),level:e.level};if(!D){D=true;Rl(e.w/256)}Il(I[e.e+1])}break;case 161:o["!autofilter"]={ref:Na(e)};break;case 476:o["!margins"]=e;break;case 147:if(!n.Sheets[t])n.Sheets[t]={};if(e.name)n.Sheets[t].CodeName=e.name;if(e.above||e.left)o["!outline"]={above:e.above,left:e.left};break;case 137:if(!n.Views)n.Views=[{}];if(!n.Views[0])n.Views[0]={};if(e.RTL)n.Views[0].RTL=true;break;case 485:break;case 64:;case 1053:break;case 151:break;case 152:;case 175:;case 644:;case 625:;case 562:;case 396:;case 1112:;case 1146:;case 471:;case 1050:;case 649:;case 1105:;case 589:;case 607:;case 564:;case 1055:;case 168:;case 174:;case 1180:;case 499:;case 507:;case 550:;case 171:;case 167:;case 1177:;case 169:;case 1181:;case 551:;case 552:;case 661:;case 639:;case 478:;case 537:;case 477:;case 536:;case 1103:;case 680:;case 1104:;case 1024:;case 663:;case 535:;case 678:;case 504:;case 1043:;case 428:;case 170:;case 3072:;case 50:;case 2070:;case 1045:break;case 35:u=true;break;case 36:u=false;break;case 37:h.push(g);u=true;break;case 38:h.pop();u=false;break;default:if(r.T){}else if(!u||f.WTF)throw new Error("Unexpected record 0x"+g.toString(16));}},f);delete f.supbooks;delete f["!row"];if(!o["!ref"]&&(c.s.r<2e6||l&&(l.e.r>0||l.e.c>0||l.s.r>0||l.s.c>0)))o["!ref"]=Na(l||c);if(f.sheetRows&&o["!ref"]){var L=Da(o["!ref"]);if(f.sheetRows<=+L.e.r){L.e.r=f.sheetRows-1;if(L.e.r>c.e.r)L.e.r=c.e.r;if(L.e.rc.e.c)L.e.c=c.e.c;if(L.e.c0)o["!merges"]=C;if(I.length>0)o["!cols"]=I;if(N.length>0)o["!rows"]=N;return o}function Ap(e,r,t,a,n,i,s){if(r.v===undefined)return false;var f="";switch(r.t){case"b":f=r.v?"1":"0";break;case"d":r=br(r);r.z=r.z||X[14];r.v=fr(gr(r.v));r.t="n";break;case"n":;case"e":f=""+r.v;break;default:f=r.v;break;}var o={r:t,c:a};o.s=Fd(n.cellXfs,r,n);if(r.l)i["!links"].push([Ra(o),r.l]);if(r.c)i["!comments"].push([Ra(o),r.c]);switch(r.t){case"s":;case"str":if(n.bookSST){f=Id(n.Strings,r.v,n.revStrings);o.t="s";o.v=f;if(s)pa(e,18,Bv(r,o));else pa(e,7,Mv(r,o))}else{o.t="str";if(s)pa(e,17,Zv(r,o));else pa(e,6,Jv(r,o))}return true;case"n":if(r.v==(r.v|0)&&r.v>-1e3&&r.v<1e3){if(s)pa(e,13,$v(r,o));else pa(e,2,jv(r,o))}else{if(s)pa(e,16,Vv(r,o));else pa(e,5,Hv(r,o))}return true;case"b":o.t="b";if(s)pa(e,15,Iv(r,o));else pa(e,4,Ov(r,o));return true;case"e":o.t="e";if(s)pa(e,14,Pv(r,o));else pa(e,3,Dv(r,o));return true;}if(s)pa(e,12,_v(r,o));else pa(e,1,yv(r,o));return true}function Ep(e,r,t,a){var n=Da(r["!ref"]||"A1"),i,s="",f=[];pa(e,145);var o=Array.isArray(r);var l=n.e.r;if(r["!rows"])l=Math.max(n.e.r,r["!rows"].length-1);for(var c=n.s.r;c<=l;++c){s=Ta(c);bv(e,r,n,c);var h=false;if(c<=n.e.r)for(var u=n.s.c;u<=n.e.c;++u){if(c===n.s.r)f[u]=ya(u);i=f[u]+s;var d=o?(r[c]||[])[u]:r[i];if(!d){h=false;continue}h=Ap(e,d,c,u,a,r,h)}}pa(e,146)}function Cp(e,r){if(!r||!r["!merges"])return;pa(e,177,ip(r["!merges"].length));r["!merges"].forEach(function(r){pa(e,176,np(r))});pa(e,178)}function yp(e,r){if(!r||!r["!cols"])return;pa(e,390);r["!cols"].forEach(function(r,t){if(r)pa(e,60,hp(t,r))});pa(e,391)}function Sp(e,r){if(!r||!r["!ref"])return;pa(e,648);pa(e,649,mp(Da(r["!ref"])));pa(e,650)}function _p(e,r,t){r["!links"].forEach(function(r){if(!r[1].Target)return;var a=ri(t,-1,r[1].Target.replace(/#.*$/,""),qn.HLINK);pa(e,494,fp(r,a))});delete r["!links"]}function xp(e,r,t,a){if(r["!comments"].length>0){var n=ri(a,-1,"../drawings/vmlDrawing"+(t+1)+".vml",qn.VML);pa(e,551,sn("rId"+n));r["!legacy"]=n}}function Op(e,r,t,a){if(!r["!autofilter"])return;var n=r["!autofilter"];var i=typeof n.ref==="string"?n.ref:Na(n.ref);if(!t.Workbook)t.Workbook={Sheets:[]};if(!t.Workbook.Names)t.Workbook.Names=[];var s=t.Workbook.Names;var f=Ia(i);if(f.s.r==f.e.r){f.e.r=Ia(r["!ref"]).e.r;i=Na(f)}for(var o=0;o16383||l.e.r>1048575){if(r.WTF)throw new Error("Range "+(s["!ref"]||"A1")+" exceeds format limit A1:XFD1048576");l.e.c=Math.min(l.e.c,16383);l.e.r=Math.min(l.e.c,1048575)}s["!links"]=[];s["!comments"]=[];pa(n,129);if(t.vbaraw||s["!outline"])pa(n,147,Ev(f,s["!outline"]));pa(n,148,kv(l));Rp(n,s,t.Workbook);Ip(n,s);yp(n,s,e,r,t);Ep(n,s,e,r,t);Np(n,s);Op(n,s,t,e);Cp(n,s);_p(n,s,a);if(s["!margins"])pa(n,476,vp(s["!margins"]));if(!r||r.ignoreEC||r.ignoreEC==void 0)Sp(n,s);xp(n,s,e,a);pa(n,130);return n.end()}function Fp(e){var r=[];var t=e.match(/^/);var a;(e.match(/(.*?)<\/c:pt>/gm)||[]).forEach(function(e){var a=e.match(/(.*)<\/c:v><\/c:pt>/);if(!a)return;r[+a[1]]=t?+a[2]:a[2]});var n=Yr((e.match(/([\s\S]*?)<\/c:formatCode>/)||["","General"])[1]);(e.match(/(.*?)<\/c:f>/gm)||[]).forEach(function(e){a=e.replace(/<.*?>/g,"")});return[r,n,a]}function Pp(e,r,t,a,n,i){var s=i||{"!type":"chart"};if(!e)return i;var f=0,o=0,l="A";var c={s:{r:2e6,c:2e6},e:{r:0,c:0}};(e.match(/[\s\S]*?<\/c:numCache>/gm)||[]).forEach(function(e){var r=Fp(e);c.s.r=c.s.c=0;c.e.c=f;l=ya(f);r[0].forEach(function(e,t){s[l+Ta(t)]={t:"n",v:e,z:r[1]};o=t});if(c.e.r0)s["!ref"]=Na(c);return s}function Lp(e,r,t,a,n){if(!e)return e;if(!a)a={"!id":{}};var i={"!type":"chart","!drawel":null,"!rel":""};var s;var f=e.match(jd);if(f)Jd(f[0],i,n,t);if(s=e.match(/drawing r:id="(.*?)"/))i["!rel"]=s[1];if(a["!id"][i["!rel"]])i["!drawel"]=a["!id"][i["!rel"]];return i}function Mp(e,r,t,a){var n=[Mr,kt("chartsheet",null,{xmlns:St[0],"xmlns:r":yt.r})];n[n.length]=kt("drawing",null,{"r:id":"rId1"});ri(a,-1,"../drawings/drawing"+(e+1)+".xml",qn.DRAW);if(n.length>2){n[n.length]="";n[1]=n[1].replace("/>",">")}return n.join("")}function Up(e,r){e.l+=10;var t=Ha(e,r-10);return{name:t}}function Bp(e,r,t,a,n){if(!e)return e;if(!a)a={"!id":{}};var i={"!type":"chart","!drawel":null,"!rel":""};var s=[];var f=false;da(e,function o(e,a,l){switch(l){case 550:i["!rel"]=e;break;case 651:if(!n.Sheets[t])n.Sheets[t]={};if(e.name)n.Sheets[t].CodeName=e.name;break;case 562:;case 652:;case 669:;case 679:;case 551:;case 552:;case 476:;case 3072:break;case 35:f=true;break;case 36:f=false;break;case 37:s.push(l);break;case 38:s.pop();break;default:if(a.T>0)s.push(l);else if(a.T<0)s.pop();else if(!f||r.WTF)throw new Error("Unexpected record 0x"+l.toString(16));}},r);if(a["!id"][i["!rel"]])i["!drawel"]=a["!id"][i["!rel"]];return i}function Wp(){var e=va();pa(e,129);pa(e,130);return e.end()}var Hp=[["allowRefreshQuery",false,"bool"],["autoCompressPictures",true,"bool"],["backupFile",false,"bool"],["checkCompatibility",false,"bool"],["CodeName",""],["date1904",false,"bool"],["defaultThemeVersion",0,"int"],["filterPrivacy",false,"bool"],["hidePivotFieldList",false,"bool"],["promptedSolutions",false,"bool"],["publishItems",false,"bool"],["refreshAllConnections",false,"bool"],["saveExternalLinkValues",true,"bool"],["showBorderUnselectedTables",true,"bool"],["showInkAnnotation",true,"bool"],["showObjects","all"],["showPivotChartFilter",false,"bool"],["updateLinks","userSet"]];var zp=[["activeTab",0,"int"],["autoFilterDateGrouping",true,"bool"],["firstSheet",0,"int"],["minimized",false,"bool"],["showHorizontalScroll",true,"bool"],["showSheetTabs",true,"bool"],["showVerticalScroll",true,"bool"],["tabRatio",600,"int"],["visibility","visible"]];var Vp=[];var Gp=[["calcCompleted","true"],["calcMode","auto"],["calcOnSave","true"],["concurrentCalc","true"],["fullCalcOnLoad","false"],["fullPrecision","true"],["iterate","false"],["iterateCount","100"],["iterateDelta","0.001"],["refMode","A1"]];function jp(e,r){for(var t=0;t!=e.length;++t){var a=e[t];for(var n=0;n!=r.length;++n){var i=r[n];if(a[i[0]]==null)a[i[0]]=i[1];else switch(i[2]){case"bool":if(typeof a[i[0]]=="string")a[i[0]]=nt(a[i[0]]);break;case"int":if(typeof a[i[0]]=="string")a[i[0]]=parseInt(a[i[0]],10);break;}}}}function Xp(e,r){for(var t=0;t!=r.length;++t){var a=r[t];if(e[a[0]]==null)e[a[0]]=a[1];else switch(a[2]){case"bool":if(typeof e[a[0]]=="string")e[a[0]]=nt(e[a[0]]);break;case"int":if(typeof e[a[0]]=="string")e[a[0]]=parseInt(e[a[0]],10);break;}}}function $p(e){Xp(e.WBProps,Hp);Xp(e.CalcPr,Gp);jp(e.WBView,zp);jp(e.Sheets,Vp);Od.date1904=nt(e.WBProps.date1904)}function Yp(e){if(!e.Workbook)return"false";if(!e.Workbook.WBProps)return"false";return nt(e.Workbook.WBProps.date1904)?"true":"false"}var Kp="][*?/\\".split("");function Jp(e,r){if(e.length>31){if(r)return false;throw new Error("Sheet names cannot exceed 31 chars")}var t=true;Kp.forEach(function(a){if(e.indexOf(a)==-1)return;if(!r)throw new Error("Sheet name cannot contain : \\ / ? * [ ]");t=false});return t}function qp(e,r,t){e.forEach(function(a,n){Jp(a);for(var i=0;i22)throw new Error("Bad Code Name: Worksheet"+s)}})}function Zp(e){if(!e||!e.SheetNames||!e.Sheets)throw new Error("Invalid Workbook");if(!e.SheetNames.length)throw new Error("Workbook is empty");var r=e.Workbook&&e.Workbook.Sheets||[];qp(e.SheetNames,r,!!e.vbaraw);for(var t=0;t":break;case"":;case"":break;case"":break;case"":Hp.forEach(function(e){if(c[e[0]]==null)return;switch(e[2]){case"bool":t.WBProps[e[0]]=nt(c[e[0]]);break;case"int":t.WBProps[e[0]]=parseInt(c[e[0]],10);break;default:t.WBProps[e[0]]=c[e[0]];}});if(c.codeName)t.WBProps.CodeName=lt(c.codeName);break;case"":break;case"":break;case"":;case"":break;case"":delete c[0];t.WBView.push(c);break;case"":break;case"":;case"":break;case"":break;case"":break;case"":;case"":break;case"":break;case"":;case"":a=false;break;case"":{i.Ref=Yr(lt(e.slice(s,l)));t.Names.push(i)}break;case"":break;case"":delete c[0];t.CalcPr=c;break;case"":break;case"":;case"":;case"":break;case"":;case"":;case"":break;case"":;case"":break;case"":break;case"":break;case"":;case"":break;case"":;case"":;case"":break;case"":a=false;break;case"":a=true;break;case"":a=false;break;case"0;var a={codeName:"ThisWorkbook"};if(e.Workbook&&e.Workbook.WBProps){Hp.forEach(function(r){if(e.Workbook.WBProps[r[0]]==null)return;if(e.Workbook.WBProps[r[0]]==r[1])return;a[r[0]]=e.Workbook.WBProps[r[0]]});if(e.Workbook.WBProps.CodeName){a.codeName=e.Workbook.WBProps.CodeName;delete a.CodeName}}r[r.length]=kt("workbookPr",null,a);var n=e.Workbook&&e.Workbook.Sheets||[];var i=0;if(n&&n[0]&&!!n[0].Hidden){r[r.length]="";for(i=0;i!=e.SheetNames.length;++i){if(!n[i])break;if(!n[i].Hidden)break}if(i==e.SheetNames.length)i=0;r[r.length]='';r[r.length]=""}r[r.length]="";for(i=0;i!=e.SheetNames.length;++i){var s={name:qr(e.SheetNames[i].slice(0,31))};s.sheetId=""+(i+1);s["r:id"]="rId"+(i+1);if(n[i])switch(n[i].Hidden){case 1:s.state="hidden";break;case 2:s.state="veryHidden";break;}r[r.length]=kt("sheet",null,s)}r[r.length]="";if(t){r[r.length]="";if(e.Workbook&&e.Workbook.Names)e.Workbook.Names.forEach(function(e){var t={name:e.Name};if(e.Comment)t.comment=e.Comment;if(e.Sheet!=null)t.localSheetId=""+e.Sheet;if(e.Hidden)t.hidden="1";if(!e.Ref)return;r[r.length]=kt("definedName",qr(e.Ref),t)});r[r.length]=""}if(r.length>2){r[r.length]="";r[1]=r[1].replace("/>",">")}return r.join("")}function tg(e,r){var t={};t.Hidden=e._R(4);t.iTabID=e._R(4);t.strRelID=nn(e,r-8);t.name=Ha(e);return t}function ag(e,r){if(!r)r=ua(127);r._W(4,e.Hidden);r._W(4,e.iTabID);sn(e.strRelID,r);za(e.name.slice(0,31),r);return r.length>r.l?r.slice(0,r.l):r}function ng(e,r){var t={};var a=e._R(4);t.defaultThemeVersion=e._R(4);var n=r>8?Ha(e):"";if(n.length>0)t.CodeName=n;t.autoCompressPictures=!!(a&65536);t.backupFile=!!(a&64);t.checkCompatibility=!!(a&4096);t.date1904=!!(a&1);t.filterPrivacy=!!(a&8);t.hidePivotFieldList=!!(a&1024);t.promptedSolutions=!!(a&16);t.publishItems=!!(a&2048);t.refreshAllConnections=!!(a&262144);t.saveExternalLinkValues=!!(a&128);t.showBorderUnselectedTables=!!(a&4);t.showInkAnnotation=!!(a&32);t.showObjects=["all","placeholders","none"][a>>13&3];t.showPivotChartFilter=!!(a&32768);t.updateLinks=["userSet","never","always"][a>>8&3];return t}function ig(e,r){if(!r)r=ua(72);var t=0;if(e){if(e.filterPrivacy)t|=8}r._W(4,t);r._W(4,0);en(e&&e.CodeName||"ThisWorkbook",r);return r.slice(0,r.l)}function sg(e,r){var t={};e._R(4);t.ArchID=e._R(4);e.l+=r-8;return t}function fg(e,r,t){var a=e.l+r;e.l+=4;e.l+=1;var n=e._R(4);var i=an(e);var s=wd(e,0,t);var f=rn(e);e.l=a;var o={Name:i,Ptg:s};if(n<268435455)o.Sheet=n;if(f)o.Comment=f;return o}function og(e,r){var t={AppVersion:{},WBProps:{},WBView:[],Sheets:[],CalcPr:{},xmlns:""};var a=[];var n=false;if(!r)r={};r.biff=12;var i=[];var s=[[]];s.SheetNames=[];s.XTI=[];gm[16]={n:"BrtFRTArchID$",f:sg};da(e,function f(e,o,l){switch(l){case 156:s.SheetNames.push(e.name);t.Sheets.push(e);break;case 153:t.WBProps=e;break;case 39:if(e.Sheet!=null)r.SID=e.Sheet;e.Ref=fd(e.Ptg,null,null,s,r);delete r.SID;delete e.Ptg;i.push(e);break;case 1036:break;case 357:;case 358:;case 355:;case 667:if(!s[0].length)s[0]=[l,e];else s.push([l,e]);s[s.length-1].XTI=[];break;case 362:if(s.length===0){s[0]=[];s[0].XTI=[]}s[s.length-1].XTI=s[s.length-1].XTI.concat(e);s.XTI=s.XTI.concat(e);break;case 361:break;case 2071:;case 158:;case 143:;case 664:;case 353:break;case 3072:;case 3073:;case 534:;case 677:;case 157:;case 610:;case 2050:;case 155:;case 548:;case 676:;case 128:;case 665:;case 2128:;case 2125:;case 549:;case 2053:;case 596:;case 2076:;case 2075:;case 2082:;case 397:;case 154:;case 1117:;case 553:;case 2091: -break;case 35:a.push(l);n=true;break;case 36:a.pop();n=false;break;case 37:a.push(l);n=true;break;case 38:a.pop();n=false;break;case 16:break;default:if(o.T){}else if(!n||r.WTF&&a[a.length-1]!=37&&a[a.length-1]!=35)throw new Error("Unexpected record 0x"+l.toString(16));}},r);$p(t);t.Names=i;t.supbooks=s;return t}function lg(e,r){pa(e,143);for(var t=0;t!=r.SheetNames.length;++t){var a=r.Workbook&&r.Workbook.Sheets&&r.Workbook.Sheets[t]&&r.Workbook.Sheets[t].Hidden||0;var n={Hidden:a,iTabID:t+1,strRelID:"rId"+(t+1),name:r.SheetNames[t]};pa(e,156,ag(n))}pa(e,144)}function cg(r,t){if(!t)t=ua(127);for(var a=0;a!=4;++a)t._W(4,0);za("SheetJS",t);za(e.version,t);za(e.version,t);za("7262",t);return t.length>t.l?t.slice(0,t.l):t}function hg(e,r){if(!r)r=ua(29);r._W(-4,0);r._W(-4,460);r._W(4,28800);r._W(4,17600);r._W(4,500);r._W(4,e);r._W(4,e);var t=120;r._W(1,t);return r.length>r.l?r.slice(0,r.l):r}function ug(e,r){if(!r.Workbook||!r.Workbook.Sheets)return;var t=r.Workbook.Sheets;var a=0,n=-1,i=-1;for(;an)return;pa(e,135);pa(e,158,hg(n));pa(e,136)}function dg(e,r){var t=va();pa(t,131);pa(t,128,cg());pa(t,153,ig(e.Workbook&&e.Workbook.WBProps||null));ug(t,e,r);lg(t,e,r);pa(t,132);return t.end()}function vg(e,r,t){if(r.slice(-4)===".bin")return og(e,t);return eg(e,t)}function pg(e,r,t,a,n,i,s,f){if(r.slice(-4)===".bin")return Tp(e,a,t,n,i,s,f);return Yd(e,a,t,n,i,s,f)}function gg(e,r,t,a,n,i,s,f){if(r.slice(-4)===".bin")return Bp(e,a,t,n,i,s,f);return Lp(e,a,t,n,i,s,f)}function mg(e,r,t,a,n,i,s,f){if(r.slice(-4)===".bin")return Ch(e,a,t,n,i,s,f);return yh(e,a,t,n,i,s,f)}function bg(e,r,t,a,n,i,s,f){if(r.slice(-4)===".bin")return Ah(e,a,t,n,i,s,f);return Eh(e,a,t,n,i,s,f)}function wg(e,r,t,a){if(r.slice(-4)===".bin")return lc(e,t,a);return Xl(e,t,a)}function kg(e,r,t){return Rc(e,t)}function Tg(e,r,t){if(r.slice(-4)===".bin")return Vo(e,t);return Bo(e,t)}function Ag(e,r,t){if(r.slice(-4)===".bin")return gh(e,t);return sh(e,t)}function Eg(e,r,t){if(r.slice(-4)===".bin")return Qc(e,r,t);return qc(e,r,t)}function Cg(e,r,t,a){if(t.slice(-4)===".bin")return rh(e,r,t,a);return eh(e,r,t,a)}function yg(e,r,t){if(r.slice(-4)===".bin")return $c(e,r,t);return Kc(e,r,t)}function Sg(e,r,t){return(r.slice(-4)===".bin"?dg:rg)(e,t)}function _g(e,r,t,a,n){return(r.slice(-4)===".bin"?Dp:pv)(e,t,a,n)}function xg(e,r,t,a,n){return(r.slice(-4)===".bin"?Wp:Mp)(e,t,a,n)}function Og(e,r,t){return(r.slice(-4)===".bin"?kc:$l)(e,t)}function Rg(e,r,t){return(r.slice(-4)===".bin"?Xo:Ho)(e,t)}function Ig(e,r,t){return(r.slice(-4)===".bin"?mh:fh)(e,t)}function Ng(e){return(e.slice(-4)===".bin"?Yc:Jc)()}var Dg=/([\w:]+)=((?:")([^"]*)(?:")|(?:')([^']*)(?:'))/g;var Fg=/([\w:]+)=((?:")(?:[^"]*)(?:")|(?:')(?:[^']*)(?:'))/;function Pg(e,r){var t=e.split(/\s+/);var a=[];if(!r)a[0]=t[0];if(t.length===1)return a;var n=e.match(Dg),i,s,f,o;if(n)for(o=0;o!=n.length;++o){i=n[o].match(Fg);if((s=i[1].indexOf(":"))===-1)a[i[1]]=i[2].slice(1,i[2].length-1);else{if(i[1].slice(0,6)==="xmlns:")f="xmlns"+i[1].slice(6);else f=i[1].slice(s+1);a[f]=i[2].slice(1,i[2].length-1)}}return a}function Lg(e){var r=e.split(/\s+/);var t={};if(r.length===1)return t;var a=e.match(Dg),n,i,s,f;if(a)for(f=0;f!=a.length;++f){n=a[f].match(Fg);if((i=n[1].indexOf(":"))===-1)t[n[1]]=n[2].slice(1,n[2].length-1);else{if(n[1].slice(0,6)==="xmlns:")s="xmlns"+n[1].slice(6);else s=n[1].slice(i+1);t[s]=n[2].slice(1,n[2].length-1)}}return t}var Mg;function Ug(e,r){var t=Mg[e]||Yr(e);if(t==="General")return fe(r);return Be(t,r)}function Bg(e,r,t,a){var n=a;switch((t[0].match(/dt:dt="([\w.]+)"/)||["",""])[1]){case"boolean":n=nt(a);break;case"i2":;case"int":n=parseInt(a,10);break;case"r4":;case"float":n=parseFloat(a);break;case"date":;case"dateTime.tz":n=gr(a);break;case"i8":;case"string":;case"fixed":;case"uuid":;case"bin.base64":break;default:throw new Error("bad custprop:"+t[0]);}e[Yr(r)]=n}function Wg(e,r,t){if(e.t==="z")return;if(!t||t.cellText!==false)try{if(e.t==="e"){e.w=e.w||Gn[e.v]}else if(r==="General"){if(e.t==="n"){if((e.v|0)===e.v)e.w=e.v.toString(10);else e.w=se(e.v)}else e.w=fe(e.v)}else e.w=Ug(r||"General",e.v)}catch(a){if(t.WTF)throw a}try{var n=Mg[r]||r||"General";if(t.cellNF)e.z=n;if(t.cellDates&&e.t=="n"&&Fe(n)){var i=q(e.v);if(i){e.t="d";e.v=new Date(i.y,i.m-1,i.d,i.H,i.M,i.S,i.u)}}}catch(a){if(t.WTF)throw a}}function Hg(e,r,t){if(t.cellStyles){if(r.Interior){var a=r.Interior;if(a.Pattern)a.patternType=Ll[a.Pattern]||a.Pattern}}e[r.ID]=r}function zg(e,r,t,a,n,i,s,f,o,l){var c="General",h=a.StyleID,u={};l=l||{};var d=[];var v=0;if(h===undefined&&f)h=f.StyleID;if(h===undefined&&s)h=s.StyleID;while(i[h]!==undefined){if(i[h].nf)c=i[h].nf;if(i[h].Interior)d.push(i[h].Interior);if(!i[h].Parent)break;h=i[h].Parent}switch(t.Type){case"Boolean":a.t="b";a.v=nt(e);break;case"String":a.t="s";a.r=tt(Yr(e));a.v=e.indexOf("<")>-1?Yr(r||e).replace(/<.*?>/g,""):a.r;break;case"DateTime":if(e.slice(-1)!="Z")e+="Z";a.v=(gr(e)-new Date(Date.UTC(1899,11,30)))/(24*60*60*1e3);if(a.v!==a.v)a.v=Yr(e);else if(a.v<60)a.v=a.v-1;if(!c||c=="General")c="yyyy-mm-dd";case"Number":if(a.v===undefined)a.v=+e;if(!a.t)a.t="n";break;case"Error":a.t="e";a.v=jn[e];if(l.cellText!==false)a.w=e;break;default:if(e==""&&r==""){a.t="z"}else{a.t="s";a.v=tt(r||e)}break;}Wg(a,c,l);if(l.cellFormula!==false){if(a.Formula){var p=Yr(a.Formula);if(p.charCodeAt(0)==61)p=p.slice(1);a.f=Sh(p,n);delete a.Formula;if(a.ArrayRange=="RC")a.F=Sh("RC:RC",n);else if(a.ArrayRange){a.F=Sh(a.ArrayRange,n);o.push([Da(a.F),a.F])}}else{for(v=0;v=o[v][0].s.r&&n.r<=o[v][0].e.r)if(n.c>=o[v][0].s.c&&n.c<=o[v][0].e.c)a.F=o[v][1]}}if(l.cellStyles){d.forEach(function(e){if(!u.patternType&&e.patternType)u.patternType=e.patternType});a.s=u}if(a.StyleID!==undefined)a.ixfe=a.StyleID}function Vg(e){e.t=e.v||"";e.t=e.t.replace(/\r\n/g,"\n").replace(/\r/g,"\n");e.v=e.w=e.ixfe=undefined}function Gg(e,r){var t=r||{};ze();var n=d(Et(e));if(t.type=="binary"||t.type=="array"||t.type=="base64"){if(typeof a!=="undefined")n=a.utils.decode(65001,c(n));else n=lt(n)}var i=n.slice(0,1024).toLowerCase(),s=false;i=i.replace(/".*?"/g,"");if((i.indexOf(">")&1023)>Math.min(i.indexOf(",")&1023,i.indexOf(";")&1023)){var f=br(t);f.type="string";return So.to_workbook(n,f)}if(i.indexOf("=0)s=true});if(s)return Hm(n,t);Mg={"General Number":"General","General Date":X[22],"Long Date":"dddd, mmmm dd, yyyy","Medium Date":X[15],"Short Date":X[14],"Long Time":X[19],"Medium Time":X[18],"Short Time":X[20],Currency:'"$"#,##0.00_);[Red]\\("$"#,##0.00\\)',Fixed:X[2],Standard:X[4],Percent:X[10],Scientific:X[11],"Yes/No":'"Yes";"Yes";"No";@',"True/False":'"True";"True";"False";@',"On/Off":'"Yes";"Yes";"No";@'};var o;var l=[],h;if(g!=null&&t.dense==null)t.dense=g;var u={},v=[],p=t.dense?[]:{},m="";var b={},w={};var k=Pg(''),T=0;var A=0,E=0;var C={s:{r:2e6,c:2e6},e:{r:0,c:0}};var y={},S={};var _="",x=0;var O=[];var R={},I={},N=0,D=[];var F=[],P={};var L=[],M,U=false;var B=[];var W=[],H={},z=0,V=0;var G={Sheets:[],WBProps:{date1904:false}},j={};Ct.lastIndex=0;n=n.replace(//gm,"");var $="";while(o=Ct.exec(n))switch(o[3]=($=o[3]).toLowerCase()){case"data":if($=="data"){if(o[1]==="/"){if((h=l.pop())[0]!==o[3])throw new Error("Bad state: "+h.join("|"))}else if(o[0].charAt(o[0].length-2)!=="/")l.push([o[3],true]);break}if(l[l.length-1][1])break;if(o[1]==="/")zg(n.slice(T,o.index),_,k,l[l.length-1][0]=="comment"?P:b,{c:A,r:E},y,L[A],w,B,t);else{_="";k=Pg(o[0]);T=o.index+o[0].length}break;case"cell":if(o[1]==="/"){if(F.length>0)b.c=F;if((!t.sheetRows||t.sheetRows>E)&&b.v!==undefined){if(t.dense){if(!p[E])p[E]=[];p[E][A]=b}else p[ya(A)+Ta(E)]=b}if(b.HRef){b.l={Target:Yr(b.HRef)};if(b.HRefScreenTip)b.l.Tooltip=b.HRefScreenTip;delete b.HRef;delete b.HRefScreenTip}if(b.MergeAcross||b.MergeDown){z=A+(parseInt(b.MergeAcross,10)|0);V=E+(parseInt(b.MergeDown,10)|0);O.push({s:{c:A,r:E},e:{c:z,r:V}})}if(!t.sheetStubs){if(b.MergeAcross)A=z+1;else++A}else if(b.MergeAcross||b.MergeDown){for(var Y=A;Y<=z;++Y){for(var K=E;K<=V;++K){if(Y>A||K>E){if(t.dense){if(!p[K])p[K]=[];p[K][Y]={t:"z"}}else p[ya(Y)+Ta(K)]={t:"z"}}}}A=z+1}else++A}else{b=Lg(o[0]);if(b.Index)A=+b.Index-1;if(AC.e.c)C.e.c=A;if(o[0].slice(-2)==="/>")++A;F=[]}break;case"row":if(o[1]==="/"||o[0].slice(-2)==="/>"){if(EC.e.r)C.e.r=E;if(o[0].slice(-2)==="/>"){w=Pg(o[0]);if(w.Index)E=+w.Index-1}A=0;++E}else{w=Pg(o[0]);if(w.Index)E=+w.Index-1;H={};if(w.AutoFitHeight=="0"||w.Height){H.hpx=parseInt(w.Height,10);H.hpt=Fl(H.hpx);W[E]=H}if(w.Hidden=="1"){H.hidden=true;W[E]=H}}break;case"worksheet":if(o[1]==="/"){if((h=l.pop())[0]!==o[3])throw new Error("Bad state: "+h.join("|"));v.push(m);if(C.s.r<=C.e.r&&C.s.c<=C.e.c){p["!ref"]=Na(C);if(t.sheetRows&&t.sheetRows<=C.e.r){p["!fullref"]=p["!ref"];C.e.r=t.sheetRows-1;p["!ref"]=Na(C)}}if(O.length)p["!merges"]=O;if(L.length>0)p["!cols"]=L;if(W.length>0)p["!rows"]=W;u[m]=p}else{C={s:{r:2e6,c:2e6},e:{r:0,c:0}};E=A=0;l.push([o[3],false]);h=Pg(o[0]);m=Yr(h.Name);p=t.dense?[]:{};O=[];B=[];W=[];j={name:m,Hidden:0};G.Sheets.push(j)}break;case"table":if(o[1]==="/"){if((h=l.pop())[0]!==o[3])throw new Error("Bad state: "+h.join("|"))}else if(o[0].slice(-2)=="/>")break;else{l.push([o[3],false]);L=[];U=false}break;case"style":if(o[1]==="/")Hg(y,S,t);else S=Pg(o[0]);break;case"numberformat":S.nf=Yr(Pg(o[0]).Format||"General");if(Mg[S.nf])S.nf=Mg[S.nf];for(var J=0;J!=392;++J)if(X[J]==S.nf)break;if(J==392)for(J=57;J!=392;++J)if(X[J]==null){We(S.nf,J);break}break;case"column":if(l[l.length-1][0]!=="table")break;M=Pg(o[0]);if(M.Hidden){M.hidden=true;delete M.Hidden}if(M.Width)M.wpx=parseInt(M.Width,10);if(!U&&M.wpx>10){U=true;yl=Al;for(var q=0;q0)ee.Sheet=G.Sheets.length-1;G.Names.push(ee);break;case"namedcell":break;case"b":break;case"i":break;case"u":break;case"s":break;case"em":break;case"h2":break;case"h3":break;case"sub":break;case"sup":break;case"span":break;case"alignment":break;case"borders":break;case"border":break;case"font":if(o[0].slice(-2)==="/>")break;else if(o[1]==="/")_+=n.slice(x,o.index);else x=o.index+o[0].length;break;case"interior":if(!t.cellStyles)break;S.Interior=Pg(o[0]);break;case"protection":break;case"author":;case"title":;case"description":;case"created":;case"keywords":;case"subject":;case"category":;case"company":;case"lastauthor":;case"lastsaved":;case"lastprinted":;case"version":;case"revision":;case"totaltime":;case"hyperlinkbase":;case"manager":;case"contentstatus":;case"identifier":;case"language":;case"appname":if(o[0].slice(-2)==="/>")break;else if(o[1]==="/")Ci(R,$,n.slice(N,o.index));else N=o.index+o[0].length;break;case"paragraphs":break;case"styles":;case"workbook":if(o[1]==="/"){if((h=l.pop())[0]!==o[3])throw new Error("Bad state: "+h.join("|"))}else l.push([o[3],false]);break;case"comment":if(o[1]==="/"){if((h=l.pop())[0]!==o[3])throw new Error("Bad state: "+h.join("|"));Vg(P);F.push(P)}else{l.push([o[3],false]);h=Pg(o[0]);P={a:h.Author}}break;case"autofilter":if(o[1]==="/"){if((h=l.pop())[0]!==o[3])throw new Error("Bad state: "+h.join("|"))}else if(o[0].charAt(o[0].length-2)!=="/"){var re=Pg(o[0]);p["!autofilter"]={ref:Sh(re.Range).replace(/\$/g,"")};l.push([o[3],true])}break;case"name":break;case"datavalidation":if(o[1]==="/"){if((h=l.pop())[0]!==o[3])throw new Error("Bad state: "+h.join("|"))}else{if(o[0].charAt(o[0].length-2)!=="/")l.push([o[3],true])}break;case"pixelsperinch":break;case"componentoptions":;case"documentproperties":;case"customdocumentproperties":;case"officedocumentsettings":;case"pivottable":;case"pivotcache":;case"names":;case"mapinfo":;case"pagebreaks":;case"querytable":;case"sorting":;case"schema":;case"conditionalformatting":;case"smarttagtype":;case"smarttags":;case"excelworkbook":;case"workbookoptions":;case"worksheetoptions":if(o[1]==="/"){if((h=l.pop())[0]!==o[3])throw new Error("Bad state: "+h.join("|"))}else if(o[0].charAt(o[0].length-2)!=="/")l.push([o[3],true]);break;case"null":break;default:if(l.length==0&&o[3]=="document")return Qm(n,t);if(l.length==0&&o[3]=="uof")return Qm(n,t);var te=true;switch(l[l.length-1][0]){case"officedocumentsettings":switch(o[3]){case"allowpng":break;case"removepersonalinformation":break;case"downloadcomponents":break;case"locationofcomponents":break;case"colors":break;case"color":break;case"index":break;case"rgb":break;case"targetscreensize":break;case"readonlyrecommended":break;default:te=false;}break;case"componentoptions":switch(o[3]){case"toolbar":break;case"hideofficelogo":break;case"spreadsheetautofit":break;case"label":break;case"caption":break;case"maxheight":break;case"maxwidth":break;case"nextsheetnumber":break;default:te=false;}break;case"excelworkbook":switch(o[3]){case"date1904":G.WBProps.date1904=true;break;case"windowheight":break;case"windowwidth":break;case"windowtopx":break;case"windowtopy":break;case"tabratio":break;case"protectstructure":break;case"protectwindow":break;case"protectwindows":break;case"activesheet":break;case"displayinknotes":break;case"firstvisiblesheet":break;case"supbook":break;case"sheetname":break;case"sheetindex":break;case"sheetindexfirst":break;case"sheetindexlast":break;case"dll":break;case"acceptlabelsinformulas":break;case"donotsavelinkvalues":break;case"iteration":break;case"maxiterations":break;case"maxchange":break;case"path":break;case"xct":break;case"count":break;case"selectedsheets":break;case"calculation":break;case"uncalced":break;case"startupprompt":break;case"crn":break;case"externname":break;case"formula":break;case"colfirst":break;case"collast":break;case"wantadvise":break;case"boolean":break;case"error":break;case"text":break;case"ole":break;case"noautorecover":break;case"publishobjects":break;case"donotcalculatebeforesave":break;case"number":break;case"refmoder1c1":break;case"embedsavesmarttags":break;default:te=false;}break;case"workbookoptions":switch(o[3]){case"owcversion":break;case"height":break;case"width":break;default:te=false;}break;case"worksheetoptions":switch(o[3]){case"visible":if(o[0].slice(-2)==="/>"){}else if(o[1]==="/")switch(n.slice(N,o.index)){case"SheetHidden":j.Hidden=1;break;case"SheetVeryHidden":j.Hidden=2;break;}else N=o.index+o[0].length;break;case"header":if(!p["!margins"])Dd(p["!margins"]={},"xlml");if(!isNaN(+Gr(o[0]).Margin))p["!margins"].header=+Gr(o[0]).Margin;break;case"footer":if(!p["!margins"])Dd(p["!margins"]={},"xlml");if(!isNaN(+Gr(o[0]).Margin))p["!margins"].footer=+Gr(o[0]).Margin;break;case"pagemargins":var ae=Gr(o[0]);if(!p["!margins"])Dd(p["!margins"]={},"xlml");if(!isNaN(+ae.Top))p["!margins"].top=+ae.Top;if(!isNaN(+ae.Left))p["!margins"].left=+ae.Left;if(!isNaN(+ae.Right))p["!margins"].right=+ae.Right;if(!isNaN(+ae.Bottom))p["!margins"].bottom=+ae.Bottom;break;case"displayrighttoleft":if(!G.Views)G.Views=[];if(!G.Views[0])G.Views[0]={};G.Views[0].RTL=true;break;case"freezepanes":break;case"frozennosplit":break;case"splithorizontal":;case"splitvertical":break;case"donotdisplaygridlines":break;case"activerow":break;case"activecol":break;case"toprowbottompane":break;case"leftcolumnrightpane":break;case"unsynced":break;case"print":break;case"printerrors":break;case"panes":break;case"scale":break;case"pane":break;case"number":break;case"layout":break;case"pagesetup":break;case"selected":break;case"protectobjects":break;case"enableselection":break;case"protectscenarios":break;case"validprinterinfo":break;case"horizontalresolution":break;case"verticalresolution":break;case"numberofcopies":break;case"activepane":break;case"toprowvisible":break;case"leftcolumnvisible":break;case"fittopage":break;case"rangeselection":break;case"papersizeindex":break;case"pagelayoutzoom":break;case"pagebreakzoom":break;case"filteron":break;case"fitwidth":break;case"fitheight":break;case"commentslayout":break;case"zoom":break;case"lefttoright":break;case"gridlines":break;case"allowsort":break;case"allowfilter":break;case"allowinsertrows":break;case"allowdeleterows":break;case"allowinsertcols":break;case"allowdeletecols":break;case"allowinserthyperlinks":break;case"allowformatcells":break;case"allowsizecols":break;case"allowsizerows":break;case"nosummaryrowsbelowdetail":if(!p["!outline"])p["!outline"]={};p["!outline"].above=true;break;case"tabcolorindex":break;case"donotdisplayheadings":break;case"showpagelayoutzoom":break;case"nosummarycolumnsrightdetail":if(!p["!outline"])p["!outline"]={};p["!outline"].left=true;break;case"blackandwhite":break;case"donotdisplayzeros":break;case"displaypagebreak":break;case"rowcolheadings":break;case"donotdisplayoutline":break;case"noorientation":break;case"allowusepivottables":break;case"zeroheight":break;case"viewablerange":break;case"selection":break;case"protectcontents":break;default:te=false;}break;case"pivottable":;case"pivotcache":switch(o[3]){case"immediateitemsondrop":break;case"showpagemultipleitemlabel":break;case"compactrowindent":break;case"location":break;case"pivotfield":break;case"orientation":break;case"layoutform":break;case"layoutsubtotallocation":break;case"layoutcompactrow":break;case"position":break;case"pivotitem":break;case"datatype":break;case"datafield":break;case"sourcename":break;case"parentfield":break;case"ptlineitems":break;case"ptlineitem":break;case"countofsameitems":break;case"item":break;case"itemtype":break;case"ptsource":break;case"cacheindex":break;case"consolidationreference":break;case"filename":break;case"reference":break;case"nocolumngrand":break;case"norowgrand":break;case"blanklineafteritems":break;case"hidden":break;case"subtotal":break;case"basefield":break;case"mapchilditems":break;case"function":break;case"refreshonfileopen":break;case"printsettitles":break;case"mergelabels":break;case"defaultversion":break;case"refreshname":break;case"refreshdate":break;case"refreshdatecopy":break;case"versionlastrefresh":break;case"versionlastupdate":break;case"versionupdateablemin":break;case"versionrefreshablemin":break;case"calculation":break;default:te=false;}break;case"pagebreaks":switch(o[3]){case"colbreaks":break;case"colbreak":break;case"rowbreaks":break;case"rowbreak":break;case"colstart":break;case"colend":break;case"rowend":break;default:te=false;}break;case"autofilter":switch(o[3]){case"autofiltercolumn":break;case"autofiltercondition":break;case"autofilterand":break;case"autofilteror":break;default:te=false;}break;case"querytable":switch(o[3]){case"id":break;case"autoformatfont":break;case"autoformatpattern":break;case"querysource":break;case"querytype":break;case"enableredirections":break;case"refreshedinxl9":break;case"urlstring":break;case"htmltables":break;case"connection":break;case"commandtext":break;case"refreshinfo":break;case"notitles":break;case"nextid":break;case"columninfo":break;case"overwritecells":break;case"donotpromptforfile":break;case"textwizardsettings":break;case"source":break;case"number":break;case"decimal":break;case"thousandseparator":break;case"trailingminusnumbers":break;case"formatsettings":break;case"fieldtype":break;case"delimiters":break;case"tab":break;case"comma":break;case"autoformatname":break;case"versionlastedit":break;case"versionlastrefresh":break;default:te=false;}break;case"datavalidation":switch(o[3]){case"range":break;case"type":break;case"min":break;case"max":break;case"sort":break;case"descending":break;case"order":break;case"casesensitive":break;case"value":break;case"errorstyle":break;case"errormessage":break;case"errortitle":break;case"inputmessage":break;case"inputtitle":break;case"combohide":break;case"inputhide":break;case"condition":break;case"qualifier":break;case"useblank":break;case"value1":break;case"value2":break;case"format":break;case"cellrangelist":break;default:te=false;}break;case"sorting":;case"conditionalformatting":switch(o[3]){case"range":break;case"type":break;case"min":break;case"max":break;case"sort":break;case"descending":break;case"order":break;case"casesensitive":break;case"value":break;case"errorstyle":break;case"errormessage":break;case"errortitle":break;case"cellrangelist":break;case"inputmessage":break;case"inputtitle":break;case"combohide":break;case"inputhide":break;case"condition":break;case"qualifier":break;case"useblank":break;case"value1":break;case"value2":break;case"format":break;default:te=false;}break;case"mapinfo":;case"schema":;case"data":switch(o[3]){case"map":break;case"entry":break;case"range":break;case"xpath":break;case"field":break;case"xsdtype":break;case"filteron":break;case"aggregate":break;case"elementtype":break;case"attributetype":break;case"schema":;case"element":;case"complextype":;case"datatype":;case"all":;case"attribute":;case"extends":break;case"row":break;default:te=false;}break;case"smarttags":break;default:te=false;break;}if(te)break;if(o[3].match(/!\[CDATA/))break;if(!l[l.length-1][1])throw"Unrecognized tag: "+o[3]+"|"+l.join("|");if(l[l.length-1][0]==="customdocumentproperties"){if(o[0].slice(-2)==="/>")break;else if(o[1]==="/")Bg(I,$,D,n.slice(N,o.index));else{D=o;N=o.index+o[0].length}break}if(t.WTF)throw"Unrecognized tag: "+o[3]+"|"+l.join("|");}var ne={};if(!t.bookSheets&&!t.bookProps)ne.Sheets=u;ne.SheetNames=v;ne.Workbook=G;ne.SSF=br(X);ne.Props=R;ne.Custprops=I;return ne}function jg(e,r){Wb(r=r||{});switch(r.type||"base64"){case"base64":return Gg(k(e),r);case"binary":;case"buffer":;case"file":return Gg(e,r);case"array":return Gg(_(e),r);}}function Xg(e,r){var t=[];if(e.Props)t.push(yi(e.Props,r));if(e.Custprops)t.push(Si(e.Props,e.Custprops,r));return t.join("")}function $g(){return""}function Yg(e,r){var t=[''];r.cellXfs.forEach(function(e,r){var a=[];a.push(kt("NumberFormat",null,{"ss:Format":qr(X[e.numFmtId])}));var n={"ss:ID":"s"+(21+r)};t.push(kt("Style",a.join(""),n))});return kt("Styles",t.join(""))}function Kg(e){return kt("NamedRange",null,{"ss:Name":e.Name,"ss:RefersTo":"="+xh(e.Ref,{r:0,c:0})})}function Jg(e){if(!((e||{}).Workbook||{}).Names)return"";var r=e.Workbook.Names;var t=[];for(var a=0;a");if(e["!margins"].header)n.push(kt("Header",null,{"x:Margin":e["!margins"].header}));if(e["!margins"].footer)n.push(kt("Footer",null,{"x:Margin":e["!margins"].footer}));n.push(kt("PageMargins",null,{"x:Bottom":e["!margins"].bottom||"0.75","x:Left":e["!margins"].left||"0.7","x:Right":e["!margins"].right||"0.7","x:Top":e["!margins"].top||"0.75"}));n.push("")}if(a&&a.Workbook&&a.Workbook.Sheets&&a.Workbook.Sheets[t]){if(a.Workbook.Sheets[t].Hidden)n.push(kt("Visible",a.Workbook.Sheets[t].Hidden==1?"SheetHidden":"SheetVeryHidden",{}));else{for(var i=0;i")}}if(((((a||{}).Workbook||{}).Views||[])[0]||{}).RTL)n.push("");if(e["!protect"]){n.push(bt("ProtectContents","True"));if(e["!protect"].objects)n.push(bt("ProtectObjects","True"));if(e["!protect"].scenarios)n.push(bt("ProtectScenarios","True"));if(e["!protect"].selectLockedCells!=null&&!e["!protect"].selectLockedCells)n.push(bt("EnableSelection","NoSelection"));else if(e["!protect"].selectUnlockedCells!=null&&!e["!protect"].selectUnlockedCells)n.push(bt("EnableSelection","UnlockedCells"));[["formatCells","AllowFormatCells"],["formatColumns","AllowSizeCols"],["formatRows","AllowSizeRows"],["insertColumns","AllowInsertCols"],["insertRows","AllowInsertRows"],["insertHyperlinks","AllowInsertHyperlinks"],["deleteColumns","AllowDeleteCols"],["deleteRows","AllowDeleteRows"],["sort","AllowSort"],["autoFilter","AllowFilter"],["pivotTables","AllowUsePivotTables"]].forEach(function(r){if(e["!protect"][r[0]])n.push("<"+r[1]+"/>")})}if(n.length==0)return"";return kt("WorksheetOptions",n.join(""),{xmlns:_t.x})}function Qg(e){return e.map(function(e){var r=at(e.t||"");var t=kt("ss:Data",r,{xmlns:"http://www.w3.org/TR/REC-html40"});return kt("Comment",t,{"ss:Author":e.a})}).join("")}function em(e,r,t,a,n,i,s){if(!e||e.v==undefined&&e.f==undefined)return"";var f={};if(e.f)f["ss:Formula"]="="+qr(xh(e.f,s));if(e.F&&e.F.slice(0,r.length)==r){var o=Oa(e.F.slice(r.length+1));f["ss:ArrayRange"]="RC:R"+(o.r==s.r?"":"["+(o.r-s.r)+"]")+"C"+(o.c==s.c?"":"["+(o.c-s.c)+"]")}if(e.l&&e.l.Target){f["ss:HRef"]=qr(e.l.Target);if(e.l.Tooltip)f["x:HRefScreenTip"]=qr(e.l.Tooltip)}if(t["!merges"]){var l=t["!merges"];for(var c=0;c!=l.length;++c){if(l[c].s.c!=s.c||l[c].s.r!=s.r)continue;if(l[c].e.c>l[c].s.c)f["ss:MergeAcross"]=l[c].e.c-l[c].s.c;if(l[c].e.r>l[c].s.r)f["ss:MergeDown"]=l[c].e.r-l[c].s.r}}var h="",u="";switch(e.t){case"z":if(!a.sheetStubs)return"";break;case"n":h="Number";u=String(e.v);break;case"b":h="Boolean";u=e.v?"1":"0";break;case"e":h="Error";u=Gn[e.v];break;case"d":h="DateTime";u=new Date(e.v).toISOString();if(e.z==null)e.z=e.z||X[14];break;case"s":h="String";u=rt(e.v||"");break;}var d=Fd(a.cellXfs,e,a);f["ss:StyleID"]="s"+(21+d);f["ss:Index"]=s.c+1;var v=e.v!=null?u:"";var p=e.t=="z"?"":''+v+"";if((e.c||[]).length>0)p+=Qg(e.c);return kt("Cell",p,f)}function rm(e,r){var t='"}function tm(e,r,t,a){if(!e["!ref"])return"";var n=Da(e["!ref"]);var i=e["!merges"]||[],s=0;var f=[];if(e["!cols"])e["!cols"].forEach(function(e,r){Il(e);var t=!!e.width;var a=Nd(r,e);var n={"ss:Index":r+1};if(t)n["ss:Width"]=Sl(a.width);if(e.hidden)n["ss:Hidden"]="1";f.push(kt("Column",null,n))});var o=Array.isArray(e);for(var l=n.s.r;l<=n.e.r;++l){var c=[rm(l,(e["!rows"]||[])[l])];for(var h=n.s.c;h<=n.e.c;++h){var u=false;for(s=0;s!=i.length;++s){if(i[s].s.c>h)continue;if(i[s].s.r>l)continue;if(i[s].e.c");if(c.length>2)f.push(c.join(""))}return f.join("")}function am(e,r,t){var a=[];var n=t.SheetNames[e];var i=t.Sheets[n];var s=i?qg(i,r,e,t):"";if(s.length>0)a.push(""+s+"");s=i?tm(i,r,e,t):"";if(s.length>0)a.push("
    sortiert die Tabelle. Zwei Modi: - * - Normale Tabellen: sortiert
    "+s+"
    ");a.push(Zg(i,r,e,t));return a.join("")}function nm(e,r){if(!r)r={};if(!e.SSF)e.SSF=br(X);if(e.SSF){ze();He(e.SSF);r.revssf=nr(e.SSF);r.revssf[e.SSF[65535]]=0;r.ssf=e.SSF;r.cellXfs=[];Fd(r.cellXfs,{},{revssf:{General:0}})}var t=[];t.push(Xg(e,r));t.push($g(e,r));t.push("");t.push("");for(var a=0;a40)return r;t.l-=4;r.Reserved1=t._R(0,"lpstr-ansi");if(t.length-t.l<=4)return r;a=t._R(4);if(a!==1907505652)return r;r.UnicodeClipboardFormat=Tn(t);a=t._R(4);if(a==0||a>40)return r;t.l-=4;r.Reserved2=t._R(0,"lpwstr")}var sm=[60,1084,2066,2165,2175];function fm(e,r,t,a,n){var i=a;var s=[];var f=t.slice(t.l,t.l+i);if(n&&n.enc&&n.enc.insitu&&f.length>0)switch(e){case 9:;case 521:;case 1033:;case 2057:;case 47:;case 405:;case 225:;case 406:;case 312:;case 404:;case 10:break;case 133:break;default:n.enc.insitu(f);}s.push(f);t.l+=i;var o=Qt(t,t.l),l=mm[o];var c=0;while(l!=null&&sm.indexOf(o)>-1){i=Qt(t,t.l+2);c=t.l+4;if(o==2066)c+=4;else if(o==2165||o==2175){c+=12}f=t.slice(c,t.l+4+i);s.push(f);t.l+=4+i;l=mm[o=Qt(t,t.l)]}var h=R(s);ca(h,0);var u=0;h.lens=[];for(var d=0;d1)return;if(t.sheetRows&&e.r>=t.sheetRows)return;if(t.cellStyles&&r.XF&&r.XF.data)x(e,r,t);delete r.ixfe;delete r.XF;u=e;d=Ra(e);if(!s||!s.s||!s.e)s={s:{r:0,c:0},e:{r:0,c:0}};if(e.rs.e.r)s.e.r=e.r+1;if(e.c+1>s.e.c)s.e.c=e.c+1;if(t.cellFormula&&r.f){for(var a=0;ae.c||k[a][0].s.r>e.r)continue;if(k[a][0].e.c>8)!==$)throw new Error("rt mismatch: "+J+"!="+$);if(K.r==12){e.l+=10;Y-=10}}var q={};if($===10)q=K.f(e,Y,R);else q=fm($,K,e,Y,R);if(B==0&&[9,521,1033,2057].indexOf(U)===-1)continue;switch($){case 34:t.opts.Date1904=y.WBProps.date1904=q;break;case 134:t.opts.WriteProtect=true;break;case 47:if(!R.enc)e.l=0;R.enc=q;if(!r.password)throw new Error("File is password-protected");if(q.valid==null)throw new Error("Encryption scheme unsupported");if(!q.valid)throw new Error("Password is incorrect");break;case 92:R.lastuser=q;break;case 66:var Z=Number(q);switch(Z){case 21010:Z=1200;break;case 32768:Z=1e4;break;case 32769:Z=1252;break;}o(R.codepage=Z);G=true;break;case 317:R.rrtabid=q;break;case 25:R.winlocked=q;break;case 439:t.opts["RefreshAll"]=q;break;case 12:t.opts["CalcCount"]=q;break;case 16:t.opts["CalcDelta"]=q;break;case 17: -t.opts["CalcIter"]=q;break;case 13:t.opts["CalcMode"]=q;break;case 14:t.opts["CalcPrecision"]=q;break;case 95:t.opts["CalcSaveRecalc"]=q;break;case 15:R.CalcRefMode=q;break;case 2211:t.opts.FullCalc=q;break;case 129:if(q.fDialog)n["!type"]="dialog";if(!q.fBelow)(n["!outline"]||(n["!outline"]={})).above=true;if(!q.fRight)(n["!outline"]||(n["!outline"]={})).left=true;break;case 224:E.push(q);break;case 430:M.push([q]);M[M.length-1].XTI=[];break;case 35:;case 547:M[M.length-1].push(q);break;case 24:;case 536:V={Name:q.Name,Ref:fd(q.rgce,s,null,M,R)};if(q.itab>0)V.Sheet=q.itab-1;M.names.push(V);if(!M[0]){M[0]=[];M[0].XTI=[]}M[M.length-1].push(q);if(q.Name=="_xlnm._FilterDatabase"&&q.itab>0)if(q.rgce&&q.rgce[0]&&q.rgce[0][0]&&q.rgce[0][0][0]=="PtgArea3d")z[q.itab-1]={ref:Na(q.rgce[0][0][1][2])};break;case 22:R.ExternCount=q;break;case 23:if(M.length==0){M[0]=[];M[0].XTI=[]}M[M.length-1].XTI=M[M.length-1].XTI.concat(q);M.XTI=M.XTI.concat(q);break;case 2196:if(R.biff<8)break;if(V!=null)V.Comment=q[1];break;case 18:n["!protect"]=q;break;case 19:if(q!==0&&R.WTF)console.error("Password verifier: "+q);break;case 133:{i[q.pos]=q;R.snames.push(q.name)}break;case 10:{if(--B)break;if(s.e){if(s.e.r>0&&s.e.c>0){s.e.r--;s.e.c--;n["!ref"]=Na(s);if(r.sheetRows&&r.sheetRows<=s.e.r){var Q=s.e.r;s.e.r=r.sheetRows-1;n["!fullref"]=n["!ref"];n["!ref"]=Na(s);s.e.r=Q}s.e.r++;s.e.c++}if(N.length>0)n["!merges"]=N;if(D.length>0)n["!objects"]=D;if(F.length>0)n["!cols"]=F;if(P.length>0)n["!rows"]=P;y.Sheets.push(S)}if(c==="")h=n;else a[c]=n;n=r.dense?[]:{}}break;case 9:;case 521:;case 1033:;case 2057:{if(R.biff===8)R.biff={9:2,521:3,1033:4}[$]||{512:2,768:3,1024:4,1280:5,1536:8,2:2,7:2}[q.BIFFVer]||8;R.biffguess=q.BIFFVer==0;if(q.BIFFVer==0&&q.dt==4096){R.biff=5;G=true;o(R.codepage=28591)}if(R.biff==8&&q.BIFFVer==0&&q.dt==16)R.biff=2;if(B++)break;n=r.dense?[]:{};if(R.biff<8&&!G){G=true;o(R.codepage=r.codepage||1252)}if(R.biff<5||q.BIFFVer==0&&q.dt==4096){if(c==="")c="Sheet1";s={s:{r:0,c:0},e:{r:0,c:0}};var ee={pos:e.l-Y,name:c};i[ee.pos]=ee;R.snames.push(c)}else c=(i[j]||{name:""}).name;if(q.dt==32)n["!type"]="chart";if(q.dt==64)n["!type"]="macro";N=[];D=[];R.arrayf=k=[];F=[];P=[];L=false;S={Hidden:(i[j]||{hs:0}).hs,name:c}}break;case 515:;case 3:;case 2:{if(n["!type"]=="chart")if(r.dense?(n[q.r]||[])[q.c]:n[Ra({c:q.c,r:q.r})])++q.c;T={ixfe:q.ixfe,XF:E[q.ixfe]||{},v:q.val,t:"n"};if(W>0)T.z=H[T.ixfe>>8&63];om(T,r,t.opts.Date1904);O({c:q.c,r:q.r},T,r)}break;case 5:;case 517:{T={ixfe:q.ixfe,XF:E[q.ixfe],v:q.val,t:q.t};if(W>0)T.z=H[T.ixfe>>8&63];om(T,r,t.opts.Date1904);O({c:q.c,r:q.r},T,r)}break;case 638:{T={ixfe:q.ixfe,XF:E[q.ixfe],v:q.rknum,t:"n"};if(W>0)T.z=H[T.ixfe>>8&63];om(T,r,t.opts.Date1904);O({c:q.c,r:q.r},T,r)}break;case 189:{for(var re=q.c;re<=q.C;++re){var te=q.rkrec[re-q.c][0];T={ixfe:te,XF:E[te],v:q.rkrec[re-q.c][1],t:"n"};if(W>0)T.z=H[T.ixfe>>8&63];om(T,r,t.opts.Date1904);O({c:re,r:q.r},T,r)}}break;case 6:;case 518:;case 1030:{if(q.val=="String"){f=q;break}T=lm(q.val,q.cell.ixfe,q.tt);T.XF=E[T.ixfe];if(r.cellFormula){var ae=q.formula;if(ae&&ae[0]&&ae[0][0]&&ae[0][0][0]=="PtgExp"){var ne=ae[0][0][1][0],ie=ae[0][0][1][1];var se=Ra({r:ne,c:ie});if(w[se])T.f=""+fd(q.formula,s,q.cell,M,R);else T.F=((r.dense?(n[ne]||[])[ie]:n[se])||{}).F}else T.f=""+fd(q.formula,s,q.cell,M,R)}if(W>0)T.z=H[T.ixfe>>8&63];om(T,r,t.opts.Date1904);O(q.cell,T,r);f=q}break;case 7:;case 519:{if(f){f.val=q;T=lm(q,f.cell.ixfe,"s");T.XF=E[T.ixfe];if(r.cellFormula){T.f=""+fd(f.formula,s,f.cell,M,R)}if(W>0)T.z=H[T.ixfe>>8&63];om(T,r,t.opts.Date1904);O(f.cell,T,r);f=null}else throw new Error("String record expects Formula")}break;case 33:;case 545:{k.push(q);var fe=Ra(q[0].s);v=r.dense?(n[q[0].s.r]||[])[q[0].s.c]:n[fe];if(r.cellFormula&&v){if(!f)break;if(!fe||!v)break;v.f=""+fd(q[1],s,q[0],M,R);v.F=Na(q[0])}}break;case 1212:{if(!r.cellFormula)break;if(d){if(!f)break;w[Ra(f.cell)]=q[0];v=r.dense?(n[f.cell.r]||[])[f.cell.c]:n[Ra(f.cell)];(v||{}).f=""+fd(q[0],s,u,M,R)}}break;case 253:T=lm(l[q.isst].t,q.ixfe,"s");if(l[q.isst].h)T.h=l[q.isst].h;T.XF=E[T.ixfe];if(W>0)T.z=H[T.ixfe>>8&63];om(T,r,t.opts.Date1904);O({c:q.c,r:q.r},T,r);break;case 513:if(r.sheetStubs){T={ixfe:q.ixfe,XF:E[q.ixfe],t:"z"};if(W>0)T.z=H[T.ixfe>>8&63];om(T,r,t.opts.Date1904);O({c:q.c,r:q.r},T,r)}break;case 190:if(r.sheetStubs){for(var oe=q.c;oe<=q.C;++oe){var le=q.ixfe[oe-q.c];T={ixfe:le,XF:E[le],t:"z"};if(W>0)T.z=H[T.ixfe>>8&63];om(T,r,t.opts.Date1904);O({c:oe,r:q.r},T,r)}}break;case 214:;case 516:;case 4:T=lm(q.val,q.ixfe,"s");T.XF=E[T.ixfe];if(W>0)T.z=H[T.ixfe>>8&63];om(T,r,t.opts.Date1904);O({c:q.c,r:q.r},T,r);break;case 0:;case 512:{if(B===1)s=q}break;case 252:{l=q}break;case 1054:{if(R.biff==4){H[W++]=q[1];for(var ce=0;ce=163)We(q[1],W+163)}else We(q[1],q[0])}break;case 30:{H[W++]=q;for(var he=0;he=163)We(q,W+163)}break;case 229:N=N.concat(q);break;case 93:D[q.cmo[0]]=R.lastobj=q;break;case 438:R.lastobj.TxO=q;break;case 127:R.lastobj.ImData=q;break;case 440:{for(b=q[0].s.r;b<=q[0].e.r;++b)for(m=q[0].s.c;m<=q[0].e.c;++m){v=r.dense?(n[b]||[])[m]:n[Ra({c:m,r:b})];if(v)v.l=q[1]}}break;case 2048:{for(b=q[0].s.r;b<=q[0].e.r;++b)for(m=q[0].s.c;m<=q[0].e.c;++m){v=r.dense?(n[b]||[])[m]:n[Ra({c:m,r:b})];if(v&&v.l)v.l.Tooltip=q[1]}}break;case 28:{if(R.biff<=5&&R.biff>=2)break;v=r.dense?(n[q[0].r]||[])[q[0].c]:n[Ra(q[0])];var ue=D[q[2]];if(!v){if(r.dense){if(!n[q[0].r])n[q[0].r]=[];v=n[q[0].r][q[0].c]={t:"z"}}else{v=n[Ra(q[0])]={t:"z"}}s.e.r=Math.max(s.e.r,q[0].r);s.s.r=Math.min(s.s.r,q[0].r);s.e.c=Math.max(s.e.c,q[0].c);s.s.c=Math.min(s.s.c,q[0].c)}if(!v.c)v.c=[];p={a:q[1],t:ue.TxO.t};v.c.push(p)}break;case 2173:Bc(E[q.ixfe],q.ext);break;case 125:{if(!R.cellStyles)break;while(q.e>=q.s){F[q.e--]={width:q.w/256,level:q.level||0,hidden:!!(q.flags&1)};if(!L){L=true;Rl(q.w/256)}Il(F[q.e+1])}}break;case 520:{var de={};if(q.level!=null){P[q.r]=de;de.level=q.level}if(q.hidden){P[q.r]=de;de.hidden=true}if(q.hpt){P[q.r]=de;de.hpt=q.hpt;de.hpx=Pl(q.hpt)}}break;case 38:;case 39:;case 40:;case 41:if(!n["!margins"])Dd(n["!margins"]={});n["!margins"][{38:"left",39:"right",40:"top",41:"bottom"}[$]]=q;break;case 161:if(!n["!margins"])Dd(n["!margins"]={});n["!margins"].header=q.header;n["!margins"].footer=q.footer;break;case 574:if(q.RTL)y.Views[0].RTL=true;break;case 146:C=q;break;case 2198:I=q;break;case 140:A=q;break;case 442:{if(!c)y.WBProps.CodeName=q||"ThisWorkbook";else S.CodeName=q||S.name}break;}}else{if(!K)console.error("Missing Info for XLS Record 0x"+$.toString(16));e.l+=Y}}t.SheetNames=rr(i).sort(function(e,r){return Number(e)-Number(r)}).map(function(e){return i[e].name});if(!r.bookSheets)t.Sheets=a;if(!t.SheetNames.length&&h["!ref"]){t.SheetNames.push("Sheet1");if(t.Sheets)t.Sheets["Sheet1"]=h}else t.Preamble=h;if(t.Sheets)z.forEach(function(e,r){t.Sheets[t.SheetNames[r]]["!autofilter"]=e});t.Strings=l;t.SSF=br(X);if(R.enc)t.Encryption=R.enc;if(I)t.Themes=I;t.Metadata={};if(A!==undefined)t.Metadata.Country=A;if(M.names.length>0)y.Names=M.names;t.Workbook=y;return t}var hm={SI:"e0859ff2f94f6810ab9108002b27b3d9",DSI:"02d5cdd59c2e1b10939708002b2cf9ae",UDI:"05d5cdd59c2e1b10939708002b2cf9ae"};function um(e,r,t){var a=Ke.find(e,"/!DocumentSummaryInformation");if(a&&a.size>0)try{var n=$i(a,Mn,hm.DSI);for(var i in n)r[i]=n[i]}catch(s){if(t.WTF)throw s}var f=Ke.find(e,"/!SummaryInformation");if(f&&f.size>0)try{var o=$i(f,Un,hm.SI);for(var l in o)if(r[l]==null)r[l]=o[l]}catch(s){if(t.WTF)throw s}if(r.HeadingPairs&&r.TitlesOfParts){gi(r.HeadingPairs,r.TitlesOfParts,r,t);delete r.HeadingPairs;delete r.TitlesOfParts}}function dm(e,r){var t=[],a=[],n=[];var i=0,s;var f=tr(Mn,"n");var o=tr(Un,"n");if(e.Props){s=rr(e.Props);for(i=0;i-1||pi.indexOf(n[i][0])>-1)continue;if(n[i][1]==null)continue;l.push(n[i])}if(a.length)Ke.utils.cfb_add(r,"/SummaryInformation",Yi(a,hm.SI,o,Un));if(t.length||l.length)Ke.utils.cfb_add(r,"/DocumentSummaryInformation",Yi(t,hm.DSI,f,Mn,l.length?l:null,hm.UDI))}function vm(e,r){if(!r)r={};Wb(r);l();if(r.codepage)s(r.codepage);var t,a;if(e.FullPaths){if(Ke.find(e,"/encryption"))throw new Error("File is password-protected");t=Ke.find(e,"!CompObj");a=Ke.find(e,"/Workbook")||Ke.find(e,"/Book")}else{switch(r.type){case"base64":e=y(k(e));break;case"binary":e=y(e);break;case"buffer":break;case"array":if(!Array.isArray(e))e=Array.prototype.slice.call(e);break;}ca(e,0);a={content:e}}var n;var i;if(t)im(t);if(r.bookProps&&!r.bookSheets)n={};else{var f=T?"buffer":"array";if(a&&a.content)n=cm(a.content,r);else if((i=Ke.find(e,"PerfectOffice_MAIN"))&&i.content)n=xo.to_workbook(i.content,(r.type=f,r));else if((i=Ke.find(e,"NativeContent_MAIN"))&&i.content)n=xo.to_workbook(i.content,(r.type=f,r));else if((i=Ke.find(e,"MN0"))&&i.content)throw new Error("Unsupported Works 4 for Mac file");else throw new Error("Cannot find Workbook stream");if(r.bookVBA&&e.FullPaths&&Ke.find(e,"/_VBA_PROJECT_CUR/VBA/dir"))n.vbaraw=wh(e)}var o={};if(e.FullPaths)um(e,o,r);n.Props=n.Custprops=o;if(r.bookFiles)n.cfb=e;return n}function pm(e,r){var t=r||{};var a=Ke.utils.cfb_new({root:"R"});var n="/Workbook";switch(t.bookType||"xls"){case"xls":t.bookType="biff8";case"xla":if(!t.bookType)t.bookType="xla";case"biff8":n="/Workbook";t.biff=8;break;case"biff5":n="/Book";t.biff=5;break;default:throw new Error("invalid type "+t.bookType+" for XLS CFB");}Ke.utils.cfb_add(a,n,Lm(e,t));if(t.biff==8&&(e.Props||e.Custprops))dm(e,a);if(t.biff==8&&e.vbaraw)kh(a,Ke.read(e.vbaraw,{type:typeof e.vbaraw=="string"?"binary":"buffer"}));return a}var gm={0:{f:gv},1:{f:Cv},2:{f:Gv},3:{f:Nv},4:{f:xv},5:{f:Wv},6:{f:Kv},7:{f:Lv},8:{f:tp},9:{f:rp},10:{f:Qv},11:{f:ep},12:{f:Sv},13:{f:Xv},14:{f:Fv},15:{f:Rv},16:{f:zv},17:{f:qv},18:{f:Uv},19:{f:ja},20:{},21:{},22:{},23:{},24:{},25:{},26:{},27:{},28:{},29:{},30:{},31:{},32:{},33:{},34:{},35:{T:1},36:{T:-1},37:{T:1},38:{T:-1},39:{f:fg},40:{},42:{},43:{f:Jl},44:{f:Yl},45:{f:ec},46:{f:ic},47:{f:tc},48:{},49:{f:Ba},50:{},51:{f:zc},52:{T:1},53:{T:-1},54:{T:1},55:{T:-1},56:{T:1},57:{T:-1},58:{},59:{},60:{f:ao},62:{f:Yv},63:{f:Zc},64:{f:wp},65:{},66:{},67:{},68:{},69:{},70:{},128:{},129:{T:1},130:{T:-1},131:{T:1,f:ha,p:0},132:{T:-1},133:{T:1},134:{T:-1},135:{T:1},136:{T:-1},137:{T:1,f:pp},138:{T:-1},139:{T:1},140:{T:-1},141:{T:1},142:{T:-1},143:{T:1},144:{T:-1},145:{T:1},146:{T:-1},147:{f:Av},148:{f:wv,p:16},151:{f:op},152:{},153:{f:ng},154:{},155:{},156:{f:tg},157:{},158:{},159:{T:1,f:zo},160:{T:-1},161:{T:1,f:hn},162:{T:-1},163:{T:1},164:{T:-1},165:{T:1},166:{T:-1},167:{},168:{},169:{},170:{},171:{},172:{T:1},173:{T:-1},174:{},175:{},176:{f:ap},177:{T:1},178:{T:-1},179:{T:1},180:{T:-1},181:{T:1},182:{T:-1},183:{T:1},184:{T:-1},185:{T:1},186:{T:-1},187:{T:1},188:{T:-1},189:{T:1},190:{T:-1},191:{T:1},192:{T:-1},193:{T:1},194:{T:-1},195:{T:1},196:{T:-1},197:{T:1},198:{T:-1},199:{T:1},200:{T:-1},201:{T:1},202:{T:-1},203:{T:1},204:{T:-1},205:{T:1},206:{T:-1},207:{T:1},208:{T:-1},209:{T:1},210:{T:-1},211:{T:1},212:{T:-1},213:{T:1},214:{T:-1},215:{T:1},216:{T:-1},217:{T:1},218:{T:-1},219:{T:1},220:{T:-1},221:{T:1},222:{T:-1},223:{T:1},224:{T:-1},225:{T:1},226:{T:-1},227:{T:1},228:{T:-1},229:{T:1},230:{T:-1},231:{T:1},232:{T:-1},233:{T:1},234:{T:-1},235:{T:1},236:{T:-1},237:{T:1},238:{T:-1},239:{T:1},240:{T:-1},241:{T:1},242:{T:-1},243:{T:1},244:{T:-1},245:{T:1},246:{T:-1},247:{T:1},248:{T:-1},249:{T:1},250:{T:-1},251:{T:1},252:{T:-1},253:{T:1},254:{T:-1},255:{T:1},256:{T:-1},257:{T:1},258:{T:-1},259:{T:1},260:{T:-1},261:{T:1},262:{T:-1},263:{T:1},264:{T:-1},265:{T:1},266:{T:-1},267:{T:1},268:{T:-1},269:{T:1},270:{T:-1},271:{T:1},272:{T:-1},273:{T:1},274:{T:-1},275:{T:1},276:{T:-1},277:{},278:{T:1},279:{T:-1},280:{T:1},281:{T:-1},282:{T:1},283:{T:1},284:{T:-1},285:{T:1},286:{T:-1},287:{T:1},288:{T:-1},289:{T:1},290:{T:-1},291:{T:1},292:{T:-1},293:{T:1},294:{T:-1},295:{T:1},296:{T:-1},297:{T:1},298:{T:-1},299:{T:1},300:{T:-1},301:{T:1},302:{T:-1},303:{T:1},304:{T:-1},305:{T:1},306:{T:-1},307:{T:1},308:{T:-1},309:{T:1},310:{T:-1},311:{T:1},312:{T:-1},313:{T:-1},314:{T:1},315:{T:-1},316:{T:1},317:{T:-1},318:{T:1},319:{T:-1},320:{T:1},321:{T:-1},322:{T:1},323:{T:-1},324:{T:1},325:{T:-1},326:{T:1},327:{T:-1},328:{T:1},329:{T:-1},330:{T:1},331:{T:-1},332:{T:1},333:{T:-1},334:{T:1},335:{f:Wc},336:{T:-1},337:{f:jc,T:1},338:{T:-1},339:{T:1},340:{T:-1},341:{T:1},342:{T:-1},343:{T:1},344:{T:-1},345:{T:1},346:{T:-1},347:{T:1},348:{T:-1},349:{T:1},350:{T:-1},351:{},352:{},353:{T:1},354:{T:-1},355:{f:nn},357:{},358:{},359:{},360:{T:1},361:{},362:{f:Ff},363:{},364:{},366:{},367:{},368:{},369:{},370:{},371:{},372:{T:1},373:{T:-1},374:{T:1},375:{T:-1},376:{T:1},377:{T:-1},378:{T:1},379:{T:-1},380:{T:1},381:{T:-1},382:{T:1},383:{T:-1},384:{T:1},385:{T:-1},386:{T:1},387:{T:-1},388:{T:1},389:{T:-1},390:{T:1},391:{T:-1},392:{T:1},393:{T:-1},394:{T:1},395:{T:-1},396:{},397:{},398:{},399:{},400:{},401:{T:1},403:{},404:{},405:{},406:{},407:{},408:{},409:{},410:{},411:{},412:{},413:{},414:{},415:{},416:{},417:{},418:{},419:{},420:{},421:{},422:{T:1},423:{T:1},424:{T:-1},425:{T:-1},426:{f:lp},427:{f:cp},428:{},429:{T:1},430:{T:-1},431:{T:1},432:{T:-1},433:{T:1},434:{T:-1},435:{T:1},436:{T:-1},437:{T:1},438:{T:-1},439:{T:1},440:{T:-1},441:{T:1},442:{T:-1},443:{T:1},444:{T:-1},445:{T:1},446:{T:-1},447:{T:1},448:{T:-1},449:{T:1},450:{T:-1},451:{T:1},452:{T:-1},453:{T:1},454:{T:-1},455:{T:1},456:{T:-1},457:{T:1},458:{T:-1},459:{T:1},460:{T:-1},461:{T:1},462:{T:-1},463:{T:1},464:{T:-1},465:{T:1},466:{T:-1},467:{T:1},468:{T:-1},469:{T:1},470:{T:-1},471:{},472:{},473:{T:1},474:{T:-1},475:{},476:{f:dp},477:{},478:{},479:{T:1},480:{T:-1},481:{T:1},482:{T:-1},483:{T:1},484:{T:-1},485:{f:Tv},486:{T:1},487:{T:-1},488:{T:1},489:{T:-1},490:{T:1},491:{T:-1},492:{T:1},493:{T:-1},494:{f:sp},495:{T:1},496:{T:-1},497:{T:1},498:{T:-1},499:{},500:{T:1},501:{T:-1},502:{T:1},503:{T:-1},504:{},505:{T:1},506:{T:-1},507:{},508:{T:1},509:{T:-1},510:{T:1},511:{T:-1},512:{},513:{},514:{T:1},515:{T:-1},516:{T:1},517:{T:-1},518:{T:1},519:{T:-1},520:{T:1},521:{T:-1},522:{},523:{},524:{},525:{},526:{},527:{},528:{T:1},529:{T:-1},530:{T:1},531:{T:-1},532:{T:1},533:{T:-1},534:{},535:{},536:{},537:{},538:{T:1},539:{T:-1},540:{T:1},541:{T:-1},542:{T:1},548:{},549:{},550:{f:nn},551:{},552:{},553:{},554:{T:1},555:{T:-1},556:{T:1},557:{T:-1},558:{T:1},559:{T:-1},560:{T:1},561:{T:-1},562:{},564:{},565:{T:1},566:{T:-1},569:{T:1},570:{T:-1},572:{},573:{T:1},574:{T:-1},577:{},578:{},579:{},580:{},581:{},582:{},583:{},584:{},585:{},586:{},587:{},588:{T:-1},589:{},590:{T:1},591:{T:-1},592:{T:1},593:{T:-1},594:{T:1},595:{T:-1},596:{},597:{T:1},598:{T:-1},599:{T:1},600:{T:-1},601:{T:1},602:{T:-1},603:{T:1},604:{T:-1},605:{T:1},606:{T:-1},607:{},608:{T:1},609:{T:-1},610:{},611:{T:1},612:{T:-1},613:{T:1},614:{T:-1},615:{T:1},616:{T:-1},617:{T:1},618:{T:-1},619:{T:1},620:{T:-1},625:{},626:{T:1},627:{T:-1},628:{T:1},629:{T:-1},630:{T:1},631:{T:-1},632:{f:vh},633:{T:1},634:{T:-1},635:{T:1,f:uh},636:{T:-1},637:{f:$a},638:{T:1},639:{},640:{T:-1},641:{T:1},642:{T:-1},643:{T:1},644:{},645:{T:-1},646:{T:1},648:{T:1},649:{},650:{T:-1},651:{f:Up},652:{},653:{T:1},654:{T:-1},655:{T:1},656:{T:-1},657:{T:1},658:{T:-1},659:{},660:{T:1},661:{},662:{T:-1},663:{},664:{T:1},665:{},666:{T:-1},667:{},668:{},669:{},671:{T:1},672:{T:-1},673:{T:1},674:{T:-1},675:{},676:{},677:{},678:{},679:{},680:{},681:{},1024:{},1025:{},1026:{T:1},1027:{T:-1},1028:{T:1},1029:{T:-1},1030:{},1031:{T:1},1032:{T:-1},1033:{T:1},1034:{T:-1},1035:{},1036:{},1037:{},1038:{T:1},1039:{T:-1},1040:{},1041:{T:1},1042:{T:-1},1043:{},1044:{},1045:{},1046:{T:1},1047:{T:-1},1048:{T:1},1049:{T:-1},1050:{},1051:{T:1},1052:{T:1},1053:{f:kp},1054:{T:1},1055:{},1056:{T:1},1057:{T:-1},1058:{T:1},1059:{T:-1},1061:{},1062:{T:1},1063:{T:-1},1064:{T:1},1065:{T:-1},1066:{T:1},1067:{T:-1},1068:{T:1},1069:{T:-1},1070:{T:1},1071:{T:-1},1072:{T:1},1073:{T:-1},1075:{T:1},1076:{T:-1},1077:{T:1},1078:{T:-1},1079:{T:1},1080:{T:-1},1081:{T:1},1082:{T:-1},1083:{T:1},1084:{T:-1},1085:{},1086:{T:1},1087:{T:-1},1088:{T:1},1089:{T:-1},1090:{T:1},1091:{T:-1},1092:{T:1},1093:{T:-1},1094:{T:1},1095:{T:-1},1096:{},1097:{T:1},1098:{},1099:{T:-1},1100:{T:1},1101:{T:-1},1102:{},1103:{},1104:{},1105:{},1111:{},1112:{},1113:{T:1},1114:{T:-1},1115:{T:1},1116:{T:-1},1117:{},1118:{T:1},1119:{T:-1},1120:{T:1},1121:{T:-1},1122:{T:1},1123:{T:-1},1124:{T:1},1125:{T:-1},1126:{},1128:{T:1},1129:{T:-1},1130:{},1131:{T:1},1132:{T:-1},1133:{T:1},1134:{T:-1},1135:{T:1},1136:{T:-1},1137:{T:1},1138:{T:-1},1139:{T:1},1140:{T:-1},1141:{},1142:{T:1},1143:{T:-1},1144:{T:1},1145:{T:-1},1146:{},1147:{T:1},1148:{T:-1},1149:{T:1},1150:{T:-1},1152:{T:1},1153:{T:-1},1154:{T:-1},1155:{T:-1},1156:{T:-1},1157:{T:1},1158:{T:-1},1159:{T:1},1160:{T:-1},1161:{T:1},1162:{T:-1},1163:{T:1},1164:{T:-1},1165:{T:1},1166:{T:-1},1167:{T:1},1168:{T:-1},1169:{T:1},1170:{T:-1},1171:{},1172:{T:1},1173:{T:-1},1177:{},1178:{T:1},1180:{},1181:{},1182:{},2048:{T:1},2049:{T:-1},2050:{},2051:{T:1},2052:{T:-1},2053:{},2054:{},2055:{T:1},2056:{T:-1},2057:{T:1},2058:{T:-1},2060:{},2067:{},2068:{T:1},2069:{T:-1},2070:{},2071:{},2072:{T:1},2073:{T:-1},2075:{},2076:{},2077:{T:1},2078:{T:-1},2079:{},2080:{T:1},2081:{T:-1},2082:{},2083:{T:1},2084:{T:-1},2085:{T:1},2086:{T:-1},2087:{T:1},2088:{T:-1},2089:{T:1},2090:{T:-1},2091:{},2092:{},2093:{T:1},2094:{T:-1},2095:{},2096:{T:1},2097:{T:-1},2098:{T:1},2099:{T:-1},2100:{T:1},2101:{T:-1},2102:{},2103:{T:1},2104:{T:-1},2105:{},2106:{T:1},2107:{T:-1},2108:{},2109:{T:1},2110:{T:-1},2111:{T:1},2112:{T:-1},2113:{T:1},2114:{T:-1},2115:{},2116:{},2117:{},2118:{T:1},2119:{T:-1},2120:{},2121:{T:1},2122:{T:-1},2123:{T:1},2124:{T:-1},2125:{},2126:{T:1},2127:{T:-1},2128:{},2129:{T:1},2130:{T:-1},2131:{T:1},2132:{T:-1},2133:{T:1},2134:{},2135:{},2136:{},2137:{T:1},2138:{T:-1},2139:{T:1},2140:{T:-1},2141:{},3072:{},3073:{},4096:{T:1},4097:{T:-1},5002:{T:1},5003:{T:-1},5081:{T:1},5082:{T:-1},5083:{},5084:{T:1},5085:{T:-1},5086:{T:1},5087:{T:-1},5088:{},5089:{},5090:{},5092:{T:1},5093:{T:-1},5094:{},5095:{T:1},5096:{T:-1},5097:{},5099:{},65535:{n:""}};var mm={6:{f:vd},10:{f:Ki},12:{f:es},13:{f:es},14:{f:Zi},15:{f:Zi},16:{f:dn},17:{f:Zi},18:{f:Zi},19:{f:es},20:{f:Of},21:{f:Of},23:{f:Ff},24:{f:Df},25:{f:Zi},26:{},27:{},28:{f:Hf},29:{},34:{f:Zi},35:{f:If},38:{f:dn},39:{f:dn},40:{f:dn},41:{f:dn},42:{f:Zi},43:{f:Zi},47:{f:pl},49:{f:sf},51:{f:es},60:{},61:{f:ef},64:{f:Zi},65:{f:nf},66:{f:es},77:{},80:{},81:{},82:{},85:{f:es},89:{},90:{},91:{},92:{f:zs},93:{f:Gf},94:{},95:{f:Zi},96:{},97:{},99:{f:Zi},125:{f:ao},128:{f:Ef},129:{f:Gs},130:{f:es},131:{f:Zi},132:{f:Zi},133:{f:js},134:{},140:{f:Zf},141:{f:es},144:{},146:{f:ro},151:{},152:{},153:{},154:{},155:{},156:{f:es},157:{},158:{},160:{f:lo},161:{f:io},174:{},175:{},176:{},177:{},178:{},180:{},181:{},182:{},184:{},185:{},189:{f:bf},190:{f:wf},193:{f:Ki},197:{},198:{},199:{},200:{},201:{},202:{f:Zi},203:{},204:{},205:{},206:{},207:{},208:{},209:{},210:{},211:{},213:{},215:{},216:{},217:{},218:{f:es},220:{},221:{f:Zi},222:{},224:{f:Tf},225:{f:Hs},226:{f:Ki},227:{},229:{f:zf},233:{},235:{},236:{},237:{},239:{},240:{},241:{},242:{},244:{},245:{},246:{},247:{},248:{},249:{},251:{},252:{f:$s},253:{f:of},255:{f:Ks},256:{},259:{},290:{},311:{},312:{},315:{},317:{f:ts},318:{},319:{},320:{},330:{},331:{},333:{},334:{},335:{},336:{},337:{},338:{},339:{},340:{},351:{},352:{f:Zi},353:{f:Ki},401:{},402:{},403:{},404:{},405:{},406:{},407:{},408:{},425:{},426:{},427:{},428:{},429:{},430:{f:Rf},431:{f:Zi},432:{},433:{},434:{},437:{},438:{f:$f},439:{f:Zi},440:{f:Yf},441:{},442:{f:ls},443:{},444:{f:es},445:{},446:{},448:{f:Ki},449:{f:Zs,r:2},450:{f:Ki},512:{f:pf},513:{f:oo},515:{f:_f},516:{f:cf},517:{f:yf},519:{f:co},520:{f:Js},523:{},545:{f:Uf},549:{f:Qs},566:{},574:{f:tf},638:{f:mf},659:{},1048:{},1054:{f:uf},1084:{},1212:{f:Mf},2048:{f:Jf},2049:{},2050:{},2051:{},2052:{},2053:{},2054:{},2055:{},2056:{},2057:{f:Bs},2058:{},2059:{},2060:{},2061:{},2062:{},2063:{},2064:{},2066:{},2067:{},2128:{},2129:{},2130:{},2131:{},2132:{},2133:{},2134:{},2135:{},2136:{},2137:{},2138:{},2146:{},2147:{r:12},2148:{},2149:{},2150:{},2151:{f:Ki},2152:{},2154:{},2155:{},2156:{},2161:{},2162:{},2164:{},2165:{},2166:{},2167:{},2168:{},2169:{},2170:{},2171:{},2172:{f:to,r:12},2173:{f:Uc,r:12},2174:{},2175:{},2180:{},2181:{},2182:{},2183:{},2184:{},2185:{},2186:{},2187:{},2188:{f:Zi,r:12},2189:{},2190:{r:12},2191:{},2192:{},2194:{},2195:{},2196:{f:Lf,r:12},2197:{},2198:{f:Nc,r:12},2199:{},2200:{},2201:{},2202:{f:Bf,r:12},2203:{f:Ki},2204:{},2205:{},2206:{},2207:{},2211:{f:qs},2212:{},2213:{},2214:{},2215:{},4097:{},4098:{},4099:{},4102:{},4103:{},4105:{},4106:{},4107:{},4108:{},4109:{},4116:{},4117:{},4118:{},4119:{},4120:{},4121:{},4122:{},4123:{},4124:{},4125:{},4126:{},4127:{},4128:{},4129:{},4130:{},4132:{},4133:{},4134:{f:es},4135:{},4146:{},4147:{},4148:{},4149:{},4154:{},4156:{},4157:{},4158:{},4159:{},4160:{},4161:{},4163:{},4164:{f:so},4165:{},4166:{},4168:{},4170:{},4171:{},4174:{},4175:{},4176:{},4177:{},4187:{},4188:{f:eo},4189:{},4191:{},4192:{},4193:{},4194:{},4195:{},4196:{},4197:{},4198:{},4199:{},4200:{},0:{f:pf},1:{},2:{f:go},3:{f:vo},4:{f:uo},5:{f:yf},7:{f:bo},8:{},9:{f:Bs},11:{},22:{f:es},30:{f:vf},31:{},32:{},33:{f:Uf},36:{},37:{f:Qs},50:{f:wo},62:{},52:{},67:{},68:{f:es},69:{},86:{},126:{},127:{f:ho},135:{},136:{},137:{},145:{},148:{},149:{},150:{},169:{},171:{},188:{},191:{},192:{},194:{},195:{},214:{f:ko},223:{},234:{},354:{},421:{},518:{f:vd},521:{f:Bs},536:{f:Df},547:{f:If},561:{},579:{},1030:{f:vd},1033:{f:Bs},1091:{},2157:{},2163:{},2177:{},2240:{},2241:{},2242:{},2243:{},2244:{},2245:{},2246:{},2247:{},2248:{},2249:{},2250:{},2251:{},2262:{r:12},29282:{}};function bm(e,r,t,a){var n=r;if(isNaN(n))return;var i=a||(t||[]).length||0;var s=e.next(4);s._W(2,n);s._W(2,i);if(i>0&&Jt(t))e.push(t)}function wm(e,r,t,a){var n=a||(t||[]).length||0;if(n<=8224)return bm(e,r,t,n);var i=r;if(isNaN(i))return;var s=t.parts||[],f=0;var o=0,l=0;while(l+(s[f]||8224)<=8224){l+=s[f]||8224;f++}var c=e.next(4);c._W(2,i);c._W(2,l);e.push(t.slice(o,o+l));o+=l;while(o=0&&n<65536)bm(e,2,mo(t,a,n));else bm(e,3,po(t,a,n));return;case"b":;case"e":bm(e,5,Tm(t,a,r.v,r.t));return;case"s":;case"str":bm(e,4,Am(t,a,(r.v||"").slice(0,255)));return;}bm(e,1,km(null,t,a))}function Cm(e,r,t,a){var n=Array.isArray(r);var i=Da(r["!ref"]||"A1"),s,f="",o=[];if(i.e.c>255||i.e.r>16383){if(a.WTF)throw new Error("Range "+(r["!ref"]||"A1")+" exceeds format limit A1:IV16384");i.e.c=Math.min(i.e.c,255);i.e.r=Math.min(i.e.c,16383);s=Na(i)}for(var l=i.s.r;l<=i.e.r;++l){f=Ta(l);for(var c=i.s.c;c<=i.e.c;++c){if(l===i.s.r)o[c]=ya(c);s=o[c]+f;var h=n?(r[l]||[])[c]:r[s];if(!h)continue;Em(e,h,l,c,a)}}}function ym(e,r){var t=r||{};if(g!=null&&t.dense==null)t.dense=g;var a=va();var n=0;for(var i=0;i255||d.e.r>=v){if(r.WTF)throw new Error("Range "+(i["!ref"]||"A1")+" exceeds format limit A1:IV16384");d.e.c=Math.min(d.e.c,255);d.e.r=Math.min(d.e.c,v-1)}bm(a,2057,Ws(t,16,r));bm(a,13,rs(1));bm(a,12,rs(100));bm(a,15,Qi(true));bm(a,17,Qi(false));bm(a,16,vn(.001));bm(a,95,Qi(true));bm(a,42,Qi(false));bm(a,43,Qi(false));bm(a,130,rs(1));bm(a,128,Cf([0,0]));bm(a,131,Qi(false));bm(a,132,Qi(false));if(l)Im(a,i["!cols"]);bm(a,512,gf(d,r));if(l)i["!links"]=[];for(var p=d.s.r;p<=d.e.r;++p){h=Ta(p);for(var g=d.s.c;g<=d.e.c;++g){if(p===d.s.r)u[g]=ya(g);c=u[g]+h;var m=o?(i[p]||[])[g]:i[c];if(!m)continue;Nm(a,m,p,g,r);if(l&&m.l)i["!links"].push([c,m.l])}}var b=f.CodeName||f.name||n;if(l)bm(a,574,af((s.Views||[])[0]));if(l&&(i["!merges"]||[]).length)bm(a,229,Vf(i["!merges"]));if(l)Rm(a,i);bm(a,442,hs(b,r));if(l)xm(a,i);bm(a,10);return a.end()}function Fm(e,r,t){var a=va();var n=(e||{}).Workbook||{};var i=n.Sheets||[];var s=n.WBProps||{};var f=t.biff==8,o=t.biff==5;bm(a,2057,Ws(e,5,t));if(t.bookType=="xla")bm(a,135);bm(a,225,f?rs(1200):null);bm(a,193,Ji(2));if(o)bm(a,191);if(o)bm(a,192);bm(a,226);bm(a,92,Vs("SheetJS",t));bm(a,66,rs(f?1200:1252));if(f)bm(a,353,rs(0));if(f)bm(a,448);bm(a,317,fo(e.SheetNames.length));if(f&&e.vbaraw)bm(a,211);if(f&&e.vbaraw){var l=s.CodeName||"ThisWorkbook";bm(a,442,hs(l,t))}bm(a,156,rs(17));bm(a,25,Qi(false));bm(a,18,Qi(false));bm(a,19,rs(0));if(f)bm(a,431,Qi(false));if(f)bm(a,444,rs(0));bm(a,61,rf(t));bm(a,64,Qi(false));bm(a,141,rs(0));bm(a,34,Qi(Yp(e)=="true"));bm(a,14,Qi(true));if(f)bm(a,439,Qi(false));bm(a,218,rs(0));Sm(a,e,t);_m(a,e.SSF,t);Om(a,t);if(f)bm(a,352,Qi(false));var c=a.end();var h=va();if(f)bm(h,140,Qf());if(f&&t.Strings)wm(h,252,Ys(t.Strings,t));bm(h,10);var u=h.end();var d=va();var v=0,p=0;for(p=0;p255){if(typeof console!="undefined"&&console.error)console.error("Worksheet '"+e.SheetNames[t]+"' extends beyond column IV (255). Data may be lost.")}}var i=r||{};switch(i.biff||2){case 8:;case 5:return Pm(e,r);case 4:;case 3:;case 2:return ym(e,r);}throw new Error("invalid type "+i.bookType+" for BIFF")}function Mm(e,r){var t=r||{};if(g!=null&&t.dense==null)t.dense=g;var a=t.dense?[]:{};e=e.replace(//g,"");var n=e.match(/");var i=e.match(/<\/table/i);var s=n.index,f=i&&i.index||e.length;var o=Er(e.slice(s,f),/(:?]*>)/i,"");var l=-1,c=0,h=0,u=0;var d={s:{r:1e7,c:1e7},e:{r:0,c:0}};var v=[];for(s=0;s/i);for(f=0;f"))>-1)k=k.slice(T+1);for(var A=0;A")));u=C.colspan?+C.colspan:1;if((h=+C.rowspan)>1||u>1)v.push({s:{r:l,c:c},e:{r:l+(h||1)-1,c:c+u-1}});var y=C.t||C["data-t"]||"";if(!k.length){c+=u;continue}k=ut(k);if(d.s.r>l)d.s.r=l;if(d.e.rc)d.s.c=c;if(d.e.ct||n[l].s.c>s)continue;if(n[l].e.r1)d.rowspan=f;if(o>1)d.colspan=o;if(a.editable)u=''+u+"";else if(h){d["data-t"]=h&&h.t||"z";if(h.v!=null)d["data-v"]=h.v;if(h.z!=null)d["data-z"]=h.z;if(h.l&&(h.l.Target||"#").charAt(0)!="#")u=''+u+""}d.id=(a.id||"sjs")+"-"+c;i.push(kt("td",u,d))}var v="";return v+i.join("")+""}var Bm='SheetJS Table Export';var Wm="";function Hm(e,r){var t=e.match(/[\s\S]*?<\/table>/gi);if(!t||t.length==0)throw new Error("Invalid HTML: could not find
    ");if(t.length==1)return La(Mm(t[0],r),r);var a=Nw();t.forEach(function(e,t){Dw(a,Mm(e,r),"Sheet"+(t+1))});return a}function zm(e,r,t){var a=[];return a.join("")+""}function Vm(e,r){var t=r||{};var a=t.header!=null?t.header:Bm;var n=t.footer!=null?t.footer:Wm;var i=[a];var s=Ia(e["!ref"]);t.dense=Array.isArray(e);i.push(zm(e,s,t));for(var f=s.s.r;f<=s.e.r;++f)i.push(Um(e,s,f,t));i.push("
    "+n);return i.join("")}function Gm(e,r,t){var a=t||{};if(g!=null)a.dense=g;var n=0,i=0;if(a.origin!=null){if(typeof a.origin=="number")n=a.origin;else{var s=typeof a.origin=="string"?Oa(a.origin):a.origin;n=s.r;i=s.c}}var f=r.getElementsByTagName("tr"); -var o=Math.min(a.sheetRows||1e7,f.length);var l={s:{r:0,c:0},e:{r:n,c:i}};if(e["!ref"]){var c=Ia(e["!ref"]);l.s.r=Math.min(l.s.r,c.s.r);l.s.c=Math.min(l.s.c,c.s.c);l.e.r=Math.max(l.e.r,c.e.r);l.e.c=Math.max(l.e.c,c.e.c);if(n==-1)l.e.r=n=c.e.r+1}var h=[],u=0;var d=e["!rows"]||(e["!rows"]=[]);var v=0,p=0,m=0,b=0,w=0,k=0;if(!e["!cols"])e["!cols"]=[];for(;v1||k>1)h.push({s:{r:p+n,c:b+i},e:{r:p+n+(w||1)-1,c:b+i+(k||1)-1}});var _={t:"s",v:C};var x=E.getAttribute("data-t")||E.getAttribute("t")||"";if(C!=null){if(C.length==0)_.t=x||"z";else if(a.raw||C.trim().length==0||x=="s"){}else if(C==="TRUE")_={t:"b",v:true};else if(C==="FALSE")_={t:"b",v:false};else if(!isNaN(kr(C)))_={t:"n",v:kr(C)};else if(!isNaN(Ar(C).getDate())){_={t:"d",v:gr(C)};if(!a.cellDates)_={t:"n",v:fr(_.v)};_.z=a.dateNF||X[14]}}if(_.z===undefined&&y!=null)_.z=y;var O="",R=E.getElementsByTagName("A");if(R&&R.length)for(var I=0;I=o)e["!fullref"]=Na((l.e.r=f.length-v+p-1+n,l));return e}function jm(e,r){var t=r||{};var a=t.dense?[]:{};return Gm(a,e,r)}function Xm(e,r){return La(jm(e,r),r)}function $m(e){var r="";var t=Ym(e);if(t)r=t(e).getPropertyValue("display");if(!r)r=e.style&&e.style.display;return r==="none"}function Ym(e){if(e.ownerDocument.defaultView&&typeof e.ownerDocument.defaultView.getComputedStyle==="function")return e.ownerDocument.defaultView.getComputedStyle;if(typeof getComputedStyle==="function")return getComputedStyle;return null}function Km(e){var r=e.replace(/[\t\r\n]/g," ").trim().replace(/ +/g," ").replace(//g," ").replace(//g,function(e,r){return Array(parseInt(r,10)+1).join(" ")}).replace(/]*\/>/g,"\t").replace(//g,"\n");var t=Yr(r.replace(/<[^>]*>/g,""));return[t]}var Jm={day:["d","dd"],month:["m","mm"],year:["y","yy"],hours:["h","hh"],minutes:["m","mm"],seconds:["s","ss"],"am-pm":["A/P","AM/PM"],"day-of-week":["ddd","dddd"],era:["e","ee"],quarter:["\\Qm",'m\\"th quarter"']};function qm(e,r){var t=r||{};if(g!=null&&t.dense==null)t.dense=g;var a=Et(e);var n=[],i;var s;var f={name:""},o="",l=0;var c;var h;var u={},d=[];var v=t.dense?[]:{};var p,m;var b={value:""};var w="",k=0,T;var A=[];var E=-1,C=-1,y={s:{r:1e6,c:1e7},e:{r:0,c:0}};var S=0;var _={};var x=[],O={},R=0,I=0;var N=[],D=1,F=1;var P=[];var L={Names:[]};var M={};var U=["",""];var B=[],W={};var H="",z=0;var V=false,G=false;var j=0;Ct.lastIndex=0;a=a.replace(//gm,"").replace(//gm,"");while(p=Ct.exec(a))switch(p[3]=p[3].replace(/_.*$/,"")){case"table":;case"工作表":if(p[1]==="/"){if(y.e.c>=y.s.c&&y.e.r>=y.s.r)v["!ref"]=Na(y);else v["!ref"]="A1:A1";if(t.sheetRows>0&&t.sheetRows<=y.e.r){v["!fullref"]=v["!ref"];y.e.r=t.sheetRows-1;v["!ref"]=Na(y)}if(x.length)v["!merges"]=x;if(N.length)v["!rows"]=N;c.name=c["名称"]||c.name;if(typeof JSON!=="undefined")JSON.stringify(c);d.push(c.name);u[c.name]=v;G=false}else if(p[0].charAt(p[0].length-2)!=="/"){c=Gr(p[0],false);E=C=-1;y.s.r=y.s.c=1e7;y.e.r=y.e.c=0;v=t.dense?[]:{};x=[];N=[];G=true}break;case"table-row-group":if(p[1]==="/")--S;else++S;break;case"table-row":;case"行":if(p[1]==="/"){E+=D;D=1;break}h=Gr(p[0],false);if(h["行号"])E=h["行号"]-1;else if(E==-1)E=0;D=+h["number-rows-repeated"]||1;if(D<10)for(j=0;j0)N[E+j]={level:S};C=-1;break;case"covered-table-cell":if(p[1]!=="/")++C;if(t.sheetStubs){if(t.dense){if(!v[E])v[E]=[];v[E][C]={t:"z"}}else v[Ra({r:E,c:C})]={t:"z"}}w="";A=[];break;case"table-cell":;case"数据":if(p[0].charAt(p[0].length-2)==="/"){++C;b=Gr(p[0],false);F=parseInt(b["number-columns-repeated"]||"1",10);m={t:"z",v:null};if(b.formula&&t.cellFormula!=false)m.f=Cd(Yr(b.formula));if((b["数据类型"]||b["value-type"])=="string"){m.t="s";m.v=Yr(b["string-value"]||"");if(t.dense){if(!v[E])v[E]=[];v[E][C]=m}else{v[Ra({r:E,c:C})]=m}}C+=F-1}else if(p[1]!=="/"){++C;w="";k=0;A=[];F=1;var X=D?E+D-1:E;if(C>y.e.c)y.e.c=C;if(Cy.e.r)y.e.r=X;b=Gr(p[0],false);B=[];W={};m={t:b["数据类型"]||b["value-type"],v:null};if(t.cellFormula){if(b.formula)b.formula=Yr(b.formula);if(b["number-matrix-columns-spanned"]&&b["number-matrix-rows-spanned"]){R=parseInt(b["number-matrix-rows-spanned"],10)||0;I=parseInt(b["number-matrix-columns-spanned"],10)||0;O={s:{r:E,c:C},e:{r:E+R-1,c:C+I-1}};m.F=Na(O);P.push([O,m.F])}if(b.formula)m.f=Cd(b.formula);else for(j=0;j=P[j][0].s.r&&E<=P[j][0].e.r)if(C>=P[j][0].s.c&&C<=P[j][0].e.c)m.F=P[j][1]}if(b["number-columns-spanned"]||b["number-rows-spanned"]){R=parseInt(b["number-rows-spanned"],10)||0;I=parseInt(b["number-columns-spanned"],10)||0;O={s:{r:E,c:C},e:{r:E+R-1,c:C+I-1}};x.push(O)}if(b["number-columns-repeated"])F=parseInt(b["number-columns-repeated"],10);switch(m.t){case"boolean":m.t="b";m.v=nt(b["boolean-value"]);break;case"float":m.t="n";m.v=parseFloat(b.value);break;case"percentage":m.t="n";m.v=parseFloat(b.value);break;case"currency":m.t="n";m.v=parseFloat(b.value);break;case"date":m.t="d";m.v=gr(b["date-value"]);if(!t.cellDates){m.t="n";m.v=fr(m.v)}m.z="m/d/yy";break;case"time":m.t="n";m.v=ur(b["time-value"])/86400;if(t.cellDates){m.t="d";m.v=hr(m.v)}m.z="HH:MM:SS";break;case"number":m.t="n";m.v=parseFloat(b["数据数值"]);break;default:if(m.t==="string"||m.t==="text"||!m.t){m.t="s";if(b["string-value"]!=null){w=Yr(b["string-value"]);A=[]}}else throw new Error("Unsupported value type "+m.t);}}else{V=false;if(m.t==="s"){m.v=w||"";if(A.length)m.R=A;V=k==0}if(M.Target)m.l=M;if(B.length>0){m.c=B;B=[]}if(w&&t.cellText!==false)m.w=w;if(V){m.t="z";delete m.v}if(!V||t.sheetStubs){if(!(t.sheetRows&&t.sheetRows<=E)){for(var $=0;$0)v[E+$][C+F]=br(m)}else{v[Ra({r:E+$,c:C})]=m;while(--F>0)v[Ra({r:E+$,c:C+F})]=br(m)}if(y.e.c<=C)y.e.c=C}}}F=parseInt(b["number-columns-repeated"]||"1",10);C+=F-1;F=0;m={};w="";A=[]}M={};break;case"document":;case"document-content":;case"电子表格文档":;case"spreadsheet":;case"主体":;case"scripts":;case"styles":;case"font-face-decls":;case"master-styles":if(p[1]==="/"){if((i=n.pop())[0]!==p[3])throw"Bad state: "+i}else if(p[0].charAt(p[0].length-2)!=="/")n.push([p[3],true]);break;case"annotation":if(p[1]==="/"){if((i=n.pop())[0]!==p[3])throw"Bad state: "+i;W.t=w;if(A.length)W.R=A;W.a=H;B.push(W)}else if(p[0].charAt(p[0].length-2)!=="/"){n.push([p[3],false])}H="";z=0;w="";k=0;A=[];break;case"creator":if(p[1]==="/"){H=a.slice(z,p.index)}else z=p.index+p[0].length;break;case"meta":;case"元数据":;case"settings":;case"config-item-set":;case"config-item-map-indexed":;case"config-item-map-entry":;case"config-item-map-named":;case"shapes":;case"frame":;case"text-box":;case"image":;case"data-pilot-tables":;case"list-style":;case"form":;case"dde-links":;case"event-listeners":;case"chart":if(p[1]==="/"){if((i=n.pop())[0]!==p[3])throw"Bad state: "+i}else if(p[0].charAt(p[0].length-2)!=="/")n.push([p[3],false]);w="";k=0;A=[];break;case"scientific-number":break;case"currency-symbol":break;case"currency-style":break;case"number-style":;case"percentage-style":;case"date-style":;case"time-style":if(p[1]==="/"){_[f.name]=o;if((i=n.pop())[0]!==p[3])throw"Bad state: "+i}else if(p[0].charAt(p[0].length-2)!=="/"){o="";f=Gr(p[0],false);n.push([p[3],true])}break;case"script":break;case"libraries":break;case"automatic-styles":break;case"default-style":;case"page-layout":break;case"style":break;case"map":break;case"font-face":break;case"paragraph-properties":break;case"table-properties":break;case"table-column-properties":break;case"table-row-properties":break;case"table-cell-properties":break;case"number":switch(n[n.length-1][0]){case"time-style":;case"date-style":s=Gr(p[0],false);o+=Jm[p[3]][s.style==="long"?1:0];break;}break;case"fraction":break;case"day":;case"month":;case"year":;case"era":;case"day-of-week":;case"week-of-year":;case"quarter":;case"hours":;case"minutes":;case"seconds":;case"am-pm":switch(n[n.length-1][0]){case"time-style":;case"date-style":s=Gr(p[0],false);o+=Jm[p[3]][s.style==="long"?1:0];break;}break;case"boolean-style":break;case"boolean":break;case"text-style":break;case"text":if(p[0].slice(-2)==="/>")break;else if(p[1]==="/")switch(n[n.length-1][0]){case"number-style":;case"date-style":;case"time-style":o+=a.slice(l,p.index);break;}else l=p.index+p[0].length;break;case"named-range":s=Gr(p[0],false);U=Sd(s["cell-range-address"]);var Y={Name:s.name,Ref:U[0]+"!"+U[1]};if(G)Y.Sheet=d.length;L.Names.push(Y);break;case"text-content":break;case"text-properties":break;case"embedded-text":break;case"body":;case"电子表格":break;case"forms":break;case"table-column":break;case"table-header-rows":break;case"table-rows":break;case"table-column-group":break;case"table-header-columns":break;case"table-columns":break;case"null-date":break;case"graphic-properties":break;case"calculation-settings":break;case"named-expressions":break;case"label-range":break;case"label-ranges":break;case"named-expression":break;case"sort":break;case"sort-by":break;case"sort-groups":break;case"tab":break;case"line-break":break;case"span":break;case"p":;case"文本串":if(["master-styles"].indexOf(n[n.length-1][0])>-1)break;if(p[1]==="/"&&(!b||!b["string-value"])){var K=Km(a.slice(k,p.index),T);w=(w.length>0?w+"\n":"")+K[0]}else{T=Gr(p[0],false);k=p.index+p[0].length}break;case"s":break;case"database-range":if(p[1]==="/")break;try{U=Sd(Gr(p[0])["target-range-address"]);u[U[0]]["!autofilter"]={ref:U[1]}}catch(J){}break;case"date":break;case"object":break;case"title":;case"标题":break;case"desc":break;case"binary-data":break;case"table-source":break;case"scenario":break;case"iteration":break;case"content-validations":break;case"content-validation":break;case"help-message":break;case"error-message":break;case"database-ranges":break;case"filter":break;case"filter-and":break;case"filter-or":break;case"filter-condition":break;case"list-level-style-bullet":break;case"list-level-style-number":break;case"list-level-properties":break;case"sender-firstname":;case"sender-lastname":;case"sender-initials":;case"sender-title":;case"sender-position":;case"sender-email":;case"sender-phone-private":;case"sender-fax":;case"sender-company":;case"sender-phone-work":;case"sender-street":;case"sender-city":;case"sender-postal-code":;case"sender-country":;case"sender-state-or-province":;case"author-name":;case"author-initials":;case"chapter":;case"file-name":;case"template-name":;case"sheet-name":break;case"event-listener":break;case"initial-creator":;case"creation-date":;case"print-date":;case"generator":;case"document-statistic":;case"user-defined":;case"editing-duration":;case"editing-cycles":break;case"config-item":break;case"page-number":break;case"page-count":break;case"time":break;case"cell-range-source":break;case"detective":break;case"operation":break;case"highlighted-range":break;case"data-pilot-table":;case"source-cell-range":;case"source-service":;case"data-pilot-field":;case"data-pilot-level":;case"data-pilot-subtotals":;case"data-pilot-subtotal":;case"data-pilot-members":;case"data-pilot-member":;case"data-pilot-display-info":;case"data-pilot-sort-info":;case"data-pilot-layout-info":;case"data-pilot-field-reference":;case"data-pilot-groups":;case"data-pilot-group":;case"data-pilot-group-member":break;case"rect":break;case"dde-connection-decls":;case"dde-connection-decl":;case"dde-link":;case"dde-source":break;case"properties":break;case"property":break;case"a":if(p[1]!=="/"){M=Gr(p[0],false);if(!M.href)break;M.Target=Yr(M.href);delete M.href;if(M.Target.charAt(0)=="#"&&M.Target.indexOf(".")>-1){U=Sd(M.Target.slice(1));M.Target="#"+U[0]+"!"+U[1]}else if(M.Target.match(/^\.\.[\\\/]/))M.Target=M.Target.slice(3)}break;case"table-protection":break;case"data-pilot-grand-total":break;case"office-document-common-attrs":break;default:switch(p[2]){case"dc:":;case"calcext:":;case"loext:":;case"ooo:":;case"chartooo:":;case"draw:":;case"style:":;case"chart:":;case"form:":;case"uof:":;case"表:":;case"字:":break;default:if(t.WTF)throw new Error(p);};}var q={Sheets:u,SheetNames:d,Workbook:L};if(t.bookSheets)delete q.Sheets;return q}function Zm(e,r){r=r||{};if(_r(e,"META-INF/manifest.xml"))ai(Or(e,"META-INF/manifest.xml"),r);var t=Rr(e,"content.xml");if(!t)throw new Error("Missing content.xml in ODS / UOF file");var a=qm(lt(t),r);if(_r(e,"meta.xml"))a.Props=hi(Or(e,"meta.xml"));return a}function Qm(e,r){return qm(e,r)}var eb=function(){var e=["",'',"",'',"",'',"",""].join("");var r=""+e+"";return function t(){return Mr+r}}();var rb=function(){var e=function(e){return qr(e).replace(/ +/g,function(e){return''}).replace(/\t/g,"").replace(/\n/g,"").replace(/^ /,"").replace(/ $/,"")};var r=" \n";var t=" \n";var a=function(a,n,i){var s=[];s.push(' \n');var f=0,o=0,l=Ia(a["!ref"]||"A1");var c=a["!merges"]||[],h=0;var u=Array.isArray(a);if(a["!cols"]){for(o=0;o<=l.e.c;++o)s.push(" \n")}var d="",v=a["!rows"]||[];for(f=0;f\n")}for(;f<=l.e.r;++f){d=v[f]?' table:style-name="ro'+v[f].ods+'"':"";s.push(" \n");for(o=0;oo)continue;if(c[h].s.r>f)continue;if(c[h].e.c\n")}s.push(" \n");return s.join("")};var n=function(e,r){e.push(" \n");e.push(' \n');e.push(' \n');e.push(" /\n");e.push(' \n');e.push(" /\n");e.push(" \n");e.push(" \n");var t=0;r.SheetNames.map(function(e){return r.Sheets[e]}).forEach(function(r){if(!r)return;if(r["!cols"]){for(var a=0;a\n');e.push(' \n');e.push(" \n");++t}}});var a=0;r.SheetNames.map(function(e){return r.Sheets[e]}).forEach(function(r){if(!r)return;if(r["!rows"]){for(var t=0;t\n');e.push(' \n');e.push(" \n");++a}}});e.push(' \n');e.push(' \n');e.push(" \n");e.push(' \n');e.push(" \n")};return function i(e,r){var t=[Mr];var i=wt({"xmlns:office":"urn:oasis:names:tc:opendocument:xmlns:office:1.0","xmlns:table":"urn:oasis:names:tc:opendocument:xmlns:table:1.0","xmlns:style":"urn:oasis:names:tc:opendocument:xmlns:style:1.0","xmlns:text":"urn:oasis:names:tc:opendocument:xmlns:text:1.0","xmlns:draw":"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0","xmlns:fo":"urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0","xmlns:xlink":"http://www.w3.org/1999/xlink","xmlns:dc":"http://purl.org/dc/elements/1.1/","xmlns:meta":"urn:oasis:names:tc:opendocument:xmlns:meta:1.0","xmlns:number":"urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0","xmlns:presentation":"urn:oasis:names:tc:opendocument:xmlns:presentation:1.0","xmlns:svg":"urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0","xmlns:chart":"urn:oasis:names:tc:opendocument:xmlns:chart:1.0","xmlns:dr3d":"urn:oasis:names:tc:opendocument:xmlns:dr3d:1.0","xmlns:math":"http://www.w3.org/1998/Math/MathML","xmlns:form":"urn:oasis:names:tc:opendocument:xmlns:form:1.0","xmlns:script":"urn:oasis:names:tc:opendocument:xmlns:script:1.0","xmlns:ooo":"http://openoffice.org/2004/office","xmlns:ooow":"http://openoffice.org/2004/writer","xmlns:oooc":"http://openoffice.org/2004/calc","xmlns:dom":"http://www.w3.org/2001/xml-events","xmlns:xforms":"http://www.w3.org/2002/xforms","xmlns:xsd":"http://www.w3.org/2001/XMLSchema","xmlns:xsi":"http://www.w3.org/2001/XMLSchema-instance","xmlns:sheet":"urn:oasis:names:tc:opendocument:sh33tjs:1.0","xmlns:rpt":"http://openoffice.org/2005/report","xmlns:of":"urn:oasis:names:tc:opendocument:xmlns:of:1.2","xmlns:xhtml":"http://www.w3.org/1999/xhtml","xmlns:grddl":"http://www.w3.org/2003/g/data-view#","xmlns:tableooo":"http://openoffice.org/2009/table","xmlns:drawooo":"http://openoffice.org/2010/draw","xmlns:calcext":"urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0","xmlns:loext":"urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0","xmlns:field":"urn:openoffice:names:experimental:ooo-ms-interop:xmlns:field:1.0","xmlns:formx":"urn:openoffice:names:experimental:ooxml-odf-interop:xmlns:form:1.0","xmlns:css3t":"http://www.w3.org/TR/css3-text/","office:version":"1.2"});var s=wt({"xmlns:config":"urn:oasis:names:tc:opendocument:xmlns:config:1.0","office:mimetype":"application/vnd.oasis.opendocument.spreadsheet"});if(r.bookType=="fods"){t.push("\n");t.push(oi().replace(/office:document-meta/g,"office:meta"))}else t.push("\n");n(t,e);t.push(" \n");t.push(" \n");for(var f=0;f!=e.SheetNames.length;++f)t.push(a(e.Sheets[e.SheetNames[f]],e,f,r));t.push(" \n");t.push(" \n");if(r.bookType=="fods")t.push("");else t.push("");return t.join("")}}();function tb(e,r){if(r.bookType=="fods")return rb(e,r);var t=Fr();var a="";var n=[];var i=[];a="mimetype";Dr(t,a,"application/vnd.oasis.opendocument.spreadsheet");a="content.xml";Dr(t,a,rb(e,r));n.push([a,"text/xml"]);i.push([a,"ContentFile"]);a="styles.xml";Dr(t,a,eb(e,r));n.push([a,"text/xml"]);i.push([a,"StylesFile"]);a="meta.xml";Dr(t,a,Mr+oi());n.push([a,"text/xml"]);i.push([a,"MetadataFile"]);a="manifest.rdf";Dr(t,a,fi(i));n.push([a,"application/rdf+xml"]);a="META-INF/manifest.xml";Dr(t,a,ni(n));return t}function ab(e){return new DataView(e.buffer,e.byteOffset,e.byteLength)}function nb(e){return typeof TextDecoder!="undefined"?(new TextDecoder).decode(e):lt(_(e))}function ib(e){return typeof TextEncoder!="undefined"?(new TextEncoder).encode(e):y(ct(e))}function sb(e,r){e:for(var t=0;t<=e.length-r.length;++t){for(var a=0;a>1&1431655765;e=(e&858993459)+(e>>2&858993459);return(e+(e>>4)&252645135)*16843009>>>24}function lb(e,r){var t=(e[r+15]&127)<<7|e[r+14]>>1;var a=e[r+14]&1;for(var n=r+13;n>=r;--n)a=a*256+e[n];return(e[r+15]&128?-a:a)*Math.pow(10,t-6176)}function cb(e,r,t){var a=Math.floor(t==0?0:Math.LOG10E*Math.log(Math.abs(t)))+6176-20;var n=t/Math.pow(10,a-6176);e[r+15]|=a>>7;e[r+14]|=(a&127)<<1;for(var i=0;n>=1;++i,n/=256)e[r+i]=n&255;e[r+15]|=t>=0?0:128}function hb(e,r){var t=r?r[0]:0;var a=e[t]&127;e:if(e[t++]>=128){a|=(e[t]&127)<<7;if(e[t++]<128)break e;a|=(e[t]&127)<<14;if(e[t++]<128)break e;a|=(e[t]&127)<<21;if(e[t++]<128)break e;a+=(e[t]&127)*Math.pow(2,28);++t;if(e[t++]<128)break e;a+=(e[t]&127)*Math.pow(2,35);++t;if(e[t++]<128)break e;a+=(e[t]&127)*Math.pow(2,42);++t;if(e[t++]<128)break e}if(r)r[0]=t;return a}function ub(e){var r=new Uint8Array(7);r[0]=e&127;var t=1;e:if(e>127){r[t-1]|=128;r[t]=e>>7&127;++t;if(e<=16383)break e;r[t-1]|=128;r[t]=e>>14&127;++t;if(e<=2097151)break e;r[t-1]|=128;r[t]=e>>21&127;++t;if(e<=268435455)break e;r[t-1]|=128;r[t]=e/256>>>21&127;++t;if(e<=34359738367)break e;r[t-1]|=128;r[t]=e/65536>>>21&127;++t;if(e<=4398046511103)break e;r[t-1]|=128;r[t]=e/16777216>>>21&127;++t}return r.slice(0,t)}function db(e){var r=0,t=e[r]&127;e:if(e[r++]>=128){t|=(e[r]&127)<<7;if(e[r++]<128)break e;t|=(e[r]&127)<<14;if(e[r++]<128)break e;t|=(e[r]&127)<<21;if(e[r++]<128)break e;t|=(e[r]&127)<<28}return t}function vb(e){var r=[],t=[0];while(t[0]=128);f=e.slice(o,t[0])}break;case 5:s=4;f=e.slice(t[0],t[0]+s);t[0]+=s;break;case 1:s=8;f=e.slice(t[0],t[0]+s);t[0]+=s;break;case 2:s=hb(e,t);f=e.slice(t[0],t[0]+s);t[0]+=s;break;case 3:;case 4:;default:throw new Error("PB Type ".concat(i," for Field ").concat(n," at offset ").concat(a));}var l={data:f,type:i};if(r[n]==null)r[n]=[l];else r[n].push(l)}return r}function pb(e){var r=[];e.forEach(function(e,t){e.forEach(function(e){if(!e.data)return;r.push(ub(t*8+e.type));if(e.type==2)r.push(ub(e.data.length));r.push(e.data)})});return fb(r)}function gb(e,r){return(e==null?void 0:e.map(function(e){return r(e.data)}))||[]}function mb(e){var r;var t=[],a=[0];while(a[0]>>0>0;t.push(s)}return t}function bb(e){var r=[];e.forEach(function(e){var t=[];t[1]=[{data:ub(e.id),type:0}];t[2]=[];if(e.merge!=null)t[3]=[{data:ub(+!!e.merge),type:0}];var a=[];e.messages.forEach(function(e){a.push(e.data);e.meta[3]=[{type:0,data:ub(e.data.length)}];t[2].push({data:pb(e.meta),type:2})});var n=pb(t);r.push(ub(n.length));r.push(n);a.forEach(function(e){return r.push(e)})});return fb(r)}function wb(e,r){if(e!=0)throw new Error("Unexpected Snappy chunk type ".concat(e));var t=[0];var a=hb(r,t);var n=[];while(t[0]>2;if(s<60)++s;else{var f=s-59;s=r[t[0]];if(f>1)s|=r[t[0]+1]<<8;if(f>2)s|=r[t[0]+2]<<16;if(f>3)s|=r[t[0]+3]<<24;s>>>=0;s++;t[0]+=f}n.push(r.slice(t[0],t[0]+s));t[0]+=s;continue}else{var o=0,l=0;if(i==1){l=(r[t[0]]>>2&7)+4;o=(r[t[0]++]&224)<<3;o|=r[t[0]++]}else{l=(r[t[0]++]>>2)+1;if(i==2){o=r[t[0]]|r[t[0]+1]<<8;t[0]+=2}else{o=(r[t[0]]|r[t[0]+1]<<8|r[t[0]+2]<<16|r[t[0]+3]<<24)>>>0;t[0]+=4}}n=[fb(n)];if(o==0)throw new Error("Invalid offset 0");if(o>n[0].length)throw new Error("Invalid offset beyond length");if(l>=o){n.push(n[0].slice(-o));l-=o;while(l>=n[n.length-1].length){n.push(n[n.length-1]);l-=n[n.length-1].length}}n.push(n[0].slice(-o,-o+l))}}var c=fb(n);if(c.length!=a)throw new Error("Unexpected length: ".concat(c.length," != ").concat(a));return c}function kb(e){var r=[];var t=0;while(t>8&255]))}else if(a<=16777216){s+=4;r.push(new Uint8Array([248,a-1&255,a-1>>8&255,a-1>>16&255]))}else if(a<=4294967296){s+=5;r.push(new Uint8Array([252,a-1&255,a-1>>8&255,a-1>>16&255,a-1>>>24&255]))}r.push(e.slice(t,t+a));s+=a;n[0]=0;n[1]=s&255;n[2]=s>>8&255;n[3]=s>>16&255;t+=a}return fb(r)}function Ab(e,r,t,a){var n=ab(e);var i=n.getUint32(4,true);var s=(a>1?12:8)+ob(i&(a>1?3470:398))*4;var f=-1,o=-1,l=NaN,c=new Date(2001,0,1);if(i&512){f=n.getUint32(s,true);s+=4}s+=ob(i&(a>1?12288:4096))*4;if(i&16){o=n.getUint32(s,true);s+=4}if(i&32){l=n.getFloat64(s,true);s+=8}if(i&64){c.setTime(c.getTime()+n.getFloat64(s,true)*1e3);s+=8}var h;switch(e[2]){case 0:break;case 2:h={t:"n",v:l};break;case 3:h={t:"s",v:r[o]};break;case 5:h={t:"d",v:c};break;case 6:h={t:"b",v:l>0};break;case 7:h={t:"n",v:l/86400};break;case 8:h={t:"e",v:0};break;case 9:{if(f>-1)h={t:"s",v:t[f]};else if(o>-1)h={t:"s",v:r[o]};else if(!isNaN(l))h={t:"n",v:l};else throw new Error("Unsupported cell type ".concat(e.slice(0,4)))}break;default:throw new Error("Unsupported cell type ".concat(e.slice(0,4)));}return h}function Eb(e,r,t){var a=ab(e);var n=a.getUint32(8,true);var i=12;var s=-1,f=-1,o=NaN,l=NaN,c=new Date(2001,0,1);if(n&1){o=lb(e,i);i+=16}if(n&2){l=a.getFloat64(i,true);i+=8}if(n&4){c.setTime(c.getTime()+a.getFloat64(i,true)*1e3);i+=8}if(n&8){f=a.getUint32(i,true);i+=4}if(n&16){s=a.getUint32(i,true);i+=4}var h;switch(e[1]){case 0:break;case 2:h={t:"n",v:o};break;case 3:h={t:"s",v:r[f]};break;case 5:h={t:"d",v:c};break;case 6:h={t:"b",v:l>0};break;case 7:h={t:"n",v:l/86400};break;case 8:h={t:"e",v:0};break;case 9:{if(s>-1)h={t:"s",v:t[s]};else throw new Error("Unsupported cell type ".concat(e[1]," : ").concat(n&31," : ").concat(e.slice(0,4)))}break;case 10:h={t:"n",v:o};break;default:throw new Error("Unsupported cell type ".concat(e[1]," : ").concat(n&31," : ").concat(e.slice(0,4)));}return h}function Cb(e,r){var t=new Uint8Array(32),a=ab(t),n=12,i=0;t[0]=5;switch(e.t){case"n":t[1]=2;cb(t,n,e.v);i|=1;n+=16;break;case"b":t[1]=6;a.setFloat64(n,e.v?1:0,true);i|=2;n+=8;break;case"s":if(r.indexOf(e.v)==-1)throw new Error("Value ".concat(e.v," missing from SST!"));t[1]=3;a.setUint32(n,r.indexOf(e.v),true);i|=8;n+=4;break;default:throw"unsupported cell type "+e.t;}a.setUint32(8,i,true);return t.slice(0,n)}function yb(e,r){var t=new Uint8Array(32),a=ab(t),n=12,i=0;t[0]=3;switch(e.t){case"n":t[2]=2;a.setFloat64(n,e.v,true);i|=32;n+=8;break;case"b":t[2]=6;a.setFloat64(n,e.v?1:0,true);i|=32;n+=8;break;case"s":if(r.indexOf(e.v)==-1)throw new Error("Value ".concat(e.v," missing from SST!"));t[2]=3;a.setUint32(n,r.indexOf(e.v),true);i|=16;n+=4;break;default:throw"unsupported cell type "+e.t;}a.setUint32(4,i,true);return t.slice(0,n)}function Sb(e,r,t){switch(e[0]){case 0:;case 1:;case 2:;case 3:return Ab(e,r,t,e[0]);case 5:return Eb(e,r,t);default:throw new Error("Unsupported payload version ".concat(e[0]));}}function _b(e){var r=vb(e);return hb(r[1][0].data)}function xb(e){var r=[];r[1]=[{type:0,data:ub(e)}];return pb(r)}function Ob(e,r){var t=vb(r.data);var a=db(t[1][0].data);var n=t[3];var i=[];(n||[]).forEach(function(r){var t=vb(r.data);var n=db(t[1][0].data)>>>0;switch(a){case 1:i[n]=nb(t[3][0].data);break;case 8:{var s=e[_b(t[9][0].data)][0];var f=vb(s.data);var o=e[_b(f[1][0].data)][0];var l=db(o.meta[1][0].data);if(l!=2001)throw new Error("2000 unexpected reference to ".concat(l));var c=vb(o.data);i[n]=c[3].map(function(e){return nb(e.data)}).join("")}break;}});return i}function Rb(e,r){var t,a,n,i,s,f,o,l,c,h,u,d,v,p;var g=vb(e);var m=db(g[1][0].data)>>>0;var b=db(g[2][0].data)>>>0;var w=((a=(t=g[8])==null?void 0:t[0])==null?void 0:a.data)&&db(g[8][0].data)>0||false;var k,T;if(((i=(n=g[7])==null?void 0:n[0])==null?void 0:i.data)&&r!=0){k=(f=(s=g[7])==null?void 0:s[0])==null?void 0:f.data;T=(l=(o=g[6])==null?void 0:o[0])==null?void 0:l.data}else if(((h=(c=g[4])==null?void 0:c[0])==null?void 0:h.data)&&r!=1){k=(d=(u=g[4])==null?void 0:u[0])==null?void 0:d.data;T=(p=(v=g[3])==null?void 0:v[0])==null?void 0:p.data}else throw"NUMBERS Tile missing ".concat(r," cell storage");var A=w?4:1;var E=ab(k);var C=[];for(var y=0;y=1)_[C[C.length-1][0]]=T.subarray(C[C.length-1][1]*A);return{R:m,cells:_}}function Ib(e,r){var t;var a=vb(r.data);var n=((t=a==null?void 0:a[7])==null?void 0:t[0])?db(a[7][0].data)>>>0>0?1:0:-1;var i=gb(a[5],function(e){return Rb(e,n)});return{nrows:db(a[4][0].data)>>>0,data:i.reduce(function(e,r){if(!e[r.R])e[r.R]=[];r.cells.forEach(function(t,a){if(e[r.R][a])throw new Error("Duplicate cell r=".concat(r.R," c=").concat(a));e[r.R][a]=t});return e},[])}}function Nb(e,r,t){var a;var n=vb(r.data);var i={s:{r:0,c:0},e:{r:0,c:0}};i.e.r=(db(n[6][0].data)>>>0)-1;if(i.e.r<0)throw new Error("Invalid row varint ".concat(n[6][0].data));i.e.c=(db(n[7][0].data)>>>0)-1;if(i.e.c<0)throw new Error("Invalid col varint ".concat(n[7][0].data));t["!ref"]=Na(i);var s=vb(n[4][0].data);var f=Ob(e,e[_b(s[4][0].data)][0]); -var o=((a=s[17])==null?void 0:a[0])?Ob(e,e[_b(s[17][0].data)][0]):[];var l=vb(s[3][0].data);var c=0;l[1].forEach(function(r){var a=vb(r.data);var n=e[_b(a[2][0].data)][0];var i=db(n.meta[1][0].data);if(i!=6002)throw new Error("6001 unexpected reference to ".concat(i));var s=Ib(e,n);s.data.forEach(function(e,r){e.forEach(function(e,a){var n=Ra({r:c+r,c:a});var i=Sb(e,f,o);if(i)t[n]=i})});c+=s.nrows})}function Db(e,r){var t=vb(r.data);var a={"!ref":"A1"};var n=e[_b(t[2][0].data)];var i=db(n[0].meta[1][0].data);if(i!=6001)throw new Error("6000 unexpected reference to ".concat(i));Nb(e,n[0],a);return a}function Fb(e,r){var t;var a=vb(r.data);var n={name:((t=a[1])==null?void 0:t[0])?nb(a[1][0].data):"",sheets:[]};var i=gb(a[2],_b);i.forEach(function(r){e[r].forEach(function(r){var t=db(r.meta[1][0].data);if(t==6e3)n.sheets.push(Db(e,r))})});return n}function Pb(e,r){var t=Nw();var a=vb(r.data);var n=gb(a[1],_b);n.forEach(function(r){e[r].forEach(function(r){var a=db(r.meta[1][0].data);if(a==2){var n=Fb(e,r);n.sheets.forEach(function(e,r){Dw(t,e,r==0?n.name:n.name+"_"+r,true)})}})});if(t.SheetNames.length==0)throw new Error("Empty NUMBERS file");return t}function Lb(e){var r,t,a,n;var i={},s=[];e.FullPaths.forEach(function(e){if(e.match(/\.iwpv2/))throw new Error("Unsupported password protection")});e.FileIndex.forEach(function(e){if(!e.name.match(/\.iwa$/))return;var r;try{r=kb(e.content)}catch(t){return console.log("?? "+e.content.length+" "+(t.message||t))}var a;try{a=mb(r)}catch(t){return console.log("## "+(t.message||t))}a.forEach(function(e){i[e.id]=e.messages;s.push(e.id)})});if(!s.length)throw new Error("File has no messages");var f=((n=(a=(t=(r=i==null?void 0:i[1])==null?void 0:r[0])==null?void 0:t.meta)==null?void 0:a[1])==null?void 0:n[0].data)&&db(i[1][0].meta[1][0].data)==1&&i[1][0];if(!f)s.forEach(function(e){i[e].forEach(function(e){var r=db(e.meta[1][0].data)>>>0;if(r==1){if(!f)f=e;else throw new Error("Document has multiple roots")}})});if(!f)throw new Error("Cannot find Document root");return Pb(i,f)}function Mb(e,r,t){var a,n,i,s;if(!((a=e[6])==null?void 0:a[0])||!((n=e[7])==null?void 0:n[0]))throw"Mutation only works on post-BNC storages!";var f=((s=(i=e[8])==null?void 0:i[0])==null?void 0:s.data)&&db(e[8][0].data)>0||false;if(f)throw"Math only works with normal offsets";var o=0;var l=ab(e[7][0].data),c=0,h=[];var u=ab(e[4][0].data),d=0,v=[];for(var p=0;p1)console.error("The Numbers writer currently writes only the first table");var a=Ia(t["!ref"]);a.s.r=a.s.c=0;var n=false;if(a.e.c>9){n=true;a.e.c=9}if(a.e.r>49){n=true;a.e.r=49}if(n)console.error("The Numbers writer is currently limited to ".concat(Na(a)));var i=Aw(t,{range:a,header:1});var s=["~Sh33tJ5~"];i.forEach(function(e){return e.forEach(function(e){if(typeof e=="string")s.push(e)})});var f={};var o=[];var l=Ke.read(r.numbers,{type:"base64"});l.FileIndex.map(function(e,r){return[e,l.FullPaths[r]]}).forEach(function(e){var r=e[0],t=e[1];if(r.type!=2)return;if(!r.name.match(/\.iwa/))return;var a=r.content;var n=kb(a);var i=mb(n);i.forEach(function(e){o.push(e.id);f[e.id]={deps:[],location:t,type:db(e.messages[0].meta[1][0].data)}})});o.sort(function(e,r){return e-r});var c=o.filter(function(e){return e>1}).map(function(e){return[e,ub(e)]});l.FileIndex.map(function(e,r){return[e,l.FullPaths[r]]}).forEach(function(e){var r=e[0],t=e[1];if(!r.name.match(/\.iwa/))return;var a=mb(kb(r.content));a.forEach(function(e){e.messages.forEach(function(r){c.forEach(function(r){if(e.messages.some(function(e){return db(e.meta[1][0].data)!=11006&&sb(e.data,r[1])})){f[r[0]].deps.push(e.id)}})})})});function h(){for(var e=927262;e<2e6;++e)if(!f[e])return e;throw new Error("Too many messages")}var u=Ke.find(l,f[1].location);var d=mb(kb(u.content));var v;for(var p=0;p-1)return"sheet";if(qn.CS&&e==qn.CS)return"chart";if(qn.DS&&e==qn.DS)return"dialog";if(qn.MS&&e==qn.MS)return"macro";return e&&e.length?e:"sheet"}function Vb(e,r){if(!e)return 0;try{e=r.map(function a(r){if(!r.id)r.id=r.strRelID;return[r.name,e["!id"][r.id].Target,zb(e["!id"][r.id].Type)]})}catch(t){return null}return!e||e.length===0?null:e}function Gb(e,r,t,a,n,i,s,f,o,l,c,h){try{i[a]=Qn(Rr(e,t,true),r);var u=Or(e,r);var d;switch(f){case"sheet":d=pg(u,r,n,o,i[a],l,c,h);break;case"chart":d=gg(u,r,n,o,i[a],l,c,h);if(!d||!d["!drawel"])break;var v=Lr(d["!drawel"].Target,r);var p=Zn(v);var g=th(Rr(e,v,true),Qn(Rr(e,p,true),v));var m=Lr(g,v);var b=Zn(m);d=Pp(Rr(e,m,true),m,o,Qn(Rr(e,b,true),m),l,d);break;case"macro":d=mg(u,r,n,o,i[a],l,c,h);break;case"dialog":d=bg(u,r,n,o,i[a],l,c,h);break;default:throw new Error("Unrecognized sheet type "+f);}s[a]=d;var w=[];if(i&&i[a])rr(i[a]).forEach(function(t){var n="";if(i[a][t].Type==qn.CMNT){n=Lr(i[a][t].Target,r);var s=Ag(Or(e,n,true),n,o);if(!s||!s.length)return;ih(d,s,false)}if(i[a][t].Type==qn.TCMNT){n=Lr(i[a][t].Target,r);w=w.concat(oh(Or(e,n,true),o))}});if(w&&w.length)ih(d,w,true,o.people||[])}catch(k){if(o.WTF)throw k}}function jb(e){return e.charAt(0)=="/"?e.slice(1):e}function Xb(e,r){ze();r=r||{};Wb(r);if(_r(e,"META-INF/manifest.xml"))return Zm(e,r);if(_r(e,"objectdata.xml"))return Zm(e,r);if(_r(e,"Index/Document.iwa")){if(typeof Uint8Array=="undefined")throw new Error("NUMBERS file parsing requires Uint8Array support");if(typeof Lb!="undefined"){if(e.FileIndex)return Lb(e);var t=Ke.utils.cfb_new();Nr(e).forEach(function(r){Dr(t,r,Ir(e,r))});return Lb(t)}throw new Error("Unsupported NUMBERS file")}if(!_r(e,"[Content_Types].xml")){if(_r(e,"index.xml.gz"))throw new Error("Unsupported NUMBERS 08 file");if(_r(e,"index.xml"))throw new Error("Unsupported NUMBERS 09 file");throw new Error("Unsupported ZIP file")}var a=Nr(e);var n=Kn(Rr(e,"[Content_Types].xml"));var i=false;var s,f;if(n.workbooks.length===0){f="xl/workbook.xml";if(Or(e,f,true))n.workbooks.push(f)}if(n.workbooks.length===0){f="xl/workbook.bin";if(!Or(e,f,true))throw new Error("Could not find workbook");n.workbooks.push(f);i=true}if(n.workbooks[0].slice(-3)=="bin")i=true;var o={};var l={};if(!r.bookSheets&&!r.bookProps){xd=[];if(n.sst)try{xd=Tg(Or(e,jb(n.sst)),n.sst,r)}catch(c){if(r.WTF)throw c}if(r.cellStyles&&n.themes.length)o=kg(Rr(e,n.themes[0].replace(/^\//,""),true)||"",n.themes[0],r);if(n.style)l=wg(Or(e,jb(n.style)),n.style,o,r)}n.links.map(function(t){try{var a=Qn(Rr(e,Zn(jb(t))),t);return Cg(Or(e,jb(t)),a,t,r)}catch(n){}});var h=vg(Or(e,jb(n.workbooks[0])),n.workbooks[0],r);var u={},d="";if(n.coreprops.length){d=Or(e,jb(n.coreprops[0]),true);if(d)u=hi(d);if(n.extprops.length!==0){d=Or(e,jb(n.extprops[0]),true);if(d)mi(d,u,r)}}var v={};if(!r.bookSheets||r.bookProps){if(n.custprops.length!==0){d=Rr(e,jb(n.custprops[0]),true);if(d)v=ki(d,r)}}var p={};if(r.bookSheets||r.bookProps){if(h.Sheets)s=h.Sheets.map(function I(e){return e.name});else if(u.Worksheets&&u.SheetNames.length>0)s=u.SheetNames;if(r.bookProps){p.Props=u;p.Custprops=v}if(r.bookSheets&&typeof s!=="undefined")p.SheetNames=s;if(r.bookSheets?p.SheetNames:r.bookProps)return p}s={};var g={};if(r.bookDeps&&n.calcchain)g=Eg(Or(e,jb(n.calcchain)),n.calcchain,r);var m=0;var b={};var w,k;{var T=h.Sheets;u.Worksheets=T.length;u.SheetNames=[];for(var A=0;A!=T.length;++A){u.SheetNames[A]=T[A].name}}var E=i?"bin":"xml";var C=n.workbooks[0].lastIndexOf("/");var y=(n.workbooks[0].slice(0,C+1)+"_rels/"+n.workbooks[0].slice(C+1)+".rels").replace(/^\//,"");if(!_r(e,y))y="xl/_rels/workbook."+E+".rels";var S=Qn(Rr(e,y,true),y.replace(/_rels.*/,"s5s"));if((n.metadata||[]).length>=1){r.xlmeta=yg(Or(e,jb(n.metadata[0])),n.metadata[0],r)}if((n.people||[]).length>=1){r.people=ch(Or(e,jb(n.people[0])),r)}if(S)S=Vb(S,h.Sheets);var _=Or(e,"xl/worksheets/sheet.xml",true)?1:0;e:for(m=0;m!=u.Worksheets;++m){var x="sheet";if(S&&S[m]){w="xl/"+S[m][1].replace(/[\/]?xl\//,"");if(!_r(e,w))w=S[m][1];if(!_r(e,w))w=y.replace(/_rels\/.*$/,"")+S[m][1];x=S[m][2]}else{w="xl/worksheets/sheet"+(m+1-_)+"."+E;w=w.replace(/sheet0\./,"sheet.")}k=w.replace(/^(.*)(\/)([^\/]*)$/,"$1/_rels/$3.rels");if(r&&r.sheets!=null)switch(typeof r.sheets){case"number":if(m!=r.sheets)continue e;break;case"string":if(u.SheetNames[m].toLowerCase()!=r.sheets.toLowerCase())continue e;break;default:if(Array.isArray&&Array.isArray(r.sheets)){var O=false;for(var R=0;R!=r.sheets.length;++R){if(typeof r.sheets[R]=="number"&&r.sheets[R]==m)O=1;if(typeof r.sheets[R]=="string"&&r.sheets[R].toLowerCase()==u.SheetNames[m].toLowerCase())O=1}if(!O)continue e};}Gb(e,w,k,u.SheetNames[m],m,b,s,x,r,h,o,l)}p={Directory:n,Workbook:h,Props:u,Custprops:v,Deps:g,Sheets:s,SheetNames:u.SheetNames,Strings:xd,Styles:l,Themes:o,SSF:br(X)};if(r&&r.bookFiles){if(e.files){p.keys=a;p.files=e.files}else{p.keys=[];p.files={};e.FullPaths.forEach(function(r,t){r=r.replace(/^Root Entry[\/]/,"");p.keys.push(r);p.files[r]=e.FileIndex[t]})}}if(r&&r.bookVBA){if(n.vba.length>0)p.vbaraw=Or(e,jb(n.vba[0]),true);else if(n.defaults&&n.defaults.bin===bh)p.vbaraw=Or(e,"xl/vbaProject.bin",true)}return p}function $b(e,r){var t=r||{};var a="Workbook",n=Ke.find(e,a);try{a="/!DataSpaces/Version";n=Ke.find(e,a);if(!n||!n.content)throw new Error("ECMA-376 Encrypted file missing "+a);Ko(n.content);a="/!DataSpaces/DataSpaceMap";n=Ke.find(e,a);if(!n||!n.content)throw new Error("ECMA-376 Encrypted file missing "+a);var i=qo(n.content);if(i.length!==1||i[0].comps.length!==1||i[0].comps[0].t!==0||i[0].name!=="StrongEncryptionDataSpace"||i[0].comps[0].v!=="EncryptedPackage")throw new Error("ECMA-376 Encrypted file bad "+a);a="/!DataSpaces/DataSpaceInfo/StrongEncryptionDataSpace";n=Ke.find(e,a);if(!n||!n.content)throw new Error("ECMA-376 Encrypted file missing "+a);var s=Zo(n.content);if(s.length!=1||s[0]!="StrongEncryptionTransform")throw new Error("ECMA-376 Encrypted file bad "+a);a="/!DataSpaces/TransformInfo/StrongEncryptionTransform/!Primary";n=Ke.find(e,a);if(!n||!n.content)throw new Error("ECMA-376 Encrypted file missing "+a);el(n.content)}catch(f){}a="/EncryptionInfo";n=Ke.find(e,a);if(!n||!n.content)throw new Error("ECMA-376 Encrypted file missing "+a);var o=al(n.content);a="/EncryptedPackage";n=Ke.find(e,a);if(!n||!n.content)throw new Error("ECMA-376 Encrypted file missing "+a);if(o[0]==4&&typeof decrypt_agile!=="undefined")return decrypt_agile(o[1],n.content,t.password||"",t);if(o[0]==2&&typeof decrypt_std76!=="undefined")return decrypt_std76(o[1],n.content,t.password||"",t);throw new Error("File is password-protected")}function Yb(e,r){if(r.bookType=="ods")return tb(e,r);if(r.bookType=="numbers")return Ub(e,r);if(r.bookType=="xlsb")return Kb(e,r);return Jb(e,r)}function Kb(e,r){ah=1024;if(e&&!e.SSF){e.SSF=br(X)}if(e&&e.SSF){ze();He(e.SSF);r.revssf=nr(e.SSF);r.revssf[e.SSF[65535]]=0;r.ssf=e.SSF}r.rels={};r.wbrels={};r.Strings=[];r.Strings.Count=0;r.Strings.Unique=0;if(Rd)r.revStrings=new Map;else{r.revStrings={};r.revStrings.foo=[];delete r.revStrings.foo}var t=r.bookType=="xlsb"?"bin":"xml";var a=Th.indexOf(r.bookType)>-1;var n=Yn();Hb(r=r||{});var i=Fr();var s="",f=0;r.cellXfs=[];Fd(r.cellXfs,{},{revssf:{General:0}});if(!e.Props)e.Props={};s="docProps/core.xml";Dr(i,s,di(e.Props,r));n.coreprops.push(s);ri(r.rels,2,s,qn.CORE_PROPS);s="docProps/app.xml";if(e.Props&&e.Props.SheetNames){}else if(!e.Workbook||!e.Workbook.Sheets)e.Props.SheetNames=e.SheetNames;else{var o=[];for(var l=0;l0){s="docProps/custom.xml";Dr(i,s,Ti(e.Custprops,r));n.custprops.push(s);ri(r.rels,4,s,qn.CUST_PROPS)}for(f=1;f<=e.SheetNames.length;++f){var c={"!id":{}};var h=e.Sheets[e.SheetNames[f-1]];var u=(h||{})["!type"]||"sheet";switch(u){case"chart":;default:s="xl/worksheets/sheet"+f+"."+t;Dr(i,s,_g(f-1,s,r,e,c));n.sheets.push(s);ri(r.wbrels,-1,"worksheets/sheet"+f+"."+t,qn.WS[0]);}if(h){var d=h["!comments"];var v=false;var p="";if(d&&d.length>0){p="xl/comments"+f+"."+t;Dr(i,p,Ig(d,p,r));n.comments.push(p);ri(c,-1,"../comments"+f+"."+t,qn.CMNT);v=true}if(h["!legacy"]){if(v)Dr(i,"xl/drawings/vmlDrawing"+f+".vml",nh(f,h["!comments"]))}delete h["!comments"];delete h["!legacy"]}if(c["!id"].rId1)Dr(i,Zn(s),ei(c))}if(r.Strings!=null&&r.Strings.length>0){s="xl/sharedStrings."+t;Dr(i,s,Rg(r.Strings,s,r));n.strs.push(s);ri(r.wbrels,-1,"sharedStrings."+t,qn.SST)}s="xl/workbook."+t;Dr(i,s,Sg(e,s,r));n.workbooks.push(s);ri(r.rels,1,s,qn.WB);s="xl/theme/theme1.xml";Dr(i,s,Ic(e.Themes,r));n.themes.push(s);ri(r.wbrels,-1,"theme/theme1.xml",qn.THEME);s="xl/styles."+t;Dr(i,s,Og(e,s,r));n.styles.push(s);ri(r.wbrels,-1,"styles."+t,qn.STY);if(e.vbaraw&&a){s="xl/vbaProject.bin";Dr(i,s,e.vbaraw);n.vba.push(s);ri(r.wbrels,-1,"vbaProject.bin",qn.VBA)}s="xl/metadata."+t;Dr(i,s,Ng(s));n.metadata.push(s);ri(r.wbrels,-1,"metadata."+t,qn.XLMETA);Dr(i,"[Content_Types].xml",Jn(n,r));Dr(i,"_rels/.rels",ei(r.rels));Dr(i,"xl/_rels/workbook."+t+".rels",ei(r.wbrels));delete r.revssf;delete r.ssf;return i}function Jb(e,r){ah=1024;if(e&&!e.SSF){e.SSF=br(X)}if(e&&e.SSF){ze();He(e.SSF);r.revssf=nr(e.SSF);r.revssf[e.SSF[65535]]=0;r.ssf=e.SSF}r.rels={};r.wbrels={};r.Strings=[];r.Strings.Count=0;r.Strings.Unique=0;if(Rd)r.revStrings=new Map;else{r.revStrings={};r.revStrings.foo=[];delete r.revStrings.foo}var t="xml";var a=Th.indexOf(r.bookType)>-1;var n=Yn();Hb(r=r||{});var i=Fr();var s="",f=0;r.cellXfs=[];Fd(r.cellXfs,{},{revssf:{General:0}});if(!e.Props)e.Props={};s="docProps/core.xml";Dr(i,s,di(e.Props,r));n.coreprops.push(s);ri(r.rels,2,s,qn.CORE_PROPS);s="docProps/app.xml";if(e.Props&&e.Props.SheetNames){}else if(!e.Workbook||!e.Workbook.Sheets)e.Props.SheetNames=e.SheetNames;else{var o=[];for(var l=0;l0){s="docProps/custom.xml";Dr(i,s,Ti(e.Custprops,r));n.custprops.push(s);ri(r.rels,4,s,qn.CUST_PROPS)}var c=["SheetJ5"];r.tcid=0;for(f=1;f<=e.SheetNames.length;++f){var h={"!id":{}};var u=e.Sheets[e.SheetNames[f-1]];var d=(u||{})["!type"]||"sheet";switch(d){case"chart":;default:s="xl/worksheets/sheet"+f+"."+t;Dr(i,s,pv(f-1,r,e,h));n.sheets.push(s);ri(r.wbrels,-1,"worksheets/sheet"+f+"."+t,qn.WS[0]);}if(u){var v=u["!comments"];var p=false;var g="";if(v&&v.length>0){var m=false;v.forEach(function(e){e[1].forEach(function(e){if(e.T==true)m=true})});if(m){g="xl/threadedComments/threadedComment"+f+"."+t;Dr(i,g,lh(v,c,r));n.threadedcomments.push(g);ri(h,-1,"../threadedComments/threadedComment"+f+"."+t,qn.TCMNT)}g="xl/comments"+f+"."+t;Dr(i,g,fh(v,r));n.comments.push(g);ri(h,-1,"../comments"+f+"."+t,qn.CMNT);p=true}if(u["!legacy"]){if(p)Dr(i,"xl/drawings/vmlDrawing"+f+".vml",nh(f,u["!comments"]))}delete u["!comments"];delete u["!legacy"]}if(h["!id"].rId1)Dr(i,Zn(s),ei(h))}if(r.Strings!=null&&r.Strings.length>0){s="xl/sharedStrings."+t;Dr(i,s,Ho(r.Strings,r));n.strs.push(s);ri(r.wbrels,-1,"sharedStrings."+t,qn.SST)}s="xl/workbook."+t;Dr(i,s,rg(e,r));n.workbooks.push(s);ri(r.rels,1,s,qn.WB);s="xl/theme/theme1.xml";Dr(i,s,Ic(e.Themes,r));n.themes.push(s);ri(r.wbrels,-1,"theme/theme1.xml",qn.THEME);s="xl/styles."+t;Dr(i,s,$l(e,r));n.styles.push(s);ri(r.wbrels,-1,"styles."+t,qn.STY);if(e.vbaraw&&a){s="xl/vbaProject.bin";Dr(i,s,e.vbaraw);n.vba.push(s);ri(r.wbrels,-1,"vbaProject.bin",qn.VBA)}s="xl/metadata."+t;Dr(i,s,Jc());n.metadata.push(s);ri(r.wbrels,-1,"metadata."+t,qn.XLMETA);if(c.length>1){s="xl/persons/person.xml";Dr(i,s,hh(c,r));n.people.push(s);ri(r.wbrels,-1,"persons/person.xml",qn.PEOPLE)}Dr(i,"[Content_Types].xml",Jn(n,r));Dr(i,"_rels/.rels",ei(r.rels));Dr(i,"xl/_rels/workbook."+t+".rels",ei(r.wbrels));delete r.revssf;delete r.ssf;return i}function qb(e,r){var t="";switch((r||{}).type||"base64"){case"buffer":return[e[0],e[1],e[2],e[3],e[4],e[5],e[6],e[7]];case"base64":t=k(e.slice(0,12));break;case"binary":t=e;break;case"array":return[e[0],e[1],e[2],e[3],e[4],e[5],e[6],e[7]];default:throw new Error("Unrecognized type "+(r&&r.type||"undefined"));}return[t.charCodeAt(0),t.charCodeAt(1),t.charCodeAt(2),t.charCodeAt(3),t.charCodeAt(4),t.charCodeAt(5),t.charCodeAt(6),t.charCodeAt(7)]}function Zb(e,r){if(Ke.find(e,"EncryptedPackage"))return $b(e,r);return vm(e,r)}function Qb(e,r){var t,a=e;var n=r||{};if(!n.type)n.type=T&&Buffer.isBuffer(e)?"buffer":"base64";t=Pr(a,n);return Xb(t,n)}function ew(e,r){var t=0;e:while(t=2&&n[3]===0)return xo.to_workbook(a,t);if(n[2]===0&&(n[3]===8||n[3]===9))return xo.to_workbook(a,t)}break;case 3:;case 131:;case 139:;case 140:return Ao.to_workbook(a,t);case 123:if(n[1]===92&&n[2]===114&&n[3]===116)return gl.to_workbook(a,t);break;case 10:;case 13:;case 32:return rw(a,t);case 137:if(n[1]===80&&n[2]===78&&n[3]===71)throw new Error("PNG Image File is not a spreadsheet");break;}if(To.indexOf(n[0])>-1&&n[2]<=12&&n[3]<=31)return Ao.to_workbook(a,t);return nw(e,a,t,i)}function sw(e,r){var t=r||{};t.type="file";return iw(e,t)}function fw(e,r){switch(r.type){case"base64":;case"binary":break;case"buffer":;case"array":r.type="";break;case"file":return Qe(r.file,Ke.write(e,{type:T?"buffer":""}));case"string":throw new Error("'string' output type invalid for '"+r.bookType+"' files");default:throw new Error("Unrecognized type "+r.type);}return Ke.write(e,r)}function ow(e,r){var t=br(r||{});var a=Yb(e,t);return cw(a,t)}function lw(e,r){var t=br(r||{});var a=Jb(e,t);return cw(a,t)}function cw(e,r){var t={};var a=T?"nodebuffer":typeof Uint8Array!=="undefined"?"array":"string";if(r.compression)t.compression="DEFLATE";if(r.password)t.type=a;else switch(r.type){case"base64":t.type="base64";break;case"binary":t.type="string";break;case"string":throw new Error("'string' output type invalid for '"+r.bookType+"' files");case"buffer":;case"file":t.type=a;break;default:throw new Error("Unrecognized type "+r.type);}var n=e.FullPaths?Ke.write(e,{fileType:"zip",type:{nodebuffer:"buffer",string:"binary"}[t.type]||t.type,compression:!!r.compression}):e.generate(t);if(typeof Deno!=="undefined"){if(typeof n=="string"){if(r.type=="binary"||r.type=="base64")return n;n=new Uint8Array(S(n))}}if(r.password&&typeof encrypt_agile!=="undefined")return fw(encrypt_agile(n,r.password),r);if(r.type==="file")return Qe(r.file,n);return r.type=="string"?lt(n):n}function hw(e,r){var t=r||{};var a=pm(e,t);return fw(a,t)}function uw(e,r,t){if(!t)t="";var a=t+e;switch(r.type){case"base64":return w(ct(a));case"binary":return ct(a);case"string":return e;case"file":return Qe(r.file,a,"utf8");case"buffer":{if(T)return A(a,"utf8");else if(typeof TextEncoder!=="undefined")return(new TextEncoder).encode(a);else return uw(a,{type:"binary"}).split("").map(function(e){return e.charCodeAt(0)})};}throw new Error("Unrecognized type "+r.type)}function dw(e,r){switch(r.type){case"base64":return w(e);case"binary":return e;case"string":return e;case"file":return Qe(r.file,e,"binary");case"buffer":{if(T)return A(e,"binary");else return e.split("").map(function(e){return e.charCodeAt(0)})};}throw new Error("Unrecognized type "+r.type)}function vw(e,r){switch(r.type){case"string":;case"base64":;case"binary":var t="";for(var a=0;a0)n=0;var h=Ta(o.s.r);var u=[];var d=[];var v=0,p=0;var g=Array.isArray(e);var m=o.s.r,b=0;var w={};if(g&&!e[m])e[m]=[];var k=l.skipHidden&&e["!cols"]||[];var T=l.skipHidden&&e["!rows"]||[];for(b=o.s.c;b<=o.e.c;++b){if((k[b]||{}).hidden)continue;u[b]=ya(b);t=g?e[m][b]:e[u[b]+h];switch(a){case 1:i[b]=b-o.s.c;break;case 2:i[b]=u[b];break;case 3:i[b]=l.header[b-o.s.c];break;default:if(t==null)t={w:"__EMPTY",t:"s"};f=s=Pa(t,null,l);p=w[s]||0;if(!p)w[s]=1;else{do{f=s+"_"+p++}while(w[f]);w[s]=p;w[f]=1}i[b]=f;}}for(m=o.s.r+n;m<=o.e.r;++m){if((T[m]||{}).hidden)continue;var A=Tw(e,o,m,u,a,i,g,l);if(A.isempty===false||(a===1?l.blankrows!==false:!!l.blankrows))d[v++]=A.row}d.length=v;return d}var Ew=/"/g;function Cw(e,r,t,a,n,i,s,f){var o=true;var l=[],c="",h=Ta(t);for(var u=r.s.c;u<=r.e.c;++u){if(!a[u])continue;var d=f.dense?(e[t]||[])[u]:e[a[u]+h];if(d==null)c="";else if(d.v!=null){o=false;c=""+(f.rawNumbers&&d.t=="n"?d.v:Pa(d,null,f));for(var v=0,p=0;v!==c.length;++v)if((p=c.charCodeAt(v))===n||p===i||p===34||f.forceQuotes){c='"'+c.replace(Ew,'""')+'"';break}if(c=="ID")c='"ID"'}else if(d.f!=null&&!d.F){o=false;c="="+d.f;if(c.indexOf(",")>=0)c='"'+c.replace(Ew,'""')+'"'}else c="";l.push(c)}if(f.blankrows===false&&o)return null;return l.join(s)}function yw(e,r){var t=[];var a=r==null?{}:r;if(e==null||e["!ref"]==null)return"";var n=Da(e["!ref"]);var i=a.FS!==undefined?a.FS:",",s=i.charCodeAt(0);var f=a.RS!==undefined?a.RS:"\n",o=f.charCodeAt(0);var l=new RegExp((i=="|"?"\\|":i)+"+$");var c="",h=[];a.dense=Array.isArray(e);var u=a.skipHidden&&e["!cols"]||[];var d=a.skipHidden&&e["!rows"]||[];for(var v=n.s.c;v<=n.e.c;++v)if(!(u[v]||{}).hidden)h[v]=ya(v);var p=0;for(var g=n.s.r;g<=n.e.r;++g){if((d[g]||{}).hidden)continue;c=Cw(e,n,g,h,s,o,i,a);if(c==null){continue}if(a.strip)c=c.replace(l,"");if(c||a.blankrows!==false)t.push((p++?f:"")+c)}delete a.dense;return t.join("")}function Sw(e,r){if(!r)r={};r.FS="\t";r.RS="\n";var t=yw(e,r);if(typeof a=="undefined"||r.type=="string")return t;var n=a.utils.encode(1200,t,"str");return String.fromCharCode(255)+String.fromCharCode(254)+n}function _w(e){var r="",t,a="";if(e==null||e["!ref"]==null)return[];var n=Da(e["!ref"]),i="",s=[],f; -var o=[];var l=Array.isArray(e);for(f=n.s.c;f<=n.e.c;++f)s[f]=ya(f);for(var c=n.s.r;c<=n.e.r;++c){i=Ta(c);for(f=n.s.c;f<=n.e.c;++f){r=s[f]+i;t=l?(e[c]||[])[f]:e[r];a="";if(t===undefined)continue;else if(t.F!=null){r=t.F;if(!t.f)continue;a=t.f;if(r.indexOf(":")==-1)r=r+":"+r}if(t.f!=null)a=t.f;else if(t.t=="z")continue;else if(t.t=="n"&&t.v!=null)a=""+t.v;else if(t.t=="b")a=t.v?"TRUE":"FALSE";else if(t.w!==undefined)a="'"+t.w;else if(t.v===undefined)continue;else if(t.t=="s")a="'"+t.v;else a=""+t.v;o[o.length]=r+"="+a}}return o}function xw(e,r,t){var a=t||{};var n=+!a.skipHeader;var i=e||{};var s=0,f=0;if(i&&a.origin!=null){if(typeof a.origin=="number")s=a.origin;else{var o=typeof a.origin=="string"?Oa(a.origin):a.origin;s=o.r;f=o.c}}var l;var c={s:{c:0,r:0},e:{c:f,r:s+r.length-1+n}};if(i["!ref"]){var h=Da(i["!ref"]);c.e.c=Math.max(c.e.c,h.e.c);c.e.r=Math.max(c.e.r,h.e.r);if(s==-1){s=h.e.r+1;c.e.r=s+r.length-1+n}}else{if(s==-1){s=0;c.e.r=r.length-1+n}}var u=a.header||[],d=0;r.forEach(function(e,r){rr(e).forEach(function(t){if((d=u.indexOf(t))==-1)u[d=u.length]=t;var o=e[t];var c="z";var h="";var v=Ra({c:f+d,r:s+r+n});l=Rw(i,v);if(o&&typeof o==="object"&&!(o instanceof Date)){i[v]=o}else{if(typeof o=="number")c="n";else if(typeof o=="boolean")c="b";else if(typeof o=="string")c="s";else if(o instanceof Date){c="d";if(!a.cellDates){c="n";o=fr(o)}h=a.dateNF||X[14]}else if(o===null&&a.nullError){c="e";o=0}if(!l)i[v]=l={t:c,v:o};else{l.t=c;l.v=o;delete l.w;delete l.R;if(h)l.z=h}if(h)l.z=h}})});c.e.c=Math.max(c.e.c,f+u.length-1);var v=Ta(s);if(n)for(d=0;d=0&&e.SheetNames.length>r)return r;throw new Error("Cannot find sheet # "+r)}else if(typeof r=="string"){var t=e.SheetNames.indexOf(r);if(t>-1)return t;throw new Error("Cannot find sheet name |"+r+"|")}else throw new Error("Cannot find sheet |"+r+"|")}function Nw(){return{SheetNames:[],Sheets:{}}}function Dw(e,r,t,a){var n=1;if(!t)for(;n<=65535;++n,t=undefined)if(e.SheetNames.indexOf(t="Sheet"+n)==-1)break;if(!t||e.SheetNames.length>=65535)throw new Error("Too many worksheets");if(a&&e.SheetNames.indexOf(t)>=0){var i=t.match(/(^.*?)(\d+)$/);n=i&&+i[2]||0;var s=i&&i[1]||t;for(++n;n<=65535;++n)if(e.SheetNames.indexOf(t=s+n)==-1)break}Jp(t);if(e.SheetNames.indexOf(t)>=0)throw new Error("Worksheet with name |"+t+"| already exists!");e.SheetNames.push(t);e.Sheets[t]=r;return t}function Fw(e,r,t){if(!e.Workbook)e.Workbook={};if(!e.Workbook.Sheets)e.Workbook.Sheets=[];var a=Iw(e,r);if(!e.Workbook.Sheets[a])e.Workbook.Sheets[a]={};switch(t){case 0:;case 1:;case 2:break;default:throw new Error("Bad sheet visibility setting "+t);}e.Workbook.Sheets[a].Hidden=t}function Pw(e,r){e.z=r;return e}function Lw(e,r,t){if(!r){delete e.l}else{e.l={Target:r};if(t)e.l.Tooltip=t}return e}function Mw(e,r,t){return Lw(e,"#"+r,t)}function Uw(e,r,t){if(!e.c)e.c=[];e.c.push({t:r,a:t||"SheetJS"})}function Bw(e,r,t,a){var n=typeof r!="string"?r:Da(r);var i=typeof r=="string"?r:Na(r);for(var s=n.s.r;s<=n.e.r;++s)for(var f=n.s.c;f<=n.e.c;++f){var o=Rw(e,s,f);o.t="n";o.F=i;delete o.v;if(s==n.s.r&&f==n.s.c){o.f=t;if(a)o.D=true}}return e}var Ww={encode_col:ya,encode_row:Ta,encode_cell:Ra,encode_range:Na,decode_col:Ca,decode_row:ka,split_cell:xa,decode_cell:Oa,decode_range:Ia,format_cell:Pa,sheet_add_aoa:Ma,sheet_add_json:xw,sheet_add_dom:Gm,aoa_to_sheet:Ua,json_to_sheet:Ow,table_to_sheet:jm,table_to_book:Xm,sheet_to_csv:yw,sheet_to_txt:Sw,sheet_to_json:Aw,sheet_to_html:Vm,sheet_to_formulae:_w,sheet_to_row_object_array:Aw,sheet_get_cell:Rw,book_new:Nw,book_append_sheet:Dw,book_set_sheet_visibility:Fw,cell_set_number_format:Pw,cell_set_hyperlink:Lw,cell_set_internal_link:Mw,cell_add_comment:Uw,sheet_set_array_formula:Bw,consts:{SHEET_VISIBLE:0,SHEET_HIDDEN:1,SHEET_VERY_HIDDEN:2}};var Hw;function zw(e){Hw=e}function Vw(e,r){var t=Hw();var a=r==null?{}:r;if(e==null||e["!ref"]==null){t.push(null);return t}var n=Da(e["!ref"]);var i=a.FS!==undefined?a.FS:",",s=i.charCodeAt(0);var f=a.RS!==undefined?a.RS:"\n",o=f.charCodeAt(0);var l=new RegExp((i=="|"?"\\|":i)+"+$");var c="",h=[];a.dense=Array.isArray(e);var u=a.skipHidden&&e["!cols"]||[];var d=a.skipHidden&&e["!rows"]||[];for(var v=n.s.c;v<=n.e.c;++v)if(!(u[v]||{}).hidden)h[v]=ya(v);var p=n.s.r;var g=false,m=0;t._read=function(){if(!g){g=true;return t.push("\ufeff")}while(p<=n.e.r){++p;if((d[p-1]||{}).hidden)continue;c=Cw(e,n,p-1,h,s,o,i,a);if(c!=null){if(a.strip)c=c.replace(l,"");if(c||a.blankrows!==false)return t.push((m++?f:"")+c)}}return t.push(null)};return t}function Gw(e,r){var t=Hw();var a=r||{};var n=a.header!=null?a.header:Bm;var i=a.footer!=null?a.footer:Wm;t.push(n);var s=Ia(e["!ref"]);a.dense=Array.isArray(e);t.push(zm(e,s,a));var f=s.s.r;var o=false;t._read=function(){if(f>s.e.r){if(!o){o=true;t.push(""+i)}return t.push(null)}while(f<=s.e.r){t.push(Um(e,s,f,a));++f;break}};return t}function jw(e,r){var t=Hw({objectMode:true});if(e==null||e["!ref"]==null){t.push(null);return t}var a={t:"n",v:0},n=0,i=1,s=[],f=0,o="";var l={s:{r:0,c:0},e:{r:0,c:0}};var c=r||{};var h=c.range!=null?c.range:e["!ref"];if(c.header===1)n=1;else if(c.header==="A")n=2;else if(Array.isArray(c.header))n=3;switch(typeof h){case"string":l=Da(h);break;case"number":l=Da(e["!ref"]);l.s.r=h;break;default:l=h;}if(n>0)i=0;var u=Ta(l.s.r);var d=[];var v=0;var p=Array.isArray(e);var g=l.s.r,m=0;var b={};if(p&&!e[g])e[g]=[];var w=c.skipHidden&&e["!cols"]||[];var k=c.skipHidden&&e["!rows"]||[];for(m=l.s.c;m<=l.e.c;++m){if((w[m]||{}).hidden)continue;d[m]=ya(m);a=p?e[g][m]:e[d[m]+u];switch(n){case 1:s[m]=m-l.s.c;break;case 2:s[m]=d[m];break;case 3:s[m]=c.header[m-l.s.c];break;default:if(a==null)a={w:"__EMPTY",t:"s"};o=f=Pa(a,null,c);v=b[f]||0;if(!v)b[f]=1;else{do{o=f+"_"+v++}while(b[o]);b[f]=v;b[o]=1}s[m]=o;}}g=l.s.r+i;t._read=function(){while(g<=l.e.r){if((k[g-1]||{}).hidden)continue;var r=Tw(e,l,g,d,n,s,p,c);++g;if(r.isempty===false||(n===1?c.blankrows!==false:!!c.blankrows)){t.push(r.row);return}}return t.push(null)};return t}var Xw={to_json:jw,to_html:Gw,to_csv:Vw,set_readable:zw};if(typeof vm!=="undefined")e.parse_xlscfb=vm;e.parse_zip=Xb;e.read=iw;e.readFile=sw;e.readFileSync=sw;e.write=gw;e.writeFile=bw;e.writeFileSync=bw;e.writeFileAsync=kw;e.utils=Ww;e.writeXLSX=pw;e.writeFileXLSX=ww;e.SSF=Ve;if(typeof Xw!=="undefined")e.stream=Xw;if(typeof Ke!=="undefined")e.CFB=Ke;if(typeof require!=="undefined"){var $w=undefined;if(($w||{}).Readable)zw($w.Readable)}}if(typeof exports!=="undefined")make_xlsx_lib(exports);else if(typeof module!=="undefined"&&module.exports)make_xlsx_lib(module.exports);else if(typeof define==="function"&&define.amd)define("xlsx",function(){if(!XLSX.version)make_xlsx_lib(XLSX);return XLSX});else make_xlsx_lib(XLSX);if(typeof window!=="undefined"&&!window.XLSX)try{window.XLSX=XLSX}catch(e){} diff --git a/srv/tesm/static/js/vendor/xterm-addon-fit.js b/srv/tesm/static/js/vendor/xterm-addon-fit.js deleted file mode 100644 index 0388971..0000000 --- a/srv/tesm/static/js/vendor/xterm-addon-fit.js +++ /dev/null @@ -1,2 +0,0 @@ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})())); -//# sourceMappingURL=addon-fit.js.map \ No newline at end of file diff --git a/srv/tesm/static/js/vendor/xterm.js b/srv/tesm/static/js/vendor/xterm.js deleted file mode 100644 index 7ca75f8..0000000 --- a/srv/tesm/static/js/vendor/xterm.js +++ /dev/null @@ -1,2 +0,0 @@ -!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(9924),a=i(844),h=i(4725),c=i(2585),l=i(3656);let d=t.AccessibilityManager=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;ethis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,l.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.innerText=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),n.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=document.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let n=t=this._terminal.cols&&(++s,n=0),{row:s,column:n}},n=r(t),o=r(i);if(n&&o){if(n.row>o.row||n.row===o.row&&n.column>=o.column)throw new Error("invalid range");this._terminal.select(n.column,n.row,(o.row-n.row)*this._terminal.cols-n.column+o.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){this._accessibilityContainer.style.width=`${this._renderService.dimensions.css.canvas.width}px`,this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function i(e){return e.replace(/\r?\n/g,"\r")}function s(e,t){return t?"[200~"+e+"[201~":e}function r(e,t,r,n){e=s(e=i(e),r.decPrivateModes.bracketedPasteMode&&!0!==n.rawOptions.ignoreBracketedPasteMode),r.triggerDataEvent(e,!0),t.value=""}function n(e,t,i){const s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}Object.defineProperty(t,"__esModule",{value:!0}),t.rightClickHandler=t.moveTextAreaUnderMouseCursor=t.paste=t.handlePasteEvent=t.copyHandler=t.bracketTextForPaste=t.prepareTextForTerminal=void 0,t.prepareTextForTerminal=i,t.bracketTextForPaste=s,t.copyHandler=function(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()},t.handlePasteEvent=function(e,t,i,s){e.stopPropagation(),e.clipboardData&&r(e.clipboardData.getData("text/plain"),t,i,s)},t.paste=r,t.moveTextAreaUnderMouseCursor=n,t.rightClickHandler=function(e,t,i,s,r){n(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}},7239:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorContrastCache=void 0;const s=i(1505);t.ColorContrastCache=class{constructor(){this._color=new s.TwoKeyMap,this._css=new s.TwoKeyMap}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}}},3656:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.addDisposableDomListener=void 0,t.addDisposableDomListener=function(e,t,i,s){e.addEventListener(t,i,s);let r=!1;return{dispose:()=>{r||(r=!0,e.removeEventListener(t,i,s))}}}},3551:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const n=i(3656),o=i(8460),a=i(844),h=i(2585),c=i(4725);let l=t.Linkifier=class extends a.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this.register(new o.EventEmitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this.register(new o.EventEmitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this.register((0,a.getDisposeArrayDisposable)(this._linkCacheDisposables)),this.register((0,a.toDisposable)((()=>{this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()}))),this.register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0}))),this.register((0,n.addDisposableDomListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this.register((0,n.addDisposableDomListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach((e=>{e.link.dispose&&e.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,(t=>{if(this._isMouseOut)return;const r=t?.map((e=>({link:e})));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)}))}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=n;e<=o;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t)));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t)));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);t&&this._mouseDownLink===this._currentLink&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,a.disposeArray)(this._linkCacheDisposables))}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._askForLink(e,!1)}}))))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t,i){const s=i.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(s)return{x:s[0],y:s[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=l=s([r(1,c.IMouseService),r(2,c.IRenderService),r(3,h.IBufferService),r(4,c.ILinkProviderService)],l)},9042:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0,t.promptLabel="Terminal input",t.tooMuchOutput="Too much output to announce, navigate to rows manually to read"},3730:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const n=i(511),o=i(2585);let a=t.OscLinkProvider=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=new n.CellData,a=i.getTrimmedLength();let c=-1,l=-1,d=!1;for(let t=0;tr?r.activate(e,t,n):h(0,t),hover:(e,t)=>r?.hover?.(e,t,n),leave:(e,t)=>r?.leave?.(e,t,n)})}d=!1,o.hasExtendedAttrs()&&o.extended.urlId?(l=t,c=o.extended.urlId):(l=-1,c=-1)}}t(s)}};function h(e,t){if(confirm(`Do you want to navigate to ${t}?\n\nWARNING: This link could potentially be dangerous`)){const e=window.open();if(e){try{e.opener=null}catch{}e.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}t.OscLinkProvider=a=s([r(0,o.IBufferService),r(1,o.IOptionsService),r(2,o.IOscLinkService)],a)},6193:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.RenderDebouncer=void 0,t.RenderDebouncer=class{constructor(e,t){this._renderCallback=e,this._coreBrowserService=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},3236:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Terminal=void 0;const s=i(3614),r=i(3656),n=i(3551),o=i(9042),a=i(3730),h=i(1680),c=i(3107),l=i(5744),d=i(2950),_=i(1296),u=i(428),f=i(4269),v=i(5114),p=i(8934),g=i(3230),m=i(9312),S=i(4725),C=i(6731),b=i(8055),w=i(8969),y=i(8460),E=i(844),k=i(6114),L=i(8437),D=i(2584),R=i(7399),x=i(5941),A=i(9074),B=i(2585),T=i(5435),M=i(4567),O=i(779);class P extends w.CoreTerminal{get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(e={}){super(e),this.browser=k,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this.register(new E.MutableDisposable),this._onCursorMove=this.register(new y.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this.register(new y.EventEmitter),this.onKey=this._onKey.event,this._onRender=this.register(new y.EventEmitter),this.onRender=this._onRender.event,this._onSelectionChange=this.register(new y.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this.register(new y.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this.register(new y.EventEmitter),this.onBell=this._onBell.event,this._onFocus=this.register(new y.EventEmitter),this._onBlur=this.register(new y.EventEmitter),this._onA11yCharEmitter=this.register(new y.EventEmitter),this._onA11yTabEmitter=this.register(new y.EventEmitter),this._onWillOpen=this.register(new y.EventEmitter),this._setup(),this._decorationService=this._instantiationService.createInstance(A.DecorationService),this._instantiationService.setService(B.IDecorationService,this._decorationService),this._linkProviderService=this._instantiationService.createInstance(O.LinkProviderService),this._instantiationService.setService(S.ILinkProviderService,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(a.OscLinkProvider)),this.register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this.register(this._inputHandler.onRequestRefreshRows(((e,t)=>this.refresh(e,t)))),this.register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this.register(this._inputHandler.onRequestReset((()=>this.reset()))),this.register(this._inputHandler.onRequestWindowsOptionsReport((e=>this._reportWindowsOptions(e)))),this.register(this._inputHandler.onColor((e=>this._handleColorEvent(e)))),this.register((0,y.forwardEvent)(this._inputHandler.onCursorMove,this._onCursorMove)),this.register((0,y.forwardEvent)(this._inputHandler.onTitleChange,this._onTitleChange)),this.register((0,y.forwardEvent)(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this.register((0,y.forwardEvent)(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this.register(this._bufferService.onResize((e=>this._afterResize(e.cols,e.rows)))),this.register((0,E.toDisposable)((()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)})))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i="";switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=b.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`${D.C0.ESC}]${i};${(0,x.toRgbString)(s)}${D.C1_ESCAPED.ST}`);break;case 1:if("ansi"===e)this._themeService.modifyColors((e=>e.ansi[t.index]=b.channels.toColor(...t.color)));else{const i=e;this._themeService.modifyColors((e=>e[i]=b.channels.toColor(...t.color)))}break;case 2:this._themeService.restoreColor(t.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[I"),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),n=this._renderService.dimensions.css.cell.width*r,o=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=o+"px",this.textarea.style.width=n+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this.register((0,r.addDisposableDomListener)(this.element,"copy",(e=>{this.hasSelection()&&(0,s.copyHandler)(e,this._selectionService)})));const e=e=>(0,s.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this.register((0,r.addDisposableDomListener)(this.textarea,"paste",e)),this.register((0,r.addDisposableDomListener)(this.element,"paste",e)),k.isFirefox?this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>{2===e.button&&(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this.register((0,r.addDisposableDomListener)(this.element,"contextmenu",(e=>{(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),k.isLinux&&this.register((0,r.addDisposableDomListener)(this.element,"auxclick",(e=>{1===e.button&&(0,s.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)})))}_bindKeys(){this.register((0,r.addDisposableDomListener)(this.textarea,"keyup",(e=>this._keyUp(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keydown",(e=>this._keyDown(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keypress",(e=>this._keyPress(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionupdate",(e=>this._compositionHelper.compositionupdate(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this.register((0,r.addDisposableDomListener)(this.textarea,"input",(e=>this._inputEvent(e)),!0)),this.register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this._viewportScrollArea=this._document.createElement("div"),this._viewportScrollArea.classList.add("xterm-scroll-area"),this._viewportElement.appendChild(this._viewportScrollArea),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this.register((0,r.addDisposableDomListener)(this.screenElement,"mousemove",(e=>this.updateCursorStyle(e)))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement),this.textarea=this._document.createElement("textarea"),this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",o.promptLabel),k.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._coreBrowserService=this.register(this._instantiationService.createInstance(v.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??"undefined"!=typeof window?window.document:null)),this._instantiationService.setService(S.ICoreBrowserService,this._coreBrowserService),this.register((0,r.addDisposableDomListener)(this.textarea,"focus",(e=>this._handleTextAreaFocus(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(u.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(S.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(C.ThemeService),this._instantiationService.setService(S.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(f.CharacterJoinerService),this._instantiationService.setService(S.ICharacterJoinerService,this._characterJoinerService),this._renderService=this.register(this._instantiationService.createInstance(g.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(S.IRenderService,this._renderService),this.register(this._renderService.onRenderedViewportChange((e=>this._onRender.fire(e)))),this.onResize((e=>this._renderService.resize(e.cols,e.rows))),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(d.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseService=this._instantiationService.createInstance(p.MouseService),this._instantiationService.setService(S.IMouseService,this._mouseService),this.linkifier=this.register(this._instantiationService.createInstance(n.Linkifier,this.screenElement)),this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch{}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this.viewport=this._instantiationService.createInstance(h.Viewport,this._viewportElement,this._viewportScrollArea),this.viewport.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent,1))),this.register(this._inputHandler.onRequestSyncScrollBar((()=>this.viewport.syncScrollArea()))),this.register(this.viewport),this.register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this.register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this.register(this.onBlur((()=>this._renderService.handleBlur()))),this.register(this.onFocus((()=>this._renderService.handleFocus()))),this.register(this._renderService.onDimensionsChange((()=>this.viewport.syncScrollArea()))),this._selectionService=this.register(this._instantiationService.createInstance(m.SelectionService,this.element,this.screenElement,this.linkifier)),this._instantiationService.setService(S.ISelectionService,this._selectionService),this.register(this._selectionService.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent)))),this.register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this.register(this._selectionService.onRequestRedraw((e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode)))),this.register(this._selectionService.onLinuxMouseSelection((e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()}))),this.register(this._onScroll.event((e=>{this.viewport.syncScrollArea(),this._selectionService.refresh()}))),this.register((0,r.addDisposableDomListener)(this._viewportElement,"scroll",(()=>this._selectionService.refresh()))),this.register(this._instantiationService.createInstance(c.BufferDecorationRenderer,this.screenElement)),this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>this._selectionService.handleMouseDown(e)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)),this.register(this.optionsService.onSpecificOptionChange("screenReaderMode",(e=>this._handleScreenReaderModeOptionChange(e)))),this.options.overviewRulerWidth&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRulerWidth",(e=>{!this._overviewRulerRenderer&&e&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(_.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}bindMouse(){const e=this,t=this.element;function i(t){const i=e._mouseService.getMouseReportCoords(t,e.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(e._customWheelEventHandler&&!1===e._customWheelEventHandler(t))return!1;if(0===e.viewport.getLinesScrolled(t))return!1;r=t.deltaY<0?0:1,s=4;break;default:return!1}return!(void 0===r||void 0===s||s>4)&&e.coreMouseService.triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:t.altKey,shift:t.shiftKey})}const s={mouseup:null,wheel:null,mousedrag:null,mousemove:null},n={mouseup:e=>(i(e),e.buttons||(this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag)),this.cancel(e)),wheel:e=>(i(e),this.cancel(e,!0)),mousedrag:e=>{e.buttons&&i(e)},mousemove:e=>{e.buttons||i(e)}};this.register(this.coreMouseService.onProtocolChange((e=>{e?("debug"===this.optionsService.rawOptions.logLevel&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(e)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&e?s.mousemove||(t.addEventListener("mousemove",n.mousemove),s.mousemove=n.mousemove):(t.removeEventListener("mousemove",s.mousemove),s.mousemove=null),16&e?s.wheel||(t.addEventListener("wheel",n.wheel,{passive:!1}),s.wheel=n.wheel):(t.removeEventListener("wheel",s.wheel),s.wheel=null),2&e?s.mouseup||(s.mouseup=n.mouseup):(this._document.removeEventListener("mouseup",s.mouseup),s.mouseup=null),4&e?s.mousedrag||(s.mousedrag=n.mousedrag):(this._document.removeEventListener("mousemove",s.mousedrag),s.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this.register((0,r.addDisposableDomListener)(t,"mousedown",(e=>{if(e.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(e))return i(e),s.mouseup&&this._document.addEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.addEventListener("mousemove",s.mousedrag),this.cancel(e)}))),this.register((0,r.addDisposableDomListener)(t,"wheel",(e=>{if(!s.wheel){if(this._customWheelEventHandler&&!1===this._customWheelEventHandler(e))return!1;if(!this.buffer.hasScrollback){const t=this.viewport.getLinesScrolled(e);if(0===t)return;const i=D.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(e.deltaY<0?"A":"B");let s="";for(let e=0;e{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchStart(e),this.cancel(e)}),{passive:!0})),this.register((0,r.addDisposableDomListener)(t,"touchmove",(e=>{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchMove(e)?void 0:this.cancel(e)}),{passive:!1}))}refresh(e,t){this._renderService?.refreshRows(e,t)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t,i=0){1===i?(super.scrollLines(e,t,i),this.refresh(0,this.rows-1)):this.viewport?.scrollLines(e)}paste(e){(0,s.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this._customWheelEventHandler=e}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=(0,R.evaluateKeyboardEvent)(e,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),this.cancel(e,!0)}return 1===i.type&&this.selectAll(),!!this._isThirdLevelShift(this.browser,e)||(i.cancel&&this.cancel(e,!0),!i.key||!!(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(i.key!==D.C0.ETX&&i.key!==D.C0.CR||(this.textarea.value=""),this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!0),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey?this.cancel(e,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e)||(function(e){return 16===e.keyCode||17===e.keyCode||18===e.keyCode}(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure(),this.viewport?.syncScrollArea(!0)}clear(){if(0!==this.buffer.ybase||0!==this.buffer.y){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e{Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=Date.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=Date.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},1680:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const n=i(3656),o=i(4725),a=i(8460),h=i(844),c=i(2585);let l=t.Viewport=class extends h.Disposable{constructor(e,t,i,s,r,o,h,c){super(),this._viewportElement=e,this._scrollArea=t,this._bufferService=i,this._optionsService=s,this._charSizeService=r,this._renderService=o,this._coreBrowserService=h,this.scrollBarWidth=0,this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._wheelPartialScroll=0,this._refreshAnimationFrame=null,this._ignoreNextScrollEvent=!1,this._smoothScrollState={startTime:0,origin:-1,target:-1},this._onRequestScrollLines=this.register(new a.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this.scrollBarWidth=this._viewportElement.offsetWidth-this._scrollArea.offsetWidth||15,this.register((0,n.addDisposableDomListener)(this._viewportElement,"scroll",this._handleScroll.bind(this))),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._renderDimensions=this._renderService.dimensions,this.register(this._renderService.onDimensionsChange((e=>this._renderDimensions=e))),this._handleThemeChange(c.colors),this.register(c.onChangeColors((e=>this._handleThemeChange(e)))),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.syncScrollArea()))),setTimeout((()=>this.syncScrollArea()))}_handleThemeChange(e){this._viewportElement.style.backgroundColor=e.background.css}reset(){this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._coreBrowserService.window.requestAnimationFrame((()=>this.syncScrollArea()))}_refresh(e){if(e)return this._innerRefresh(),void(null!==this._refreshAnimationFrame&&this._coreBrowserService.window.cancelAnimationFrame(this._refreshAnimationFrame));null===this._refreshAnimationFrame&&(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._charSizeService.height>0){this._currentRowHeight=this._renderDimensions.device.cell.height/this._coreBrowserService.dpr,this._currentDeviceCellHeight=this._renderDimensions.device.cell.height,this._lastRecordedViewportHeight=this._viewportElement.offsetHeight;const e=Math.round(this._currentRowHeight*this._lastRecordedBufferLength)+(this._lastRecordedViewportHeight-this._renderDimensions.css.canvas.height);this._lastRecordedBufferHeight!==e&&(this._lastRecordedBufferHeight=e,this._scrollArea.style.height=this._lastRecordedBufferHeight+"px")}const e=this._bufferService.buffer.ydisp*this._currentRowHeight;this._viewportElement.scrollTop!==e&&(this._ignoreNextScrollEvent=!0,this._viewportElement.scrollTop=e),this._refreshAnimationFrame=null}syncScrollArea(e=!1){if(this._lastRecordedBufferLength!==this._bufferService.buffer.lines.length)return this._lastRecordedBufferLength=this._bufferService.buffer.lines.length,void this._refresh(e);this._lastRecordedViewportHeight===this._renderService.dimensions.css.canvas.height&&this._lastScrollTop===this._activeBuffer.ydisp*this._currentRowHeight&&this._renderDimensions.device.cell.height===this._currentDeviceCellHeight||this._refresh(e)}_handleScroll(e){if(this._lastScrollTop=this._viewportElement.scrollTop,!this._viewportElement.offsetParent)return;if(this._ignoreNextScrollEvent)return this._ignoreNextScrollEvent=!1,void this._onRequestScrollLines.fire({amount:0,suppressScrollEvent:!0});const t=Math.round(this._lastScrollTop/this._currentRowHeight)-this._bufferService.buffer.ydisp;this._onRequestScrollLines.fire({amount:t,suppressScrollEvent:!0})}_smoothScroll(){if(this._isDisposed||-1===this._smoothScrollState.origin||-1===this._smoothScrollState.target)return;const e=this._smoothScrollPercent();this._viewportElement.scrollTop=this._smoothScrollState.origin+Math.round(e*(this._smoothScrollState.target-this._smoothScrollState.origin)),e<1?this._coreBrowserService.window.requestAnimationFrame((()=>this._smoothScroll())):this._clearSmoothScrollState()}_smoothScrollPercent(){return this._optionsService.rawOptions.smoothScrollDuration&&this._smoothScrollState.startTime?Math.max(Math.min((Date.now()-this._smoothScrollState.startTime)/this._optionsService.rawOptions.smoothScrollDuration,1),0):1}_clearSmoothScrollState(){this._smoothScrollState.startTime=0,this._smoothScrollState.origin=-1,this._smoothScrollState.target=-1}_bubbleScroll(e,t){const i=this._viewportElement.scrollTop+this._lastRecordedViewportHeight;return!(t<0&&0!==this._viewportElement.scrollTop||t>0&&i0&&(i=e),s=""}}return{bufferElements:r,cursorElement:i}}getLinesScrolled(e){if(0===e.deltaY||e.shiftKey)return 0;let t=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(t/=this._currentRowHeight+0,this._wheelPartialScroll+=t,t=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(t*=this._bufferService.rows),t}_applyScrollModifier(e,t){const i=this._optionsService.rawOptions.fastScrollModifier;return"alt"===i&&t.altKey||"ctrl"===i&&t.ctrlKey||"shift"===i&&t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}handleTouchStart(e){this._lastTouchY=e.touches[0].pageY}handleTouchMove(e){const t=this._lastTouchY-e.touches[0].pageY;return this._lastTouchY=e.touches[0].pageY,0!==t&&(this._viewportElement.scrollTop+=t,this._bubbleScroll(e,t))}};t.Viewport=l=s([r(2,c.IBufferService),r(3,c.IOptionsService),r(4,o.ICharSizeService),r(5,o.IRenderService),r(6,o.ICoreBrowserService),r(7,o.IThemeService)],l)},3107:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const n=i(4725),o=i(844),a=i(2585);let h=t.BufferDecorationRenderer=class extends o.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this.register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this.register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this.register(this._coreBrowserService.onDprChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this.register(this._decorationService.onDecorationRemoved((e=>this._removeDecoration(e)))),this.register((0,o.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose((()=>{this._decorationElements.delete(e),i.remove()}))),i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.display=this._altBufferIsActive?"none":"block",e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=h=s([r(1,a.IBufferService),r(2,n.ICoreBrowserService),r(3,a.IDecorationService),r(4,n.IRenderService)],h)},5871:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},5744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const n=i(5871),o=i(4725),a=i(844),h=i(2585),c={full:0,left:0,center:0,right:0},l={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let _=t.OverviewRulerRenderer=class extends a.Disposable{get _width(){return this._optionsService.options.overviewRulerWidth||0}constructor(e,t,i,s,r,o,h){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=o,this._coreBrowserService=h,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement);const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._registerDecorationListeners(),this._registerBufferChangeListeners(),this._registerDimensionChangeListeners(),this.register((0,a.toDisposable)((()=>{this._canvas?.remove()})))}_registerDecorationListeners(){this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this.register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0))))}_registerBufferChangeListeners(){this.register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this.register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})))}_registerDimensionChangeListeners(){this.register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this.register(this._optionsService.onSpecificOptionChange("overviewRulerWidth",(()=>this._queueRefresh(!0)))),this.register(this._coreBrowserService.onDprChange((()=>this._queueRefresh(!0)))),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor(this._canvas.width/3),t=Math.ceil(this._canvas.width/3);l.full=this._canvas.width,l.left=e,l.center=t,l.right=e,this._refreshDrawHeightConstants(),d.full=0,d.left=0,d.center=l.left,d.right=l.left+l.center}_refreshDrawHeightConstants(){c.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);c.left=t,c.center=t,c.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowserService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1;const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-c[e.position||"full"]/2),l[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+c[e.position||"full"]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=_=s([r(2,h.IBufferService),r(3,h.IDecorationService),r(4,o.IRenderService),r(5,h.IOptionsService),r(6,o.ICoreBrowserService)],_)},2950:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const n=i(4725),o=i(2585),a=i(2584);let h=t.CompositionHelper=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let t;this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,e.end):this._textarea.value.substring(e.start),t.length>0&&this._coreService.triggerDataEvent(t,!0)}}),0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0)),0)}}};t.CompositionHelper=h=s([r(2,o.IBufferService),r(3,o.IOptionsService),r(4,o.ICoreService),r(5,n.IRenderService)],h)},9806:(e,t)=>{function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left")),o=parseInt(r.getPropertyValue("padding-top"));return[t.clientX-s.left-n,t.clientY-s.top-o]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoords=t.getCoordsRelativeToElement=void 0,t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,n,o,a,h,c){if(!o)return;const l=i(e,t,s);return l?(l[0]=Math.ceil((l[0]+(c?a/2:0))/a),l[1]=Math.ceil(l[1]/h),l[0]=Math.min(Math.max(l[0],1),r+(c?1:0)),l[1]=Math.min(Math.max(l[1],1),n),l):void 0}},9504:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.moveToCellSequence=void 0;const s=i(2584);function r(e,t,i,s){const r=e-n(e,i),a=t-n(t,i),l=Math.abs(r-a)-function(e,t,i){let s=0;const r=e-n(e,i),a=t-n(t,i);for(let n=0;n=0&&et?"A":"B"}function a(e,t,i,s,r,n){let o=e,a=t,h="";for(;o!==i||a!==s;)o+=r?1:-1,r&&o>n.cols-1?(h+=n.buffer.translateBufferLineToString(a,!1,e,o),o=0,e=0,a++):!r&&o<0&&(h+=n.buffer.translateBufferLineToString(a,!1,0,e+1),o=n.cols-1,e=o,a--);return h+n.buffer.translateBufferLineToString(a,!1,e,o)}function h(e,t){const i=t?"O":"[";return s.C0.ESC+i+e}function c(e,t){e=Math.floor(e);let i="";for(let s=0;s0?s-n(s,o):t;const _=s,u=function(e,t,i,s,o,a){let h;return h=r(i,s,o,a).length>0?s-n(s,o):t,e=i&&he?"D":"C",c(Math.abs(o-e),h(d,s));d=l>t?"D":"C";const _=Math.abs(l-t);return c(function(e,t){return t.cols-e}(l>t?e:o,i)+(_-1)*i.cols+1+((l>t?o:e)-1),h(d,s))}},1296:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const n=i(3787),o=i(2550),a=i(2223),h=i(6171),c=i(6052),l=i(4725),d=i(8055),_=i(8460),u=i(844),f=i(2585),v="xterm-dom-renderer-owner-",p="xterm-rows",g="xterm-fg-",m="xterm-bg-",S="xterm-focus",C="xterm-selection";let b=1,w=t.DomRenderer=class extends u.Disposable{constructor(e,t,i,s,r,a,l,d,f,g,m,S,w){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=l,this._charSizeService=f,this._optionsService=g,this._bufferService=m,this._coreBrowserService=S,this._themeService=w,this._terminalClass=b++,this._rowElements=[],this._selectionRenderModel=(0,c.createSelectionRenderModel)(),this.onRequestRedraw=this.register(new _.EventEmitter).event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add(p),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add(C),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this.register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._themeService.onChangeColors((e=>this._injectCss(e)))),this._injectCss(this._themeService.colors),this._rowFactory=d.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(v+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this.register(this._linkifier2.onShowLinkUnderline((e=>this._handleLinkHover(e)))),this.register(this._linkifier2.onHideLinkUnderline((e=>this._handleLinkLeave(e)))),this.register((0,u.toDisposable)((()=>{this._element.classList.remove(v+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new o.WidthCache(this._document,this._helperContainer),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .${p} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${p} { color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${p} .xterm-dim { color: ${d.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${C} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${C} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${C} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .${g}${i} { color: ${s.css}; }${this._terminalSelector} .${g}${i}.xterm-dim { color: ${d.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .${m}${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .${g}${a.INVERTED_DEFAULT_COLOR} { color: ${d.color.opaque(e.background).css}; }${this._terminalSelector} .${g}${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${d.color.multiplyOpacity(d.color.opaque(e.background),.5).css}; }${this._terminalSelector} .${m}${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(S),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add(S),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,i){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i),this.renderRows(0,this._bufferService.rows-1),!e||!t)return;this._selectionRenderModel.update(this._terminal,e,t,i);const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,n=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow;if(n>=this._bufferService.rows||o<0)return;const a=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];a.appendChild(this._createSelectionElement(n,i?t[0]:e[0],i?e[0]:t[0],o-n+1))}else{const i=s===n?e[0]:0,h=n===r?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(n,i,h));const c=o-n-1;if(a.appendChild(this._createSelectionElement(n+1,0,this._bufferService.cols,c)),n!==o){const e=r===o?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(o,0,e))}}this._selectionContainer.appendChild(a)}_createSelectionElement(e,t,i,s=1){const r=this._document.createElement("div"),n=t*this.dimensions.css.cell.width;let o=this.dimensions.css.cell.width*(i-t);return n+o>this.dimensions.css.canvas.width&&(o=this.dimensions.css.canvas.width-n),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${n}px`,r.style.width=`${o}px`,r}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren()}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._optionsService.rawOptions.cursorBlink,o=this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle;for(let h=e;h<=t;h++){const e=h+i.ydisp,t=this._rowElements[h],c=i.lines.get(e);if(!t||!c)break;t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,o,a,r,n,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${v}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);const o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,c=Math.min(a.x,r-1),l=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle;for(let o=i;o<=s;++o){const u=o+a.ydisp,f=this._rowElements[o],v=a.lines.get(u);if(!f||!v)break;f.replaceChildren(...this._rowFactory.createRow(v,u,u===h,d,_,c,l,this.dimensions.css.cell.width,this._widthCache,n?o===i?e:0:-1,n?(o===s?t:r)-1:-1))}}};t.DomRenderer=w=s([r(7,f.IInstantiationService),r(8,l.ICharSizeService),r(9,f.IOptionsService),r(10,f.IBufferService),r(11,l.ICoreBrowserService),r(12,l.IThemeService)],w)},3787:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const n=i(2223),o=i(643),a=i(511),h=i(2585),c=i(8055),l=i(4725),d=i(4269),_=i(6171),u=i(3734);let f=t.DomRendererRowFactory=class{constructor(e,t,i,s,r,n,o){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=n,this._themeService=o,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,l,_,f,p){const g=[],m=this._characterJoinerService.getJoinedCharacters(t),S=this._themeService.colors;let C,b=e.getNoBgTrimmedLength();i&&b0&&M===m[0][0]){O=!0;const t=m.shift();I=new d.JoinedCellData(this._workCell,e.translateToString(!0,t[0],t[1]),t[1]-t[0]),P=t[1]-1,b=I.getWidth()}const H=this._isCellInSelection(M,t),F=i&&M===a,W=T&&M>=f&&M<=p;let U=!1;this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{U=!0}));let N=I.getChars()||o.WHITESPACE_CELL_CHAR;if(" "===N&&(I.isUnderline()||I.isOverline())&&(N=" "),A=b*l-_.get(N,I.isBold(),I.isItalic()),C){if(w&&(H&&x||!H&&!x&&I.bg===E)&&(H&&x&&S.selectionForeground||I.fg===k)&&I.extended.ext===L&&W===D&&A===R&&!F&&!O&&!U){I.isInvisible()?y+=o.WHITESPACE_CELL_CHAR:y+=N,w++;continue}w&&(C.textContent=y),C=this._document.createElement("span"),w=0,y=""}else C=this._document.createElement("span");if(E=I.bg,k=I.fg,L=I.extended.ext,D=W,R=A,x=H,O&&a>=M&&a<=P&&(a=M),!this._coreService.isCursorHidden&&F&&this._coreService.isCursorInitialized)if(B.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&B.push("xterm-cursor-blink"),B.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":B.push("xterm-cursor-outline");break;case"block":B.push("xterm-cursor-block");break;case"bar":B.push("xterm-cursor-bar");break;case"underline":B.push("xterm-cursor-underline")}if(I.isBold()&&B.push("xterm-bold"),I.isItalic()&&B.push("xterm-italic"),I.isDim()&&B.push("xterm-dim"),y=I.isInvisible()?o.WHITESPACE_CELL_CHAR:I.getChars()||o.WHITESPACE_CELL_CHAR,I.isUnderline()&&(B.push(`xterm-underline-${I.extended.underlineStyle}`)," "===y&&(y=" "),!I.isUnderlineColorDefault()))if(I.isUnderlineColorRGB())C.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(I.getUnderlineColor()).join(",")})`;else{let e=I.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&I.isBold()&&e<8&&(e+=8),C.style.textDecorationColor=S.ansi[e].css}I.isOverline()&&(B.push("xterm-overline")," "===y&&(y=" ")),I.isStrikethrough()&&B.push("xterm-strikethrough"),W&&(C.style.textDecoration="underline");let $=I.getFgColor(),j=I.getFgColorMode(),z=I.getBgColor(),K=I.getBgColorMode();const q=!!I.isInverse();if(q){const e=$;$=z,z=e;const t=j;j=K,K=t}let V,G,X,J=!1;switch(this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{"top"!==e.options.layer&&J||(e.backgroundColorRGB&&(K=50331648,z=e.backgroundColorRGB.rgba>>8&16777215,V=e.backgroundColorRGB),e.foregroundColorRGB&&(j=50331648,$=e.foregroundColorRGB.rgba>>8&16777215,G=e.foregroundColorRGB),J="top"===e.options.layer)})),!J&&H&&(V=this._coreBrowserService.isFocused?S.selectionBackgroundOpaque:S.selectionInactiveBackgroundOpaque,z=V.rgba>>8&16777215,K=50331648,J=!0,S.selectionForeground&&(j=50331648,$=S.selectionForeground.rgba>>8&16777215,G=S.selectionForeground)),J&&B.push("xterm-decoration-top"),K){case 16777216:case 33554432:X=S.ansi[z],B.push(`xterm-bg-${z}`);break;case 50331648:X=c.channels.toColor(z>>16,z>>8&255,255&z),this._addStyle(C,`background-color:#${v((z>>>0).toString(16),"0",6)}`);break;default:q?(X=S.foreground,B.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):X=S.background}switch(V||I.isDim()&&(V=c.color.multiplyOpacity(X,.5)),j){case 16777216:case 33554432:I.isBold()&&$<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&($+=8),this._applyMinimumContrast(C,X,S.ansi[$],I,V,void 0)||B.push(`xterm-fg-${$}`);break;case 50331648:const e=c.channels.toColor($>>16&255,$>>8&255,255&$);this._applyMinimumContrast(C,X,e,I,V,G)||this._addStyle(C,`color:#${v($.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(C,X,S.foreground,I,V,G)||q&&B.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}B.length&&(C.className=B.join(" "),B.length=0),F||O||U?C.textContent=y:w++,A!==this.defaultSpacing&&(C.style.letterSpacing=`${A}px`),g.push(C),M=P}return C&&w&&(C.textContent=y),g}_applyMinimumContrast(e,t,i,s,r,n){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const o=this._getContrastCache(s);let a;if(r||n||(a=o.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=c.color.ensureContrastRatio(r||t,n||i,e),o.setColor((r||t).rgba,(n||i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};function v(e,t,i){for(;e.length{Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0,t.WidthCache=class{constructor(e,t){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=e.createElement("div"),this._container.classList.add("xterm-width-cache-measure-container"),this._container.setAttribute("aria-hidden","true"),this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const i=e.createElement("span");i.classList.add("xterm-char-measure-element");const s=e.createElement("span");s.classList.add("xterm-char-measure-element"),s.style.fontWeight="bold";const r=e.createElement("span");r.classList.add("xterm-char-measure-element"),r.style.fontStyle="italic";const n=e.createElement("span");n.classList.add("xterm-char-measure-element"),n.style.fontWeight="bold",n.style.fontStyle="italic",this._measureElements=[i,s,r,n],this._container.appendChild(i),this._container.appendChild(s),this._container.appendChild(r),this._container.appendChild(n),t.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${i}`,this._measureElements[1].style.fontWeight=`${s}`,this._measureElements[2].style.fontWeight=`${i}`,this._measureElements[3].style.fontWeight=`${s}`,this.clear())}get(e,t,i){let s=0;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(void 0===n){let s=0;t&&(s|=1),i&&(s|=2),n=this._measure(e,s),n>0&&this._holey.set(r,n)}return n}_measure(e,t){const i=this._measureElements[t];return i.textContent=e.repeat(32),i.offsetWidth/32}}},2223:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.TEXT_BASELINE=t.DIM_OPACITY=t.INVERTED_DEFAULT_COLOR=void 0;const s=i(6114);t.INVERTED_DEFAULT_COLOR=257,t.DIM_OPACITY=.5,t.TEXT_BASELINE=s.isFirefox||s.isLegacyEdge?"bottom":"ideographic"},6171:(e,t)=>{function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.computeNextVariantOffset=t.createRenderDimensions=t.treatGlyphAsBackgroundColor=t.allowRescaling=t.isEmoji=t.isRestrictedPowerlineGlyph=t.isPowerlineGlyph=t.throwIfFalsy=void 0,t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,n){return 1===t&&r>Math.ceil(1.5*n)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},6052:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=void 0;class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,n=t[1]-r,o=i[1]-r,a=Math.max(n,0),h=Math.min(o,e.rows-1);a>=e.rows||h<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=n,this.viewportEndRow=o,this.viewportCappedStartRow=a,this.viewportCappedEndRow=h,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}t.createSelectionRenderModel=function(){return new i}},456:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},428:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const n=i(2585),o=i(8460),a=i(844);let h=t.CharSizeService=class extends a.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this.register(new o.EventEmitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this.register(new d(this._optionsService))}catch{this._measureStrategy=this.register(new l(e,t,this._optionsService))}this.register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h=s([r(2,n.IOptionsService)],h);class c extends a.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class l extends c{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends c{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},4269:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(3734),o=i(643),a=i(511),h=i(2585);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let l=t.CharacterJoinerService=class e{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new a.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserService=void 0;const s=i(844),r=i(8460),n=i(3656);class o extends s.Disposable{constructor(e,t,i){super(),this._textarea=e,this._window=t,this.mainDocument=i,this._isFocused=!1,this._cachedIsFocused=void 0,this._screenDprMonitor=new a(this._window),this._onDprChange=this.register(new r.EventEmitter),this.onDprChange=this._onDprChange.event,this._onWindowChange=this.register(new r.EventEmitter),this.onWindowChange=this._onWindowChange.event,this.register(this.onWindowChange((e=>this._screenDprMonitor.setWindow(e)))),this.register((0,r.forwardEvent)(this._screenDprMonitor.onDprChange,this._onDprChange)),this._textarea.addEventListener("focus",(()=>this._isFocused=!0)),this._textarea.addEventListener("blur",(()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}t.CoreBrowserService=o;class a extends s.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this.register(new s.MutableDisposable),this._onDprChange=this.register(new r.EventEmitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this.register((0,s.toDisposable)((()=>this.clearListener())))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,n.addDisposableDomListener)(this._parentWindow,"resize",(()=>this._setDprAndFireIfDiffers()))}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},779:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(844);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this.register((0,s.toDisposable)((()=>this.linkProviders.length=0)))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},8934:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseService=void 0;const n=i(4725),o=i(9806);let a=t.MouseService=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,i,s,r){return(0,o.getCoords)(window,e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,o.getCoordsRelativeToElement)(window,e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseService=a=s([r(0,n.IRenderService),r(1,n.ICharSizeService)],a)},3230:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const n=i(6193),o=i(4725),a=i(8460),h=i(844),c=i(7226),l=i(2585);let d=t.RenderService=class extends h.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,o,l,d){super(),this._rowCount=e,this._charSizeService=s,this._renderer=this.register(new h.MutableDisposable),this._pausedResizeTask=new c.DebouncedIdleTask,this._observerDisposable=this.register(new h.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this.register(new a.EventEmitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this.register(new a.EventEmitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this.register(new a.EventEmitter),this.onRender=this._onRender.event,this._onRefreshRequest=this.register(new a.EventEmitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new n.RenderDebouncer(((e,t)=>this._renderRows(e,t)),l),this.register(this._renderDebouncer),this.register(l.onDprChange((()=>this.handleDevicePixelRatioChange()))),this.register(o.onResize((()=>this._fullRefresh()))),this.register(o.buffers.onBufferActivate((()=>this._renderer.value?.clear()))),this.register(i.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this.register(r.onDecorationRegistered((()=>this._fullRefresh()))),this.register(r.onDecorationRemoved((()=>this._fullRefresh()))),this.register(i.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],(()=>{this.clear(),this.handleResize(o.cols,o.rows),this._fullRefresh()}))),this.register(i.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(o.buffer.y,o.buffer.y,!0)))),this.register(d.onChangeColors((()=>this._fullRefresh()))),this._registerIntersectionObserver(l.window,t),this.register(l.onWindowChange((e=>this._registerIntersectionObserver(e,t))))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver((e=>this._handleIntersectionChange(e[e.length-1])),{threshold:0});i.observe(t),this._observerDisposable.value=(0,h.toDisposable)((()=>i.disconnect()))}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1){this._isPaused?this._needsFullRefresh=!0:(i||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount))}_renderRows(e,t){this._renderer.value&&(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0)}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw((e=>this.refreshRows(e.start,e.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value?.handleResize(e,t))):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d=s([r(2,l.IOptionsService),r(3,o.ICharSizeService),r(4,l.IDecorationService),r(5,l.IBufferService),r(6,o.ICoreBrowserService),r(7,o.IThemeService)],d)},9312:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionService=void 0;const n=i(9806),o=i(9504),a=i(456),h=i(4725),c=i(8460),l=i(844),d=i(6114),_=i(4841),u=i(511),f=i(2585),v=String.fromCharCode(160),p=new RegExp(v,"g");let g=t.SelectionService=class extends l.Disposable{constructor(e,t,i,s,r,n,o,h,d){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseService=n,this._optionsService=o,this._renderService=h,this._coreBrowserService=d,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new u.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this.register(new c.EventEmitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this.register(new c.EventEmitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this.register(new c.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this.register(new c.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=e=>this._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((e=>this._handleTrim(e))),this.register(this._bufferService.buffers.onBufferActivate((e=>this._handleBufferActivate(e)))),this.enable(),this._model=new a.SelectionModel(this._bufferService),this._activeSelectionMode=0,this.register((0,l.toDisposable)((()=>{this._removeMouseDownListeners()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(p," "))).join(d.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),d.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,_.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return d.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,(2!==e.button||!this.hasSelection)&&0===e.button){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&0===t.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(d.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,o.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.dispose(),this._trimListener=e.activeBuffer.lines.onTrim((e=>this._handleTrim(e)))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;const o=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(n,e[0]),h=a;const c=e[0]-a;let l=0,d=0,_=0,u=0;if(" "===o.charAt(a)){for(;a>0&&" "===o.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(n.loadCell(t-1,this._workCell));){n.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(l++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+c-l+_,v=Math.min(this._bufferService.cols,h-a+l+d-_-u);if(t||""!==o.slice(a,h).trim()){if(i&&0===f&&32!==n.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&n.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,v+=e}}}if(s&&f+v===this._bufferService.cols&&32!==n.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(v+=t.length)}}return{start:f,length:v}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,_.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=g=s([r(3,f.IBufferService),r(4,f.ICoreService),r(5,h.IMouseService),r(6,f.IOptionsService),r(7,h.IRenderService),r(8,h.ICoreBrowserService)],g)},4725:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(8343);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService")},6731:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=t.DEFAULT_ANSI_COLORS=void 0;const n=i(7239),o=i(8055),a=i(8460),h=i(844),c=i(2585),l=o.css.toColor("#ffffff"),d=o.css.toColor("#000000"),_=o.css.toColor("#ffffff"),u=o.css.toColor("#000000"),f={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117};t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[o.css.toColor("#2e3436"),o.css.toColor("#cc0000"),o.css.toColor("#4e9a06"),o.css.toColor("#c4a000"),o.css.toColor("#3465a4"),o.css.toColor("#75507b"),o.css.toColor("#06989a"),o.css.toColor("#d3d7cf"),o.css.toColor("#555753"),o.css.toColor("#ef2929"),o.css.toColor("#8ae234"),o.css.toColor("#fce94f"),o.css.toColor("#729fcf"),o.css.toColor("#ad7fa8"),o.css.toColor("#34e2e2"),o.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const s=t[i/36%6|0],r=t[i/6%6|0],n=t[i%6];e.push({css:o.channels.toCss(s,r,n),rgba:o.channels.toRgba(s,r,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:o.channels.toCss(i,i,i),rgba:o.channels.toRgba(i,i,i)})}return e})());let v=t.ThemeService=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this.register(new a.EventEmitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:l,background:d,cursor:_,cursorAccent:u,selectionForeground:void 0,selectionBackgroundTransparent:f,selectionBackgroundOpaque:o.color.blend(d,f),selectionInactiveBackgroundTransparent:f,selectionInactiveBackgroundOpaque:o.color.blend(d,f),ansi:t.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this.register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this.register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(e={}){const i=this._colors;if(i.foreground=p(e.foreground,l),i.background=p(e.background,d),i.cursor=p(e.cursor,_),i.cursorAccent=p(e.cursorAccent,u),i.selectionBackgroundTransparent=p(e.selectionBackground,f),i.selectionBackgroundOpaque=o.color.blend(i.background,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundTransparent=p(e.selectionInactiveBackground,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundOpaque=o.color.blend(i.background,i.selectionInactiveBackgroundTransparent),i.selectionForeground=e.selectionForeground?p(e.selectionForeground,o.NULL_COLOR):void 0,i.selectionForeground===o.NULL_COLOR&&(i.selectionForeground=void 0),o.color.isOpaque(i.selectionBackgroundTransparent)){const e=.3;i.selectionBackgroundTransparent=o.color.opacity(i.selectionBackgroundTransparent,e)}if(o.color.isOpaque(i.selectionInactiveBackgroundTransparent)){const e=.3;i.selectionInactiveBackgroundTransparent=o.color.opacity(i.selectionInactiveBackgroundTransparent,e)}if(i.ansi=t.DEFAULT_ANSI_COLORS.slice(),i.ansi[0]=p(e.black,t.DEFAULT_ANSI_COLORS[0]),i.ansi[1]=p(e.red,t.DEFAULT_ANSI_COLORS[1]),i.ansi[2]=p(e.green,t.DEFAULT_ANSI_COLORS[2]),i.ansi[3]=p(e.yellow,t.DEFAULT_ANSI_COLORS[3]),i.ansi[4]=p(e.blue,t.DEFAULT_ANSI_COLORS[4]),i.ansi[5]=p(e.magenta,t.DEFAULT_ANSI_COLORS[5]),i.ansi[6]=p(e.cyan,t.DEFAULT_ANSI_COLORS[6]),i.ansi[7]=p(e.white,t.DEFAULT_ANSI_COLORS[7]),i.ansi[8]=p(e.brightBlack,t.DEFAULT_ANSI_COLORS[8]),i.ansi[9]=p(e.brightRed,t.DEFAULT_ANSI_COLORS[9]),i.ansi[10]=p(e.brightGreen,t.DEFAULT_ANSI_COLORS[10]),i.ansi[11]=p(e.brightYellow,t.DEFAULT_ANSI_COLORS[11]),i.ansi[12]=p(e.brightBlue,t.DEFAULT_ANSI_COLORS[12]),i.ansi[13]=p(e.brightMagenta,t.DEFAULT_ANSI_COLORS[13]),i.ansi[14]=p(e.brightCyan,t.DEFAULT_ANSI_COLORS[14]),i.ansi[15]=p(e.brightWhite,t.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const s=Math.min(i.ansi.length-16,e.extendedAnsi.length);for(let r=0;r{Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this.register(new s.EventEmitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this.register(new s.EventEmitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this.register(new s.EventEmitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.clone=void 0,t.clone=function e(t,i=5){if("object"!=typeof t)return t;const s=Array.isArray(t)?[]:{};for(const r in t)s[r]=i<=1?t[r]:t[r]&&e(t[r],i-1);return s}},8055:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.contrastRatio=t.toPaddedHex=t.rgba=t.rgb=t.css=t.color=t.channels=t.NULL_COLOR=void 0;let i=0,s=0,r=0,n=0;var o,a,h,c,l;function d(e){const t=e.toString(16);return t.length<2?"0"+t:t}function _(e,t){return e>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(o||(t.channels=o={})),function(e){function t(e,t){return n=Math.round(255*t),[i,s,r]=l.toChannels(e.rgba),{css:o.toCss(i,s,r,n),rgba:o.toRgba(i,s,r,n)}}e.blend=function(e,t){if(n=(255&t.rgba)/255,1===n)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,h=t.rgba>>16&255,c=t.rgba>>8&255,l=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return i=l+Math.round((a-l)*n),s=d+Math.round((h-d)*n),r=_+Math.round((c-_)*n),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},e.isOpaque=function(e){return 255==(255&e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=l.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=l.toChannels(t),{css:o.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return n=255&e.rgba,t(e,n*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n=parseInt(e.slice(4,5).repeat(2),16),o.toColor(i,s,r,n);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const h=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(h)return i=parseInt(h[1]),s=parseInt(h[2]),r=parseInt(h[3]),n=Math.round(255*(void 0===h[5]?1:parseFloat(h[5]))),o.toColor(i,s,r,n);if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,n]=t.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,n),css:e}}}(h||(t.css=h={})),function(e){function t(e,t,i){const s=e/255,r=t/255,n=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(c||(t.rgb=c={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));for(;l0||a>0||h>0);)o-=Math.max(0,Math.ceil(.1*o)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));return(o<<24|a<<16|h<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));for(;l>>0}e.blend=function(e,t){if(n=(255&t)/255,1===n)return t;const a=t>>24&255,h=t>>16&255,c=t>>8&255,l=e>>24&255,d=e>>16&255,_=e>>8&255;return i=l+Math.round((a-l)*n),s=d+Math.round((h-d)*n),r=_+Math.round((c-_)*n),o.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=c.relativeLuminance(e>>8),n=c.relativeLuminance(i>>8);if(_(r,n)>8));if(o_(r,c.relativeLuminance(t>>8))?n:t}return n}const o=a(e,i,s),h=_(r,c.relativeLuminance(o>>8));if(h_(r,c.relativeLuminance(n>>8))?o:n}return o}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(l||(t.rgba=l={})),t.toPaddedHex=d,t.contrastRatio=_},8969:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(844),r=i(2585),n=i(4348),o=i(7866),a=i(744),h=i(7302),c=i(6975),l=i(8460),d=i(1753),_=i(1480),u=i(7994),f=i(9282),v=i(5435),p=i(5981),g=i(2660);let m=!1;class S extends s.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this.register(new l.EventEmitter),this._onScroll.event((e=>{this._onScrollApi?.fire(e.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this.register(new s.MutableDisposable),this._onBinary=this.register(new l.EventEmitter),this.onBinary=this._onBinary.event,this._onData=this.register(new l.EventEmitter),this.onData=this._onData.event,this._onLineFeed=this.register(new l.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this.register(new l.EventEmitter),this.onResize=this._onResize.event,this._onWriteParsed=this.register(new l.EventEmitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this.register(new l.EventEmitter),this._instantiationService=new n.InstantiationService,this.optionsService=this.register(new h.OptionsService(e)),this._instantiationService.setService(r.IOptionsService,this.optionsService),this._bufferService=this.register(this._instantiationService.createInstance(a.BufferService)),this._instantiationService.setService(r.IBufferService,this._bufferService),this._logService=this.register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(r.ILogService,this._logService),this.coreService=this.register(this._instantiationService.createInstance(c.CoreService)),this._instantiationService.setService(r.ICoreService,this.coreService),this.coreMouseService=this.register(this._instantiationService.createInstance(d.CoreMouseService)),this._instantiationService.setService(r.ICoreMouseService,this.coreMouseService),this.unicodeService=this.register(this._instantiationService.createInstance(_.UnicodeService)),this._instantiationService.setService(r.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(u.CharsetService),this._instantiationService.setService(r.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(g.OscLinkService),this._instantiationService.setService(r.IOscLinkService,this._oscLinkService),this._inputHandler=this.register(new v.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this.register((0,l.forwardEvent)(this._inputHandler.onLineFeed,this._onLineFeed)),this.register(this._inputHandler),this.register((0,l.forwardEvent)(this._bufferService.onResize,this._onResize)),this.register((0,l.forwardEvent)(this.coreService.onData,this._onData)),this.register((0,l.forwardEvent)(this.coreService.onBinary,this._onBinary)),this.register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom()))),this.register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this.register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this.register(this._bufferService.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this.register(this._inputHandler.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this.register(new p.WriteBuffer(((e,t)=>this._inputHandler.parse(e,t)))),this.register((0,l.forwardEvent)(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=r.LogLevelEnum.WARN&&!m&&(this._logService.warn("writeSync is unreliable and will be removed soon."),m=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,a.MINIMUM_COLS),t=Math.max(t,a.MINIMUM_ROWS),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t,i){this._bufferService.scrollLines(e,t,i)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.buildNumber&&void 0!==t.buildNumber?e=!!("conpty"===t.backend&&t.buildNumber<21376):this.optionsService.rawOptions.windowsMode&&(e=!0),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(f.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},(()=>((0,f.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,s.toDisposable)((()=>{for(const t of e)t.dispose()}))}}}t.CoreTerminal=S},8460:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.runAndSubscribe=t.forwardEvent=t.EventEmitter=void 0,t.EventEmitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=e=>(this._listeners.push(e),{dispose:()=>{if(!this._disposed)for(let t=0;tt.fire(e)))},t.runAndSubscribe=function(e,t){return t(void 0),e((e=>t(e)))}},5435:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0;const n=i(2584),o=i(7116),a=i(2015),h=i(844),c=i(482),l=i(8437),d=i(8460),_=i(643),u=i(511),f=i(3734),v=i(2585),p=i(1480),g=i(6242),m=i(6351),S=i(5941),C={"(":0,")":1,"*":2,"+":3,"-":1,".":2},b=131072;function w(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var y;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(y||(t.WindowsOptionsReportType=y={}));let E=0;class k extends h.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,h,_,f,v=new a.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=h,this._coreMouseService=_,this._unicodeService=f,this._parser=v,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new c.StringToUtf32,this._utf8Decoder=new c.Utf8ToUtf32,this._workCell=new u.CellData,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this.register(new d.EventEmitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this.register(new d.EventEmitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this.register(new d.EventEmitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this.register(new d.EventEmitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this.register(new d.EventEmitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this.register(new d.EventEmitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this.register(new d.EventEmitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this.register(new d.EventEmitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this.register(new d.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this.register(new d.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this.register(new d.EventEmitter),this.onScroll=this._onScroll.event,this._onTitleChange=this.register(new d.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this.register(new d.EventEmitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this.register(this._parser),this._dirtyRowTracker=new L(this._bufferService),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._parser.setCsiHandlerFallback(((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})})),this._parser.setEscHandlerFallback((e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})})),this._parser.setExecuteHandlerFallback((e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})})),this._parser.setOscHandlerFallback(((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})})),this._parser.setDcsHandlerFallback(((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})})),this._parser.setPrintHandler(((e,t,i)=>this.print(e,t,i))),this._parser.registerCsiHandler({final:"@"},(e=>this.insertChars(e))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(e=>this.scrollLeft(e))),this._parser.registerCsiHandler({final:"A"},(e=>this.cursorUp(e))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(e=>this.scrollRight(e))),this._parser.registerCsiHandler({final:"B"},(e=>this.cursorDown(e))),this._parser.registerCsiHandler({final:"C"},(e=>this.cursorForward(e))),this._parser.registerCsiHandler({final:"D"},(e=>this.cursorBackward(e))),this._parser.registerCsiHandler({final:"E"},(e=>this.cursorNextLine(e))),this._parser.registerCsiHandler({final:"F"},(e=>this.cursorPrecedingLine(e))),this._parser.registerCsiHandler({final:"G"},(e=>this.cursorCharAbsolute(e))),this._parser.registerCsiHandler({final:"H"},(e=>this.cursorPosition(e))),this._parser.registerCsiHandler({final:"I"},(e=>this.cursorForwardTab(e))),this._parser.registerCsiHandler({final:"J"},(e=>this.eraseInDisplay(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(e=>this.eraseInDisplay(e,!0))),this._parser.registerCsiHandler({final:"K"},(e=>this.eraseInLine(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(e=>this.eraseInLine(e,!0))),this._parser.registerCsiHandler({final:"L"},(e=>this.insertLines(e))),this._parser.registerCsiHandler({final:"M"},(e=>this.deleteLines(e))),this._parser.registerCsiHandler({final:"P"},(e=>this.deleteChars(e))),this._parser.registerCsiHandler({final:"S"},(e=>this.scrollUp(e))),this._parser.registerCsiHandler({final:"T"},(e=>this.scrollDown(e))),this._parser.registerCsiHandler({final:"X"},(e=>this.eraseChars(e))),this._parser.registerCsiHandler({final:"Z"},(e=>this.cursorBackwardTab(e))),this._parser.registerCsiHandler({final:"`"},(e=>this.charPosAbsolute(e))),this._parser.registerCsiHandler({final:"a"},(e=>this.hPositionRelative(e))),this._parser.registerCsiHandler({final:"b"},(e=>this.repeatPrecedingCharacter(e))),this._parser.registerCsiHandler({final:"c"},(e=>this.sendDeviceAttributesPrimary(e))),this._parser.registerCsiHandler({prefix:">",final:"c"},(e=>this.sendDeviceAttributesSecondary(e))),this._parser.registerCsiHandler({final:"d"},(e=>this.linePosAbsolute(e))),this._parser.registerCsiHandler({final:"e"},(e=>this.vPositionRelative(e))),this._parser.registerCsiHandler({final:"f"},(e=>this.hVPosition(e))),this._parser.registerCsiHandler({final:"g"},(e=>this.tabClear(e))),this._parser.registerCsiHandler({final:"h"},(e=>this.setMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(e=>this.setModePrivate(e))),this._parser.registerCsiHandler({final:"l"},(e=>this.resetMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(e=>this.resetModePrivate(e))),this._parser.registerCsiHandler({final:"m"},(e=>this.charAttributes(e))),this._parser.registerCsiHandler({final:"n"},(e=>this.deviceStatus(e))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(e=>this.deviceStatusPrivate(e))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(e=>this.softReset(e))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(e=>this.setCursorStyle(e))),this._parser.registerCsiHandler({final:"r"},(e=>this.setScrollRegion(e))),this._parser.registerCsiHandler({final:"s"},(e=>this.saveCursor(e))),this._parser.registerCsiHandler({final:"t"},(e=>this.windowOptions(e))),this._parser.registerCsiHandler({final:"u"},(e=>this.restoreCursor(e))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(e=>this.insertColumns(e))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(e=>this.deleteColumns(e))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(e=>this.selectProtected(e))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(e=>this.requestMode(e,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(e=>this.requestMode(e,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new g.OscHandler((e=>(this.setTitle(e),this.setIconName(e),!0)))),this._parser.registerOscHandler(1,new g.OscHandler((e=>this.setIconName(e)))),this._parser.registerOscHandler(2,new g.OscHandler((e=>this.setTitle(e)))),this._parser.registerOscHandler(4,new g.OscHandler((e=>this.setOrReportIndexedColor(e)))),this._parser.registerOscHandler(8,new g.OscHandler((e=>this.setHyperlink(e)))),this._parser.registerOscHandler(10,new g.OscHandler((e=>this.setOrReportFgColor(e)))),this._parser.registerOscHandler(11,new g.OscHandler((e=>this.setOrReportBgColor(e)))),this._parser.registerOscHandler(12,new g.OscHandler((e=>this.setOrReportCursorColor(e)))),this._parser.registerOscHandler(104,new g.OscHandler((e=>this.restoreIndexedColor(e)))),this._parser.registerOscHandler(110,new g.OscHandler((e=>this.restoreFgColor(e)))),this._parser.registerOscHandler(111,new g.OscHandler((e=>this.restoreBgColor(e)))),this._parser.registerOscHandler(112,new g.OscHandler((e=>this.restoreCursorColor(e)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},(()=>this.selectCharset("("+e))),this._parser.registerEscHandler({intermediates:")",final:e},(()=>this.selectCharset(")"+e))),this._parser.registerEscHandler({intermediates:"*",final:e},(()=>this.selectCharset("*"+e))),this._parser.registerEscHandler({intermediates:"+",final:e},(()=>this.selectCharset("+"+e))),this._parser.registerEscHandler({intermediates:"-",final:e},(()=>this.selectCharset("-"+e))),this._parser.registerEscHandler({intermediates:".",final:e},(()=>this.selectCharset("."+e))),this._parser.registerEscHandler({intermediates:"/",final:e},(()=>this.selectCharset("/"+e)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((e=>(this._logService.error("Parsing error: ",e),e))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new m.DcsHandler(((e,t)=>this.requestStatusString(e,t))))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){this._logService.logLevel<=v.LogLevelEnum.WARN&&Promise.race([e,new Promise(((e,t)=>setTimeout((()=>t("#SLOW_TIMEOUT")),5e3)))]).catch((e=>{if("#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0;const o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>b&&(n=this._parseStack.position+b)}if(this._logService.logLevel<=v.LogLevelEnum.DEBUG&&this._logService.debug("parsing data"+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,(e=>String.fromCharCode(e))).join("")}"`),"string"==typeof e?e.split("").map((e=>e.charCodeAt(0))):e),this._parseBuffer.lengthb)for(let t=n;t0&&2===f.getWidth(this._activeBuffer.x-1)&&f.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let v=this._parser.precedingJoinState;for(let g=t;ga)if(h){const e=f;let t=this._activeBuffer.x-m;for(this._activeBuffer.x=m,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),f=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),m>0&&f instanceof l.BufferLine&&f.copyCellsFrom(e,t,0,m,!1);t=0;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}else if(d&&(f.insertCells(this._activeBuffer.x,r-m,this._activeBuffer.getNullCell(u)),2===f.getWidth(a-1)&&f.setCellFromCodepoint(a-1,_.NULL_CELL_CODE,_.NULL_CELL_WIDTH,u)),f.setCellFromCodepoint(this._activeBuffer.x++,s,r,u),r>0)for(;--r;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=v,this._activeBuffer.x0&&0===f.getWidth(this._activeBuffer.x)&&!f.hasContent(this._activeBuffer.x)&&f.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,(e=>!w(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e)))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new m.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new g.OscHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(n.isWrapped=!1)}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols&&(this._activeBuffer.lines.get(i+1).isWrapped=!1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let h=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(e){return 0===(this._optionsService.rawOptions.termName+"").indexOf(e)}setMode(e){for(let t=0;te?1:2,u=e.params[0];return f=u,v=t?2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(d.convertEol):0:1===u?_(i.applicationCursorKeys):3===u?d.windowOptions.setWinLines?80===h?2:132===h?1:0:0:6===u?_(i.origin):7===u?_(i.wraparound):8===u?3:9===u?_("X10"===s):12===u?_(d.cursorBlink):25===u?_(!o.isCursorHidden):45===u?_(i.reverseWraparound):66===u?_(i.applicationKeypad):67===u?4:1e3===u?_("VT200"===s):1002===u?_("DRAG"===s):1003===u?_("ANY"===s):1004===u?_(i.sendFocus):1005===u?4:1006===u?_("SGR"===r):1015===u?4:1016===u?_("SGR_PIXELS"===r):1048===u?1:47===u||1047===u||1049===u?_(c===l):2004===u?_(i.bracketedPasteMode):0,o.triggerDataEvent(`${n.C0.ESC}[${t?"":"?"}${f};${v}$y`),!0;var f,v}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=f.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-50331904,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){const i=e.getSubParams(t+n);let o=0;do{5===s[1]&&(r=1),s[n+o+1+r]=i[o]}while(++o=2||2===s[1]&&n+r>=5)break;s[1]&&(r=1)}while(++n+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-50331904,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-50331904,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-50331904,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-50331904,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):100===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg,s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${e};${t}R`)}return!0}deviceStatusPrivate(e){if(6===e.params[0]){const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${e};${t}R`)}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=e.params[0]||1;switch(t){case 1:case 2:this._optionsService.options.cursorStyle="block";break;case 3:case 4:this._optionsService.options.cursorStyle="underline";break;case 5:case 6:this._optionsService.options.cursorStyle="bar"}const i=t%2==1;return this._optionsService.options.cursorBlink=i,!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!w(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(y.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(y.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){const t=[],i=e.split(";");for(;i.length>1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e);if(D(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,S.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.split(";");return!(t.length<2)&&(t[1]?this._createHyperlink(t[0],t[1]):!t[0]&&this._finishHyperlink())}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex((e=>e.startsWith("id=")));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,S.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new u.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${n.C0.ESC}${e}${n.C0.ESC}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}}t.InputHandler=k;let L=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(E=e,e=t,t=E),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function D(e){return 0<=e&&e<256}L=s([r(0,v.IBufferService)],L)},844:(e,t)=>{function i(e){for(const t of e)t.dispose();e.length=0}Object.defineProperty(t,"__esModule",{value:!0}),t.getDisposeArrayDisposable=t.disposeArray=t.toDisposable=t.MutableDisposable=t.Disposable=void 0,t.Disposable=class{constructor(){this._disposables=[],this._isDisposed=!1}dispose(){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.length=0}register(e){return this._disposables.push(e),e}unregister(e){const t=this._disposables.indexOf(e);-1!==t&&this._disposables.splice(t,1)}},t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}},t.toDisposable=function(e){return{dispose:e}},t.disposeArray=i,t.getDisposeArrayDisposable=function(e){return{dispose:()=>i(e)}}},1505:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,n){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,n)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},6114:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isIphone=t.isIpad=t.isMac=t.getSafariVersion=t.isSafari=t.isLegacyEdge=t.isFirefox=t.isNode=void 0,t.isNode="undefined"!=typeof process&&"title"in process;const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1])},t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isIpad="iPad"===s,t.isIphone="iPhone"===s,t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},6106:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;let i=0;t.SortedList=class{constructor(e){this._getKey=e,this._array=[]}clear(){this._array.length=0}insert(e){0!==this._array.length?(i=this._search(this._getKey(e)),this._array.splice(i,0,e)):this._array.push(e)}delete(e){if(0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(i=this._search(t),-1===i)return!1;if(this._getKey(this._array[i])!==t)return!1;do{if(this._array[i]===e)return this._array.splice(i,1),!0}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{yield this._array[i]}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{t(this._array[i])}while(++i=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},7226:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;const s=i(6114);class r{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ir)return s-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),void this._start();s=r}this.clear()}}class n extends r{_requestCallback(e){return setTimeout((()=>e(this._createDeadline(16))))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=Date.now()+e;return{timeRemaining:()=>Math.max(0,t-Date.now())}}}t.PriorityTaskQueue=n,t.IdleTaskQueue=!s.isNode&&"requestIdleCallback"in window?class extends r{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:n,t.DebouncedIdleTask=class{constructor(){this._queue=new t.IdleTaskQueue}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}}},9282:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=void 0;const s=i(643);t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)}},3734:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return 50331648==(50331648&this.fg)}isBgRGB(){return 50331648==(50331648&this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return 0==(50331648&this.fg)}isBgDefault(){return 0==(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?50331648==(50331648&this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?0==(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},9092:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(6349),r=i(7226),n=i(3734),o=i(8437),a=i(4634),h=i(511),c=i(643),l=i(4863),d=i(7116);t.MAX_BUFFER_SIZE=4294967295,t.Buffer=class{constructor(e,t,i){this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=o.DEFAULT_ATTR_DATA.clone(),this.savedCharset=d.DEFAULT_CHARSET,this.markers=[],this._nullCell=h.CellData.fromCharData([0,c.NULL_CELL_CHAR,c.NULL_CELL_WIDTH,c.NULL_CELL_CODE]),this._whitespaceCell=h.CellData.fromCharData([0,c.WHITESPACE_CELL_CHAR,c.WHITESPACE_CELL_WIDTH,c.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new r.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new o.BufferLine(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){void 0===e&&(e=o.DEFAULT_ATTR_DATA);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(o.DEFAULT_ATTR_DATA);let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new o.BufferLine(e,i)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=(0,a.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(o.DEFAULT_ATTR_DATA));if(i.length>0){const s=(0,a.reflowLargerCreateNewLayout)(this.lines,i);(0,a.reflowLargerApplyNewLayout)(this.lines,s.layout),this._reflowLargerAdjustViewport(e,t,s.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(o.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);const l=this.ybase+this.y;if(l>=n&&l0&&(s.push({start:n+c.length+r,newLines:v}),r+=v.length),c.push(...v);let p=_.length-1,g=_[p];0===g&&(p--,g=_[p]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[p])break;if(c[p].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(p--,g=_[p]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,a.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;c--)if(a&&a.start>n+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(c--,a.newLines[e]);c++,e.push({index:n+1,amount:a.newLines.length}),h+=a.newLines.length,a=s[++o]}else this.lines.set(c,t[n--]);let c=0;for(let t=e.length-1;t>=0;t--)e[t].index+=c,this.lines.onInsertEmitter.fire(e[t]),c+=e[t].amount;const l=Math.max(0,i+r-this.lines.maxLength);l>0&&this.lines.onTrimEmitter.fire(l)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(null==e&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()}))),t.register(this.lines.onInsert((e=>{t.line>=e.index&&(t.line+=e.amount)}))),t.register(this.lines.onDelete((e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)}))),t.register(t.onDispose((()=>this._removeMarker(t)))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},8437:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(3734),r=i(511),n=i(643),o=i(482);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let a=0;class h{constructor(e,t,i=!1){this.isWrapped=i,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*e);const s=t||r.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let t=0;t>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._data[3*e+1]=t[n.CHAR_DATA_ATTR_INDEX],t[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,o.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,t){return a=3*e,t.content=this._data[a+0],t.fg=this._data[a+1],t.bg=this._data[a+2],2097152&t.content&&(t.combinedData=this._combined[e]),268435456&t.bg&&(t.extended=this._extendedAttrs[e]),t}setCell(e,t){2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,o.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,o.stringFromCodePoint)(2097151&s)+(0,o.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if((e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,s));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){const n=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=n[3*(t+r)+e];268435456&n[3*(t+r)+2]&&(this._extendedAttrs[i+r]=e._extendedAttrs[t+r])}else for(let r=0;r=t&&(this._combined[r-t+i]=e._combined[r])}}translateToString(e,t,i,s){t=t??0,i=i??this.length,e&&(i=Math.min(i,this.getTrimmedLength())),s&&(s.length=0);let r="";for(;t>22||1}return s&&s.push(t),r}}t.BufferLine=h},4841:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=void 0,t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},4634:(e,t)=>{function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.getWrappedLineTrimmedLength=t.reflowSmallerGetNewLineLengths=t.reflowLargerApplyNewLayout=t.reflowLargerCreateNewLayout=t.reflowLargerGetLinesToRemove=void 0,t.reflowLargerGetLinesToRemove=function(e,t,s,r,n){const o=[];for(let a=0;a=a&&r0&&(e>d||0===l[e].getTrimmedLength());e--)v++;v>0&&(o.push(a+l.length-v),o.push(v)),a+=l.length-1}return o},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],n=0;for(let o=0;oi(e,r,t))).reduce(((e,t)=>e+t));let o=0,a=0,h=0;for(;hc&&(o-=c,a++);const l=2===e[a].getWidth(o-1);l&&o--;const d=l?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},5295:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(8460),r=i(844),n=i(9092);class o extends r.Disposable{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this.register(new s.EventEmitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this.register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new n.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new n.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=o},511:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(482),r=i(643),n=i(3734);class o extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new o;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.CellData=o},643:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=256|t.DEFAULT_COLOR<<9,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},4863:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(8460),r=i(844);class n{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new s.EventEmitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,r.disposeArray)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=n,n._nextId=1},7116:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},2584:(e,t)=>{var i,s,r;Object.defineProperty(t,"__esModule",{value:!0}),t.C1_ESCAPED=t.C1=t.C0=void 0,function(e){e.NUL="\0",e.SOH="",e.STX="",e.ETX="",e.EOT="",e.ENQ="",e.ACK="",e.BEL="",e.BS="\b",e.HT="\t",e.LF="\n",e.VT="\v",e.FF="\f",e.CR="\r",e.SO="",e.SI="",e.DLE="",e.DC1="",e.DC2="",e.DC3="",e.DC4="",e.NAK="",e.SYN="",e.ETB="",e.CAN="",e.EM="",e.SUB="",e.ESC="",e.FS="",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="€",e.HOP="",e.BPH="‚",e.NBH="ƒ",e.IND="„",e.NEL="…",e.SSA="†",e.ESA="‡",e.HTS="ˆ",e.HTJ="‰",e.VTS="Š",e.PLD="‹",e.PLU="Œ",e.RI="",e.SS2="Ž",e.SS3="",e.DCS="",e.PU1="‘",e.PU2="’",e.STS="“",e.CCH="”",e.MW="•",e.SPA="–",e.EPA="—",e.SOS="˜",e.SGCI="™",e.SCI="š",e.CSI="›",e.ST="œ",e.OSC="",e.PM="ž",e.APC="Ÿ"}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},7399:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=void 0;const s=i(2584),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};t.evaluateKeyboardEvent=function(e,t,i,n){const o={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A":"UIKeyInputLeftArrow"===e.key?o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D":"UIKeyInputRightArrow"===e.key?o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C":"UIKeyInputDownArrow"===e.key&&(o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B");break;case 8:o.key=e.ctrlKey?"\b":s.C0.DEL,e.altKey&&(o.key=s.C0.ESC+o.key);break;case 9:if(e.shiftKey){o.key=s.C0.ESC+"[Z";break}o.key=s.C0.HT,o.cancel=!0;break;case 13:o.key=e.altKey?s.C0.ESC+s.C0.CR:s.C0.CR,o.cancel=!0;break;case 27:o.key=s.C0.ESC,e.altKey&&(o.key=s.C0.ESC+s.C0.ESC),o.cancel=!0;break;case 37:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"D",o.key===s.C0.ESC+"[1;3D"&&(o.key=s.C0.ESC+(i?"b":"[1;5D"))):o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D";break;case 39:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"C",o.key===s.C0.ESC+"[1;3C"&&(o.key=s.C0.ESC+(i?"f":"[1;5C"))):o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C";break;case 38:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"A",i||o.key!==s.C0.ESC+"[1;3A"||(o.key=s.C0.ESC+"[1;5A")):o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A";break;case 40:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"B",i||o.key!==s.C0.ESC+"[1;3B"||(o.key=s.C0.ESC+"[1;5B")):o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B";break;case 45:e.shiftKey||e.ctrlKey||(o.key=s.C0.ESC+"[2~");break;case 46:o.key=a?s.C0.ESC+"[3;"+(a+1)+"~":s.C0.ESC+"[3~";break;case 36:o.key=a?s.C0.ESC+"[1;"+(a+1)+"H":t?s.C0.ESC+"OH":s.C0.ESC+"[H";break;case 35:o.key=a?s.C0.ESC+"[1;"+(a+1)+"F":t?s.C0.ESC+"OF":s.C0.ESC+"[F";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key=s.C0.ESC+"[5;"+(a+1)+"~":o.key=s.C0.ESC+"[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key=s.C0.ESC+"[6;"+(a+1)+"~":o.key=s.C0.ESC+"[6~";break;case 112:o.key=a?s.C0.ESC+"[1;"+(a+1)+"P":s.C0.ESC+"OP";break;case 113:o.key=a?s.C0.ESC+"[1;"+(a+1)+"Q":s.C0.ESC+"OQ";break;case 114:o.key=a?s.C0.ESC+"[1;"+(a+1)+"R":s.C0.ESC+"OR";break;case 115:o.key=a?s.C0.ESC+"[1;"+(a+1)+"S":s.C0.ESC+"OS";break;case 116:o.key=a?s.C0.ESC+"[15;"+(a+1)+"~":s.C0.ESC+"[15~";break;case 117:o.key=a?s.C0.ESC+"[17;"+(a+1)+"~":s.C0.ESC+"[17~";break;case 118:o.key=a?s.C0.ESC+"[18;"+(a+1)+"~":s.C0.ESC+"[18~";break;case 119:o.key=a?s.C0.ESC+"[19;"+(a+1)+"~":s.C0.ESC+"[19~";break;case 120:o.key=a?s.C0.ESC+"[20;"+(a+1)+"~":s.C0.ESC+"[20~";break;case 121:o.key=a?s.C0.ESC+"[21;"+(a+1)+"~":s.C0.ESC+"[21~";break;case 122:o.key=a?s.C0.ESC+"[23;"+(a+1)+"~":s.C0.ESC+"[23~";break;case 123:o.key=a?s.C0.ESC+"[24;"+(a+1)+"~":s.C0.ESC+"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(i&&!n||!e.altKey||e.metaKey)!i||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey?e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length?o.key=e.key:e.key&&e.ctrlKey&&("_"===e.key&&(o.key=s.C0.US),"@"===e.key&&(o.key=s.C0.NUL)):65===e.keyCode&&(o.type=1);else{const t=r[e.keyCode],i=t?.[e.shiftKey?1:0];if(i)o.key=s.C0.ESC+i;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=s.C0.ESC+i}else if(32===e.keyCode)o.key=s.C0.ESC+(e.ctrlKey?s.C0.NUL:" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=s.C0.ESC+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key=s.C0.NUL:e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key=s.C0.DEL:219===e.keyCode?o.key=s.C0.ESC:220===e.keyCode?o.key=s.C0.FS:221===e.keyCode&&(o.key=s.C0.GS)}return o}},482:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=t.utf32ToString=t.stringFromCodePoint=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let n=r;n=i)return this._interim=r,s;const o=e.charCodeAt(n);56320<=o&&o<=57343?t[s++]=1024*(r-55296)+o-56320+65536:(t[s++]=r,t[s++]=o)}else 65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,n,o,a=0,h=0,c=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let n,o=0;for(;(n=63&this.interim[++o])&&o<4;)r<<=6,r|=n;const h=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,l=h-o;for(;c=i)return 0;if(n=e[c++],128!=(192&n)){c--,s=!0;break}this.interim[o++]=n,r<<=6,r|=63&n}s||(2===h?r<128?c--:t[a++]=r:3===h?r<2048||r>=55296&&r<=57343||65279===r||(t[a++]=r):r<65536||r>1114111||(t[a++]=r)),this.interim.fill(0)}const l=i-4;let d=c;for(;d=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(h=(31&s)<<6|63&r,h<128){d--;continue}t[a++]=h}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(h=(15&s)<<12|(63&r)<<6|63&n,h<2048||h>=55296&&h<=57343||65279===h)continue;t[a++]=h}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=n,a;if(o=e[d++],128!=(192&o)){d--;continue}if(h=(7&s)<<18|(63&r)<<12|(63&n)<<6|63&o,h<65536||h>1114111)continue;t[a++]=h}}return a}}},225:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(1480),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],n=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let o;t.UnicodeV6=class{constructor(){if(this.version="6",!o){o=new Uint8Array(65536),o.fill(1),o[0]=0,o.fill(0,1,32),o.fill(0,127,160),o.fill(2,4352,4448),o[9001]=2,o[9002]=2,o.fill(2,11904,42192),o[12351]=1,o.fill(2,44032,55204),o.fill(2,63744,64256),o.fill(2,65040,65050),o.fill(2,65072,65136),o.fill(2,65280,65377),o.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},5981:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this.register(new s.EventEmitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(e,t){if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){const i=e||Date.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>Date.now()-i>=12?setTimeout((()=>this._innerWrite(0,e))):this._innerWrite(i,e);return void s.catch((e=>(queueMicrotask((()=>{throw e})),Promise.resolve(!1)))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,Date.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},5941:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.toRgbString=t.parseColor=void 0;const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(0===t.indexOf("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(0===t.indexOf("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,n]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(n,t)}`}},5770:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.PAYLOAD_LIMIT=void 0,t.PAYLOAD_LIMIT=1e7},6351:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(482),r=i(8742),n=i(5770),o=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=o,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=o,this._ident=0}};const a=new r.Params;a.addParam(0),t.DcsHandler=class{constructor(e){this._handler=e,this._data="",this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,s.utf32ToString)(e,t,i),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data,this._params),t instanceof Promise))return t.then((e=>(this._params=a,this._data="",this._hitLimit=!1,e)));return this._params=a,this._data="",this._hitLimit=!1,t}}},2015:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(844),r=i(8742),n=i(6242),o=i(6351);class a{constructor(e){this.table=new Uint8Array(e)}setDefault(e,t){this.table.fill(e<<4|t)}add(e,t,i,s){this.table[t<<8|e]=i<<4|s}addMany(e,t,i,s){for(let r=0;rt)),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const n=i(0,14);let o;for(o in e.setDefault(1,0),e.addMany(s,0,2,0),n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158,159],o,0,7),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94,95],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(28,32),9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(i(28,32),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(28,32),10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(28,32),12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(h,0,2,0),e.add(h,8,5,8),e.add(h,6,0,6),e.add(h,11,0,11),e.add(h,13,13,13),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this.register((0,s.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this.register(new n.OscParser),this._dcsParser=this.register(new o.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);void 0===this._escHandlers[i]&&(this._escHandlers[i]=[]);const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);void 0===this._csiHandlers[i]&&(this._csiHandlers[i]=[]);const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r=0,n=0,o=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,o=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let n=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](this._params),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 4:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 6:if(r=e[this._parseStack.chunkPos],s=this._dcsParser.unhook(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(r=e[this._parseStack.chunkPos],s=this._oscParser.end(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,o=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=15&this._parseStack.transition}for(let i=o;i>4){case 2:for(let s=i+1;;++s){if(s>=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=0&&(s=o[a](this._params),!0!==s);a--)if(s instanceof Promise)return this._preserveStack(3,o,a,n,i),s;a<0&&this._csiHandlerFb(this._collect<<8|r,this._params),this.precedingJoinState=0;break;case 8:do{switch(r){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(r-48)}}while(++i47&&r<60);i--;break;case 9:this._collect<<=8,this._collect|=r;break;case 10:const c=this._escHandlers[this._collect<<8|r];let l=c?c.length-1:-1;for(;l>=0&&(s=c[l](),!0!==s);l--)if(s instanceof Promise)return this._preserveStack(4,c,l,n,i),s;l<0&&this._escHandlerFb(this._collect<<8|r),this.precedingJoinState=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|r,this._params);break;case 13:for(let s=i+1;;++s)if(s>=t||24===(r=e[s])||26===r||27===r||r>127&&r=t||(r=e[s])<32||r>127&&r{Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(5770),r=i(482),n=[];t.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,r.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=n,this._id=-1,this._state=0}}},t.OscHandler=class{constructor(e){this._handler=e,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,r.utf32ToString)(e,t,i),this._data.length>s.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data),t instanceof Promise))return t.then((e=>(this._data="",this._hitLimit=!1,e)));return this._data="",this._hitLimit=!1,t}}},8742:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;const i=2147483647;class s{static fromArray(e){const t=new s;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new s(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>i?i:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=e>i?i:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const s=this._digitIsSub?this._subParams:this.params,r=s[t-1];s[t-1]=~r?Math.min(10*r+e,i):e}}t.Params=s},5741:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferApiView=void 0;const s=i(3785),r=i(511);t.BufferApiView=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){const t=this._buffer.lines.get(e);if(t)return new s.BufferLineApiView(t)}getNullCell(){return new r.CellData}}},3785:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLineApiView=void 0;const s=i(511);t.BufferLineApiView=class{constructor(e){this._line=e}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},8285:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(8771),r=i(8460),n=i(844);class o extends n.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this.register(new r.EventEmitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=o},7975:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,(e=>t(e.toArray())))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,((e,i)=>t(e,i.toArray())))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}}},7090:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=t.MINIMUM_ROWS=t.MINIMUM_COLS=void 0;const n=i(8460),o=i(844),a=i(5295),h=i(2585);t.MINIMUM_COLS=2,t.MINIMUM_ROWS=1;let c=t.BufferService=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e){super(),this.isUserScrolling=!1,this._onResize=this.register(new n.EventEmitter),this.onResize=this._onResize.event,this._onScroll=this.register(new n.EventEmitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,t.MINIMUM_COLS),this.rows=Math.max(e.rawOptions.rows||0,t.MINIMUM_ROWS),this.buffers=this.register(new a.BufferSet(e,this))}resize(e,t){this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;n===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=n-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t,i){const s=this.buffer;if(e<0){if(0===s.ydisp)return;this.isUserScrolling=!0}else e+s.ydisp>=s.ybase&&(this.isUserScrolling=!1);const r=s.ydisp;s.ydisp=Math.max(Math.min(s.ydisp+e,s.ybase),0),r!==s.ydisp&&(t||this._onScroll.fire(s.ydisp))}};t.BufferService=c=s([r(0,h.IOptionsService)],c)},7994:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},1753:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreMouseService=void 0;const n=i(2585),o=i(8460),a=i(844),h={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function c(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const l=String.fromCharCode,d={DEFAULT:e=>{const t=[c(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${l(t[0])}${l(t[1])}${l(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.x};${e.y}${t}`}};let _=t.CoreMouseService=class extends a.Disposable{constructor(e,t){super(),this._bufferService=e,this._coreService=t,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._onProtocolChange=this.register(new o.EventEmitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(h))this.addProtocol(e,h[e]);for(const e of Object.keys(d))this.addEncoding(e,d[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,"SGR_PIXELS"===this._activeEncoding))return!1;if(!this._protocols[this._activeProtocol].restrict(e))return!1;const t=this._encodings[this._activeEncoding](e);return t&&("DEFAULT"===this._activeEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.CoreMouseService=_=s([r(0,n.IBufferService),r(1,n.ICoreService)],_)},6975:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const n=i(1439),o=i(8460),a=i(844),h=i(2585),c=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,origin:!1,reverseWraparound:!1,sendFocus:!1,wraparound:!0});let d=t.CoreService=class extends a.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this.register(new o.EventEmitter),this.onData=this._onData.event,this._onUserInput=this.register(new o.EventEmitter),this.onUserInput=this._onUserInput.event,this._onBinary=this.register(new o.EventEmitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this.register(new o.EventEmitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}reset(){this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onBinary.fire(e))}};t.CoreService=d=s([r(0,h.IBufferService),r(1,h.ILogService),r(2,h.IOptionsService)],d)},9074:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationService=void 0;const s=i(8055),r=i(8460),n=i(844),o=i(6106);let a=0,h=0;class c extends n.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new o.SortedList((e=>e?.marker.line)),this._onDecorationRegistered=this.register(new r.EventEmitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this.register(new r.EventEmitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this.register((0,n.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const t=new l(e);if(t){const e=t.marker.onDispose((()=>t.dispose()));t.onDispose((()=>{t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),e.dispose())})),this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,i){let s=0,r=0;for(const n of this._decorations.getKeyIterator(t))s=n.options.x??0,r=s+(n.options.width??1),e>=s&&e{a=t.options.x??0,h=a+(t.options.width??1),e>=a&&e{Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(2585),r=i(8343);class n{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=n,t.InstantiationService=class{constructor(){this._services=new n,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort(((e,t)=>e.index-t.index)),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id}.`);s.push(i)}const n=i.length>0?i[0].index:t.length;if(t.length!==n)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${n+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7866:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.traceCall=t.setTraceLogger=t.LogService=void 0;const n=i(844),o=i(2585),a={trace:o.LogLevelEnum.TRACE,debug:o.LogLevelEnum.DEBUG,info:o.LogLevelEnum.INFO,warn:o.LogLevelEnum.WARN,error:o.LogLevelEnum.ERROR,off:o.LogLevelEnum.OFF};let h,c=t.LogService=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=o.LogLevelEnum.OFF,this._updateLogLevel(),this.register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),h=this}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;tJSON.stringify(e))).join(", ")})`);const t=s.apply(this,e);return h.trace(`GlyphRenderer#${s.name} return`,t),t}}},7302:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(8460),r=i(844),n=i(6114);t.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"courier-new, courier, monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},rescaleOverlappingGlyphs:!1,rightClickSelectsWord:n.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRulerWidth:0};const o=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends r.Disposable{constructor(e){super(),this._onOptionChange=this.register(new s.EventEmitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this.register((0,r.toDisposable)((()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null})))}onSpecificOptionChange(e,t){return this.onOptionChange((i=>{i===e&&t(this.rawOptions[e])}))}onMultipleOptionChange(e,t){return this.onOptionChange((i=>{-1!==e.indexOf(i)&&t()}))}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=o.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},2660:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const n=i(2585);let o=t.OscLinkService=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose((()=>this._removeMarkerFromLink(s,i))),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose((()=>this._removeMarkerFromLink(o,n))),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every((e=>e.line!==t))){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose((()=>this._removeMarkerFromLink(i,e)))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=o=s([r(0,n.IBufferService)],o)},8343:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createDecorator=t.getServiceDependencies=t.serviceRegistry=void 0;const i="di$target",s="di$dependencies";t.serviceRegistry=new Map,t.getServiceDependencies=function(e){return e[s]||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const r=function(e,t,n){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,r){t[i]===t?t[s].push({id:e,index:r}):(t[s]=[{id:e,index:r}],t[i]=t)}(r,e,n)};return r.toString=()=>e,t.serviceRegistry.set(e,r),r}},2585:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.ICoreMouseService=t.IBufferService=void 0;const s=i(8343);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.ICoreMouseService=(0,s.createDecorator)("CoreMouseService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},1480:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8460),r=i(225);class n{static extractShouldJoin(e){return 0!=(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.EventEmitter,this.onChange=this._onChange.event;const e=new r.UnicodeV6;this.register(e),this._active=e.version,this._activeProvider=e}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let r=0;r=s)return t+this.wcwidth(o);const i=e.charCodeAt(r);56320<=i&&i<=57343?o=1024*(o-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(o,i);let h=n.extractWidth(a);n.extractShouldJoin(a)&&(h-=n.extractWidth(i)),t+=h,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=n}},t={};function i(s){var r=t[s];if(void 0!==r)return r.exports;var n=t[s]={exports:{}};return e[s].call(n.exports,n,n.exports,i),n.exports}var s={};return(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0}),e.Terminal=void 0;const t=i(9042),r=i(3236),n=i(844),o=i(5741),a=i(8285),h=i(7975),c=i(7090),l=["cols","rows"];class d extends n.Disposable{constructor(e){super(),this._core=this.register(new r.Terminal(e)),this._addonManager=this.register(new o.AddonManager),this._publicOptions={...this._core.options};const t=e=>this._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(l.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new h.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new c.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this.register(new a.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,wraparoundMode:e.wraparound}}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._checkProposedApi(),this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return t}_verifyIntegers(...e){for(const t of e)if(t===1/0||isNaN(t)||t%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(const t of e)if(t&&(t===1/0||isNaN(t)||t%1!=0||t<0))throw new Error("This API only accepts positive integers")}}e.Terminal=d})(),s})())); -//# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/srv/tesm/templates/_audit_log_macros.html b/srv/tesm/templates/_audit_log_macros.html deleted file mode 100644 index 25ae453..0000000 --- a/srv/tesm/templates/_audit_log_macros.html +++ /dev/null @@ -1,67 +0,0 @@ -{# - Gemeinsame Auditlog-Zeilendarstellung -- von activity_log.html (Erstladung) - UND _audit_log_rows.html (AJAX-Nachladen über "Weitere 500 laden") - importiert, damit beide garantiert dieselbe Darstellung erzeugen und - action_labels/action_icons/category_of nicht an zwei Stellen gepflegt - werden müssen. -#} -{% set action_labels = { - "settings.update": "Einstellung geändert", - "device.create": "Gerät angelegt", - "device.edit": "Gerät bearbeitet", - "device.delete": "Gerät gelöscht", - "device.activate": "Gerät aktiviert", - "device.deactivate": "Gerät deaktiviert", - "switch.create": "Switch angelegt", - "switch.edit": "Switch bearbeitet", - "switch.delete": "Switch gelöscht", - "credential.create": "Zugangsdaten angelegt", - "credential.edit": "Zugangsdaten bearbeitet", - "credential.delete": "Zugangsdaten gelöscht", - "user.create": "Benutzer angelegt", - "user.edit": "Benutzer bearbeitet", - "user.delete": "Benutzer gelöscht", - "user.assign_group": "Gruppe zugewiesen", - "group.create": "Gruppe angelegt", - "group.edit": "Gruppe bearbeitet", - "group.delete": "Gruppe gelöscht", - "group.assign_admins": "Admin-Zuweisung geändert", - "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", - "auditlog.archive": "Auditlog archiviert", - "auditlog.export_delete": "Auditlog-Archiv exportiert", -} %} -{% set action_icons = { - "delete": '', - "create": '', - "edit": '', - "activate": '', - "deactivate": '', -} %} -{% set create_kinds = ("create", "upload", "mkdir") %} - -{% macro category_of(kind) %}{% if kind in create_kinds %}create{% elif kind == "delete" %}delete{% else %}edit{% endif %}{% endmacro %} - -{% macro audit_row(e) %} -{% set kind = e['action'].split('.')[-1] %} -{% set cat = category_of(kind)|trim %} - - {{ e['ts'] }} - {{ e['username'] }} - - - {{ (action_icons.get(kind) or action_icons['edit'])|safe }} - {{ action_labels.get(e['action'], e['action']) }} - - - {{ e['target'] or '—' }} - {{ e['details'] or '—' }} - -{% endmacro %} diff --git a/srv/tesm/templates/_audit_log_rows.html b/srv/tesm/templates/_audit_log_rows.html deleted file mode 100644 index b3659cc..0000000 --- a/srv/tesm/templates/_audit_log_rows.html +++ /dev/null @@ -1,5 +0,0 @@ -{# Reines Zeilenfragment fürs AJAX-Nachladen (activity_log_more()) -- kein - umschließendes /, wird per insertAdjacentHTML direkt an das - bestehende tbody von #auditTable angehängt. #} -{% import "_audit_log_macros.html" as m %} -{% for e in entries %}{{ m.audit_row(e) }}{% endfor %} diff --git a/srv/tesm/templates/_dashboard_tiles.html b/srv/tesm/templates/_dashboard_tiles.html deleted file mode 100644 index b9a198c..0000000 --- a/srv/tesm/templates/_dashboard_tiles.html +++ /dev/null @@ -1,94 +0,0 @@ -
    - -
    -
    -
    Online
    -
    {{ stats.online }}
    -
    -
    -
    Offline
    -
    {{ stats.offline }}
    -
    - {% if current_user.is_authenticated %} -
    -
    Deaktiviert
    -
    {{ stats.disabled }}
    -
    - {% endif %} -
    -
    Gesamt
    -
    {{ stats.total }}
    -
    -
    - -{% macro device_tile(d) %} -{% set st = status.get(d['mac'], 'unbekannt') %} -
    -
    -
    - -
    - {% if d['is_active'] == 0 %} - Deaktiviert - {% else %} - {{ {'online':'Online','offline':'Offline','unbekannt':'Unbekannt'}[st] }} - {% endif %} -
    -
    {{ d['name'] }}
    - {% if current_user.is_authenticated %}
    {{ d['ip'] }}
    {% endif %} -
    -{% endmacro %} - -{% if device_count %} - - {% macro section_chevron() %}{% endmacro %} - - {% if offline_devices %} -
    -
    {{ section_chevron() }}Offline {{ offline_devices|length }}
    -
    - {% for d in offline_devices %}{{ device_tile(d) }}{% endfor %} -
    -
    - {% endif %} - - {% if online_devices %} -
    -
    {{ section_chevron() }}Online {{ online_devices|length }}
    -
    - {% for d in online_devices %}{{ device_tile(d) }}{% endfor %} -
    -
    - {% endif %} - - {% if disabled_devices %} -
    -
    {{ section_chevron() }}Deaktiviert {{ disabled_devices|length }}
    -
    - {% for d in disabled_devices %}{{ device_tile(d) }}{% endfor %} -
    -
    - {% endif %} - - - -{% else %} -
    - {% if current_user.is_authenticated %} - Noch keine Geräte angelegt. Füge welche unter Devices hinzu. - {% else %} - Aktuell keine Geräte verfügbar. - {% endif %} -
    -{% endif %} - -
    diff --git a/srv/tesm/templates/_hint_icon.html b/srv/tesm/templates/_hint_icon.html deleted file mode 100644 index b776835..0000000 --- a/srv/tesm/templates/_hint_icon.html +++ /dev/null @@ -1,9 +0,0 @@ -{# - Gemeinsames "i"-Hinweis-Icon: Hover zeigt den Text als Tooltip neben dem - Mauszeiger, Klick/Tap öffnet dasselbe (wiederverwendete) Modal mit dem - vollen Text -- Mechanik lebt zentral in app.js (initHintIcons), hier wird - nur das Icon mit seinen Daten-Attributen erzeugt. title = Kontext für die - Modal-Überschrift (z.B. das zugehörige Feld-Label), text = der eigentliche - Erklärungstext. -#} -{% macro hint_icon(text, title='Hinweis') %}{% endmacro %} diff --git a/srv/tesm/templates/account.html b/srv/tesm/templates/account.html deleted file mode 100644 index 5a26252..0000000 --- a/srv/tesm/templates/account.html +++ /dev/null @@ -1,64 +0,0 @@ -{% extends "base.html" %} -{% block page_title %}Mein Konto{% endblock %} -{% block page_sub %}
    {{ current_user.username }}
    {% endblock %} - -{% block content %} - -
    - -
    -
    -
    -

    Profil

    -
    Name und Profilbild ändern sich in der Sidebar und im Änderungslog.
    -
    -
    - -
    - {% if current_user.avatar_url %} - - {% else %} -
    {{ current_user.username[:2]|upper }}
    - {% endif %} -
    - - - - -
    - -
    -
    -
    -
    - - -
    - -
    -
    -
    -

    Passwort ändern

    -
    - {% if current_user.is_ldap_user %}Wird über Active Directory verwaltet.{% else %}Erfordert Eingabe des aktuellen Passworts.{% endif %} -
    -
    -
    - {% if current_user.is_ldap_user %} -

    - Dieses Konto meldet sich über Active Directory/LDAP an — das Passwort wird dort verwaltet - und kann in dieser App nicht geändert werden. Bitte das Domänen-Passwort wie gewohnt ändern. -

    - {% else %} -
    -
    -
    -
    - - - {% endif %} -
    - -
    - -{% endblock %} diff --git a/srv/tesm/templates/activity_log.html b/srv/tesm/templates/activity_log.html deleted file mode 100644 index b4e9275..0000000 --- a/srv/tesm/templates/activity_log.html +++ /dev/null @@ -1,224 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "logs" %} -{% block page_title %}Auditlog{% endblock %} -{% block page_sub %} -
    - {% if has_more %}{{ entries|length }} von {{ total_count }} Einträgen geladen{% else %}{{ entries|length }} Eintrag{{ 'e' if entries|length != 1 else '' }}{% endif %} -
    -{% endblock %} - -{% block content %} -{% import "_audit_log_macros.html" as m %} - -{% set ns = namespace(create=0, delete=0, edit=0) %} -{% for e in entries %} - {% set cat = m.category_of(e['action'].split('.')[-1])|trim %} - {% if cat == "create" %}{% set ns.create = ns.create + 1 %} - {% elif cat == "delete" %}{% set ns.delete = ns.delete + 1 %} - {% else %}{% set ns.edit = ns.edit + 1 %}{% endif %} -{% endfor %} - -{% macro fmt_count(loaded, total) %}{% if loaded < total %}{{ loaded }} / {{ total }}{% else %}{{ loaded }}{% endif %}{% endmacro %} - -
    -
    -
    Alle
    -
    {{ fmt_count(entries|length, total_count) }}
    -
    -
    -
    Hinzufügen
    -
    {{ fmt_count(ns.create, total_create) }}
    -
    -
    -
    Änderungen
    -
    {{ fmt_count(ns.edit, total_edit) }}
    -
    -
    -
    Löschungen
    -
    {{ fmt_count(ns.delete, total_delete) }}
    -
    -
    - -
    -
    -
    - - -
    -
    -
    -
    - - - - - - - - - {% for e in entries %}{{ m.audit_row(e) }}{% else %} - - {% endfor %} - -
    ZeitpunktBenutzerAktionZielDetails
    Noch keine Änderungen protokolliert.
    -
    - -
    - - -
    -
- -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm/templates/base.html b/srv/tesm/templates/base.html deleted file mode 100644 index 49a6937..0000000 --- a/srv/tesm/templates/base.html +++ /dev/null @@ -1,165 +0,0 @@ - - - - - -{{ title or "TESM" }} - - -{% block extra_head %}{% endblock %} - - - -{% set icons = { - "grid": '', - "cpu": '', - "share": '', - "users": '', - "groups": '', - "key": '', - "terminal": '', - "history": '', - "clock": '', - "shield": '', - "server": '', - "folder": '', - "sliders": '', - "logout": '', - "gear": '', - "transfer": '', - "network": '', - "wrench": '', - "trash": '', -} %} - -
- - {% if current_user.is_authenticated %} - - - - {% endif %} - -
-
-
- {% if current_user.is_authenticated %} - - {% endif %} -
-
{% block page_title %}Dashboard{% endblock %}
- {% block page_sub %}{% endblock %} -
-
- - - -
- - Nächste Prüfung in --s - {% if current_user.is_admin %} - - {% endif %} - - {% if license_topbar %} - - {{ license_topbar.text }} - - {% endif %} - {% if dhcp_topbar_active is not none %} - - DHCP - - {% endif %} - {% if not current_user.is_authenticated %} - - - Login - - {% endif %} -
-
- -
- {% block content %}{% endblock %} -
-
-
- -{% with messages = get_flashed_messages(with_categories=true) %} - -{% endwith %} - - -{% block scripts %}{% endblock %} - - diff --git a/srv/tesm/templates/credentials.html b/srv/tesm/templates/credentials.html deleted file mode 100644 index ebe4c8d..0000000 --- a/srv/tesm/templates/credentials.html +++ /dev/null @@ -1,200 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "credentials" %} -{% set can_create = current_user.has_permission('credentials.create') %} -{% set can_edit = current_user.has_permission('credentials.edit') %} -{% set can_delete = current_user.has_permission('credentials.edit') %} -{% set category_labels = dict(categories) %} -{% block page_title %}Zugangsdaten{% endblock %} -{% block page_sub %}
{{ credentials|length }} Zugangsdaten
{% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} - -
-
-

Zugangsdaten

-
Können mehreren Switchen zugleich zugeordnet werden.
-
- {% if can_create %} - - {% endif %} -
- -
-
-
- - -
-
-
- - - - - - - - - - {% for c in credentials %} - {% set usage_total = c['switch_count'] + c['device_count'] %} - - - - - - - - {% else %} - - {% endfor %} - -
NameUsernameKategorieVerwendet vonAktionen
{{ c['name'] }}{{ c['username'] }}{{ category_labels.get(c['category'], c['category']) }} - {% if usage_total == 0 %}—{% else %} - {% if c['switch_count'] %}{{ c['switch_count'] }} Switch{{ 'e' if c['switch_count'] != 1 else '' }}{% endif %} - {% if c['switch_count'] and c['device_count'] %}, {% endif %} - {% if c['device_count'] %}{{ c['device_count'] }} Gerät{{ 'e' if c['device_count'] != 1 else '' }}{% endif %} - {% endif %} - -
- {% if can_edit %} - - {% endif %} - {% if can_delete %} -
- - -
- {% endif %} -
-
Noch keine Zugangsdaten vorhanden.
-
-
- -{% if can_create %} - -{% endif %} - -{% if can_edit %} - -{% endif %} - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm/templates/devices.html b/srv/tesm/templates/devices.html deleted file mode 100644 index a03a46d..0000000 --- a/srv/tesm/templates/devices.html +++ /dev/null @@ -1,520 +0,0 @@ -{% extends "base.html" %} -{% block extra_head %} - -{% endblock %} -{% set active_page = "devices" %} -{% set can_toggle = current_user.has_permission('devices.edit') %} -{% set can_create = current_user.has_permission('devices.create') %} -{% set can_edit = current_user.has_permission('devices.edit') %} -{% set can_delete = current_user.has_permission('devices.edit') %} -{% set show_actions_col = can_edit or can_delete %} -{% set col_count = 6 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %} -{% block page_title %}Clients{% endblock %} -{% block page_sub %}
{{ devices|length }} Geräte
{% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} - -
-
-

Geräte

-
PoE-Geräte mit IP, MAC und zugeordnetem Switch-Port.
-
- {% if can_create %} - - {% endif %} -
- -
-
-
- - -
-
-
- - - - - - - - - - {% if can_toggle %}{% endif %} - {% if show_actions_col %}{% endif %} - - - - {% for d in devices %} - - - - - - - - {% if can_toggle %} - - {% endif %} - {% if show_actions_col %} - - {% endif %} - - {% else %} - - {% endfor %} - -
HostnameIP-AdresseMAC-AdresseSwitchSwitchportZugangsdatenStatusAktionen
{{ d['name'] }}{{ d['ip'] }}{{ d['mac'] }}{{ d['switch_hostname'] or '—' }}{{ d['port'] or '—' }} - {% if d['credential_name'] %} - {{ d['credential_name'] }} ({{ d['credential_username'] }}) - {% else %} - — keine — - {% endif %} - - - -
- {% if can_edit %} - - - {% endif %} - {% if can_delete %} -
- - -
- {% endif %} -
-
Noch keine Geräte vorhanden.
-
-
- -{% if can_create %} - -{% endif %} - -{% if can_edit %} - - - -{% endif %} - -{% if can_create or can_edit %} - -{% endif %} - -{% endblock %} - -{% block scripts %} - - - -{% endblock %} diff --git a/srv/tesm/templates/fileshare.html b/srv/tesm/templates/fileshare.html deleted file mode 100644 index 1b7c37d..0000000 --- a/srv/tesm/templates/fileshare.html +++ /dev/null @@ -1,617 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "fileshare" %} -{% block page_title %}Dateifreigaben{% endblock %} -{% block page_sub %}
{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}
{% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} - -{% macro render_tree_node(share, path, name, expanded_map, active_path) %} -
  • -
    - {% if path in expanded_map %} - - {% else %} - - {% endif %} - - - {{ name }} - -
    -
      - {% 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 %} -
    -
  • -{% endmacro %} - -
    -
    -

    Dateifreigaben

    -
    Freigaben je nach AD-Gruppenmitgliedschaft für diese Sitzung gemountet — wird beim Abmelden wieder ausgehängt.
    -
    -
    - -
    -
    -
    Freigaben
    -
      - {% 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 %} -
    -
    - -
    -
    -
    -
    - - -
    -
    - {% if can_create %} - - - {% endif %} -
    -
    - - - - {% if entries %} -
    - - - - - - - - - - {% for e in entries %} - - - - - - - - {% endfor %} - -
    NameGrößeGeändertAktionen
    - {% if e.is_dir %} - - - {{ e.name }} - - {% else %} - - {{ e.name }} - {% endif %} - {{ e.size_str }}{{ e.mtime_str }} -
    - {% if e.preview_kind %} - - {% endif %} - {% if not e.is_dir %} - - - - {% endif %} - {% if can_edit %} - -
    - - - - -
    - {% endif %} -
    -
    -
    - {% else %} -
    Dieser Ordner ist leer.
    - {% endif %} -
    -
    -
    - -{% if can_create %} - - - -{% endif %} - -{% if can_edit %} - -{% endif %} - - - -{% endblock %} - -{% block scripts %} - - - -{% endblock %} diff --git a/srv/tesm/templates/groups.html b/srv/tesm/templates/groups.html deleted file mode 100644 index 40f5751..0000000 --- a/srv/tesm/templates/groups.html +++ /dev/null @@ -1,394 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "groups" %} -{% block page_title %}Gruppen{% endblock %} -{% block page_sub %}
    {{ groups|length + 1 }} Gruppen · Rechteverwaltung
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} - -{% macro permission_table(group, group_key, checked_keys, readonly) %} -{% set row_types = group_row_types[group_key] %} -
    -
    - - - - - {% for row_key, row_letter, row_label in row_types %} - - {% endfor %} - - - - {% for child_key, child in group['children'].items() %} - - - {% for row_key, row_letter, row_label in row_types %} - {% set perm_key = child['rows'].get(row_key) %} - - {% endfor %} - - {% endfor %} - -
    - - {{ row_letter }}
    {{ child['label'] }} - {% if perm_key %} - - {% else %} - - {% endif %} -
    -
    -
    -{% endmacro %} - -{% macro permission_tree(checked_keys, readonly, compact=false) %} -
    - {% for group_key, group in permission_catalog.items() %} - {{ permission_table(group, group_key, checked_keys, readonly) }} - {% endfor %} -
    -
    - R = Read (Lesen) · W = Write (Anlegen) · - E = Edit (Ändern, inkl. Löschen — bei Im-/Export: Import ausführen) · - D = Dashboard (PoE-Neustart über das Dashboard-Popup, nur bei Clients) · - X = Export (nur bei Im-/Export — Export-Datei enthält Passwörter im Klartext) -
    -{% endmacro %} - -
    -
    -

    Gruppen {{ hi.hint_icon("Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich. „Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.", "Gruppen") }}

    -
    - {% if current_user.has_permission('groups.create') %} - - {% endif %} -
    - -
    -
    -
    - - -
    -
    -
    - - - - - - - - - - - - - - - - {% 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 %} - - - - - - - - {% else %} - - - - {% endfor %} -
    GruppeMitgliederAktionen
    Admin Systemrolle{{ admin_virtual_group.member_names|length }} -
    - - -
    -
    - {{ g.name }} - {% if g.is_system %}Standard{% endif %} - {{ g.member_names|length }} -
    - - - {% if current_user.has_permission('groups.edit') and not g.is_default and not g.is_system %} -
    - - -
    - {% endif %} -
    -
    Noch keine weiteren Gruppen angelegt.
    -
    -
    - - - - - -{% 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 %} - - - -{% endfor %} - - - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm/templates/index.html b/srv/tesm/templates/index.html deleted file mode 100644 index 6c68303..0000000 --- a/srv/tesm/templates/index.html +++ /dev/null @@ -1,282 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "index" %} -{% block page_title %}Dashboard{% endblock %} -{% block page_sub %}
    {{ device_count }} Geräte{{ " im Bestand" if current_user.is_authenticated else "" }}
    {% endblock %} - -{% block content %} - -
    - - -
    - -{% include "_dashboard_tiles.html" %} - -{% if not current_user.is_authenticated %} -

    - Nur Lese-Ansicht — deaktivierte Geräte werden nicht angezeigt. - Anmelden, - um alle Geräte zu verwalten oder einen PoE-Neustart auszulösen. -

    -{% endif %} - -{% if current_user.is_authenticated %} - - -{% if current_user.has_permission('devices.restart') %} - -{% endif %} -{% endif %} - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm/templates/login.html b/srv/tesm/templates/login.html deleted file mode 100644 index d0b5210..0000000 --- a/srv/tesm/templates/login.html +++ /dev/null @@ -1,44 +0,0 @@ - - - - - -Login · TESM - - - - - - - - - - diff --git a/srv/tesm/templates/logs.html b/srv/tesm/templates/logs.html deleted file mode 100644 index 77469a2..0000000 --- a/srv/tesm/templates/logs.html +++ /dev/null @@ -1,89 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "logs" %} -{% block page_title %}Live{% endblock %} -{% block page_sub %}
    {{ log_name or "kein Logfile" }}
    {% endblock %} - -{% block content %} - -
    -
    -

    Live-Log

    -
    Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline). Zeigt aus Performance-Gründen nur die letzten {{ tail_lines }} Zeilen.
    -
    -
    - {% if current_user.can_view_log_history %} - - - Verlauf - - {% endif %} - - -
    -
    - -
    -
    -
    - {{ log_name or "" }} - {% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen{% endif %} -
    -
    {{ log_content or "Keine Logfiles gefunden." }}
    -
    -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm/templates/logs_history.html b/srv/tesm/templates/logs_history.html deleted file mode 100644 index 99e5c0a..0000000 --- a/srv/tesm/templates/logs_history.html +++ /dev/null @@ -1,112 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "logs" %} -{% block page_title %}Verlauf{% endblock %} -{% block page_sub %}
    {{ selected_file.range_label if selected_file else "kein Log" }}
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} - -
    -
    -

    Log-Verlauf

    -
    Aktuelles Live-Log sowie ältere, von logrotate rotierte Kopien davon — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar, auswählbar nach Zeitraum. Aus Performance-Gründen unformatiert (RAW) dargestellt, ohne farbliche Aufbereitung.
    -
    Zusätzlich weiter unten: bereits archivierte Auditlog-Tage (das Auditlog wird ab {{ "{:,}".format(audit_threshold).replace(",", ".") }} Einträgen automatisch tageweise archiviert, bis {{ "{:,}".format(audit_target).replace(",", ".") }} Einträge unterschritten sind).
    -
    -
    - - - Zurück zur Live-Ansicht - - -
    -
    - -{% if not files %} -

    - Kein Live-Log gefunden. -

    -{% else %} - -
    - - -
    - -
    -
    -
    - {{ selected_file.range_label if selected_file else "" }} - {% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen — RAW{% endif %} -
    -
    {{ log_content or "" }}
    -
    -{% endif %} - -
    -
    -

    Archivierte Auditlog-Tage {{ hi.hint_icon("Ältere Auditlog-Einträge, tageweise als eigene Datei ausgelagert, sobald die laufende Tabelle den Schwellenwert überschreitet — die Dateien selbst bleiben unbegrenzt erhalten, bis sie hier bewusst exportiert werden.", "Archivierte Auditlog-Tage") }}

    -
    - {% if current_user.can_manage_log_history and audit_files %} -
    - -
    - {% endif %} -
    - -{% if disk_warning %} -
    {{ disk_warning }}
    -{% endif %} - -{% if not audit_files %} -

    Noch keine archivierten Auditlog-Tage vorhanden.

    -{% else %} -
    - - - - - - - - {% for f in audit_files %} - - - - - - {% endfor %} - -
    TagGröße
    {{ f.day }}{{ (f.size / 1024)|round(1) }} KB - -
    -
    -{% endif %} -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm/templates/logs_kea.html b/srv/tesm/templates/logs_kea.html deleted file mode 100644 index 1e881f4..0000000 --- a/srv/tesm/templates/logs_kea.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "logs" %} -{% block page_title %}Kea-DHCP{% endblock %} -{% block page_sub %}
    Log-Ausgabe des kea-dhcp4-server-Dienstes
    {% endblock %} - -{% block content %} - -
    -
    -

    Kea-DHCP-Log

    -
    Eigene Log-Datei des DHCP-Dienstes (siehe Systemeinstellungen → Logs für Rotation/Aufbewahrung).
    -
    - -
    - -
    -
    -
    - kea-dhcp4.log -
    -
    {{ log_content or "Keine Logdatei gefunden." }}
    -
    -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm/templates/maintenance.html b/srv/tesm/templates/maintenance.html deleted file mode 100644 index fa1d398..0000000 --- a/srv/tesm/templates/maintenance.html +++ /dev/null @@ -1,207 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "maintenance" %} -{% block page_title %}Wartung{% endblock %} -{% block page_sub %}
    {{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} - -
    -
    -

    Wartung (SSH) {{ hi.hint_icon("Bulk-Update (apt update & upgrade -y) und Neustart per SSH direkt auf dem Gerät — unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.", "Wartung (SSH)") }}

    -
    -
    - -{% if not devices %} -

    - Noch keine Geräte für die Wartung konfiguriert. Lege unter - Clients - SSH-Zugangsdaten der Kategorie „Linux-Client“ an einem Gerät an — es erscheint danach automatisch hier. -

    -{% else %} - -
    -
    -
    -
    - - -
    -
    -
    - - - {% if can_run %}{% endif %} - - - - - - - - {% for d in devices %} - {% set job = jobs.get(d['mac']) %} - - {% if can_run %} - - {% endif %} - - - - - - - - - - {% endfor %} - -
    NameIP-AdresseSSH-UserStatusAktionen
    {{ d['name'] }}{% if not d['is_active'] %} (deaktiviert){% endif %}{{ d['ip'] }}:{{ d['ssh_port'] or 22 }}{{ d['cred_username'] }} - {% if job %} - {% if job.status == 'running' %} {{ job.message }} - {% elif job.status == 'success' %}{{ job.message }} - {% else %}{{ job.message }} - {% endif %} - {% else %} - Noch keine Aktion - {% endif %} - -
    - - {% if can_run %} - - - {% endif %} -
    -
    -
    -
    - -{% if can_run %} -
    -
    - Bulk-Aktionen {{ hi.hint_icon("Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet) oder neu starten.", "Bulk-Aktionen") }} -
    -
    - - -
    -
    -{% endif %} -
    - -{% if can_run %} -{% for d in devices %} -
    - -
    -
    - -
    -{% endfor %} -{% endif %} -{% endif %} - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm/templates/papierkorb.html b/srv/tesm/templates/papierkorb.html deleted file mode 100644 index 9dd13e1..0000000 --- a/srv/tesm/templates/papierkorb.html +++ /dev/null @@ -1,280 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "papierkorb" %} -{% block page_title %}Papierkorb{% endblock %} -{% block page_sub %}
    Gelöschte Clients, Switche, Zugangsdaten, Benutzer und Gruppen
    {% endblock %} - -{% block content %} - -
    -
    -

    Papierkorb

    -
    - Gelöschte Clients, Switche, Zugangsdaten, Benutzer und Gruppen — werden nach - {{ trash_retention_days }} Tag{{ 'en' if trash_retention_days != 1 else '' }} automatisch endgültig gelöscht - (einstellbar unter Systemeinstellungen). AD/LDAP-Benutzer landen nie hier, da sie sich bei jedem gültigen - Login automatisch neu anlegen. -
    -
    -
    - -{% if not license_active() %} -
    - Wiederherstellen und endgültiges Löschen sind nur mit gültiger Lizenz verfügbar — siehe - Lizenz. Ansehen ist weiterhin uneingeschränkt möglich. -
    -{% endif %} - -
    - -
    -

    Clients ({{ trash.devices|length }})

    - {% if trash.devices %} -
    -
    -
    - - -
    -
    -
    - - - - - - - - - {% for d in trash.devices %} - - - - - - - {% endfor %} - -
    NameIPGelöscht amAktionen
    {{ d['name'] }}{{ d['ip'] }}{{ d['deleted_at'] }} - {% if current_user.has_permission('papierkorb.edit') and license_active() %} -
    -
    - -
    -
    - -
    -
    - {% endif %} -
    -
    - {% else %} -

    Papierkorb ist leer.

    - {% endif %} -
    - -
    -

    Switche ({{ trash.switches|length }})

    - {% if trash.switches %} -
    -
    -
    - - -
    -
    -
    - - - - - - - - - {% for s in trash.switches %} - - - - - - - {% endfor %} - -
    HostnameIPGelöscht amAktionen
    {{ s['hostname'] }}{{ s['ip'] }}{{ s['deleted_at'] }} - {% if current_user.has_permission('papierkorb.edit') and license_active() %} -
    -
    - -
    -
    - -
    -
    - {% endif %} -
    -
    - {% else %} -

    Papierkorb ist leer.

    - {% endif %} -
    - -
    -

    Zugangsdaten ({{ trash.credentials|length }})

    - {% if trash.credentials %} -
    -
    -
    - - -
    -
    -
    - - - - - - - - - {% for c in trash.credentials %} - - - - - - - {% endfor %} - -
    NameUsernameGelöscht amAktionen
    {{ c['name'] }}{{ c['username'] }}{{ c['deleted_at'] }} - {% if current_user.has_permission('papierkorb.edit') and license_active() %} -
    -
    - -
    -
    - -
    -
    - {% endif %} -
    -
    - {% else %} -

    Papierkorb ist leer.

    - {% endif %} -
    - -
    -

    Benutzer ({{ trash.users|length }})

    - {% if trash.users %} -
    -
    -
    - - -
    -
    -
    - - - - - - - - - {% for u in trash.users %} - {% set full_name = [u['first_name'], u['last_name']]|select|join(' ') %} - - - - - - - {% endfor %} - -
    UsernameNameGelöscht amAktionen
    {{ u['username'] }}{{ full_name or '—' }}{{ u['deleted_at'] }} - {% if current_user.has_permission('papierkorb.edit') and license_active() %} -
    -
    - -
    -
    - -
    -
    - {% endif %} -
    -
    - {% else %} -

    Papierkorb ist leer.

    - {% endif %} -
    - -
    -

    Gruppen ({{ trash.groups|length }})

    - {% if trash.groups %} -
    -
    -
    - - -
    -
    -
    - - - - - - - - {% for g in trash.groups %} - - - - - - {% endfor %} - -
    NameGelöscht amAktionen
    {{ g['name'] }}{{ g['deleted_at'] }} - {% if current_user.has_permission('papierkorb.edit') and license_active() %} -
    -
    - -
    -
    - -
    -
    - {% endif %} -
    -
    - {% else %} -

    Papierkorb ist leer.

    - {% endif %} -
    - -
    - - - -{% endblock %} diff --git a/srv/tesm/templates/settings.html b/srv/tesm/templates/settings.html deleted file mode 100644 index ca582ac..0000000 --- a/srv/tesm/templates/settings.html +++ /dev/null @@ -1,332 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "settings_system" %} -{% block page_title %}Systemeinstellungen{% endblock %} -{% block page_sub %}
    Prüfintervall und Netzwerkkonfiguration dieses Hosts
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} -
    - -
    -
    -
    -

    Host

    -
    Name und Zeitzone dieses Hosts.
    -
    -
    - {% set can_edit_host = current_user.has_permission('settings_system.edit') %} - {% if can_edit_host %} -
    -
    - - -
    - -
    -
    -
    -
    - - -
    - -
    - {% else %} -
    - - -
    -
    - - -
    - {% endif %} -
    - {% if can_edit_host and license_active() %} -
    -
    - - -
    - -
    - {% else %} -
    - - -
    - {% if not can_edit_host %} - Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“. - {% else %} - Nur mit gültiger Lizenz änderbar — siehe Lizenz. - {% endif %} -
    -
    - {% endif %} -
    - -
    -
    -
    -

    Netzwerkeinstellungen

    -
    IP/DNS/DHCP-Umschaltung dieses Hosts (nicht der DHCP-Server unter „DHCP“).
    -
    -
    - -
    - {% if net_backend == 'unknown' %} - Kein unterstütztes Backend erkannt - {% else %} - {{ {'networkmanager': 'NetworkManager', 'dhcpcd': 'dhcpcd', 'netplan': 'netplan'}[net_backend] }} - {% endif %} -
    - {% for state in active_net_states %} -
    - - {{ state.interface }} — {{ state.ip }}/{{ state.prefix }}{% if state.gateway %}, Gateway {{ state.gateway }}{% endif %} - - - {{ {'static': 'Statisch', 'dhcp': 'DHCP', 'unknown': 'Modus unbekannt'}[state.mode] }} - - {% if state.dns %} - DNS: {{ state.dns|join(', ') }} - {% endif %} -
    - {% else %} -
    Kein aktives Interface mit IPv4-Adresse gefunden.
    - {% endfor %} -
    - - {% if pending_network_token %} -
    -

    - Neue Netzwerkkonfiguration wurde angewendet. Wenn diese Seite gerade noch lädt, funktioniert die Verbindung — - bitte bestätigen, bevor automatisch zurückgerollt wird (nach {{ net_revert_seconds }}s ohne Bestätigung). -

    -
    - - -
    -
    - {% elif net_backend != 'unknown' and current_user.has_permission('settings_system.edit') %} -
    - -
    - -
    -
    - -
    -
    -
    - -
    -
    - -
    -
    - -
    -
    -
    - -
    - -

    - ⚠ Kann die Erreichbarkeit dieses Hosts unterbrechen. Ohne Bestätigung wird automatisch nach {{ net_revert_seconds }}s zurückgerollt. -

    -
    - {% elif net_backend == 'unknown' %} -

    - Weder NetworkManager, dhcpcd noch netplan/systemd-networkd aktiv erkannt — Netzwerkänderungen über diese Seite sind deaktiviert. - Bitte die Netzwerkkonfiguration dieses Hosts manuell vornehmen. -

    - {% else %} -

    Für Änderungen fehlt das Recht „Systemeinstellungen ändern“.

    - {% endif %} -
    - -
    -
    -
    -

    Logs

    -
    Rotation & Aufbewahrung von Live-, Änderungs-, App- und Kea-DHCP-Log.
    -
    -
    - {% if current_user.has_permission('settings_system.edit') %} -
    - -
    - - -
    -
    - - -
    - -
    - {% else %} -
    - - -
    -
    - - -
    - {% endif %} -
    - Live: {{ log_paths.live }}
    - Änderungen: {{ log_paths.changes }}
    - App: {{ log_paths.app }}
    - Kea-DHCP: {{ log_paths.kea }} -
    -
    - -
    -
    -
    -

    Papierkorb

    -
    Gelöschte Clients, Switche, Zugangsdaten, Benutzer und Gruppen — Liste unter Geräte → Wartung.
    -
    -
    - {% if current_user.has_permission('settings_system.edit') %} -
    -
    - - -
    - -
    -
    - - -
    - {% else %} -
    - - -
    - {% endif %} -
    - - {% if current_user.is_admin %} - {% macro nav_order_buttons() %} - - {% endmacro %} -
    -
    -
    -

    Navbar-Reihenfolge

    -
    Reihenfolge der Sidebar-Menüpunkte inkl. Unterpunkte — gilt für alle Benutzer.
    -
    -
    - -
    - {% endif %} - -
    -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm/templates/settings_dhcp.html b/srv/tesm/templates/settings_dhcp.html deleted file mode 100644 index 4491868..0000000 --- a/srv/tesm/templates/settings_dhcp.html +++ /dev/null @@ -1,646 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "settings_dhcp" %} -{% set can_edit = current_user.has_permission('settings_dhcp.edit') %} -{% block page_title %}DHCP{% endblock %} -{% block page_sub %}
    Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} -
    - -
    -
    -
    -

    DHCP-Server-Status

    -
    Installation/Dienststeuerung nur über die Buttons unten.
    -
    -
    - {% if status.installed %} -
    - Installiert - {% if status.active is sameas true %} - Dienst aktiv - {% elif status.active is sameas false %} - Dienst inaktiv - {% else %} - Dienst-Status unbekannt - {% endif %} - {% if not can_edit and status.enabled is sameas true %}Autostart an{% endif %} -
    - {% if status.version %}
    {{ status.version }}
    {% endif %} - {% if can_edit %} -
    - - Autostart beim Hochfahren des Hosts -
    -
    -
    - - -
    - {% if status.active is sameas true %} -
    - - -
    - {% endif %} -
    - {% endif %} - {% else %} - Nicht installiert -

    - Kein kea-dhcp4 auf diesem Host gefunden. -

    - {% if can_edit %} -
    - - -
    - {% endif %} - {% endif %} -
    - -
    -
    -
    -

    Globale DHCP-Einstellungen

    -
    Gelten für den gesamten Dienst, unabhängig von den Subnetzen unten.
    -
    -
    - {% if can_edit %} -
    - -
    - -
    -
    - -
    -
    - -
    -
    - -
    - -
    - {% else %} -

    Für Änderungen fehlt das Recht „DHCP ändern“.

    - {% endif %} -
    - -
    -
    -
    -

    Subnetze {{ hi.hint_icon("Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range, optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.", "Subnetze") }}

    -
    - {% if can_edit %} - - {% endif %} -
    - - {% if subnets %} -
    -
    - - - - {% for s in subnets %} - {% set st = subnet_status.get(s.id) %} - - - - - - - - - {% endfor %} - -
    InterfaceRangeGatewayDNSErkanntes NetzAktionen
    {{ s.interface }}{{ s.range_start }} – {{ s.range_end }}{{ s.gateway or '(automatisch)' }}{{ s.dns or '—' }} - {% if not s.enabled %} - Deaktiviert - {% elif st and st.ok %} - Erkannt - {{ st.network }} - {% else %} - Nicht erkannt - {% if st %}{{ st.error }}{% endif %} - {% endif %} - - {% if can_edit %} -
    -
    - - -
    - -
    - - -
    -
    - {% endif %} -
    -
    -
    - {% else %} -

    Noch kein Subnetz konfiguriert — der Dienst kann so nicht gestartet werden.

    - {% endif %} -
    - -
    -
    -
    -

    DHCP-Options {{ hi.hint_icon('Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt, eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.', "DHCP-Options") }}

    -
    - {% if can_edit %} - - {% endif %} -
    - - {% set visible_option_defs = option_defs|rejectattr("is_standard")|list + option_defs|selectattr("is_standard")|selectattr("id", "in", option_values.keys()|list)|list %} - {% if visible_option_defs %} -
    -
    - - - - {% for d in visible_option_defs|sort(attribute="code") %} - - - - - - - - - - {% endfor %} - -
    CodeNameTypHerkunftBeschreibungGlobaler WertAktionen
    {{ d.code }}{{ d.name }}{{ d.type }} - {% if d.is_standard %} - Standard - {% else %} - Eigen - {% endif %} - {{ d.description or '—' }} - {% if can_edit %} -
    - - - - -
    -
    - Standard-Option zurücksetzen {{ hi.hint_icon("Leeren Wert speichern, um eine Standard-Option wieder auszublenden.", "Standard-Option zurücksetzen") }} -
    - {% else %} - {{ option_values.get(d.id, {}).get('', '—') }} - {% endif %} -
    - {% if can_edit and not d.is_standard %} -
    - - -
    - {% elif d.is_standard %} - - - - {% endif %} -
    -
    -
    - {% else %} -

    Noch keine Options in Verwendung.

    - {% endif %} - - {% if can_edit %} - {% set unused_standard_defs = option_defs|selectattr("is_standard")|rejectattr("id", "in", option_values.keys()|list)|sort(attribute="code")|list %} - {% if unused_standard_defs %} -
    - -
    - - -
    -
    - - -
    - -
    - {% endif %} - {% endif %} -
    - -
    -
    -
    -

    Aktive Leases {{ hi.hint_icon("Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung, die sich einfach eine freie IP aus dem Pool genommen haben.", "Aktive Leases") }}

    -
    -
    - - {% if leases is none %} -

    - Keine Lease-Datei gefunden — der Dienst wurde vermutlich noch nie gestartet oder hat noch keine Adresse vergeben. -

    - {% elif leases %} -
    -
    - - {% if can_edit %}{% endif %} - - {% for l in leases %} - - - - - - - {% if can_edit %} - - {% endif %} - - {% endfor %} - -
    IP-AdresseMACHostnameLäuft abStatusAktionen
    {{ l.ip }}{{ l.mac }}{{ l.hostname or '—' }} - {% if l.expires_at %} - {{ l.expires_at }} UTC -
    - {% else %} - — - {% endif %} -
    - {% if l.reserved %} - Reserviert - {% else %} - - ⚠ Ohne Reservierung - - {% endif %} - -
    - - -
    -
    -
    -
    - {% else %} -

    Aktuell keine aktiven Leases vergeben.

    - {% endif %} -
    - -
    -
    -
    -

    Reservierungen aus den Clients

    -
    - {{ reservations|length }} aktive{{ ' Clients' if reservations|length != 1 else 'r Client' }} mit gültiger MAC + IP in einem der konfigurierten Subnetze. - {% if skipped_count %}{{ skipped_count }} aktive{{ ' Clients' if skipped_count != 1 else 'r Client' }} ohne MAC/IP übersprungen.{% endif %} - {% if out_of_subnet_count %}{{ out_of_subnet_count }} aktive{{ ' Clients außerhalb' if out_of_subnet_count != 1 else 'r Client außerhalb' }} aller konfigurierten Subnetze übersprungen.{% endif %} -
    -
    - {% if can_edit %} -
    - -
    - - -
    -
    - {% endif %} -
    - - {% if reservations %} -
    -
    - - - - {% for r in reservations %} - - - - - - - - - - - {% endfor %} - -
    HostnameMACIP-AdresseNameQuelleSubnetzOptionenAktionen
    {{ r.hostname }}{{ r.mac }}{{ r.ip }}{{ r.name }} - {% if r.source == 'manual' %} - Manuell - {% else %} - Auto (Client) - {% endif %} - {{ r.subnet_label }} - {% for d in option_defs %} - {% set override = option_values.get(d.id, {}).get(r.mac) %} - {% set global_val = option_values.get(d.id, {}).get('') %} - {% if override %} - {{ d.name }} (eigen) - {% elif global_val %} - {{ d.name }} (global) - {% endif %} - {% endfor %} - -
    - {% if option_defs and can_edit %} - - {% endif %} - {% if can_edit and r.source == 'manual' %} -
    - - -
    - {% elif r.source == 'auto' %} - - - - {% endif %} -
    -
    -
    -
    - {% if option_defs and can_edit %} - {% for r in reservations %} - - {% endfor %} - {% endif %} - {% else %} -

    Keine aktiven Clients mit MAC + IP vorhanden.

    - {% endif %} - -
    Generierte Konfiguration (Vorschau, Kea-JSON)
    -
    {{ preview }}
    -
    - -
    - -{% if can_edit %} - - - - - -{% endif %} - -{% if can_edit %} -{% for s in subnets %} - -{% endfor %} -{% endif %} - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm/templates/settings_import_export.html b/srv/tesm/templates/settings_import_export.html deleted file mode 100644 index 1a33a28..0000000 --- a/srv/tesm/templates/settings_import_export.html +++ /dev/null @@ -1,107 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "settings_importexport" %} -{% block page_title %}Im-/Export{% endblock %} -{% block page_sub %}
    Umzug auf eine neue Umgebung
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} -
    - -
    -
    -
    -

    Import

    -
    - {% if import_preview %} - Datei erfolgreich gelesen — wähle aus, welche Kategorien tatsächlich eingespielt werden sollen. - {% else %} - Exportiertes Bundle einlesen — nach dem Entschlüsseln wählst du aus, was übernommen wird. - {% endif %} -
    -
    -
    - {% if not current_user.has_permission('settings_importexport.edit') %} -

    Für den Import fehlt das Recht „Im-/Export ändern“.

    - {% elif import_preview %} -
    - -
    - -
    - {% for s in import_preview.sections %} - - {% endfor %} -
    -
    - -
    - Abbrechen / andere Datei wählen - {% else %} -
    -
    - - -
    -
    - - -
    - -
    - {% endif %} -
    - -
    -
    -
    -

    Export

    -
    Ausgewählte Kategorien verschlüsselt sichern.
    -
    -
    - {% if current_user.has_permission('settings_importexport.export') and license_export_allowed() %} -
    -
    - -
    - {% for key, label in export_sections %} - - {% endfor %} -
    -
    -
    - - -
    - -
    - {% elif current_user.has_permission('settings_importexport.export') %} -

    Für den Export ist keine gültige Lizenz vorhanden — siehe Lizenz.

    - {% else %} -

    Für den Export fehlt das Recht „Daten exportieren“.

    - {% endif %} -
    - -
    -{% endblock %} diff --git a/srv/tesm/templates/settings_ldap.html b/srv/tesm/templates/settings_ldap.html deleted file mode 100644 index 0e86a1d..0000000 --- a/srv/tesm/templates/settings_ldap.html +++ /dev/null @@ -1,454 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "settings_ldap" %} -{% set can_edit = current_user.has_permission('settings_ldap.edit') and license_active() %} -{% block page_title %}LDAP / Active Directory{% endblock %} -{% block page_sub %}
    Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} -
    - -
    -
    -
    -

    Verbindung

    -
    Server, Bind-Konto und Suchparameter für die Anbindung an AD/LDAP.
    -
    -
    - {% if can_edit %} -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    -
    - - -
    -
    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.
    -
    -
    - - -
    -
    - {% if ldap.bind_dn %} -
    - - -
    - {% endif %} - {% else %} -

    - {% if current_user.has_permission('settings_ldap.edit') %} - Nur mit Lizenz verfügbar — siehe Lizenz. - {% else %} - Für Änderungen fehlt das Recht „LDAP/AD-Konfiguration speichern“. - {% endif %} -

    - {% endif %} -
    - -
    -
    -
    -

    AD-Gruppenzuordnungen {{ hi.hint_icon("Ist ein AD-Benutzer (rekursiv, auch über verschachtelte Gruppen) Mitglied einer hier zugeordneten AD-Gruppe, erhält er beim Login zusätzlich die zugeordnete App-Rechtegruppe — additiv, mehrere Zuordnungen können gleichzeitig greifen. Wird keine Zuordnung getroffen, gilt die Standardgruppe oben.", "AD-Gruppenzuordnungen") }}

    -
    - {% if can_edit %} - - {% endif %} -
    - - {% if mappings %} -
    -
    - - - - {% for m in mappings %} - - - - - - {% endfor %} - -
    AD-GruppeApp-RechtegruppeAktionen
    {{ m.ad_group_name }}
    {{ m.ad_group_dn }}
    {{ m.app_group_name or '—' }} - {% if can_edit %} -
    - -
    - - -
    -
    - {% endif %} -
    -
    -
    - {% else %} -

    Noch keine Zuordnung angelegt — alle neuen AD-Benutzer erhalten nur die Standardgruppe.

    - {% endif %} -
    - -
    -
    -
    -

    Fileshare-Gruppenzuordnung {{ hi.hint_icon("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.", "Fileshare-Gruppenzuordnung") }}

    -
    - {% if can_edit %} - - {% endif %} -
    - - {% if fileshare_mappings %} -
    -
    - - - - {% for m in fileshare_mappings %} - - - - - - - {% endfor %} - -
    AD-GruppeFreigabePfadAktionen
    {{ m.ad_group_name }}
    {{ m.ad_group_dn }}
    {{ m.share_label }}{{ m.share_unc }} - {% if can_edit %} -
    - -
    - - -
    -
    - {% endif %} -
    -
    -
    - {% else %} -

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

    - {% endif %} -
    - -
    - -{% if can_edit %} - - - - - - - - - -{% endif %} -{% endblock %} diff --git a/srv/tesm/templates/settings_license.html b/srv/tesm/templates/settings_license.html deleted file mode 100644 index 76dae04..0000000 --- a/srv/tesm/templates/settings_license.html +++ /dev/null @@ -1,184 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "settings_license" %} -{% set can_edit = current_user.has_permission('settings_license.edit') %} -{% block page_title %}Lizenz{% endblock %} -{% block page_sub %}
    Lizenzstatus, Module und Aktivierung dieses Systems
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} -
    - -
    -
    -
    -

    Status

    -
    Aktuell installierte Lizenz und ihr Zustand.
    -
    -
    - - {% if license_error %} -
    {{ license_error }}
    - {% endif %} - - {% if not license_file %} -

    Keine Lizenzdatei vorhanden — bitte unten eine Lizenzdatei hochladen.

    - {% else %} -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    - {% if license_last_error %} -
    - - -
    - {% endif %} -
    - - -
    - {% endif %} -
    - - {% if license_file and license_valid %} -
    -
    -
    -

    Aktivierung

    -
    - Ein Protokoll, zwei Wege: automatisch online, oder — falls kein Netzwerkzugriff auf den - Lizenzserver besteht — per Code manuell mit dem Lizenzserver-Administrator ausgetauscht. -
    -
    -
    - - {% if not can_edit %} -

    Für Aktivierung/Deaktivierung fehlt das Recht „Systemeinstellungen ändern“.

    - - {% elif pending_action %} -

    - Offene {{ 'Aktivierungs' if pending_action.action == 'activate' else 'Deaktivierungs' }}-Anfrage — folgenden Code - beim Lizenzserver-Administrator eingeben: -

    -
    - -
    -
    -
    - - -
    - -
    -
    - -
    - - {% elif license_is_activated %} -

    - Lizenz ist aktiviert. Bei einem Systemwechsel zuerst hier deaktivieren — danach kann der Kunde sich beim - Anbieter selbst eine neue Lizenz für das neue System ausstellen lassen. -

    -
    - -
    - - {% else %} -
    - -
    - {% endif %} -
    - {% endif %} - -
    -
    -
    -

    Lizenzdatei hochladen

    -
    Vom Anbieter erhaltene Lizenzdatei einspielen — muss danach noch aktiviert werden.
    -
    -
    - {% if can_edit %} -
    -
    - - -
    - -
    - {% else %} -

    Für den Upload fehlt das Recht „Systemeinstellungen ändern“.

    - {% endif %} -
    - - {% if license_file and license_file.vendor %} -
    -
    -
    -

    Anbieter

    -
    Kontakt für Rückfragen zu dieser Lizenz.
    -
    -
    - {% if license_file.vendor.logo_base64 %} -
    - {{ license_file.vendor.name or 'Anbieter' }} -
    - {% endif %} -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - {% endif %} - -
    -{% endblock %} diff --git a/srv/tesm/templates/settings_nginx.html b/srv/tesm/templates/settings_nginx.html deleted file mode 100644 index f833ae4..0000000 --- a/srv/tesm/templates/settings_nginx.html +++ /dev/null @@ -1,212 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "settings_nginx" %} -{% set can_edit = current_user.has_permission('settings_nginx.edit') and license_active() %} -{% block page_title %}NGINX{% endblock %} -{% block page_sub %}
    Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} -
    - -
    -
    -
    -

    Reverse-Proxy

    -
    Domain (server_name), Ports und HTTPS/HSTS für den nginx-Reverse-Proxy vor der App.
    -
    - -
    - - {% if pending_nginx_token %} -
    -

    - Neue nginx-Konfiguration wurde angewendet. Wenn diese Seite gerade noch lädt, funktioniert die Verbindung — - bitte bestätigen, bevor automatisch zurückgerollt wird (nach {{ nginx_revert_seconds }}s ohne Bestätigung). -

    -
    - - -
    -
    - {% elif can_edit %} -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    -
    - - {% if not cert_info %}
    Erst nach Hochladen oder Anfordern eines Zertifikats verfügbar.
    {% endif %} -
    -
    - -
    - -
    - {% else %} -

    - {% if current_user.has_permission('settings_nginx.edit') %} - Nur mit Lizenz verfügbar — siehe Lizenz. - {% else %} - Für Änderungen fehlt das Recht „NGINX ändern“. - {% endif %} -

    - {% endif %} -
    - -
    -
    -
    -

    Zertifikat

    -
    Hochladen (PEM) oder automatisch per Let's Encrypt anfordern — mit automatischer Verlängerung über certbots eigenen Timer.
    -
    -
    - - {% if cert_info %} -
    -
    Quelle{{ "Let's Encrypt" if cert_info.source == "letsencrypt" else "Hochgeladen" }}
    -
    Subject{{ cert_info.subject }}
    -
    Aussteller{{ cert_info.issuer }}
    -
    - Gültig bis - - {{ cert_info.not_after.strftime('%d.%m.%Y') }} - {% if cert_info.days_left < 0 %} - abgelaufen - {% elif cert_info.days_left <= 30 %} - noch {{ cert_info.days_left }} Tage - {% else %} - noch {{ cert_info.days_left }} Tage - {% endif %} - -
    - {% if cert_info.source == "letsencrypt" %} -
    - Automatische Verlängerung - - {% if certbot_timer and certbot_timer.active %} - aktiv - {% if certbot_timer.next_run %}nächster Lauf: {{ certbot_timer.next_run }}{% endif %} - {% elif certbot_timer %} - inaktiv - {% else %} - unbekannt - {% endif %} - -
    - {% endif %} -
    - {% if cert_info.source == "letsencrypt" and can_edit %} -
    -
    - -
    -
    - -
    -
    - {% endif %} - {% else %} -

    Noch kein Zertifikat hinterlegt.

    - {% endif %} - - {% if can_edit %} -
    - -
    Setzt voraus, dass die Domain öffentlich auf diesen Host auflöst und Port 80 aus dem Internet erreichbar ist (HTTP-01-Validierung) — für rein interne Instanzen ohne öffentliche Domain nicht nutzbar, dann stattdessen hochladen. Verlängerung läuft danach automatisch über certbots eigenen Timer{% if certbot_timer %} ({{ "aktiv" if certbot_timer.active else "inaktiv!" }}){% endif %}, zweimal täglich.
    -
    -
    -
    - - -
    -
    - - -
    - -
    - -
    -
    -
    - - -
    -
    - - -
    - -
    - - {% if cert_info and not ssl_enabled %} -
    - -
    - {% elif cert_info %} -

    SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.

    - {% endif %} - {% else %} -

    - {% if current_user.has_permission('settings_nginx.edit') %} - Nur mit Lizenz verfügbar — siehe Lizenz. - {% else %} - Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“. - {% endif %} -

    - {% endif %} -
    - -
    -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm/templates/switches.html b/srv/tesm/templates/switches.html deleted file mode 100644 index ba7bcad..0000000 --- a/srv/tesm/templates/switches.html +++ /dev/null @@ -1,411 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "switches" %} -{% block extra_head %} - -{% endblock %} -{% set can_create = current_user.has_permission('switches.create') %} -{% set can_edit = current_user.has_permission('switches.edit') %} -{% set can_delete = current_user.has_permission('switches.edit') %} -{% block page_title %}Switche{% endblock %} -{% block page_sub %}
    {{ switches|length }} Switche
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} - -
    -
    -

    Switche

    -
    Aruba-Switche mit ihren Zugangsdaten für PoE-Neustart und Verbindungstest.
    -
    - {% if can_create %} - - {% endif %} -
    - -
    -
    -
    - - -
    -
    -
    - - - - - - - - - - - - {% for s in switches %} - - - - - - - - {% else %} - - {% endfor %} - -
    HostnameIP-AdresseSSH-PortZugangsdatenAktionen
    {{ s['hostname'] }}{{ s['ip'] }} - {{ s['ssh_port'] or 22 }} - {% if not s['ssh_port'] %}(Standard){% endif %} - - {% if s['credential_name'] %} - {{ s['credential_name'] }} ({{ s['credential_username'] }}) - {% else %} - — keine — - {% endif %} - -
    - {% if can_edit %} - - {% endif %} - {% if can_delete %} -
    - -
    - {% endif %} -
    -
    Noch keine Switche vorhanden.
    -
    -
    - -{% if can_edit %} -{% for s in switches %} - -{% endfor %} -{% endif %} - -{% if can_create %} - -{% endif %} - -{% if can_create or can_edit %} - -{% endif %} - -{% endblock %} - -{% block scripts %} - - - -{% endblock %} diff --git a/srv/tesm/templates/users.html b/srv/tesm/templates/users.html deleted file mode 100644 index bca4617..0000000 --- a/srv/tesm/templates/users.html +++ /dev/null @@ -1,315 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "users" %} -{% block page_title %}Benutzer{% endblock %} -{% block page_sub %}
    {{ users|length }} Benutzer
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} - -
    -
    -

    Benutzer

    -
    Die Gruppe bestimmt die Rechte eines Benutzers.
    -
    - {% if current_user.has_permission('users.create') %} -
    - {% if ldap_enabled %} - - {% endif %} - -
    - {% endif %} -
    - -
    -
    -
    - - -
    -
    -
    - - - - - - - - - - {% for u in users %} - {% set group_label = 'Admin' if u['is_admin'] else (u['group_names'] or '') %} - {% set is_ldap = u['auth_source'] == 'ldap' %} - - - - - - {% set may_touch_target = current_user.is_admin or not u['is_admin'] %} - - - {% else %} - - {% endfor %} - -
    UsernameVornameNachnameGruppeAktionen
    - {{ u['username'] }} - {% if is_ldap %}AD{% endif %} - {% if u['is_locked'] %}Gesperrt{% endif %} - {% if u['email'] %}
    {{ u['email'] }}
    {% endif %} -
    {{ u['first_name'] or '—' }}{{ u['last_name'] or '—' }} - {% if u['is_admin'] %} - Admin - {% else %} - {{ u['group_names'] or '—' }} - {% endif %} - -
    - {% if current_user.has_permission('users.edit') and may_touch_target and not is_ldap %} - - {% endif %} - {% if current_user.has_permission('users.edit') and may_touch_target %} - - {% endif %} - {% if current_user.has_permission('users.edit') and may_touch_target and u['id'] != current_user.id %} -
    - - -
    - {% endif %} - {% if current_user.has_permission('users.edit') and may_touch_target %} -
    - - -
    - {% endif %} -
    -
    Noch keine Benutzer vorhanden.
    -
    -
    - - - - - -{% if ldap_enabled %} - -{% endif %} - - - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e4ec9ec --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,211 @@ +"""Gemeinsame Testfixtures. + +Jeder Test bekommt eine frische Instanz in einem temporaeren Verzeichnis -- +eigene Datenbank, eigene Schluessel, eigenes Protokollverzeichnis. Nichts +beruehrt eine echte Installation. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Iterator + +import pytest + + +@pytest.fixture() +def instance_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + base = tmp_path / "instance" + monkeypatch.setenv("TESM_BASE_DIR", str(base)) + monkeypatch.setenv("TESM_LICENSE_BASE_DIR", str(tmp_path / "instance-license")) + # Tests laufen ueber http:// -- ein Secure-Cookie wuerde nie gesetzt. + monkeypatch.setenv("TESM_COOKIE_SECURE", "0") + monkeypatch.setenv("TESM_LICENSE_COOKIE_SECURE", "0") + monkeypatch.setenv("TESM_DEBUG", "0") + monkeypatch.setenv("TESM_LICENSE_DEBUG", "0") + return base + + +@pytest.fixture() +def core_app(instance_env: Path) -> Iterator[Any]: + """Minimale App auf Basis von ``tesm_core`` -- ohne Fachlogik der beiden Apps.""" + from flask import render_template_string + + from tesm_core import load_core_config + from tesm_core.app_factory import create_base_app + from tesm_core.auth.models import login_required + from tesm_core.nav import NavItem, NavTree + from tesm_core.rbac.common import administration_area, observability_area + from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset + + demo = Area( + key="demo", + label="Demo", + icon="grid", + resources=( + Resource( + key="widgets", + label="Widgets", + actions=(Action.VIEW, Action.CREATE, Action.EDIT, Action.DELETE, Action.SECRETS), + ), + ), + ) + tree = PermissionTree([demo, administration_area(), observability_area()]) + nav = NavTree( + [ + NavItem(key="index", label="Start", icon="grid", endpoint="index"), + NavItem( + key="widgets", label="Widgets", icon="cpu", endpoint="widgets", + permissions=("widgets.view",), + ), + NavItem( + key="administration", + label="Verwaltung", + icon="sliders", + children=( + NavItem( + key="users", label="Benutzer", icon="users", endpoint="admin.users", + permissions=("users.view",), + ), + NavItem( + key="groups", label="Gruppen", icon="shield-users", endpoint="admin.groups", + permissions=("groups.view",), + ), + NavItem( + key="trash", label="Papierkorb", icon="trash", endpoint="trash.overview", + permissions=("trash.view",), + ), + NavItem( + key="diagnostics", label="Diagnose", icon="info", + endpoint="diagnostics.overview", permissions=("diagnostics.view",), + ), + ), + ), + ] + ) + presets = ( + RolePreset( + key="viewer", + name="Benutzer", + description="Nur lesen", + grants=(("widgets", (Action.VIEW,)),), + is_default=True, + ), + ) + + config = load_core_config( + app_key="tesm", app_name="TESM Test", env_prefix="TESM_", default_base=instance_env + ) + app = create_base_app( + config=config, + permissions=tree, + nav=nav, + role_presets=presets, + app_short="TT", + app_edition="Test", + enable_license=False, + ) + + @app.get("/") + def index(): # type: ignore[no-untyped-def] + return render_template_string( + '{% extends "tesm_core/base.html" %}{% block page_title %}Start{% endblock %}' + "{% block content %}

    Start

    {% endblock %}" + ) + + @app.get("/widgets") + @login_required + def widgets(): # type: ignore[no-untyped-def] + return render_template_string( + '{% extends "tesm_core/base.html" %}{% block page_title %}Widgets{% endblock %}' + "{% block content %}

    Widgets

    {% endblock %}" + ) + + app.config.update(TESTING=True) + yield app + + +@pytest.fixture() +def client(core_app: Any) -> Any: + return core_app.test_client() + + +@pytest.fixture() +def make_user(core_app: Any): + """Erzeugt ein Konto direkt in der Datenbank.""" + + def factory( + username: str = "tester", + password: str = "Sicher-Passwort-2026", + *, + is_admin: bool = False, + permissions: tuple[str, ...] = (), + is_locked: bool = False, + ) -> dict[str, Any]: + from tesm_core.db import Database + from tesm_core.extension import core + from tesm_core.security import passwords + + extension = core(core_app) + with extension.database.session() as conn, extension.database.transaction(conn): + cursor = conn.execute( + "INSERT INTO users (username, password_hash, is_admin, is_locked, auth_source, " + "created_at, updated_at) VALUES (?,?,?,?,'local',datetime('now'),datetime('now'))", + (username, passwords.hash_password(password), int(is_admin), int(is_locked)), + ) + user_id = int(cursor.lastrowid or 0) + if permissions: + group = conn.execute( + "INSERT INTO groups (name, description, created_at, updated_at) " + "VALUES (?,'Testgruppe',datetime('now'),datetime('now'))", + (f"grp-{username}",), + ) + group_id = int(group.lastrowid or 0) + conn.executemany( + "INSERT INTO group_permissions (group_id, permission) VALUES (?,?)", + [(group_id, p) for p in extension.permissions.sanitize(permissions)], + ) + conn.execute( + "INSERT INTO user_groups (user_id, group_id) VALUES (?,?)", (user_id, group_id) + ) + row = Database.one(conn, "SELECT * FROM users WHERE id=?", (user_id,)) + assert row is not None + return {**row, "password": password} + + return factory + + +@pytest.fixture() +def login(client: Any): + def do_login(username: str, password: str) -> Any: + return client.post( + "/login", + data={"username": username, "password": password, "csrf_token": _token(client)}, + follow_redirects=True, + ) + + return do_login + + +def _token(client: Any) -> str: + """Holt ein CSRF-Token aus der Anmeldeseite.""" + page = client.get("/login") + body = page.get_data(as_text=True) + marker = 'name="csrf_token" value="' + start = body.index(marker) + len(marker) + return body[start : body.index('"', start)] + + +@pytest.fixture() +def csrf_token(client: Any): + def get(path: str = "/login") -> str: + page = client.get(path) + body = page.get_data(as_text=True) + marker = 'name="csrf_token" value="' + if marker not in body: + marker = 'data-csrf="' + start = body.index(marker) + len(marker) + return body[start : body.index('"', start)] + + return get diff --git a/tests/e2e_smoke.py b/tests/e2e_smoke.py new file mode 100644 index 0000000..66e3d3c --- /dev/null +++ b/tests/e2e_smoke.py @@ -0,0 +1,499 @@ +"""End-to-End-Rauchtest gegen eine laufende Installation. + +Laeuft auf dem Zielhost, spricht ueber HTTP mit nginx und der echten Anwendung +-- kein Testclient, keine Attrappen. Prueft genau das, was ein Mensch beim +ersten Durchklicken pruefen wuerde, plus die Dinge, die man beim Klicken leicht +uebersieht (Sicherheitsheader, CSRF, statische Dateien, Rechteschranken). + +Aufruf: + python3 e2e_smoke.py http://127.0.0.1:8080 admin 'Passwort' +""" + +from __future__ import annotations + +import http.cookiejar +import json +import re +import secrets +import ssl +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + +CSRF_RE = re.compile(r'name="csrf_token" value="([^"]+)"') +BODY_CSRF_RE = re.compile(r'data-csrf="([^"]+)"') + + +class Client: + def __init__(self, base: str) -> None: + self.base = base.rstrip("/") + self.https = self.base.startswith("https://") + self.jar = http.cookiejar.CookieJar() + + # Der Test laeuft auf dem Zielhost selbst gegen ein Zertifikat, das + # dieselbe Maschine ausgestellt hat. Es zu pruefen hiesse, das eigene + # Zertifikat gegen sich selbst zu pruefen -- geprueft wird hier, ob TLS + # steht und die Anwendung darueber antwortet. + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + self.tls = urllib.request.HTTPSHandler(context=context) + + self.opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(self.jar), + urllib.request.HTTPRedirectHandler(), + self.tls, + ) + self.opener.addheaders = [("User-Agent", "tesm-e2e/2.0")] + + class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): # type: ignore[override] + return None + + # Zweiter Opener ohne Weiterleitung: nur so laesst sich pruefen, ob eine + # Seite tatsaechlich mit 302 auf die Anmeldung verweist. + self.plain = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(self.jar), _NoRedirect(), self.tls + ) + self.plain.addheaders = [("User-Agent", "tesm-e2e/2.0")] + + def get(self, path: str) -> tuple[int, str, dict[str, str]]: + request = urllib.request.Request(self.base + path) + try: + with self.opener.open(request, timeout=20) as response: + return response.status, response.read().decode("utf-8", "replace"), dict( + response.headers + ) + except urllib.error.HTTPError as exc: + return exc.code, exc.read().decode("utf-8", "replace"), dict(exc.headers) + + def get_raw(self, path: str) -> int: + """Statuscode ohne Weiterleitung zu folgen.""" + request = urllib.request.Request(self.base + path) + try: + with self.plain.open(request, timeout=20) as response: + return response.status + except urllib.error.HTTPError as exc: + return exc.code + + def post(self, path: str, data: dict[str, object]) -> tuple[int, str]: + encoded = urllib.parse.urlencode( + [ + (key, str(item)) + for key, value in data.items() + for item in (value if isinstance(value, (list, tuple)) else [value]) + ] + ).encode("utf-8") + request = urllib.request.Request( + self.base + path, + data=encoded, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Origin": self.base, + "Referer": self.base + path, + }, + ) + try: + with self.opener.open(request, timeout=30) as response: + return response.status, response.read().decode("utf-8", "replace") + except urllib.error.HTTPError as exc: + return exc.code, exc.read().decode("utf-8", "replace") + + def token(self, path: str) -> str: + _, body, _ = self.get(path) + match = CSRF_RE.search(body) or BODY_CSRF_RE.search(body) + if not match: + raise AssertionError(f"Kein CSRF-Token auf {path}") + return match.group(1) + + +PASSED: list[str] = [] +FAILED: list[str] = [] + +#: Jeder Lauf legt eigene Datensaetze an. Sonst laesst sich der Test kein +#: zweites Mal gegen dieselbe Installation ausfuehren -- beim ersten Lauf +#: landet ein Geraet im Papierkorb, und die Anwendung weigert sich danach +#: voellig zu Recht, dieselbe MAC noch einmal zu vergeben. +RUN = secrets.token_hex(3) +MAC = ":".join(("AA", "BB", RUN[0:2], RUN[2:4], RUN[4:6], "01")).upper() +SUBNET = f"10.99.{int(RUN[0:2], 16)}" + +FLASH_RE = re.compile(r'id="flashed-messages">(.*?)', re.S) +ROW_SPLIT = re.compile(r"]|]") + + +def flash(body: str) -> str: + """Die Meldungen der Seite -- damit ein Fehlschlag erklaert, was schiefging.""" + match = FLASH_RE.search(body) + if not match: + return "keine Meldung" + try: + return " | ".join(text for _level, text in json.loads(match.group(1))) + except (ValueError, TypeError): + return match.group(1)[:120] + + +def id_for(body: str, needle: str, pattern: str) -> str: + """Sucht die Kennung in der Zeile, in der ``needle`` steht. + + Die Listenansicht nennt die Kennung nur in Formular- und Link-Zielen. Der + Test darf sie nicht raten -- eine feste ``1`` funktioniert genau einmal und + trifft beim naechsten Lauf einen fremden Datensatz. + """ + for chunk in ROW_SPLIT.split(body): + if needle in chunk: + match = re.search(pattern, chunk) + if match: + return match.group(1) + return "" + + +def check(name: str, condition: bool, detail: str = "") -> None: + if condition: + PASSED.append(name) + print(f" [ok] {name}") + else: + FAILED.append(f"{name}: {detail}") + print(f" [FEHL] {name} -- {detail}") + + +def local_host_fingerprints() -> set[str]: + """Die Fingerprints, die ``ssh-keyscan`` fuer diesen Host meldet. + + Der Vergleich ist der Kern der Pruefung: die Anwendung darf nicht + irgendeinen plausiblen Wert anzeigen, sondern genau den des Gegenuebers. + """ + try: + scan = subprocess.run( + ["ssh-keyscan", "-T", "5", "-p", "22", "127.0.0.1"], + capture_output=True, + text=True, + timeout=20, + ) + fingerprints: set[str] = set() + for line in scan.stdout.splitlines(): + if not line.strip() or line.startswith("#"): + continue + result = subprocess.run( + ["ssh-keygen", "-lf", "-"], input=line, capture_output=True, text=True, timeout=10 + ) + match = re.search(r"(SHA256:[A-Za-z0-9+/]{43})", result.stdout) + if match: + fingerprints.add(match.group(1)) + return fingerprints + except (OSError, subprocess.SubprocessError): + return set() + + +def main(base: str, username: str, password: str) -> int: + client = Client(base) + print(f"\n== Rauchtest gegen {base} (Lauf {RUN}) ==\n") + + # -- oeffentlich --------------------------------------------------------- + print("Oeffentlich erreichbar:") + status, body, headers = client.get("/gesundheit") + check("Gesundheitsendpunkt", status == 200 and json.loads(body)["status"] == "ok", body[:80]) + + status, body, headers = client.get("/login") + check("Anmeldeseite", status == 200 and "Anmelden" in body, f"HTTP {status}") + if client.https: + cookie = headers.get("Set-Cookie", "") + check("Sitzungscookie mit Secure", "Secure" in cookie, cookie[:80] or "kein Cookie") + check("Sitzungscookie HttpOnly", "HttpOnly" in cookie, cookie[:80] or "kein Cookie") + check("Sitzungscookie SameSite", "SameSite" in cookie, cookie[:80] or "kein Cookie") + csp = headers.get("Content-Security-Policy", "") + check("CSP mit Nonce", "nonce-" in csp and "unsafe-inline" not in csp.split("style-src")[0], + csp[:90]) + check("X-Frame-Options", headers.get("X-Frame-Options") == "DENY", str(headers.get("X-Frame-Options"))) + check("Vorgangsnummer im Header", bool(headers.get("X-Request-Id")), "fehlt") + + status, body, _ = client.get("/") + check("Oeffentliche Statusuebersicht", status == 200 and "Statusuebersicht" in body, + f"HTTP {status}") + check("Ohne Anmeldung keine Details", "oeffentliche Ansicht" in body, "Hinweis fehlt") + + for asset in ("/core-assets/css/base.css", "/core-assets/js/app.js"): + status, body, _ = client.get(asset) + check(f"Statische Datei {asset}", status == 200 and len(body) > 1000, f"HTTP {status}") + + print("\nSchranken ohne Anmeldung:") + for path in ("/clients/", "/verwaltung/benutzer", "/diagnose/"): + status = client.get_raw(path) + check(f"{path} verlangt Anmeldung", status in (302, 401), f"HTTP {status}") + + print("\nCSRF:") + status, body = client.post("/login", {"username": "x", "password": "y"}) + check("POST ohne Token wird abgewiesen", status == 403, f"HTTP {status}") + + # -- Anmeldung ----------------------------------------------------------- + print("\nAnmeldung:") + token = client.token("/login") + status, body = client.post( + "/login", {"username": username, "password": password, "csrf_token": token} + ) + check("Anmeldung erfolgreich", "Willkommen" in body or status == 200, f"HTTP {status}") + + status, body, _ = client.get("/konto/") + check("Kontoseite erreichbar", status == 200 and username in body, f"HTTP {status}") + + # -- Seiten -------------------------------------------------------------- + print("\nSeiten (angemeldet):") + pages = [ + ("/", "Statusuebersicht"), + ("/clients/", "Clients"), + ("/switche/", "Switche"), + ("/zugangsdaten/", "Zugangsdaten"), + ("/protokolle/laufend", "Protokoll"), + ("/protokolle/aenderungen", "Aenderungsprotokoll"), + ("/protokolle/neustarts", "Neustarts"), + ("/einstellungen/system", "Systemeinstellungen"), + ("/einstellungen/netzwerk", "Netzwerk"), + ("/einstellungen/webserver", "Webserver"), + ("/einstellungen/verzeichnisdienst", "Verzeichnisdienst"), + ("/lizenz/", "Lizenz"), + ("/sicherung/", "Sicherung"), + ("/papierkorb/", "Papierkorb"), + ("/diagnose/", "Diagnose"), + ("/verwaltung/benutzer", "Benutzer"), + ("/verwaltung/gruppen", "Gruppen"), + ("/verwaltung/sitzungen", "Sitzungen"), + ("/konto/sicherheit", "Sicherheit"), + ] + for path, needle in pages: + status, body, _ = client.get(path) + ok = status == 200 and needle in body and "Interner Fehler" not in body + check(f"{path}", ok, f"HTTP {status}") + + # Die Modulrouten sind an die Lizenz gebunden. Welcher Zustand gerade gilt, + # darf der Test nicht vorschreiben -- er darf nur verlangen, dass alle drei + # dasselbe sagen. Ein Modul, das ohne Lizenz durchkommt, waere ein Loch; + # ein Modul, das mit Lizenz 404 liefert, waere ein Ausfall. + print("\nLizenzpflichtige Module:") + module_status = { + path: client.get(path)[0] for path in ("/dhcp/", "/wartung/", "/dateifreigaben/") + } + licensed = set(module_status.values()) == {200} + unlicensed = set(module_status.values()) == {404} + check( + "Alle Modulrouten im selben Zustand", + licensed or unlicensed, + ", ".join(f"{path} {code}" for path, code in module_status.items()), + ) + print(f" [info] Lizenz ist {'aktiv' if licensed else 'nicht aktiv'}.") + + # -- Daten anlegen ------------------------------------------------------- + print("\nDaten anlegen:") + credential_name = f"E2E-Switchlogin-{RUN}" + switch_name = f"e2e-switch-{RUN}" + device_name = f"E2E-Kamera-{RUN}" + secret = f"Geheim-fuer-den-Test-{RUN}" + + token = client.token("/zugangsdaten/") + status, body = client.post( + "/zugangsdaten/neu", + { + "csrf_token": token, + "name": credential_name, + "username": "operator", + "secret": secret, + "category": "switch", + }, + ) + check("Zugangsdaten angelegt", "angelegt" in body, flash(body)) + credential_id = id_for(body, credential_name, r"/zugangsdaten/(\d+)/") + check("Kennung der Zugangsdaten gefunden", bool(credential_id), "nicht gefunden") + + token = client.token("/switche/") + status, body = client.post( + "/switche/neu", + { + "csrf_token": token, + "hostname": switch_name, + "ip": f"{SUBNET}.2", + "ssh_port": "22", + "credential_id": credential_id, + }, + ) + check("Switch angelegt", "angelegt" in body, flash(body)) + switch_id = id_for(body, switch_name, r"/switche/(\d+)") + check("Kennung des Switches gefunden", bool(switch_id), "nicht gefunden") + + token = client.token("/clients/") + status, body = client.post( + "/clients/neu", + { + "csrf_token": token, + "name": device_name, + "mac": MAC, + "ip": f"{SUBNET}.50", + "switch_id": switch_id, + "port": "7", + "ssh_port": "22", + "is_active": "1", + "auto_restart": "1", + }, + ) + check("Geraet angelegt", "angelegt" in body, flash(body)) + device_id = id_for(body, device_name, r"/clients/(\d+)") + check("Kennung des Geraets gefunden", bool(device_id), "nicht gefunden") + + status, body, _ = client.get("/clients/") + check( + "Geraet in der Liste", + device_name in body and MAC.lower() in body, + "nicht gefunden", + ) + + status, body, _ = client.get("/") + check("Geraet auf der Uebersicht", device_name in body, "nicht gefunden") + + print("\nValidierung:") + token = client.token("/clients/") + status, body = client.post( + "/clients/neu", + {"csrf_token": token, "name": "Kaputt", "mac": "unsinn", "ip": f"{SUBNET}.51"}, + ) + check("Ungueltige MAC abgelehnt", "hexadezimale" in body, flash(body)) + + token = client.token("/clients/") + status, body = client.post( + "/clients/neu", + {"csrf_token": token, "name": "Kaputt", "mac": MAC[:-2] + "FE", "ip": "999.1.1.1"}, + ) + check("Ungueltige IP abgelehnt", "gueltige IP-Adresse" in body, flash(body)) + + token = client.token("/clients/") + status, body = client.post( + "/clients/neu", + {"csrf_token": token, "name": "Doppelt", "mac": MAC, "ip": f"{SUBNET}.52"}, + ) + check("Doppelte MAC abgelehnt", "angelegt" not in body, "zweimal dieselbe MAC") + + # -- Host-Schluessel eines Clients --------------------------------------- + # Gegen einen echten SSH-Dienst: der Host selbst. Nur so ist geprueft, dass + # der gelesene Fingerprint tatsaechlich der des Gegenuebers ist -- eine + # Attrappe wuerde genau den Fehler durchlassen, auf den es hier ankommt. + print("\nSSH-Host-Schluessel eines Clients:") + token = client.token("/clients/") + ssh_name = f"E2E-SSH-{RUN}" + status, body = client.post( + "/clients/neu", + { + "csrf_token": token, + "name": ssh_name, + "mac": MAC[:-2] + "0F", + "ip": "127.0.0.1", + "ssh_port": "22", + "credential_id": credential_id, + "is_active": "0", + }, + ) + check("Client fuer den SSH-Test angelegt", "angelegt" in body, flash(body)) + ssh_id = id_for(body, ssh_name, r"/clients/(\d+)") + check("Kennung gefunden", bool(ssh_id), "nicht gefunden") + + if ssh_id: + status, body, _ = client.get(f"/clients/{ssh_id}") + check("Detailseite zeigt die Schluesselkarte", "SSH-Host-Schluessel" in body, "fehlt") + check("Zustand offen", "Noch kein Schluessel hinterlegt" in body, "unerwartet") + + token = client.token(f"/clients/{ssh_id}") + status, body = client.post( + f"/clients/{ssh_id}/hostkey/pruefen", {"csrf_token": token} + ) + check("Schluessel ausgelesen", "Host-Schluessel gelesen" in body, flash(body)) + + match = re.search(r"(SHA256:[A-Za-z0-9+/]{43})", body) + fingerprint = match.group(1) if match else "" + check("Fingerprint erhalten", bool(fingerprint), "nicht gefunden") + + expected = local_host_fingerprints() + check( + "Fingerprint stimmt mit ssh-keyscan ueberein", + bool(expected) and fingerprint in expected, + f"gelesen {fingerprint}, erwartet eines von {sorted(expected)}", + ) + + key_match = re.search(r'name="key_type" value="([^"]+)"', body) + key_type = key_match.group(1) if key_match else "" + token = client.token(f"/clients/{ssh_id}") + status, body = client.post( + f"/clients/{ssh_id}/hostkey/freigeben", + {"csrf_token": token, "key_type": key_type, "fingerprint": fingerprint}, + ) + check("Schluessel freigegeben", "freigegeben" in body, flash(body)) + check("Fingerprint steht auf der Seite", fingerprint in body, "nicht gefunden") + + token = client.token(f"/clients/{ssh_id}") + status, body = client.post( + f"/clients/{ssh_id}/hostkey/freigeben", + {"csrf_token": token, "key_type": key_type, "fingerprint": "SHA256:zu-kurz"}, + ) + check("Unsinniger Fingerprint abgelehnt", "kein gueltiger Fingerprint" in body, flash(body)) + + status, body, _ = client.get("/protokolle/aenderungen") + check("Freigabe protokolliert", "device.host_key_trusted" in body, "kein Eintrag") + + token = client.token(f"/clients/{ssh_id}") + status, body = client.post( + f"/clients/{ssh_id}/hostkey/verwerfen", {"csrf_token": token} + ) + check("Schluessel verworfen", "entfernt" in body, flash(body)) + + print("\nNeustart-Protokoll:") + status, body, _ = client.get("/protokolle/neustarts") + check("Seite heisst Neustarts", status == 200 and "Neustarts" in body, f"HTTP {status}") + check("Alte PoE-Adresse ist weg", client.get_raw("/protokolle/poe") == 404, "noch da") + check("Filter nach Methode vorhanden", "methode=ssh" in body, "kein Filter") + + print("\nGeheimnisse:") + token = client.token("/zugangsdaten/") + status, body = client.post( + f"/zugangsdaten/{credential_id}/anzeigen", {"csrf_token": token} + ) + check("Klartextanzeige moeglich (frisch angemeldet)", secret in body, f"HTTP {status}") + status, body, _ = client.get("/protokolle/aenderungen") + check("Zugriff protokolliert", "credential.secret_revealed" in body, "kein Eintrag") + + print("\nPapierkorb:") + token = client.token("/clients/") + client.post(f"/clients/{device_id}/loeschen", {"csrf_token": token}) + status, body, _ = client.get("/papierkorb/") + check("Geloeschtes Geraet im Papierkorb", device_name in body, "nicht gefunden") + token = client.token("/papierkorb/") + status, body = client.post( + f"/papierkorb/devices/{device_id}/wiederherstellen", {"csrf_token": token} + ) + if licensed: + check("Wiederherstellen mit Lizenz moeglich", "wiederhergestellt" in body, flash(body)) + else: + check("Wiederherstellen ohne Lizenz gesperrt", "Lizenz" in body, flash(body)) + + print("\nDiagnose:") + status, body, _ = client.get("/diagnose/") + check("Audit-Kette unversehrt", "unversehrt" in body, "Kette gebrochen?") + # Nicht "liegt die Datei da", sondern "antwortet er". Genau der + # Unterschied hat einmal verdeckt, dass jede Systemaktion an + # NoNewPrivileges scheiterte, waehrend die Seite "erreichbar" meldete. + check("Systemhelfer antwortet", "Helfer antwortet" in body, "Helfer meldet sich nicht") + check("Schluesselrechte in Ordnung", "in Ordnung" in body, "Problem gemeldet") + + print("\nAbmeldung:") + token = client.token("/konto/") + status, body = client.post("/logout", {"csrf_token": token}) + status = client.get_raw("/clients/") + check("Nach Abmeldung gesperrt", status in (302, 401), f"HTTP {status}") + + print(f"\n== Ergebnis: {len(PASSED)} bestanden, {len(FAILED)} fehlgeschlagen ==") + for entry in FAILED: + print(f" FEHLER: {entry}") + return 1 if FAILED else 0 + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print(__doc__) + raise SystemExit(2) + raise SystemExit(main(sys.argv[1], sys.argv[2], sys.argv[3])) diff --git a/tests/test_core_security.py b/tests/test_core_security.py new file mode 100644 index 0000000..1b52c57 --- /dev/null +++ b/tests/test_core_security.py @@ -0,0 +1,721 @@ +"""Tests der Sicherheitsgrenzen des Kerns. + +Jeder Test hier deckt eine Grenze ab, die im Vorgaengerprojekt entweder fehlte +oder nur im Template durchgesetzt wurde. +""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime, timedelta, timezone + +import pytest + +from tesm_core import audit +from tesm_core.db import Database, NestedConnectionError +from tesm_core.extension import core +from tesm_core.keystore import DecryptionError, Keystore +from tesm_core.migrations import split_statements +from tesm_core.rbac import service as rbac +from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset +from tesm_core.security import passwords, totp + + +# --------------------------------------------------------------------------- # +# Rechtebaum +# --------------------------------------------------------------------------- # + + +def _tree() -> PermissionTree: + return PermissionTree( + [ + Area( + key="ops", + label="Betrieb", + icon="cpu", + resources=( + Resource( + "devices", + "Geraete", + (Action.VIEW, Action.CREATE, Action.EDIT, Action.DELETE, Action.EXECUTE), + ), + Resource("switches", "Switche", (Action.VIEW, Action.EDIT)), + ), + ), + Area( + key="admin", + label="Verwaltung", + icon="sliders", + resources=(Resource("users", "Benutzer", (Action.VIEW, Action.CREATE)),), + ), + ] + ) + + +def test_area_columns_only_contain_used_actions(): + tree = _tree() + ops = tree.area("ops") + admin = tree.area("admin") + assert Action.EXECUTE in ops.columns() + assert Action.EXECUTE not in admin.columns() + assert Action.DELETE not in admin.columns() + + +def test_sanitize_adds_view_and_area_switch(): + tree = _tree() + result = tree.sanitize(["devices.delete"]) + assert result == ["devices.delete", "devices.view", "ops.view"] + + +def test_sanitize_drops_unknown_permissions(): + tree = _tree() + assert tree.sanitize(["gibtsnicht.view", "devices.view"]) == ["devices.view", "ops.view"] + + +def test_kill_switch_disables_all_children(): + tree = _tree() + granted = {"devices.view", "devices.edit", "switches.view"} + assert tree.effective(granted) == set() # ohne ops.view greift nichts + assert tree.effective(granted | {"ops.view"}) == granted | {"ops.view"} + + +def test_duplicate_resource_keys_rejected(): + with pytest.raises(ValueError): + PermissionTree( + [ + Area("a", "A", "grid", (Resource("x", "X", (Action.VIEW,)),)), + Area("b", "B", "grid", (Resource("x", "X", (Action.VIEW,)),)), + ] + ) + + +def test_resource_without_view_rejected(): + with pytest.raises(ValueError): + PermissionTree([Area("a", "A", "grid", (Resource("x", "X", (Action.EDIT,)),))]) + + +def test_preset_expansion(): + tree = _tree() + preset = RolePreset( + key="ops-read", + name="Betrieb lesen", + description="", + grants=(("devices", (Action.VIEW,)), ("switches", "*")), + ) + assert tree.expand_preset(preset) == [ + "devices.view", + "ops.view", + "switches.edit", + "switches.view", + ] + + +# --------------------------------------------------------------------------- # +# Eskalationsschutz +# --------------------------------------------------------------------------- # + + +class _Actor: + def __init__(self, is_admin: bool, permissions: set[str]): + self.is_admin = is_admin + self.id = 42 + self._permissions = permissions + + def has(self, *permissions: str) -> bool: + return self.is_admin or all(p in self._permissions for p in permissions) + + +def test_no_amplification_blocks_unknown_permission(): + tree = _tree() + actor = _Actor(False, {"ops.view", "devices.view"}) + with pytest.raises(rbac.PermissionDenied) as exc: + rbac.guard_no_amplification(actor, tree, ["devices.delete"]) + assert "nicht besitzen" in str(exc.value) + + +def test_no_amplification_allows_own_permissions(): + tree = _tree() + actor = _Actor(False, {"ops.view", "devices.view"}) + rbac.guard_no_amplification(actor, tree, ["devices.view", "ops.view"]) + + +def test_admin_may_grant_anything(): + tree = _tree() + rbac.guard_no_amplification(_Actor(True, set()), tree, ["devices.delete"]) + + +def test_group_editor_cannot_escalate_via_new_group(core_app, make_user): + """Der zentrale Fall: ein Gruppenverwalter darf sich nicht selbst hoeherstufen.""" + from tesm_core.auth.service import load_user + + extension = core(core_app) + row = make_user("gruppenchef", "Ein-gutes-Passwort-1", permissions=("groups.create", "groups.view")) + with core_app.app_context(), extension.database.session() as conn: + actor = load_user(conn, int(row["id"])) + assert actor is not None + with extension.database.transaction(conn): + with pytest.raises(rbac.PermissionDenied): + rbac.create_group( + conn, + name="Hintertuer", + description="", + permissions=["users.create", "administration.view"], + tree=extension.permissions, + actor=actor, + ) + + +def test_last_admin_cannot_be_demoted(core_app, make_user): + from tesm_core.auth.service import load_user + + extension = core(core_app) + admin_row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True) + with core_app.app_context(), extension.database.session() as conn: + actor = load_user(conn, int(admin_row["id"])) + second = conn.execute( + "INSERT INTO users (username, password_hash, is_admin, auth_source, created_at, updated_at) " + "VALUES ('zweiter','x',1,'local',datetime('now'),datetime('now'))" + ) + second_id = int(second.lastrowid or 0) + with extension.database.transaction(conn): + # Solange zwei Admins existieren, ist das Herabstufen erlaubt. + rbac.update_user( + conn, second_id, actor=actor, tree=extension.permissions, is_admin=False + ) + with extension.database.transaction(conn): + with pytest.raises(rbac.PermissionDenied): + rbac.guard_last_admin(conn, int(admin_row["id"]), action="Herabstufen") + + +def test_cannot_change_own_admin_flag(core_app, make_user): + from tesm_core.auth.service import load_user + + extension = core(core_app) + row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True) + with core_app.app_context(), extension.database.session() as conn: + actor = load_user(conn, int(row["id"])) + with extension.database.transaction(conn): + with pytest.raises(rbac.PermissionDenied): + rbac.update_user( + conn, int(row["id"]), actor=actor, tree=extension.permissions, is_admin=False + ) + + +def test_non_admin_cannot_touch_admin_account(core_app, make_user): + from tesm_core.auth.service import load_user + + extension = core(core_app) + admin_row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True) + editor_row = make_user("helfer", "Ein-gutes-Passwort-1", permissions=("users.edit", "users.view")) + with core_app.app_context(), extension.database.session() as conn: + actor = load_user(conn, int(editor_row["id"])) + with extension.database.transaction(conn): + with pytest.raises(rbac.PermissionDenied): + rbac.update_user( + conn, + int(admin_row["id"]), + actor=actor, + tree=extension.permissions, + is_locked=True, + ) + + +def test_system_group_permissions_locked(core_app, make_user): + from tesm_core.auth.service import load_user + + extension = core(core_app) + row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True) + with core_app.app_context(), extension.database.session() as conn: + actor = load_user(conn, int(row["id"])) + default = next(g for g in rbac.list_groups(conn) if g.is_default) + with extension.database.transaction(conn): + with pytest.raises(rbac.PermissionDenied): + rbac.update_group( + conn, + default.id, + permissions=[], + tree=extension.permissions, + actor=actor, + ) + # Mit ausdruecklicher Freischaltung geht es. + rbac.update_group( + conn, + default.id, + permissions=[], + tree=extension.permissions, + actor=actor, + unlock_system=True, + ) + + +def test_presets_are_not_reset_on_restart(core_app): + """Anders als im Vorgaenger bleiben Anpassungen an Systemgruppen erhalten.""" + extension = core(core_app) + with extension.database.session() as conn: + default = next(g for g in rbac.list_groups(conn) if g.is_default) + with extension.database.transaction(conn): + conn.execute("DELETE FROM group_permissions WHERE group_id=?", (default.id,)) + created = rbac.ensure_presets(conn, extension.permissions, extension.role_presets) + assert created == [] + assert rbac.group_permissions(conn, default.id) == [] + + +# --------------------------------------------------------------------------- # +# Datenbank +# --------------------------------------------------------------------------- # + + +def test_second_connection_during_write_transaction_raises(tmp_path): + database = Database(tmp_path / "test.db") + with database.session() as conn: + conn.execute("CREATE TABLE t (a INTEGER)") + with database.transaction(conn): + conn.execute("INSERT INTO t VALUES (1)") + with pytest.raises(NestedConnectionError) as exc: + database.connect() + assert "Schreibtransaktion" in str(exc.value) + + +def test_nested_transactions_commit_once(tmp_path): + database = Database(tmp_path / "test.db") + with database.session() as conn: + conn.execute("CREATE TABLE t (a INTEGER)") + with database.transaction(conn): + conn.execute("INSERT INTO t VALUES (1)") + with database.transaction(conn): + conn.execute("INSERT INTO t VALUES (2)") + assert Database.value(conn, "SELECT COUNT(*) FROM t") == 2 + + +def test_inner_rollback_keeps_outer(tmp_path): + database = Database(tmp_path / "test.db") + with database.session() as conn: + conn.execute("CREATE TABLE t (a INTEGER)") + with database.transaction(conn): + conn.execute("INSERT INTO t VALUES (1)") + try: + with database.transaction(conn): + conn.execute("INSERT INTO t VALUES (2)") + raise RuntimeError("abbruch") + except RuntimeError: + pass + assert Database.value(conn, "SELECT COUNT(*) FROM t") == 1 + + +def test_foreign_keys_enforced(tmp_path): + database = Database(tmp_path / "test.db") + with database.session() as conn: + conn.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)") + conn.execute("CREATE TABLE child (p INTEGER REFERENCES parent(id))") + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO child VALUES (99)") + + +def test_wal_mode_active(tmp_path): + database = Database(tmp_path / "test.db") + with database.session() as conn: + assert Database.value(conn, "PRAGMA journal_mode").lower() == "wal" + + +def test_split_statements_handles_comments_and_strings(): + script = """ + -- ein Kommentar mit ; Semikolon + CREATE TABLE t (a TEXT DEFAULT 'x;y'); + INSERT INTO t VALUES ('a;b'); /* Block; Kommentar */ + """ + statements = split_statements(script) + assert len(statements) == 2 + assert "x;y" in statements[0] + assert "a;b" in statements[1] + + +# --------------------------------------------------------------------------- # +# Schluessel und Verschluesselung +# --------------------------------------------------------------------------- # + + +def test_keystore_roundtrip_and_aad_binding(tmp_path): + keystore = Keystore(tmp_path / "data.keys") + token = keystore.encrypt("geheim", aad="credentials.password:1") + assert keystore.decrypt(token, aad="credentials.password:1") == "geheim" + # Dasselbe Chiffrat an anderer Stelle eingespielt -> Fehler. + with pytest.raises(DecryptionError): + keystore.decrypt(token, aad="credentials.password:2") + + +def test_keystore_rotation_keeps_old_values_readable(tmp_path): + keystore = Keystore(tmp_path / "data.keys") + old_token = keystore.encrypt("alt", aad="x") + new_id = keystore.rotate() + assert new_id == "2" + assert keystore.decrypt(old_token, aad="x") == "alt" + assert keystore.needs_reencrypt(old_token) is True + new_token = keystore.encrypt("neu", aad="x") + assert keystore.needs_reencrypt(new_token) is False + + +def test_keystore_file_is_private(tmp_path): + import os + import stat + + path = tmp_path / "data.keys" + Keystore(path) + if os.name == "posix": + mode = path.stat().st_mode + assert not mode & (stat.S_IRGRP | stat.S_IROTH) + + +def test_tampered_ciphertext_rejected(tmp_path): + keystore = Keystore(tmp_path / "data.keys") + token = keystore.encrypt("geheim", aad="x") + prefix, key_id, nonce, ciphertext = token.split(".", 3) + broken = f"{prefix}.{key_id}.{nonce}.{ciphertext[:-4]}AAAA" + with pytest.raises(DecryptionError): + keystore.decrypt(broken, aad="x") + + +# --------------------------------------------------------------------------- # +# Passwoerter und TOTP +# --------------------------------------------------------------------------- # + + +def test_argon2_hash_and_verify(): + stored = passwords.hash_password("Ein-gutes-Passwort-1") + assert stored.startswith("$argon2id$") + assert passwords.verify_password(stored, "Ein-gutes-Passwort-1") is True + assert passwords.verify_password(stored, "falsch") is False + assert passwords.needs_rehash(stored) is False + + +def test_bcrypt_legacy_hash_accepted_and_flagged_for_rehash(): + bcrypt = pytest.importorskip("bcrypt") + legacy = bcrypt.hashpw(b"Altes-Passwort-1", bcrypt.gensalt(rounds=4)).decode() + assert passwords.is_legacy_hash(legacy) is True + assert passwords.verify_password(legacy, "Altes-Passwort-1") is True + assert passwords.needs_rehash(legacy) is True + + +def test_password_normalisation_across_platforms(): + import unicodedata + + nfd = unicodedata.normalize("NFD", "Grüße-aus-München-1") + nfc = unicodedata.normalize("NFC", "Grüße-aus-München-1") + assert nfd != nfc + stored = passwords.hash_password(nfd) + assert passwords.verify_password(stored, nfc) is True + + +def test_password_policy_rules(): + policy = passwords.PasswordPolicy(min_length=12) + assert policy.check("Ein-gutes-Passwort-1") == [] + assert any("Zeichen erforderlich" in p for p in policy.check("kurz")) + assert any("Zeichenarten" in p for p in policy.check("aaaaaaaaaaaaaaa")) + assert any("Benutzernamen" in p for p in policy.check("Tester-Passwort-1", username="tester")) + assert any("haeufig" in p for p in policy.check("passwort1234")) + + +def test_totp_generates_and_verifies(): + secret = totp.generate_secret() + code = totp.current_code(secret) + assert totp.verify_code(secret, code) is True + assert totp.verify_code(secret, "000000") is False + assert totp.verify_code(secret, code, now=0) is False + + +def test_totp_drift_tolerance(): + secret = totp.generate_secret() + now = 1_700_000_000 + previous = totp.code_at(secret, int(now // totp.PERIOD) - 1) + assert totp.verify_code(secret, previous, now=now) is True + far = totp.code_at(secret, int(now // totp.PERIOD) - 5) + assert totp.verify_code(secret, far, now=now) is False + + +def test_totp_uri_contains_issuer_and_secret(): + secret = totp.generate_secret() + uri = totp.provisioning_uri(secret, account="anna", issuer="TESM Test") + assert uri.startswith("otpauth://totp/") + assert secret in uri + assert "issuer=TESM%20Test" in uri + + +# --------------------------------------------------------------------------- # +# Aenderungsprotokoll +# --------------------------------------------------------------------------- # + + +def test_audit_chain_detects_modification(core_app): + extension = core(core_app) + with extension.database.session() as conn: + with extension.database.transaction(conn): + for index in range(5): + audit.log(conn, actor="tester", action="test.event", target=f"ziel-{index}") + assert audit.verify_chain(conn)["ok"] is True + + with extension.database.transaction(conn): + conn.execute("UPDATE audit_log SET detail='manipuliert' WHERE id=3") + result = audit.verify_chain(conn) + assert result["ok"] is False + assert result["broken_at"] == 3 + assert "Hash" in result["reason"] + + +def test_audit_chain_detects_deletion(core_app): + extension = core(core_app) + with extension.database.session() as conn: + with extension.database.transaction(conn): + for index in range(5): + audit.log(conn, actor="tester", action="test.event", target=str(index)) + with extension.database.transaction(conn): + conn.execute("DELETE FROM audit_log WHERE id=3") + result = audit.verify_chain(conn) + assert result["ok"] is False + + +def test_audit_archiving_moves_full_days(core_app, tmp_path): + extension = core(core_app) + old_day = (datetime.now(timezone.utc) - timedelta(days=3)).replace(microsecond=0) + with extension.database.session() as conn: + with extension.database.transaction(conn): + for index in range(40): + audit.log( + conn, + actor="tester", + action="test.old", + target=str(index), + ts=old_day + timedelta(seconds=index), + ) + for index in range(5): + audit.log(conn, actor="tester", action="test.new", target=str(index)) + with extension.database.transaction(conn): + written = audit.archive_old_rows(conn, tmp_path / "archiv", threshold=10, target=5) + assert written + # Uebrig bleiben ausschliesslich Zeilen des laufenden Tages (inkl. der + # Startzeile, die die App beim Migrieren selbst geschrieben hat). + today = datetime.now(timezone.utc).date().isoformat() + remaining = Database.all(conn, "SELECT DISTINCT substr(ts,1,10) AS day FROM audit_log") + assert [row["day"] for row in remaining] == [today] + lines = (tmp_path / "archiv" / written[0].name).read_text(encoding="utf-8").splitlines() + assert len(lines) == 40 + assert json.loads(lines[0])["action"] == "test.old" + + +# --------------------------------------------------------------------------- # +# Sitzungen +# --------------------------------------------------------------------------- # + + +def test_session_id_rotates_on_login(client, make_user, login): + make_user("anna", "Ein-gutes-Passwort-1") + client.get("/login") + before = client.get_cookie("tesm_session") + login("anna", "Ein-gutes-Passwort-1") + after = client.get_cookie("tesm_session") + assert after is not None + if before is not None: + assert before.value != after.value + + +def test_session_revocation_takes_effect_immediately(client, core_app, make_user, login): + from tesm_core.security import sessions as session_store + + row = make_user("anna", "Ein-gutes-Passwort-1") + login("anna", "Ein-gutes-Passwort-1") + assert client.get("/konto/").status_code == 200 + + extension = core(core_app) + with extension.database.session() as conn, extension.database.transaction(conn): + session_store.revoke_all_for_user(conn, int(row["id"]), reason="test") + + response = client.get("/konto/") + assert response.status_code == 302 + assert "/login" in response.headers["Location"] + + +def test_password_change_revokes_other_sessions(core_app, make_user): + from tesm_core.security import sessions as session_store + + extension = core(core_app) + row = make_user("anna", "Ein-gutes-Passwort-1") + first = core_app.test_client() + second = core_app.test_client() + for client_instance in (first, second): + page = client_instance.get("/login") + body = page.get_data(as_text=True) + marker = 'name="csrf_token" value="' + start = body.index(marker) + len(marker) + token = body[start : body.index('"', start)] + client_instance.post( + "/login", + data={"username": "anna", "password": "Ein-gutes-Passwort-1", "csrf_token": token}, + follow_redirects=True, + ) + with extension.database.session() as conn: + assert len(session_store.active_for_user(conn, int(row["id"]))) == 2 + + page = first.get("/konto/sicherheit") + body = page.get_data(as_text=True) + marker = 'name="csrf_token" value="' + start = body.index(marker) + len(marker) + token = body[start : body.index('"', start)] + first.post( + "/konto/passwort", + data={ + "current_password": "Ein-gutes-Passwort-1", + "new_password": "Neues-Passwort-2026", + "repeat_password": "Neues-Passwort-2026", + "csrf_token": token, + }, + follow_redirects=True, + ) + assert second.get("/konto/").status_code == 302 + assert first.get("/konto/").status_code == 200 + + +def test_reauth_required_for_sensitive_action(client, make_user, login, csrf_token): + make_user("anna", "Ein-gutes-Passwort-1") + login("anna", "Ein-gutes-Passwort-1") + + # Frisch angemeldet gilt die Bestaetigung -- danach kuenstlich altern lassen. + from flask import session as flask_session + + with client.session_transaction() as sess: + sess["_reauth_at"] = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat( + timespec="seconds" + ) + token = csrf_token("/konto/sicherheit") + response = client.post("/konto/zwei-faktor/starten", data={"csrf_token": token}) + assert response.status_code == 302 + assert "/bestaetigen" in response.headers["Location"] + assert flask_session is not None + + +# --------------------------------------------------------------------------- # +# Der Rechteweg muss tatsaechlich begehbar sein +# --------------------------------------------------------------------------- # + + +def test_units_do_not_disable_the_privilege_path(): + """``NoNewPrivileges`` schaltet setuid ab -- und damit sudo, und damit den Helfer. + + Auf beiden Testhosts stand "Helfer erreichbar", waehrend in Wahrheit jede + Systemaktion an ``sudo: the "no new privileges" flag is set`` scheiterte: + nginx uebernehmen, Netzwerk aendern, Zertifikat anfordern, Kea schreiben. + Die Haertung hatte genau den Mechanismus abgeschaltet, den sie schuetzen + sollte. + """ + from pathlib import Path + + root = Path(__file__).resolve().parent.parent / "deploy" / "systemd" + units = list(root.glob("*.service")) + assert units + for unit in units: + text = unit.read_text(encoding="utf-8") + assert "NoNewPrivileges=yes" not in text, ( + f"{unit.name}: verhindert sudo und damit jede Systemaktion." + ) + # Gemessen: auch ein eingeschraenktes Bounding Set bricht sudo mit + # "unable to change to root gid" -- inklusive CapabilityBoundingSet=CAP_NET_RAW. + for line in text.splitlines(): + if line.startswith("CapabilityBoundingSet="): + raise AssertionError( + f"{unit.name}: {line!r} nimmt sudo CAP_SETUID/CAP_SETGID." + ) + # Die eigentliche Grenze bleibt bestehen. + # "strict" sperrt auch den Helfer aus /etc aus -- gemessen. + assert "ProtectSystem=full" in text, unit.name + assert "ProtectSystem=strict" not in text, ( + f"{unit.name}: strict sperrt den Helfer aus /etc aus." + ) + assert "PrivateTmp=yes" in text, unit.name + assert "User=root" not in text, unit.name + + +def test_the_helper_is_probed_not_just_looked_for(): + """"Datei liegt da" ist keine Auskunft darueber, ob der Aufruf gelingt.""" + import inspect + + from tesm_core.sysops import SysOps + + assert hasattr(SysOps, "selftest") + source = inspect.getsource(SysOps.selftest) + assert 'self.run("service-status", "nginx")' in source + assert 'self.run("nginx-test")' not in source, ( + "Ein fehlgeschlagenes nginx -t heisst 'Konfiguration kaputt', nicht 'Helfer kaputt'." + ) + # Die beiden Fehlerbilder, die im Betrieb wirklich vorkommen, werden benannt. + assert "no new privileges" in source + assert "a password is required" in source + + +def test_selftest_explains_a_blocked_sudo(monkeypatch): + from tesm_core.sysops import Result, SysOps + + class Blocked(SysOps): + @property + def available(self) -> bool: + return True + + def run(self, verb, *args, stdin="", timeout=None): + return Result( + False, "", 'sudo: The "no new privileges" flag is set, which prevents sudo', 1 + ) + + result = Blocked(app_key="tesm").selftest() + assert not result.ok + assert "NoNewPrivileges" in result.message + assert "systemctl" in result.message + + +def test_selftest_reports_a_missing_sudoers_rule(): + from tesm_core.sysops import Result, SysOps + + class NeedsPassword(SysOps): + @property + def available(self) -> bool: + return True + + def run(self, verb, *args, stdin="", timeout=None): + return Result(False, "", "sudo: a password is required", 1) + + result = NeedsPassword(app_key="tesm").selftest() + assert not result.ok + assert "sudoers" in result.message + + +def test_selftest_says_so_when_the_helper_is_absent(tmp_path): + from tesm_core.sysops import SysOps + + result = SysOps(app_key="tesm", helper=tmp_path / "gibt-es-nicht").selftest() + assert not result.ok + assert "fehlt" in result.message + + +def test_authenticate_keeps_directory_groups(core_app, monkeypatch): + """Die Verzeichnisgruppen duerfen auf dem Weg nach oben nicht verlorengehen. + + ``_verify_credentials`` hat sie korrekt geliefert, ``authenticate`` hat aus + dem Ergebnis aber ein neues Objekt gebaut und das Feld dabei fallen lassen. + Sichtbar war davon nichts: die Anmeldung gelang, nur band sich keine + Freigabe mehr ein. Genau solche stillen Verluste faengt dieser Test. + """ + from tesm_core.auth import directory, service + from tesm_core.extension import core + + profil = directory.DirectoryProfile( + username="mitarbeiter", + dn="CN=mitarbeiter,OU=Benutzer,DC=firma,DC=local", + group_dns=("CN=GG_Technik,OU=Gruppen,DC=firma,DC=local",), + ) + monkeypatch.setattr(directory, "is_enabled", lambda conn: True) + monkeypatch.setattr( + directory, "authenticate", lambda conn, name, pw: directory.DirectoryResult("ok", profile=profil) + ) + + with core_app.test_request_context("/login"): + with core(core_app).database.session() as conn: + outcome = service.authenticate(conn, "mitarbeiter", "egal", ip="127.0.0.1") + + assert outcome.ok, outcome.message + assert outcome.group_dns == profil.group_dns, "die Gruppen sind unterwegs verlorengegangen" diff --git a/tests/test_core_smoke.py b/tests/test_core_smoke.py new file mode 100644 index 0000000..8f093b9 --- /dev/null +++ b/tests/test_core_smoke.py @@ -0,0 +1,246 @@ +"""Rauchtest des Grundgeruests: Start, Anmeldung, Rechte, CSRF, Header.""" + +from __future__ import annotations + +import re + + +def test_app_starts_and_health_ok(client): + response = client.get("/gesundheit") + assert response.status_code == 200 + assert response.get_json()["status"] == "ok" + + +def test_login_page_renders_and_sets_csrf(client): + response = client.get("/login") + assert response.status_code == 200 + body = response.get_data(as_text=True) + assert "Anmelden" in body + assert 'name="csrf_token"' in body + + +def test_security_headers_present(client): + response = client.get("/login") + csp = response.headers["Content-Security-Policy"] + assert "default-src 'self'" in csp + assert "'unsafe-inline'" not in csp.split("script-src")[1].split(";")[0] + assert re.search(r"nonce-[A-Za-z0-9_-]{10,}", csp) + assert response.headers["X-Frame-Options"] == "DENY" + assert response.headers["X-Content-Type-Options"] == "nosniff" + assert "camera=()" in response.headers["Permissions-Policy"] + + +def test_login_and_logout(client, make_user, login): + make_user("anna", "Ein-gutes-Passwort-1") + response = login("anna", "Ein-gutes-Passwort-1") + assert response.status_code == 200 + assert "Willkommen" in response.get_data(as_text=True) + + page = client.get("/konto/") + assert page.status_code == 200 + assert "anna" in page.get_data(as_text=True) + + +def test_wrong_password_is_generic(client, make_user, login): + make_user("anna", "Ein-gutes-Passwort-1") + response = login("anna", "falsch") + body = response.get_data(as_text=True) + assert "Benutzername oder Passwort ist falsch" in body + # Kein Hinweis darauf, dass das Konto existiert. + assert "gesperrt" not in body + + +def test_unknown_user_same_message(client, login): + body = login("gibtsnicht", "irgendwas").get_data(as_text=True) + assert "Benutzername oder Passwort ist falsch" in body + + +def test_locked_account_rejected(client, make_user, login): + make_user("gesperrt", "Ein-gutes-Passwort-1", is_locked=True) + body = login("gesperrt", "Ein-gutes-Passwort-1").get_data(as_text=True) + assert "gesperrt" in body + + +def test_rate_limit_blocks_after_many_attempts(client, make_user, login): + make_user("anna", "Ein-gutes-Passwort-1") + messages = [login("anna", "falsch").get_data(as_text=True) for _ in range(9)] + assert any("voruebergehend gesperrt" in body for body in messages) + + +def test_protected_page_redirects_to_login(client): + response = client.get("/widgets") + assert response.status_code == 302 + assert "/login" in response.headers["Location"] + + +def test_permission_required_blocks_without_grant(client, make_user, login): + make_user("anna", "Ein-gutes-Passwort-1") + login("anna", "Ein-gutes-Passwort-1") + assert client.get("/verwaltung/benutzer").status_code == 403 + + +def test_permission_granted_allows_access(client, make_user, login): + make_user("bob", "Ein-gutes-Passwort-1", permissions=("users.view",)) + login("bob", "Ein-gutes-Passwort-1") + response = client.get("/verwaltung/benutzer") + assert response.status_code == 200 + assert "Benutzer" in response.get_data(as_text=True) + + +def test_admin_sees_everything(client, make_user, login): + make_user("root", "Ein-gutes-Passwort-1", is_admin=True) + login("root", "Ein-gutes-Passwort-1") + for path in ("/verwaltung/benutzer", "/verwaltung/gruppen", "/papierkorb/", "/diagnose/"): + assert client.get(path).status_code == 200, path + + +def test_csrf_missing_token_is_rejected(client, make_user, login): + make_user("root", "Ein-gutes-Passwort-1", is_admin=True) + login("root", "Ein-gutes-Passwort-1") + response = client.post("/verwaltung/benutzer/neu", data={"username": "boese"}) + assert response.status_code == 403 + assert "Sicherheitstoken" in response.get_data(as_text=True) + + +def test_csrf_wrong_origin_is_rejected(client, make_user, login, csrf_token): + make_user("root", "Ein-gutes-Passwort-1", is_admin=True) + login("root", "Ein-gutes-Passwort-1") + token = csrf_token("/verwaltung/benutzer") + response = client.post( + "/verwaltung/benutzer/neu", + data={"username": "boese", "csrf_token": token}, + headers={"Origin": "https://angreifer.example"}, + ) + assert response.status_code == 403 + + +def test_nav_hides_pages_without_permission(client, make_user, login): + make_user("anna", "Ein-gutes-Passwort-1") + login("anna", "Ein-gutes-Passwort-1") + body = client.get("/").get_data(as_text=True) + assert "Diagnose" not in body + assert "Widgets" not in body + + +def test_nav_shows_pages_with_permission(client, make_user, login): + make_user("bob", "Ein-gutes-Passwort-1", permissions=("widgets.view",)) + login("bob", "Ein-gutes-Passwort-1") + body = client.get("/").get_data(as_text=True) + assert "Widgets" in body + + +def test_default_group_created_from_preset(core_app): + from tesm_core.extension import core + from tesm_core.rbac import service as rbac + + extension = core(core_app) + with extension.database.session() as conn: + groups = rbac.list_groups(conn) + names = {group.name for group in groups} + assert "Benutzer" in names + default = next(group for group in groups if group.name == "Benutzer") + assert default.is_default is True + assert "widgets.view" in default.permissions + assert "demo.view" in default.permissions # Bereichsschalter wird mitgesetzt + + +def test_error_page_shows_request_id(client): + response = client.get("/gibt-es-nicht") + assert response.status_code == 404 + body = response.get_data(as_text=True) + assert "404" in body + assert "Vorgangsnummer" in body + + +# --------------------------------------------------------------------------- # +# Verzeichnisgruppen: suchen statt abtippen +# --------------------------------------------------------------------------- # + + +def test_group_search_is_open_to_fileshare_editors(): + """Wer Freigaben zuordnet, muss die Gruppen finden koennen. + + Sonst braeuchte er zusaetzlich das Recht, den Verzeichnisdienst zu + konfigurieren -- eine Rechteausweitung fuer eine reine Lesesuche. + """ + import inspect + + from tesm_core.views import hostsettings + + source = inspect.getsource(hostsettings.directory_group_search) + assert "fileshare.edit" in source + assert 'mode="any"' in source or "mode='any'" in source + + +def test_the_picker_never_forces_typing_a_dn(): + """Ein abgetippter DN ist ein Tippfehler, der erst bei der Anmeldung auffaellt.""" + from pathlib import Path + + root = Path(__file__).resolve().parent.parent + picker = ( + root / "packages" / "tesm-core" / "src" / "tesm_core" / "templates" + / "tesm_core" / "_group_picker.html" + ).read_text(encoding="utf-8") + assert "directory_group_search" in picker + assert "data-group-query" in picker + # Tippen bleibt trotzdem moeglich: ohne Dienstkonto gibt es keine Suche. + assert 'name="{{ dn_field }}"' in picker + + for template in ( + root / "packages" / "tesm-core" / "src" / "tesm_core" / "templates" + / "tesm_core" / "settings_directory.html", + root / "apps" / "tesm" / "src" / "tesm" / "templates" / "tesm" / "fileshare_mappings.html", + ): + text = template.read_text(encoding="utf-8") + assert "group_picker(" in text, template.name + + +def test_transport_choice_carries_its_port(): + """Umgestellter Transport mit altem Port ergibt einen Fehler ohne Aussage.""" + from pathlib import Path + + root = Path(__file__).resolve().parent.parent + page = ( + root / "packages" / "tesm-core" / "src" / "tesm_core" / "templates" + / "tesm_core" / "settings_directory.html" + ).read_text(encoding="utf-8") + assert 'data-behavior="transport-port"' in page + assert 'data-port-target="#ldap-port"' in page + + script = ( + root / "packages" / "tesm-core" / "src" / "tesm_core" / "static" + / "tesm_core" / "js" / "app.js" + ).read_text(encoding="utf-8") + assert "transport-port" in script + assert 'ldaps: "636"' in script + assert 'starttls: "389"' in script + # Ein absichtlich abweichender Port darf nicht ueberschrieben werden. + assert "known.includes(port.value)" in script + + +def test_permission_matrix_has_no_nameless_checkbox(client, make_user, login): + """In der Rechtematrix darf kein Kaestchen ohne ``name`` stehen. + + Fuer nicht vorgesehene Aktionen stand dort ein deaktiviertes Kaestchen. + Das JS hat beim Einschalten des Bereichs *alle* Kaestchen der Zeile + freigegeben -- auch dieses. Es liess sich anhaken, uebertrug wegen des + fehlenden ``name`` aber nichts: es sah erteilt aus und war nach dem + Speichern verschwunden. Ohne Eingabefeld kann das nicht wieder passieren. + """ + import re + + make_user("root", "Ein-gutes-Passwort-1", is_admin=True) + login("root", "Ein-gutes-Passwort-1") + body = client.get("/verwaltung/gruppen").get_data(as_text=True) + if "permission-matrix" not in body: + gruppe = re.search(r'href="(/verwaltung/gruppen/\d+)"', body) + assert gruppe, "keine Gruppe zum Pruefen gefunden" + body = client.get(gruppe.group(1)).get_data(as_text=True) + + assert "permission-matrix" in body, "Rechtematrix nicht gefunden" + kaestchen = re.findall(r"]*type=\"checkbox\"[^>]*>", body) + assert kaestchen, "keine Kaestchen in der Matrix" + ohne_namen = [k for k in kaestchen if "name=" not in k] + assert not ohne_namen, f"Kaestchen ohne name: {ohne_namen[:3]}" + # Der Platzhalter ist jetzt reine Darstellung. + assert "matrix__na" in body diff --git a/tests/test_legacy_transfer.py b/tests/test_legacy_transfer.py new file mode 100644 index 0000000..866bc3f --- /dev/null +++ b/tests/test_legacy_transfer.py @@ -0,0 +1,279 @@ +"""Der Weg vom alten TESM in dieses: Uebersetzer und Import zusammen. + +Die Testdatei wird **genau so** gebaut, wie der Vorgaenger sie schreibt +(PBKDF2-HMAC-SHA256, 390000 Runden, Fernet, derselbe Umschlag). Damit prueft +der Test das echte Format und nicht meine Vorstellung davon. +""" + +from __future__ import annotations + +import base64 +import json +from typing import Any + +import pytest + +pytest.importorskip("cryptography") + +LEGACY_PASS = "alte-passphrase-2026" +NEW_PASS = "neue-passphrase-2026" + + +@pytest.fixture() +def tesm_app(instance_env: Any): + from tesm import create_app + + return create_app(TESTING=True) + + +def _seal_legacy(payload: dict[str, Any], passphrase: str) -> str: + """Baut einen Umschlag wie ``export_data()`` im alten TESM.""" + import secrets + + from cryptography.fernet import Fernet + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + + salt = secrets.token_bytes(16) + kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=390000) + key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8"))) + token = Fernet(key).encrypt(json.dumps(payload).encode("utf-8")) + return json.dumps( + { + "app": "tesm", + "version": 2, + "exported_at": "2026-09-02 12:00:00", + "salt": base64.urlsafe_b64encode(salt).decode("utf-8"), + "payload": token.decode("utf-8"), + } + ) + + +#: Ein Ausschnitt, der die Eigenheiten des alten Formats abdeckt: Switch- und +#: Zugangsdatenbezug per Name, geschachtelte DHCP-Optionswerte, "name" statt +#: "hostname" an der Reservierung, sowie Abschnitte, die nicht uebersetzt +#: werden koennen. +LEGACY_PAYLOAD: dict[str, Any] = { + "credentials": [ + {"name": "Switch-Admin", "username": "manager", "password": "geheim-1", "category": "switch"}, + {"name": "Linux-Root", "username": "root", "password": "geheim-2", "category": "linux"}, + {"name": "Windows-Dienst", "username": "svc", "password": "geheim-3", "category": "windows"}, + ], + "switches": [ + {"hostname": "sw-keller", "ip": "192.168.80.2", "ssh_port": 22, "credential_name": "Switch-Admin"}, + ], + "devices": [ + { + "mac": "4c:52:62:25:b9:e4", + "ip": "192.168.80.137", + "port": "12", + "name": "Autodarts", + "switch_hostname": "sw-keller", + "is_active": 1, + "ssh_port": 22, + "credential_name": "Linux-Root", + }, + { + "mac": "aa:bb:cc:00:00:02", + "ip": "192.168.80.50", + "port": "3", + "name": "Kasse", + "switch_hostname": "sw-keller", + "is_active": 1, + "ssh_port": 22, + "credential_name": "Windows-Dienst", + }, + ], + "dhcp": { + "settings": {"dhcp_domain": "firma.local", "dhcp_lease_default": "1200"}, + "subnets": [ + { + "interface": "eth0", + "range_start": "192.168.80.100", + "range_end": "192.168.80.200", + "gateway": "192.168.80.1", + "dns": "192.168.80.1", + "sort_order": 0, + "enabled": 1, + } + ], + "reservations": [{"mac": "aa:bb:cc:00:00:09", "ip": "192.168.80.90", "name": "drucker"}], + "options": [ + { + "code": 225, + "name": "url", + "type": "string", + "description": "Terminal-URL", + "is_standard": 0, + "values": [ + {"device_mac": "4c:52:62:25:b9:e4", "value": "https://play.autodarts.com"}, + {"device_mac": None, "value": "https://intranet.firma.local"}, + ], + }, + # Ohne Wert gibt es nichts zu senden -- wird gemeldet, nicht uebernommen. + {"code": 226, "name": "leer", "type": "string", "description": "", "is_standard": 0, "values": []}, + ], + }, + # Diese Abschnitte kann der Uebersetzer nicht abbilden. + "users": [{"username": "anna", "password_hash": "$2b$12$abcdefghijklmnopqrstuv"}], + "known_hosts": "192.168.80.2 ssh-ed25519 AAAA...\n", +} + + +def test_legacy_envelope_needs_the_right_passphrase(): + from tesm.services.legacy_transfer import LegacyTransferError, open_legacy + + datei = _seal_legacy({"credentials": []}, LEGACY_PASS) + assert open_legacy(datei, LEGACY_PASS) == {"credentials": []} + with pytest.raises(LegacyTransferError, match="Passphrase"): + open_legacy(datei, "falsch") + + +def test_legacy_envelope_of_another_app_is_refused(): + from tesm.services.legacy_transfer import LegacyTransferError, open_legacy + + fremd = json.dumps({"app": "etwas-anderes", "salt": "AAAA", "payload": "x"}) + with pytest.raises(LegacyTransferError, match="nicht zu TESM"): + open_legacy(fremd, LEGACY_PASS) + + +def test_report_names_what_it_cannot_translate(): + """Nicht uebersetzbare Abschnitte muessen benannt werden, nicht verschwinden.""" + from tesm.services.legacy_transfer import convert, open_legacy + + _, bericht = convert(open_legacy(_seal_legacy(LEGACY_PAYLOAD, LEGACY_PASS), LEGACY_PASS)) + text = bericht.as_text() + assert "bcrypt" in text, "der Hinweis zu lokalen Konten fehlt" + assert "Host-Schluessel" in text + assert "ohne Wert" in text, "die wertlose Option wurde nicht gemeldet" + assert bericht.counts["credentials"] == 3 + assert bericht.counts["devices"] == 2 + assert bericht.counts["dhcp-Optionswerte"] == 2 + + +def test_missing_credentials_are_reported(tesm_app): + """Ein zugeordneter, aber nicht exportierter Zugang bleibt beim Import leer.""" + from tesm.services.legacy_transfer import convert, open_legacy + + ohne = {key: value for key, value in LEGACY_PAYLOAD.items() if key != "credentials"} + _, bericht = convert(open_legacy(_seal_legacy(ohne, LEGACY_PASS), LEGACY_PASS)) + hinweise = " ".join(bericht.notes) + assert "Switch-Admin" in hinweise and "Linux-Root" in hinweise + + +def test_full_round_trip_into_the_database(tesm_app): + """Alte Datei -> Uebersetzer -> neues Format -> vorhandener Import.""" + from tesm.services import transfer + from tesm.services.legacy_transfer import convert, open_legacy + from tesm_core.extension import core + + ext = core(tesm_app) + + nutzlast, _ = convert(open_legacy(_seal_legacy(LEGACY_PAYLOAD, LEGACY_PASS), LEGACY_PASS)) + # Ueber Siegel und Entsiegeln, damit auch der neue Umschlag geprueft ist. + datei = transfer.seal(nutzlast, NEW_PASS) + entsiegelt, _kopf = transfer.unseal(datei, NEW_PASS) + + with ext.database.session() as conn, ext.database.transaction(conn): + transfer.apply_import( + conn, + ext.keystore, + entsiegelt, + categories=("credentials", "switches", "devices", "dhcp", "settings"), + set_setting=lambda key, value: ext.settings.set(conn, key, value, actor="test"), + ) + + with ext.database.session() as conn: + # -- Zugangsdaten: Passwort muss wieder lesbar sein ------------------ + from tesm.services import inventory + + zugang = {c.name: c for c in inventory.list_credentials(conn)} + assert set(zugang) == {"Switch-Admin", "Linux-Root", "Windows-Dienst"} + assert zugang["Linux-Root"].category == "linux" + benutzer, passwort = inventory.credential_secret( + conn, ext.keystore, zugang["Linux-Root"].id + ) + assert (benutzer, passwort) == ("root", "geheim-2") + + # -- Switch und Verknuepfung ---------------------------------------- + switch = conn.execute("SELECT * FROM switches WHERE hostname='sw-keller'").fetchone() + assert switch is not None + assert switch["ip"] == "192.168.80.2" + assert switch["credential_id"] == zugang["Switch-Admin"].id + + # -- Clients: Switch, Port, Zugang, abgeleitete Kategorie ------------ + geraet = conn.execute( + "SELECT * FROM devices WHERE mac='4c:52:62:25:b9:e4'" + ).fetchone() + assert geraet["name"] == "Autodarts" + assert geraet["switch_id"] == switch["id"] + assert geraet["port"] == "12" + assert geraet["credential_id"] == zugang["Linux-Root"].id + assert geraet["category"] == "linux", "Kategorie aus den Zugangsdaten abgeleitet" + + windows = conn.execute("SELECT * FROM devices WHERE mac='aa:bb:cc:00:00:02'").fetchone() + assert windows["category"] == "windows" + + # -- DHCP: Subnetz, Reservierung, Optionswerte ---------------------- + subnetz = conn.execute("SELECT * FROM dhcp_subnets").fetchone() + assert (subnetz["range_start"], subnetz["range_end"]) == ( + "192.168.80.100", + "192.168.80.200", + ) + reservierung = conn.execute("SELECT * FROM dhcp_reservations").fetchone() + assert reservierung["hostname"] == "drucker", "alt 'name' -> neu 'hostname'" + + option = conn.execute("SELECT * FROM dhcp_options WHERE code=225").fetchone() + assert option is not None and option["name"] == "url" + werte = { + row["device_id"]: row["value"] + for row in conn.execute( + "SELECT device_id, value FROM dhcp_option_values WHERE option_id=?", + (option["id"],), + ) + } + # Ein globaler Wert und einer, der an genau diesem Geraet haengt. + assert werte[None] == "https://intranet.firma.local" + assert werte[geraet["id"]] == "https://play.autodarts.com" + + # -- Einstellungen: nur die DHCP-Schluessel ------------------------- + assert ext.settings.get(conn, "dhcp_domain") == "firma.local" + + +def test_option_value_of_an_unknown_device_is_not_made_global(tesm_app): + """Ein Wert fuer ein unbekanntes Geraet darf nicht an alle gehen. + + Sonst wuerde aus einer geraetespezifischen Terminal-URL beim Import eine, + die jeder Client bekommt -- die stillste Art, eine Konfiguration zu + verfaelschen. + """ + from tesm.services import transfer + from tesm_core.extension import core + + ext = core(tesm_app) + nutzlast = { + "dhcp": { + "options": [ + { + "code": 225, + "name": "url", + "type": "string", + "description": "", + "is_standard": 0, + "value": "https://nur-fuer-eines", + "device_mac": "ff:ff:ff:ff:ff:ff", + } + ] + } + } + with ext.database.session() as conn, ext.database.transaction(conn): + transfer.apply_import( + conn, + ext.keystore, + nutzlast, + categories=("dhcp",), + set_setting=lambda key, value: None, + ) + + with ext.database.session() as conn: + assert conn.execute("SELECT COUNT(*) FROM dhcp_option_values").fetchone()[0] == 0 diff --git a/tests/test_licensing.py b/tests/test_licensing.py new file mode 100644 index 0000000..a945dc8 --- /dev/null +++ b/tests/test_licensing.py @@ -0,0 +1,408 @@ +"""Phase-0-Tests: das Lizenzprotokoll isoliert, ohne Flask, ohne Datenbank. + +Deckt bewusst auch die Angriffsfaelle ab, die im Vorgaengerprojekt nicht +abgesichert waren: Signatur-Wiederverwendung ueber Domaenengrenzen, Replay +alter Anfragen/Antworten, Downgrade eines nachgelieferten Lizenz-Updates und +Ausstellung durch einen nicht gepinnten Aussteller. +""" + +from __future__ import annotations + +import copy +from datetime import timedelta + +import pytest + +import tesm_licensing as lic + + +VENDOR = { + "name": "WiS GmbH", + "phone": "+49 000 000", + "email": "lizenzen@example.invalid", + "address": "Musterweg 1, 12345 Musterstadt", + "logo_base64": "", +} +CUSTOMER = {"name": "Beispiel AG", "contact_email": "it@example.invalid", "customer_ref": "K-1"} + + +@pytest.fixture() +def master_keys() -> tuple[str, str]: + return lic.generate_keypair() + + +def issue(master_keys, **overrides): + priv, pub = master_keys + params = dict( + customer=CUSTOMER, + license_type="standard", + modules=[], + valid_days=365, + master_private_key_b64=priv, + master_public_key_b64=pub, + master_endpoint="https://lizenz.example.invalid/", + vendor=VENDOR, + ) + params.update(overrides) + return lic.issue_license(**params) + + +# --------------------------------------------------------------------------- # +# Lizenzdatei +# --------------------------------------------------------------------------- # + + +def test_issued_license_verifies_and_splits(master_keys): + _, pub = master_keys + bundle, client_pub = issue(master_keys, modules=["dhcp", "maintenance"]) + document, client_priv = lic.split_bundle(bundle) + + lic.verify_license(document, trusted_master_keys=[pub]) + assert document["license_pubkey"] == client_pub + assert lic.public_from_private(client_priv) == client_pub + # Der private Schluessel darf nicht im Lizenzdokument stehen. + assert "client_key" not in document + assert "license_privkey" not in document + + status = lic.license_status(document, issuer_pinned=True) + assert status.modules == {"dhcp", "maintenance"} + assert status.modules_active is True + assert status.expired is False + assert status.days_left == 365 + assert status.warnings == () + + +def test_tampered_field_breaks_signature(master_keys): + bundle, _ = issue(master_keys) + document, _ = lic.split_bundle(bundle) + document["modules"] = ["dhcp", "fileshare", "maintenance"] + with pytest.raises(lic.SignatureInvalid): + lic.verify_license(document) + + +def test_swapped_master_key_is_detected(master_keys): + """Ein Angreifer, der Payload *und* master_pubkey ersetzt, faellt am Pinning auf.""" + bundle, _ = issue(master_keys) + document, _ = lic.split_bundle(bundle) + + evil_priv, evil_pub = lic.generate_keypair() + forged = lic.resign_license( + document, master_private_key_b64=evil_priv, changes={"modules": list(lic.ALL_MODULES)} + ) + forged["master_pubkey"] = evil_pub + forged["master_key_id"] = lic.key_id(evil_pub) + forged["signature"] = lic.sign( + {k: v for k, v in forged.items() if k != "signature"}, + evil_priv, + lic.SIG_CONTEXT_LICENSE, + ) + + # In sich konsistent -- genau das war die Schwaeche von V1. + lic.verify_license(forged) + # Mit gepinntem Aussteller fliegt es auf. + _, real_pub = master_keys + with pytest.raises(lic.UntrustedIssuer): + lic.verify_license(forged, trusted_master_keys=[real_pub]) + + +def test_key_id_mismatch_rejected(master_keys): + bundle, _ = issue(master_keys) + document, _ = lic.split_bundle(bundle) + document["master_key_id"] = "0" * 16 + with pytest.raises(lic.InvalidLicenseFile): + lic.verify_license(document) + + +def test_license_signature_not_reusable_as_response(master_keys): + """Domaenentrennung: Lizenz-Signatur darf nicht als Antwort-Signatur gelten.""" + _, pub = master_keys + bundle, _ = issue(master_keys) + document, _ = lic.split_bundle(bundle) + payload = {k: v for k, v in document.items() if k != "signature"} + assert lic.verify(payload, document["signature"], pub, lic.SIG_CONTEXT_LICENSE) is True + assert lic.verify(payload, document["signature"], pub, lic.SIG_CONTEXT_RESPONSE) is False + assert lic.verify(payload, document["signature"], pub, lic.SIG_CONTEXT_REQUEST) is False + + +def test_lifetime_license(master_keys): + bundle, _ = issue(master_keys, license_type="enterprise", lifetime=True, valid_days=0) + document, _ = lic.split_bundle(bundle) + lic.verify_license(document) + status = lic.license_status(document, issuer_pinned=True) + assert status.lifetime is True + assert status.expires_at is not None + assert status.days_left is None + assert status.expired is False + assert status.modules_active is True + + +def test_trial_lifetime_forbidden(master_keys): + with pytest.raises(ValueError): + issue(master_keys, license_type="trial", lifetime=True, valid_days=0) + + +def test_license_server_role_rejects_modules(master_keys): + with pytest.raises(ValueError): + issue(master_keys, role="license_server", modules=["dhcp"]) + + +def test_grace_period_and_expiry(master_keys): + bundle, _ = issue(master_keys, valid_days=10, modules=["dhcp"]) + document, _ = lic.split_bundle(bundle) + issued = lic.parse_iso(document["issued_at"]) + + warn = lic.license_status(document, now=issued + timedelta(days=9), issuer_pinned=True) + assert warn.expiring_soon is True and warn.expired is False and warn.modules_active is True + + grace = lic.license_status(document, now=issued + timedelta(days=25), issuer_pinned=True) + assert grace.expired is True + assert grace.grace_active is True + assert grace.modules_active is True # Kulanz laeuft noch + assert grace.days_since_expiry == 15 + + dead = lic.license_status(document, now=issued + timedelta(days=45), issuer_pinned=True) + assert dead.grace_active is False + assert dead.modules_active is False + + +def test_not_before_blocks_future_license(master_keys): + bundle, _ = issue(master_keys) + document, _ = lic.split_bundle(bundle) + issued = lic.parse_iso(document["issued_at"]) + status = lic.license_status(document, now=issued - timedelta(hours=1), issuer_pinned=True) + assert status.not_yet_valid is True + assert status.modules_active is False + + +def test_status_without_pinning_warns(master_keys): + bundle, _ = issue(master_keys) + document, _ = lic.split_bundle(bundle) + status = lic.license_status(document) + assert any("gepinnt" in w for w in status.warnings) + + +def test_role_defaults_to_customer_when_missing(master_keys): + bundle, _ = issue(master_keys) + document, _ = lic.split_bundle(bundle) + document.pop("role") + assert lic.license_status(document).role == "customer" + + +def test_v1_bundle_compatibility(master_keys): + """Eine alte Datei mit eingebettetem license_privkey laesst sich noch aufteilen.""" + bundle, _ = issue(master_keys) + document, client_priv = lic.split_bundle(bundle) + legacy = {**document, "license_privkey": client_priv} + doc2, priv2 = lic.split_bundle(legacy) + assert priv2 == client_priv + assert "license_privkey" not in doc2 + + +# --------------------------------------------------------------------------- # +# Aktivierungsprotokoll +# --------------------------------------------------------------------------- # + + +def test_activation_round_trip(master_keys): + master_priv, master_pub = master_keys + bundle, license_pub = issue(master_keys) + document, client_priv = lic.split_bundle(bundle) + + request = lic.build_client_request("activate", document, client_priv, hostname="tesm-kunde") + lic.verify_client_request(request, license_pub, expected_action="activate") + + response = lic.build_master_response( + "activate", + license_id=request["license_id"], + fingerprint=request["fingerprint"], + request_nonce=request["nonce"], + master_private_key=master_priv, + ) + lic.verify_master_response( + response, + master_pub, + expected_license_id=document["license_id"], + expected_fingerprint=request["fingerprint"], + expected_action="activate", + expected_nonce=request["nonce"], + current_seq=document["seq"], + ) + + +def test_offline_code_round_trip_is_identical(master_keys): + master_priv, master_pub = master_keys + bundle, license_pub = issue(master_keys) + document, client_priv = lic.split_bundle(bundle) + + request = lic.build_client_request("activate", document, client_priv) + code = lic.encode_code(request) + # Ein per Mail transportierter Code darf Umbrueche/Whitespace enthalten. + wrapped = "\n".join(code[i : i + 48] for i in range(0, len(code), 48)) + assert lic.decode_code(wrapped) == request + lic.verify_client_request(lic.decode_code(wrapped), license_pub) + + response = lic.build_master_response( + "activate", + license_id=request["license_id"], + fingerprint=request["fingerprint"], + request_nonce=request["nonce"], + master_private_key=master_priv, + ) + lic.verify_master_response( + lic.decode_code(lic.encode_code(response)), + master_pub, + expected_license_id=document["license_id"], + expected_fingerprint=request["fingerprint"], + expected_action="activate", + expected_nonce=request["nonce"], + ) + + +def test_request_signature_requires_correct_license_key(master_keys): + bundle, _ = issue(master_keys) + document, client_priv = lic.split_bundle(bundle) + other_priv, other_pub = lic.generate_keypair() + request = lic.build_client_request("heartbeat", document, client_priv) + with pytest.raises(lic.SignatureInvalid): + lic.verify_client_request(request, other_pub) + assert other_priv # nur zur Vollstaendigkeit + + +def test_request_replay_is_rejected(master_keys): + bundle, license_pub = issue(master_keys) + document, client_priv = lic.split_bundle(bundle) + request = lic.build_client_request("activate", document, client_priv) + + used: set[str] = set() + + def seen(nonce: str) -> bool: + if nonce in used: + return True + used.add(nonce) + return False + + lic.verify_client_request(request, license_pub, seen_nonce=seen) + with pytest.raises(lic.ReplayDetected): + lic.verify_client_request(request, license_pub, seen_nonce=seen) + + +def test_stale_request_is_rejected(master_keys): + bundle, license_pub = issue(master_keys) + document, client_priv = lic.split_bundle(bundle) + old = lic.utcnow() - timedelta(seconds=lic.REQUEST_MAX_AGE_SECONDS + 60) + request = lic.build_client_request("heartbeat", document, client_priv, now=old) + with pytest.raises(lic.ReplayDetected): + lic.verify_client_request(request, license_pub) + + +def test_unsigned_request_does_not_burn_nonce(master_keys): + """Nonce-Verbrauch erst nach gueltiger Signatur -- sonst waere die Nonce-Tabelle + ein kostenloser DoS-Vektor.""" + bundle, license_pub = issue(master_keys) + document, client_priv = lic.split_bundle(bundle) + request = lic.build_client_request("activate", document, client_priv) + request["signature"] = lic.sign( + {"boese": True}, lic.generate_keypair()[0], lic.SIG_CONTEXT_REQUEST + ) + + calls: list[str] = [] + with pytest.raises(lic.SignatureInvalid): + lic.verify_client_request(request, license_pub, seen_nonce=lambda n: calls.append(n) or False) + assert calls == [] + + +def test_response_bound_to_request_nonce(master_keys): + master_priv, master_pub = master_keys + bundle, _ = issue(master_keys) + document, client_priv = lic.split_bundle(bundle) + + first = lic.build_client_request("activate", document, client_priv) + response = lic.build_master_response( + "activate", + license_id=first["license_id"], + fingerprint=first["fingerprint"], + request_nonce=first["nonce"], + master_private_key=master_priv, + ) + second = lic.build_client_request("activate", document, client_priv) + with pytest.raises(lic.ReplayDetected): + lic.verify_master_response( + response, + master_pub, + expected_license_id=document["license_id"], + expected_fingerprint=second["fingerprint"], + expected_action="activate", + expected_nonce=second["nonce"], + ) + + +def test_license_update_downgrade_rejected(master_keys): + master_priv, master_pub = master_keys + bundle, _ = issue(master_keys, modules=["dhcp"]) + document, client_priv = lic.split_bundle(bundle) + + updated = lic.resign_license( + document, master_private_key_b64=master_priv, changes={"modules": ["dhcp", "fileshare"]} + ) + assert updated["seq"] == document["seq"] + 1 + lic.verify_license(updated, trusted_master_keys=[master_pub]) + + request = lic.build_client_request("heartbeat", updated, client_priv) + stale = lic.build_master_response( + "heartbeat", + license_id=request["license_id"], + fingerprint=request["fingerprint"], + request_nonce=request["nonce"], + master_private_key=master_priv, + license_update=document, # alte Revision + ) + with pytest.raises(lic.ReplayDetected): + lic.verify_master_response( + stale, + master_pub, + expected_license_id=updated["license_id"], + expected_fingerprint=request["fingerprint"], + expected_action="heartbeat", + expected_nonce=request["nonce"], + current_seq=updated["seq"], + ) + + +def test_report_size_limit(master_keys): + bundle, license_pub = issue(master_keys, role="license_server") + document, client_priv = lic.split_bundle(bundle) + report = {"customers": [{"name": f"K{i}"} for i in range(lic.MAX_REPORT_ROWS + 1)]} + request = lic.build_client_request("heartbeat", document, client_priv, report=report) + with pytest.raises(lic.ProtocolViolation): + lic.verify_client_request(request, license_pub) + + +def test_malformed_fingerprint_and_nonce_rejected(master_keys): + bundle, license_pub = issue(master_keys) + document, client_priv = lic.split_bundle(bundle) + base = lic.build_client_request("activate", document, client_priv) + + bad_fp = copy.deepcopy(base) + bad_fp["fingerprint"] = "nicht-hex" + with pytest.raises(lic.ProtocolViolation): + lic.verify_client_request(bad_fp, license_pub) + + bad_nonce = copy.deepcopy(base) + bad_nonce["nonce"] = "kurz" + with pytest.raises(lic.ProtocolViolation): + lic.verify_client_request(bad_nonce, license_pub) + + +def test_wrong_action_for_endpoint(master_keys): + bundle, license_pub = issue(master_keys) + document, client_priv = lic.split_bundle(bundle) + request = lic.build_client_request("heartbeat", document, client_priv) + with pytest.raises(lic.ProtocolViolation): + lic.verify_client_request(request, license_pub, expected_action="activate") + + +def test_fingerprint_is_stable_and_hex(): + first = lic.system_fingerprint() + assert first == lic.system_fingerprint() + assert len(first) == 64 + int(first, 16) diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py new file mode 100644 index 0000000..3b48772 --- /dev/null +++ b/tests/test_repo_hygiene.py @@ -0,0 +1,326 @@ +"""Prueft das Repository selbst -- die Fehlerklassen, die erst beim Ausrollen auffallen. + +Der Vorgaenger hat sich hier zweimal die Finger verbrannt: CRLF-Zeilenenden aus +einer Windows-Buildmaschine machten Shell-Skripte auf dem Ziel unbrauchbar +("bad interpreter"), und Schluesseldateien landeten versehentlich im Repo. +Beides faellt hier auf, bevor ein Paket gebaut wird. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent + +SKIP_DIRS = { + ".git", + ".venv", + "__pycache__", + "instance", + ".pytest_cache", + ".ruff_cache", + "node_modules", +} +TEXT_SUFFIXES = { + ".py", ".sh", ".html", ".css", ".js", ".json", ".toml", ".md", + ".yaml", ".yml", ".cfg", ".ini", ".txt", ".service", ".conf", +} +#: Dateien ohne Endung, die trotzdem Text sind. +EXTENSIONLESS_TEXT = {"tesm-helper", "tesm", "tesm-license"} + +SECRET_NAMES = { + "data.keys", + "secret.key", + "license.json", + "license_key.json", + "master_signing_key.json", + "fernet.key", + "known_hosts", +} +SECRET_SUFFIXES = {".db", ".db-wal", ".db-shm", ".pem", ".p12", ".pfx"} + + +def _repo_files() -> list[Path]: + files: list[Path] = [] + for path in REPO_ROOT.rglob("*"): + if not path.is_file(): + continue + if any(part in SKIP_DIRS for part in path.relative_to(REPO_ROOT).parts): + continue + files.append(path) + return files + + +def _text_files() -> list[Path]: + return [ + path + for path in _repo_files() + if path.suffix in TEXT_SUFFIXES or path.name in EXTENSIONLESS_TEXT + ] + + +def test_no_crlf_line_endings(): + """CRLF im Paket macht Shell-Skripte auf dem Linux-Ziel unbrauchbar.""" + offenders = [ + str(path.relative_to(REPO_ROOT)) + for path in _text_files() + if b"\r\n" in path.read_bytes() + ] + assert not offenders, ( + "Diese Dateien haben CRLF-Zeilenenden und wuerden auf dem Zielsystem brechen:\n " + + "\n ".join(offenders) + ) + + +def test_no_secrets_committed(): + """Schluessel, Datenbanken und Lizenzdateien gehoeren nie ins Repo.""" + offenders = [ + str(path.relative_to(REPO_ROOT)) + for path in _repo_files() + if path.name in SECRET_NAMES or path.suffix in SECRET_SUFFIXES + ] + assert not offenders, "Geheimnisse im Repository:\n " + "\n ".join(offenders) + + +def test_shell_scripts_have_shebang_and_strict_mode(): + scripts = [path for path in _repo_files() if path.suffix == ".sh" or path.name == "tesm-helper"] + assert scripts, "Es wurden keine Shell-Skripte gefunden -- Test prueft ins Leere." + for script in scripts: + text = script.read_text(encoding="utf-8") + assert text.startswith("#!"), f"{script.name}: Shebang fehlt" + assert "set -euo pipefail" in text, f"{script.name}: 'set -euo pipefail' fehlt" + + +def test_helper_validates_every_verb(): + """Jedes Verb des privilegierten Helfers muss im case-Block auftauchen. + + Der Helfer ist die einzige Stelle mit Root-Rechten. Ein Verb, das die + Anwendung kennt, der Helfer aber nicht, waere entweder tot -- oder, + schlimmer, ein Hinweis auf eine ungeprueft durchgereichte Aktion. + """ + from tesm_core.sysops import HELPER_VERBS + + helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8") + # case-Muster koennen mehrere Verben mit | buendeln. + patterns: set[str] = set() + for line in helper.splitlines(): + match = re.match(r"^\s{2,4}([a-z|-]+)\)\s*$", line) + if match: + patterns.update(match.group(1).split("|")) + missing = [verb for verb in HELPER_VERBS if verb not in patterns] + assert not missing, f"Verben fehlen im Helfer: {missing}" + + +def test_helper_has_no_unquoted_argument_passthrough(): + """Der Helfer darf Argumente nie ungeprueft an eine Shell weiterreichen.""" + helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8") + for forbidden in ("eval ", "bash -c", "sh -c"): + assert forbidden not in helper, f"Der Helfer enthaelt {forbidden!r}" + + +def test_helper_blocks_path_traversal_everywhere(): + """Jede Pfadpruefung im Helfer muss ".." abweisen. + + Ohne diese Sperre laesst sich eine Positivliste ueber den Basisnamen + unterlaufen: ``/etc/nginx/sites-available/../../../root/tesm`` hat den + zulaessigen Basisnamen ``tesm``, liest aber ``/root/tesm``. + """ + helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8") + checks = re.findall( + r"^check_\w*(?:path|target|webroot)\w*\(\) \{(.*?)^\}", helper, re.S | re.M + ) + assert checks, "Es wurden keine Pfadpruefungen gefunden -- Test prueft ins Leere." + for body in checks: + assert '*".."*' in body, "Pfadpruefung ohne ..-Sperre: " + body + + +@pytest.mark.skipif(shutil.which("bash") is None, reason="bash nicht verfuegbar") +def test_helper_accepts_instances_but_no_invented_keys(): + """Der Namensraum einer Instanz ist eng begrenzt. + + Er landet ungefiltert in Pfaden wie ``/etc/nginx/sites-available/``. + """ + script = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8") + start = script.index("check_app_key() {") + end = script.index(chr(10) + "}", start) + 2 + prelude = 'APP_KEYS_ALLOWED=("tesm" "tesm-license")' + chr(10) + "fail() { exit 1; }" + chr(10) + snippet = prelude + script[start:end] + + def accepted(key: str) -> bool: + command = snippet + chr(10) + 'check_app_key "' + key + '"' + return subprocess.run(["bash", "-c", command], capture_output=True).returncode == 0 + + for good in ("tesm", "tesm-license", "tesm-opus", "tesm-license-opus", "tesm-test2"): + assert accepted(good), good + for bad in ("", "foo", "tesm-", "tesm-../etc", "../tesm", "tesm-GROSS", "tesm-" + "x" * 20): + assert not accepted(bad), bad + + + +def test_systemd_units_are_hardened(): + units = list((REPO_ROOT / "deploy" / "systemd").glob("*.service")) + assert units + for unit in units: + text = unit.read_text(encoding="utf-8") + assert "User=root" not in text, f"{unit.name} laeuft als root" + # ProtectSystem=full, nicht strict: strict sperrt auch den privilegierten + # Helfer aus /etc aus -- gemessen, siehe test_core_security.py. + for directive in ("ProtectSystem=full", "PrivateTmp=yes"): + assert directive in text, f"{unit.name}: {directive} fehlt" + # NoNewPrivileges waere hier kontraproduktiv: es schaltet setuid ab und + # damit sudo -- der Dienst koennte den privilegierten Helfer nicht mehr + # aufrufen, und jede Systemaktion aus der Oberflaeche waere tot. Genau + # so war es, und die Diagnoseseite meldete trotzdem "Helfer erreichbar". + assert "NoNewPrivileges=yes" not in text, ( + f"{unit.name}: NoNewPrivileges=yes verhindert sudo und damit den Helfer." + ) + + +def test_sudoers_grants_only_the_helper(): + for name in ("tesm", "tesm-license"): + raw = (REPO_ROOT / "deploy" / "sudoers" / name).read_text(encoding="utf-8") + # Kommentare erklaeren, was bewusst *nicht* getan wird -- sie duerfen den + # Test nicht ausloesen. + rules = [line for line in raw.splitlines() if line.strip() and not line.startswith("#")] + body = "\n".join(rules) + assert "NOPASSWD: ALL" not in body, f"{name}: zu weite sudo-Regel" + assert "/usr/local/lib/tesm/tesm-helper" in body + for line in rules: + if "NOPASSWD" in line: + assert line.strip().endswith("/usr/local/lib/tesm/tesm-helper"), ( + f"{name}: NOPASSWD-Regel erlaubt mehr als den Helfer: {line}" + ) + + +@pytest.mark.parametrize("app", ["tesm", "tesm-license"]) +def test_nginx_template_serves_static_from_app_root(app: str): + """Der alias-Pfad ist genau der Fehler, der im Vorgaenger jahrelang ueberlebte.""" + text = (REPO_ROOT / "deploy" / "nginx" / f"{app}.conf").read_text(encoding="utf-8") + assert f"alias /srv/{app}/static/;" in text + assert f"alias /srv/{app}/static-core/;" in text + + +def test_documentation_referenced_by_the_units_exists(): + """``Documentation=`` in der Unit muss auf eine Datei zeigen, die es gibt. + + Sonst laeuft ``systemctl help tesm`` ins Leere -- und schlimmer: der + Verweis suggeriert eine Anleitung, die niemand geschrieben hat. + """ + for unit in (REPO_ROOT / "deploy" / "systemd").glob("*.service"): + for line in unit.read_text(encoding="utf-8").splitlines(): + if not line.startswith("Documentation=file:"): + continue + target = line.split("file:", 1)[1].strip() + # /srv//docs/X -> docs/X im Repository + relative = target.split("/docs/", 1)[1] + assert (REPO_ROOT / "docs" / relative).is_file(), ( + f"{unit.name} verweist auf {target}, docs/{relative} fehlt" + ) + + +def test_readme_links_point_to_existing_files(): + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + for target in re.findall(r"\]\((docs/[^)#]+)\)", readme): + assert (REPO_ROOT / target).is_file(), f"README verweist auf {target}" + + +def _templates() -> list[Path]: + return [ + path + for path in _repo_files() + if path.suffix == ".html" and "templates" in path.parts + ] + + +def test_no_html_entities_inside_escaped_macro_calls(): + """Makros escapen ihren Text -- Entities darin erscheinen woertlich. + + Auf der Wartungsseite stand deshalb ``„Linux-Client“`` als + sichtbarer Text. Der Fehler ist unauffaellig, weil dieselbe Schreibweise + im uebrigen Vorlagentext voellig richtig ist. + """ + # Genau bis zum schliessenden "}}" des Aufrufs -- ein greedy Ausdruck + # verschluckte sonst den nachfolgenden Seiteninhalt und meldete dessen + # Tabellenmarkup als Treffer. + pattern = re.compile( + r"\{\{\s*(?:alert|empty_state|pill|stat)\((?:[^{}]|\{[^{}]*\})*?\)\s*" + r"(?:\|\s*safe\s*)?\}\}", + re.S, + ) + offenders: list[str] = [] + for path in _templates(): + for match in pattern.finditer(path.read_text(encoding="utf-8")): + call = match.group(0) + # Frueher stand hier eine Ausnahme fuer "| safe". Sie war falsch: + # das Filter wirkt auf das *Ergebnis* des Makros, escaped wurde aber + # schon beim Einsetzen des Arguments. Auf der Geraeteseite stand + # deshalb woertlich "und" im Text. + if re.search(r"&[a-zA-Z]{2,10};|", call): + offenders.append( + f"{path.relative_to(REPO_ROOT)}: {' '.join(call.split())[:100]}" + ) + assert not offenders, ( + "HTML im escapten Makrotext -- das erscheint woertlich auf der Seite:\n " + + "\n ".join(offenders) + ) + + +def test_refresh_regions_never_point_at_a_whole_page(): + """``data-refresh-url`` schreibt die Antwort in ``innerHTML``. + + Zeigt die URL auf eine vollstaendige Seite, landet die ganze Seite in + einem Ausschnitt -- bei jedem Durchlauf eine Ebene tiefer. Genau das ist + auf der Wartungsseite passiert: die Karte "Auftraege" holte sich + ``maintenance.overview``, also sich selbst. + + Erlaubt sind nur Endpunkte, deren Name sie als Ausschnitt ausweist. + """ + fragment_markers = ("fragment", "tiles", "output", "status", "partial", "ausschnitt") + pattern = re.compile(r"data-refresh-url=\"\{\{\s*url_for\('([^']+)'") + found = 0 + for path in _templates(): + for match in pattern.finditer(path.read_text(encoding="utf-8")): + endpoint = match.group(1) + found += 1 + assert any(marker in endpoint.lower() for marker in fragment_markers), ( + f"{path.relative_to(REPO_ROOT)}: data-refresh-url zeigt auf {endpoint!r} -- " + "das sieht nach einer ganzen Seite aus, nicht nach einem Ausschnitt." + ) + assert found, "Keine Aktualisierungsbereiche gefunden -- Test prueft ins Leere." + + +def test_refresh_loop_treats_zero_as_off(): + """``Number(x || 15000)`` macht aus einer 0 klammheimlich 15000.""" + script = ( + REPO_ROOT + / "packages" + / "tesm-core" + / "src" + / "tesm_core" + / "static" + / "tesm_core" + / "js" + / "app.js" + ).read_text(encoding="utf-8") + assert "refreshInterval || " not in script + assert "interval <= 0" in script, "Ein Intervall von 0 muss die Aktualisierung abschalten." + + +def test_licensing_package_is_shared_not_copied(): + """Es darf keine zweite Kopie des Lizenzprotokolls geben. + + Genau diese Doppelung -- zwei byte-identisch zu haltende ``licensing.py`` -- + war im Vorgaenger die gefaehrlichste Invariante. + """ + copies = [ + str(path.relative_to(REPO_ROOT)) + for path in _repo_files() + if path.name == "licensing.py" + ] + assert not copies, f"Kopien des Lizenzmoduls gefunden: {copies}" diff --git a/tests/test_tesm_restarts_ssh.py b/tests/test_tesm_restarts_ssh.py new file mode 100644 index 0000000..2540b91 --- /dev/null +++ b/tests/test_tesm_restarts_ssh.py @@ -0,0 +1,1376 @@ +"""Neustarts und SSH-Zugang zu Clients. + +Zwei Luecken, die erst im Betrieb auffielen: + +1. **Die Neustart-Historie zeigte nur die halbe Wahrheit.** Sie hiess + "PoE-Vorgaenge" und enthielt auch nur PoE-Vorgaenge. Ein Geraet, das die + Wartung per ``reboot`` neu gestartet hatte, fehlte dort vollstaendig -- + obwohl genau dort nachsieht, wer wissen will, warum ein Geraet weg war. + +2. **Fuer Clients gab es keine Host-Schluessel-Freigabe.** Die Wartung + verweigerte den Dienst ("Schluessel unbekannt"), und es gab keine Stelle in + der Oberflaeche, an der man ihn haette ansehen und freigeben koennen. Eine + Sackgasse, aus der nur ein Shell-Zugang auf den Server herausfuehrte. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + + +@pytest.fixture() +def tesm_app(instance_env: Any): + from tesm import create_app + + return create_app(TESTING=True) + + +@pytest.fixture() +def tclient(tesm_app: Any): + return tesm_app.test_client() + + +@pytest.fixture() +def admin(tesm_app: Any): + from tesm_core.extension import core + from tesm_core.security import passwords + + extension = core(tesm_app) + with extension.database.session() as conn, extension.database.transaction(conn): + conn.execute( + "INSERT INTO users (username, password_hash, is_admin, auth_source, created_at, " + "updated_at) VALUES ('root',?,1,'local',datetime('now'),datetime('now'))", + (passwords.hash_password("Ein-gutes-Passwort-1"),), + ) + return {"username": "root", "password": "Ein-gutes-Passwort-1"} + + +def _token(client: Any, path: str = "/login") -> str: + body = client.get(path).get_data(as_text=True) + for marker in ('name="csrf_token" value="', 'data-csrf="'): + if marker in body: + start = body.index(marker) + len(marker) + return body[start : body.index('"', start)] + raise AssertionError(f"Kein CSRF-Token in {path}") + + +@pytest.fixture() +def session(tclient: Any, admin: dict[str, str]): + tclient.post( + "/login", + data={ + "username": admin["username"], + "password": admin["password"], + "csrf_token": _token(tclient), + }, + follow_redirects=True, + ) + return tclient + + +@pytest.fixture() +def device(tesm_app: Any): + """Ein Client mit Zugangsdaten -- Voraussetzung fuer Wartung und Konsole.""" + from tesm_core.extension import core + + extension = core(tesm_app) + with extension.database.session() as conn, extension.database.transaction(conn): + conn.execute( + "INSERT INTO credentials (name, username, secret_encrypted, category, created_at, " + "updated_at) VALUES ('linux-login','wartung','', 'linux', datetime('now'), " + "datetime('now'))" + ) + conn.execute( + "INSERT INTO devices (name, mac, ip, ssh_port, credential_id, is_active, " + "created_at, updated_at) VALUES ('Kamera-1','aa:bb:cc:dd:ee:01','10.9.9.9',22,1,1," + "datetime('now'), datetime('now'))" + ) + return {"id": 1, "ip": "10.9.9.9", "port": 22} + + +# --------------------------------------------------------------------------- # +# Neustart-Historie +# --------------------------------------------------------------------------- # + + +def test_restart_log_replaces_the_poe_only_page(session): + """Die alte Adresse ist weg, die neue zeigt Neustarts jeder Art.""" + assert session.get("/protokolle/poe").status_code == 404 + response = session.get("/protokolle/neustarts") + assert response.status_code == 200 + body = response.get_data(as_text=True) + assert "Neustarts" in body + assert "PoE-Vorgaenge" not in body + + +def test_restart_log_shows_both_methods(tesm_app, session, device): + from tesm.services import monitor + from tesm_core.extension import core + + extension = core(tesm_app) + with extension.database.session() as conn, extension.database.transaction(conn): + monitor.record_restart( + conn, + device_id=1, + action="cycle", + result="ok", + trigger="auto", + actor="system", + duration_ms=1200, + detail="Port getrennt und wieder zugeschaltet", + ) + monitor.record_restart( + conn, + device_id=1, + action="reboot", + result="ok", + trigger="maintenance", + actor="root", + duration_ms=45000, + detail="Neustart ueber SSH", + method=monitor.METHOD_SSH, + ) + + body = session.get("/protokolle/neustarts").get_data(as_text=True) + assert "PoE-Port" in body + assert "SSH" in body + assert "Kamera-1" in body + + # Filter je Methode + only_ssh = session.get("/protokolle/neustarts?methode=ssh").get_data(as_text=True) + assert "maintenance" in only_ssh + assert only_ssh.count(" None: + self._output = output + self._status = status + self._sent = False + self.closed = False + + def settimeout(self, _value: float) -> None: + pass + + def set_combine_stderr(self, _value: bool) -> None: + pass + + def exec_command(self, _command: str) -> None: + pass + + def recv_ready(self) -> bool: + return not self._sent + + def recv(self, _size: int) -> bytes: + self._sent = True + return self._output + + def exit_status_ready(self) -> bool: + return self._sent + + def recv_exit_status(self) -> int: + return self._status + + def close(self) -> None: + self.closed = True + + +class FakeTransport: + def __init__(self, channel: FakeChannel) -> None: + self.channel = channel + + def is_active(self) -> bool: + return True + + def open_session(self) -> FakeChannel: + return self.channel + + def close(self) -> None: + pass + + +def test_shell_session_returns_output_and_status(): + from tesm.services.shellcli import ShellSession + + channel = FakeChannel(b"Linux poetest 6.8.0\n", 0) + session = ShellSession(None, host="h", port=22, username="u", password="p") + session._transport = FakeTransport(channel) # noqa: SLF001 + + result = session.run("uname -sr") + assert result["output"].startswith("Linux poetest") + assert result["status"] == 0 + assert result["duration_ms"] >= 0 + assert channel.closed, "Der Kanal muss geschlossen werden." + + +def test_shell_session_reports_a_failing_command(): + from tesm.services.shellcli import ShellSession + + session = ShellSession(None, host="h", port=22, username="u", password="p") + session._transport = FakeTransport(FakeChannel(b"bash: nope: not found\n", 127)) # noqa: SLF001 + result = session.run("nope") + assert result["status"] == 127 + + +def test_shell_session_refuses_a_dead_transport(): + from tesm.services.shellcli import ShellSession + from tesm.services.ssh import SshError + + session = ShellSession(None, host="h", port=22, username="u", password="p") + with pytest.raises(SshError): + session.run("uptime") + + +def test_shell_session_truncates_a_flood(): + from tesm.services.shellcli import MAX_OUTPUT_BYTES, ShellSession + + flood = b"x" * (MAX_OUTPUT_BYTES + 1024) + session = ShellSession(None, host="h", port=22, username="u", password="p") + session._transport = FakeTransport(FakeChannel(flood, 0)) # noqa: SLF001 + result = session.run("yes") + assert "abgeschnitten" in result["output"] + assert len(result["output"]) < len(flood.decode()) + + +# --------------------------------------------------------------------------- # +# Auftragsausgabe +# --------------------------------------------------------------------------- # + + +def test_job_output_keeps_one_line_per_entry(tesm_app): + """Jede Ausgabe gehoert auf eine eigene Zeile. + + ``splitlines()`` wirft das abschliessende Zeilenende weg. Ohne Korrektur + klebte jede neue Zeile an der vorherigen, und das ganze Protokoll stand am + Ende auf einer einzigen Zeile -- genau so im Betrieb aufgefallen. + """ + from tesm.services import maintenance + from tesm_core.db import Database + from tesm_core.extension import core + + extension = core(tesm_app) + with extension.database.session() as conn, extension.database.transaction(conn): + handle = maintenance.create_job( + conn, kind=maintenance.JOB_KIND_UPDATE, subject="Testgeraet", actor="root" + ) + + for line in ("--- Paketlisten aktualisieren ---", "sudo: a password is required", + "Schritt endete mit Rueckgabewert 1."): + maintenance.update_job(extension.database, handle.id, append_output=f"12:00:00 {line}\n") + + with extension.database.session() as conn: + output = str(Database.value(conn, "SELECT output FROM jobs WHERE id=?", (handle.id,))) + + lines = output.splitlines() + assert len(lines) == 3, f"Erwartet drei Zeilen, bekommen: {output!r}" + assert lines[0].endswith("--- Paketlisten aktualisieren ---") + assert lines[1].endswith("sudo: a password is required") + assert "---sudo:" not in output, "Zeilen kleben aneinander." + + +def test_job_output_is_trimmed_but_stays_line_wise(tesm_app): + from tesm.services import maintenance + from tesm_core.db import Database + from tesm_core.extension import core + + extension = core(tesm_app) + with extension.database.session() as conn, extension.database.transaction(conn): + handle = maintenance.create_job( + conn, kind=maintenance.JOB_KIND_UPDATE, subject="Flut", actor="root" + ) + for index in range(maintenance.MAX_OUTPUT_LINES + 50): + maintenance.update_job(extension.database, handle.id, append_output=f"Zeile {index}\n") + + with extension.database.session() as conn: + output = str(Database.value(conn, "SELECT output FROM jobs WHERE id=?", (handle.id,))) + lines = output.splitlines() + assert len(lines) == maintenance.MAX_OUTPUT_LINES + # Die juengsten Zeilen bleiben stehen, nicht die aeltesten. + assert lines[-1] == f"Zeile {maintenance.MAX_OUTPUT_LINES + 49}" + + +def test_known_failures_get_an_actionable_hint(): + """Die rohe Meldung sagt die Wahrheit, aber nicht, was zu tun ist.""" + from tesm.services.maintenance import hint_for + + # Passwort verlangt, aber keines hinterlegt -> auf die Zugangsdaten zeigen. + hint = hint_for("sudo: a password is required") + assert "Zugangsdaten" in hint + assert "NOPASSWD" not in hint, "Der Weg ueber die Zugangsdaten ist der einfachere." + + # Passwort abgewiesen -> nicht die Zugangsdaten, sondern sudo ist das Problem. + assert "abgewiesen" in hint_for("sudo: 1 incorrect password attempt") + + # Konto darf gar kein sudo -> sudoers-Regel noetig. + not_allowed = hint_for("wartung is not in the sudoers file. This incident will be reported.") + assert "sudoers" in not_allowed + assert "NOPASSWD" in not_allowed + + assert "requiretty" in hint_for("sudo: no tty present and no askpass program specified") + assert "apt-get" in hint_for("bash: apt-get: command not found") + assert "unattended" in hint_for("E: Could not get lock /var/lib/dpkg/lock-frontend") + assert hint_for("alles in Ordnung") == "" + + +def test_hint_survives_surrounding_output(): + from tesm.services.maintenance import hint_for + + output = "\n".join( + ["--- Paketlisten aktualisieren ---", "sudo: a password is required", "Ende"] + ) + assert hint_for(output) + + +# --------------------------------------------------------------------------- # +# sudo mit dem hinterlegten Passwort +# --------------------------------------------------------------------------- # + + +class SudoTransport: + """Zeichnet auf, welche Kommandos laufen und was ueber stdin hereinkommt.""" + + def __init__(self, passwordless_ok: bool) -> None: + self.passwordless_ok = passwordless_ok + self.commands: list[str] = [] + self.stdin: list[str] = [] + + def is_active(self) -> bool: + return True + + def open_session(self): # type: ignore[no-untyped-def] + return SudoChannel(self) + + def close(self) -> None: + pass + + +class SudoChannel: + def __init__(self, transport: SudoTransport) -> None: + self.transport = transport + self.command = "" + self.written = b"" + self._done = False + + def settimeout(self, _v: float) -> None: + pass + + def set_combine_stderr(self, _v: bool) -> None: + pass + + def exec_command(self, command: str) -> None: + self.command = command + self.transport.commands.append(command) + + def sendall(self, data: bytes) -> None: + self.written += data + + def shutdown_write(self) -> None: + self.transport.stdin.append(self.written.decode()) + + def recv_ready(self) -> bool: + return False + + def exit_status_ready(self) -> bool: + return True + + def recv(self, _n: int) -> bytes: + return b"" + + def recv_exit_status(self) -> int: + if self.command.startswith("sudo -n true"): + return 0 if self.transport.passwordless_ok else 1 + return 0 + + def close(self) -> None: + pass + + +def test_passwordless_sudo_keeps_the_password_on_the_server(): + """Geht es ohne, verlaesst das Passwort den Server gar nicht erst.""" + from tesm.services.maintenance import SUDO_PASSWORDLESS, _sudo_mode + + transport = SudoTransport(passwordless_ok=True) + lines: list[str] = [] + sudo, stdin = _sudo_mode(transport, "geheim", lines.append) + + assert sudo == SUDO_PASSWORDLESS + assert stdin == "" + assert transport.stdin == [], "Ohne Not wird kein Passwort uebertragen." + assert "ohne Passwort" in " ".join(lines) + + +def test_sudo_falls_back_to_the_stored_password(): + from tesm.services.maintenance import SUDO_WITH_PASSWORD, _sudo_mode + + transport = SudoTransport(passwordless_ok=False) + lines: list[str] = [] + sudo, stdin = _sudo_mode(transport, "geheim", lines.append) + + assert sudo == SUDO_WITH_PASSWORD + assert stdin == "geheim\n" + assert "Zugangsdaten" in " ".join(lines) + + +def test_password_never_appears_on_the_command_line(): + """Auf dem Zielgeraet steht die Kommandozeile in ``ps`` -- fuer jeden lesbar.""" + from tesm.services.maintenance import ( + REBOOT_COMMAND, + UPDATE_COMMANDS, + _run_remote_command, + _sudo_mode, + ) + + transport = SudoTransport(passwordless_ok=False) + sudo, stdin = _sudo_mode(transport, "streng-geheim", lambda _line: None) + + for _label, template in UPDATE_COMMANDS: + _run_remote_command( + transport, template.format(sudo=sudo), timeout=5, on_line=lambda _l: None, + stdin_text=stdin, + ) + _run_remote_command( + transport, REBOOT_COMMAND.format(sudo=sudo), timeout=5, on_line=lambda _l: None, + stdin_text=stdin, + ) + + assert all("streng-geheim" not in command for command in transport.commands), ( + "Das Passwort darf nie in einem Kommando stehen." + ) + # Es geht ausschliesslich ueber stdin. + assert transport.stdin, "Das Passwort muss ueber stdin gehen." + assert all(entry == "streng-geheim\n" for entry in transport.stdin) + + +def test_sudo_prompt_is_suppressed(): + """Ohne ``-p ''`` landet die Passwortaufforderung mitten in der Ausgabe.""" + from tesm.services.maintenance import SUDO_WITH_PASSWORD + + assert "-S" in SUDO_WITH_PASSWORD + assert "-p ''" in SUDO_WITH_PASSWORD + assert "-k" in SUDO_WITH_PASSWORD + + +def test_commands_carry_no_hardcoded_sudo(): + """Das Verfahren wird zur Laufzeit gewaehlt -- nicht in der Konstante festgelegt.""" + from tesm.services.maintenance import REBOOT_COMMAND, UPDATE_COMMANDS + + for _label, template in UPDATE_COMMANDS: + assert "{sudo}" in template + assert "sudo -n" not in template + assert "{sudo}" in REBOOT_COMMAND + + +def test_without_a_stored_password_nothing_is_invented(): + from tesm.services.maintenance import SUDO_PASSWORDLESS, _sudo_mode + + transport = SudoTransport(passwordless_ok=False) + lines: list[str] = [] + sudo, stdin = _sudo_mode(transport, "", lines.append) + assert sudo == SUDO_PASSWORDLESS + assert stdin == "" + assert "keines hinterlegt" in " ".join(lines) + + +# --------------------------------------------------------------------------- # +# Rueckkehr nach einem Neustart +# --------------------------------------------------------------------------- # + + +def test_reboot_waits_on_tcp_not_on_icmp(): + """ICMP kann der Webprozess gar nicht. + + Die systemd-Unit der Weboberflaeche hat ein leeres + Die systemd-Unit der Weboberflaeche bekommt keine AmbientCapabilities -- + damit greift die Datei-Capability von ``/usr/bin/ping`` nicht. Jeder Ping + schlaegt dort fehl, und der Wartungsauftrag lief deshalb *immer* in die + Zeitueberschreitung, obwohl das Geraet laengst zurueck war. + """ + import inspect + + from tesm.services import maintenance + + source = inspect.getsource(maintenance.run_reboot) + assert "tcp_reachable(" in source + assert "ping(" not in source, "ICMP ist im Webprozess nicht verfuegbar." + + +def test_web_unit_has_no_net_raw_but_the_monitor_does(): + """Die Aufteilung ist Absicht und muss so bleiben.""" + from pathlib import Path + + root = Path(__file__).resolve().parent.parent / "deploy" / "systemd" + web = (root / "tesm.service").read_text(encoding="utf-8") + monitor = (root / "tesm-monitor.service").read_text(encoding="utf-8") + + # Kein CapabilityBoundingSet mehr -- es bricht sudo (gemessen). Was den + # Webprozess von ICMP fernhaelt, ist das Fehlen von AmbientCapabilities. + assert "CapabilityBoundingSet=" not in web + assert "AmbientCapabilities=CAP_NET_RAW" not in web, ( + "Der Webprozess soll keine Rohsockets duerfen -- er ist der exponierte." + ) + assert "AmbientCapabilities=CAP_NET_RAW" in monitor, ( + "Der Ueberwachungsdienst braucht CAP_NET_RAW fuer ICMP." + ) + + +def test_tcp_probe_needs_no_privileges(): + import socket + import threading + + from tesm.services.monitor import tcp_reachable + + server = socket.socket() + server.bind(("127.0.0.1", 0)) + server.listen(1) + port = server.getsockname()[1] + threading.Thread(target=lambda: server.accept(), daemon=True).start() + + assert tcp_reachable("127.0.0.1", port, timeout=2) + server.close() + + +def test_tcp_probe_reports_a_closed_port(): + from tesm.services.monitor import tcp_reachable + + # Port 1 ist auf keinem sinnvoll konfigurierten System offen. + assert not tcp_reachable("127.0.0.1", 1, timeout=1) + + +# --------------------------------------------------------------------------- # +# Keine geschachtelten Datenbankverbindungen +# --------------------------------------------------------------------------- # + + +def test_restart_takes_the_keystore_not_a_callback(): + """Ein Rueckruf kannte die offene Verbindung nicht und oeffnete eine neue. + + Der Ueberwachungsdienst starb dadurch bei jedem automatischen PoE-Neustart + mit ``NestedConnectionError`` -- und der Geraetestatus blieb stehen. Mit dem + Schluesselspeicher als Argument gibt es die falsche Variante nicht mehr. + """ + import inspect + + from tesm.services import monitor + + for function in (monitor.restart_device_poe, monitor.run_sweep): + signature = inspect.signature(function) + assert "keystore" in signature.parameters, function.__name__ + assert "decrypt_secret" not in signature.parameters, function.__name__ + + source = inspect.getsource(monitor.restart_device_poe) + assert "credential_secret(conn," in source, "Die vorhandene Verbindung muss benutzt werden." + + +def test_no_caller_passes_a_decrypt_callback(): + from pathlib import Path + + root = Path(__file__).resolve().parent.parent / "apps" / "tesm" / "src" / "tesm" + offenders = [ + str(path.relative_to(root)) + for path in root.rglob("*.py") + if "__pycache__" not in path.parts and "decrypt_secret" in path.read_text(encoding="utf-8") + ] + assert not offenders, f"Rueckruf lebt noch in: {offenders}" + + +def test_sweep_survives_a_restart_without_nested_connection(tesm_app, device): + """Der ganze Durchlauf, inklusive Neustartversuch, ohne zweite Verbindung.""" + from tesm.services import monitor + from tesm_core.extension import core + + extension = core(tesm_app) + live = extension.registry["live_log"] + + with tesm_app.app_context(): + report = monitor.run_sweep( + database=extension.database, + live=live, + keystore=extension.keystore, + license_allows_auto_restart=True, + failures_before_restart=1, + ) + # Das Geraet ist nicht erreichbar -- entscheidend ist, dass der Durchlauf + # ueberhaupt durchlaeuft und nicht an einer geschachtelten Verbindung stirbt. + assert report.checked >= 1 + + +# --------------------------------------------------------------------------- # +# Windows: kein SSH, kein Host-Schluessel +# --------------------------------------------------------------------------- # + + +def test_windows_is_not_an_ssh_target(): + from tesm.services import inventory + + assert inventory.is_ssh_managed("linux") + assert inventory.is_ssh_managed("switch") + assert inventory.is_ssh_managed(None) + assert not inventory.is_ssh_managed("windows") + + +def test_windows_devices_do_not_appear_in_the_host_key_page(tesm_app, session): + """Sonst stuenden sie fuer immer auf "Freigabe offen".""" + from tesm_core.extension import core + + extension = core(tesm_app) + with extension.database.session() as conn, extension.database.transaction(conn): + conn.execute( + "INSERT INTO credentials (name, username, secret_encrypted, category, created_at, " + "updated_at) VALUES ('win','admin','','windows',datetime('now'),datetime('now'))" + ) + conn.execute( + "INSERT INTO credentials (name, username, secret_encrypted, category, created_at, " + "updated_at) VALUES ('lin','wartung','','linux',datetime('now'),datetime('now'))" + ) + conn.execute( + "INSERT INTO devices (name, mac, ip, ssh_port, credential_id, is_active, created_at, " + "updated_at) VALUES ('PC-Win','aa:bb:cc:00:00:01','10.5.5.1',22,1,1,datetime('now')," + "datetime('now'))" + ) + conn.execute( + "INSERT INTO devices (name, mac, ip, ssh_port, credential_id, is_active, created_at, " + "updated_at) VALUES ('PC-Linux','aa:bb:cc:00:00:02','10.5.5.2',22,2,1,datetime('now')," + "datetime('now'))" + ) + + body = session.get("/hostschluessel/").get_data(as_text=True) + assert "PC-Linux" in body + assert "PC-Win" not in body, "Windows braucht keinen Host-Schluessel." + + +def test_windows_reboot_uses_rpc_not_ssh(): + import inspect + + from tesm.services import maintenance + + source = inspect.getsource(maintenance.run_reboot) + assert "_reboot_windows(" in source + assert "WINDOWS_PROBE_PORT" in source + # Der SSH-Zweig darf fuer Windows gar nicht erst betreten werden. + assert "if is_windows:" in source + + +def test_windows_password_never_reaches_the_command_line(monkeypatch, tmp_path): + """``net -U benutzer%pass`` waere in ``ps`` sichtbar -- auf beiden Seiten.""" + import subprocess + + from tesm.services import windowsops + + seen: dict[str, object] = {} + + def fake_run(command, **kwargs): + seen["command"] = command + authfile = command[command.index("--authentication-file") + 1] + seen["authfile_content"] = pathlib_read(authfile) + return subprocess.CompletedProcess(command, 0, "Shutdown ausgeloest", "") + + def pathlib_read(path: str) -> str: + with open(path, encoding="utf-8") as handle: + return handle.read() + + monkeypatch.setattr(windowsops.shutil, "which", lambda _name: "/usr/bin/net") + monkeypatch.setattr(windowsops.subprocess, "run", fake_run) + + result = windowsops.reboot("10.5.5.1", "administrator", "streng-geheim") + assert result.ok + + command = " ".join(str(part) for part in seen["command"]) + assert "streng-geheim" not in command, "Passwort steht in der Kommandozeile." + assert "%" not in command + # Es geht ausschliesslich ueber die Anmeldedatei. + assert "password=streng-geheim" in str(seen["authfile_content"]) + + +def test_windows_auth_file_is_removed_afterwards(monkeypatch): + import os + import subprocess + + from tesm.services import windowsops + + captured: dict[str, str] = {} + + def fake_run(command, **kwargs): + captured["path"] = command[command.index("--authentication-file") + 1] + return subprocess.CompletedProcess(command, 0, "", "") + + monkeypatch.setattr(windowsops.shutil, "which", lambda _name: "/usr/bin/net") + monkeypatch.setattr(windowsops.subprocess, "run", fake_run) + windowsops.reboot("10.5.5.1", "administrator", "geheim") + + assert not os.path.exists(captured["path"]), "Die Anmeldedatei muss verschwinden." + + +def test_windows_domain_prefix_is_split(): + from tesm.services import windowsops + + captured: dict[str, str] = {} + + class FakeCompleted: + returncode = 0 + stdout = "" + stderr = "" + + def fake_run(command, **kwargs): + with open(command[command.index("--authentication-file") + 1], encoding="utf-8") as handle: + captured["content"] = handle.read() + return FakeCompleted() + + import subprocess + + original_which = windowsops.shutil.which + original_run = windowsops.subprocess.run + windowsops.shutil.which = lambda _name: "/usr/bin/net" + windowsops.subprocess.run = fake_run + try: + windowsops.reboot("10.5.5.1", "EERTMOED" + chr(92) + "administrator", "geheim") + finally: + windowsops.shutil.which = original_which + windowsops.subprocess.run = original_run + + assert "username=administrator" in captured["content"] + assert "domain=EERTMOED" in captured["content"] + assert subprocess is not None + + +def test_missing_samba_is_explained_not_silently_ignored(monkeypatch): + from tesm.services import windowsops + + monkeypatch.setattr(windowsops.shutil, "which", lambda _name: None) + result = windowsops.reboot("10.5.5.1", "administrator", "geheim") + assert not result.ok + assert windowsops.REQUIRED_PACKAGE in result.hint + + +def test_windows_errors_get_a_hint(): + from tesm.services.windowsops import hint_for + + assert "DOMAENE" in hint_for("NT_STATUS_LOGON_FAILURE") + assert "Remotesystem" in hint_for("NT_STATUS_ACCESS_DENIED") + assert "445" in hint_for("NT_STATUS_CONNECTION_REFUSED") + assert hint_for("alles gut") == "" + + +def test_samba_package_is_installable_through_the_helper(): + from pathlib import Path + + helper = (Path(__file__).resolve().parent.parent / "deploy" / "tesm-helper").read_text( + encoding="utf-8" + ) + from tesm.services.windowsops import REQUIRED_PACKAGE + + assert REQUIRED_PACKAGE in helper, "Sonst laesst es sich nicht nachinstallieren." + + +# --------------------------------------------------------------------------- # +# Seiten mit Inhalt rendern +# --------------------------------------------------------------------------- # + + +@pytest.fixture() +def populated(tesm_app: Any): + """Ein Bestand, der jede Verzweigung der Vorlagen erreicht. + + Ein Rauchtest auf leeren Seiten prueft zu wenig: Schleifenkoerper laufen + dort nie. Genau so ist eine fehlende Vorlagenvariable durchgerutscht -- + lokal gruen, auf dem Zielsystem HTTP 500, sobald der erste Datensatz da war. + """ + from tesm_core.extension import core + + extension = core(tesm_app) + with extension.database.session() as conn, extension.database.transaction(conn): + for index, (name, category) in enumerate( + [("sw-login", "switch"), ("linux-login", "linux"), ("win-login", "windows")], start=1 + ): + conn.execute( + "INSERT INTO credentials (name, username, secret_encrypted, category, " + "created_at, updated_at) VALUES (?,?,'',?,datetime('now'),datetime('now'))", + (name, f"user{index}", category), + ) + conn.execute( + "INSERT INTO switches (hostname, ip, ssh_port, credential_id, created_at, updated_at) " + "VALUES ('sw-1','10.7.0.1',22,1,datetime('now'),datetime('now'))" + ) + for index, (name, credential) in enumerate( + [("Linux-PC", 2), ("Windows-PC", 3), ("Ohne-Login", None)], start=1 + ): + conn.execute( + "INSERT INTO devices (name, mac, ip, ssh_port, switch_id, port, credential_id, " + "is_active, created_at, updated_at) " + "VALUES (?,?,?,22,1,?,?,1,datetime('now'),datetime('now'))", + (name, f"aa:bb:cc:00:0{index}:01", f"10.7.0.{10 + index}", str(index), credential), + ) + conn.execute( + "INSERT INTO switch_host_keys (host, port, key_type, fingerprint, added_at, added_by) " + "VALUES ('10.7.0.1',22,'ssh-ed25519',?,datetime('now'),'test')", + ("SHA256:" + "A" * 43,), + ) + return True + + +PAGES_WITH_CONTENT = [ + "/", + "/clients/", + "/clients/1", + "/switche/", + "/switche/1", + "/zugangsdaten/", + "/hostschluessel/", + "/protokolle/neustarts", + "/papierkorb/", + "/diagnose/", +] + + +@pytest.mark.parametrize("path", PAGES_WITH_CONTENT) +def test_pages_render_with_real_data(session, populated, path): + response = session.get(path) + body = response.get_data(as_text=True) + assert response.status_code == 200, f"{path}: HTTP {response.status_code}" + assert "Interner Fehler" not in body, path + # Eine fehlende Vorlagenvariable schlaegt als UndefinedError durch -- die + # Fehlerseite nennt sie beim Namen. + assert "is undefined" not in body, path + + +def test_maintenance_pages_render_with_real_data(session, populated, licensed): + for path in ("/wartung/", "/dateifreigaben/", "/dhcp/"): + response = session.get(path) + assert response.status_code == 200, f"{path}: HTTP {response.status_code}" + assert "Interner Fehler" not in response.get_data(as_text=True), path + + +def test_device_form_offers_creating_credentials(session, populated): + body = session.get("/clients/").get_data(as_text=True) + assert "neue Zugangsdaten anlegen" in body + assert "new_credential_name" in body + assert "new_credential_secret" in body + + +def test_new_device_can_bring_its_own_credentials(session, populated, tesm_app): + from tesm_core.db import Database + from tesm_core.extension import core + + response = session.post( + "/clients/neu", + data={ + "csrf_token": _token(session, "/clients/"), + "name": "Frisch", + "mac": "aa:bb:cc:11:22:33", + "ip": "10.7.0.90", + "ssh_port": "22", + "credential_id": "__neu__", + "new_credential_name": "Frische Zugangsdaten", + "new_credential_username": "wartung", + "new_credential_secret": "Ein-Passwort-1", + "new_credential_category": "linux", + "is_active": "1", + }, + follow_redirects=True, + ) + assert "angelegt" in response.get_data(as_text=True) + + extension = core(tesm_app) + with extension.database.session() as conn: + credential = Database.one( + conn, "SELECT * FROM credentials WHERE name='Frische Zugangsdaten'" + ) + device = Database.one(conn, "SELECT * FROM devices WHERE name='Frisch'") + assert credential is not None, "Die Zugangsdaten muessen mit angelegt werden." + assert device is not None + assert device["credential_id"] == credential["id"], "Sie muessen auch zugeordnet sein." + assert credential["secret_encrypted"], "Das Passwort muss verschluesselt gespeichert sein." + + +def test_a_nameless_new_credential_blocks_the_device(session, populated, tesm_app): + """Halb ausgefuehrt waere schlimmer als gar nicht: das Geraet haette dann + keine Zugangsdaten, obwohl welche gewollt waren.""" + from tesm_core.db import Database + from tesm_core.extension import core + + response = session.post( + "/clients/neu", + data={ + "csrf_token": _token(session, "/clients/"), + "name": "Halb", + "mac": "aa:bb:cc:44:55:66", + "ip": "10.7.0.91", + "ssh_port": "22", + "credential_id": "__neu__", + "new_credential_name": "", + "is_active": "1", + }, + follow_redirects=True, + ) + assert "Bezeichnung" in response.get_data(as_text=True) + + extension = core(tesm_app) + with extension.database.session() as conn: + assert Database.one(conn, "SELECT * FROM devices WHERE name='Halb'") is None + + +def test_sweep_holds_no_write_lock_across_the_ssh_session(): + """Eine SSH-Sitzung zum Switch dauert Sekunden -- eine Schreibsperre auch. + + Auf POETEST dauerte ein Durchlauf mit drei nicht erreichbaren Switchen + knapp 40 Sekunden, und die Oberflaeche antwortete waehrenddessen mit + "database is locked". Die langsame Netzarbeit gehoert deshalb ausserhalb + der Transaktion; geschrieben wird kurz und am Ende. + """ + import inspect + + from tesm.services import monitor + + source = inspect.getsource(monitor.run_sweep) + assert "with database.session() as conn, database.transaction(conn):\n outcome" not in source + assert "database=database," in source, "Die Aufzeichnung braucht eigene Transaktionen." + + restart = inspect.getsource(monitor.restart_device_poe) + assert "with write():" in restart + # Die SSH-Sitzung darf in keiner Transaktion stehen. + ssh_line = restart.index("with SwitchCli(") + assert "with write():" not in restart[max(0, ssh_line - 400) : ssh_line] + + +# --------------------------------------------------------------------------- # +# Neustart: mehrere Wege, klare Rangfolge +# --------------------------------------------------------------------------- # + + +def _device(**overrides): + base = { + "id": 1, + "name": "Testgeraet", + "switch_id": None, + "port": "", + "credential_id": None, + "credential_category": "", + } + return {**base, **overrides} + + +def test_poe_wins_when_several_ways_are_open(): + """PoE zuerst: es wirkt auch, wenn das Betriebssystem nicht mehr antwortet.""" + from tesm.services import restart + + device = _device(switch_id=1, port="7", credential_id=2, credential_category="linux") + options = restart.available(device) + assert [entry.key for entry in options] == ["poe", "ssh"] + assert restart.preferred(device).key == "poe" + + +def test_only_ssh_when_there_is_no_port(): + from tesm.services import restart + + device = _device(credential_id=2, credential_category="linux") + assert [entry.key for entry in restart.available(device)] == ["ssh"] + + +def test_windows_offers_rpc_never_ssh(): + from tesm.services import restart + + device = _device(credential_id=2, credential_category="windows") + assert [entry.key for entry in restart.available(device)] == ["rpc"] + + +def test_a_switch_without_a_port_is_not_a_way(): + """Ein Knopf, der verlaesslich nichts bewirkt, ist schlimmer als kein Knopf.""" + from tesm.services import restart + + assert restart.available(_device(switch_id=1, port="")) == [] + assert restart.available(_device(switch_id=None, port="7")) == [] + + +def test_nothing_configured_means_no_button(): + from tesm.services import restart + + assert restart.available(_device()) == [] + assert restart.preferred(_device()) is None + + +def test_ssh_and_rpc_need_the_maintenance_module(): + """PoE bleibt frei -- ein haengendes Geraet muss man immer neu starten koennen.""" + from tesm.services import restart + + linux = _device(switch_id=1, port="7", credential_id=2, credential_category="linux") + assert [entry.key for entry in restart.available(linux, maintenance_active=False)] == ["poe"] + + windows = _device(credential_id=2, credential_category="windows") + assert restart.available(windows, maintenance_active=False) == [] + + +def test_an_impossible_request_is_refused_not_substituted(): + """Sonst startet jemand ein Geraet anders neu, als er dachte.""" + from tesm.services import restart + + device = _device(switch_id=1, port="7") + assert restart.resolve(device, "ssh") is None + assert restart.resolve(device, "poe").key == "poe" + # Ohne Angabe gilt die Rangfolge. + assert restart.resolve(device, "").key == "poe" + + +def test_restart_route_refuses_an_unavailable_method(session, populated): + """Geraet 1 ("Linux-PC") haengt an Switch 1 Port 1 und hat Linux-Zugangsdaten.""" + response = session.post( + "/clients/3/neustart", + data={"csrf_token": _token(session, "/clients/"), "method": "poe"}, + follow_redirects=True, + ) + body = response.get_data(as_text=True) + # Geraet 3 ("Ohne-Login") hat Switch und Port, aber keine Zugangsdaten -- + # PoE geht, SSH nicht. Umgekehrt geprueft: + response = session.post( + "/clients/3/neustart", + data={"csrf_token": _token(session, "/clients/"), "method": "ssh"}, + follow_redirects=True, + ) + assert "steht fuer dieses Geraet nicht zur Verfuegung" in response.get_data(as_text=True) + assert body is not None + + +def test_device_list_shows_the_credential_category(session, populated): + body = session.get("/clients/").get_data(as_text=True) + assert "Linux-Client" in body + assert "Windows-Client" in body + + +def test_restart_buttons_appear_per_available_way(session, populated, licensed): + body = session.get("/clients/").get_data(as_text=True) + assert "/neustart" in body + assert "PoE-Port" in body + assert "Neustart ueber RPC" in body + + +def test_the_old_poe_only_endpoint_is_gone(): + """Ein zweiter Endpunkt fuer denselben Zweck waere eine Verzweigung zu viel.""" + from tesm import create_app + + rules = {rule.rule for rule in create_app(TESTING=True).url_map.iter_rules()} + assert "/clients//neustart" in rules + assert "/clients//poe-neustart" not in rules diff --git a/tests/test_tesm_smoke.py b/tests/test_tesm_smoke.py new file mode 100644 index 0000000..4c4b0b6 --- /dev/null +++ b/tests/test_tesm_smoke.py @@ -0,0 +1,968 @@ +"""Rauchtest der TESM-Anwendung: jede Seite rendert, jede Aktion greift. + +Deckt bewusst auch die Faelle ab, die im Vorgaengerprojekt kaputtgingen: +Rechteschranken, Lizenzgates auf Modulrouten, MAC-Normalisierung, und die +Trennung zwischen oeffentlicher und angemeldeter Statusuebersicht. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + + +@pytest.fixture() +def tesm_app(instance_env: Any): + from tesm import create_app + + app = create_app(TESTING=True) + yield app + + +@pytest.fixture() +def tclient(tesm_app: Any): + return tesm_app.test_client() + + +@pytest.fixture() +def admin(tesm_app: Any): + from tesm_core.extension import core + from tesm_core.security import passwords + + extension = core(tesm_app) + with extension.database.session() as conn, extension.database.transaction(conn): + conn.execute( + "INSERT INTO users (username, password_hash, is_admin, auth_source, created_at, " + "updated_at) VALUES ('root',?,1,'local',datetime('now'),datetime('now'))", + (passwords.hash_password("Ein-gutes-Passwort-1"),), + ) + return {"username": "root", "password": "Ein-gutes-Passwort-1"} + + +def _token(client: Any, path: str = "/login") -> str: + body = client.get(path).get_data(as_text=True) + for marker in ('name="csrf_token" value="', 'data-csrf="'): + if marker in body: + start = body.index(marker) + len(marker) + return body[start : body.index('"', start)] + raise AssertionError(f"Kein CSRF-Token in {path}") + + +@pytest.fixture() +def login_admin(tclient: Any, admin: dict[str, str]): + def do() -> Any: + return tclient.post( + "/login", + data={ + "username": admin["username"], + "password": admin["password"], + "csrf_token": _token(tclient), + }, + follow_redirects=True, + ) + + return do + + +# --------------------------------------------------------------------------- # +# Start und Grundzustand +# --------------------------------------------------------------------------- # + + +def test_app_boots_and_migrations_applied(tesm_app): + from tesm_core.extension import core + + status = core(tesm_app).migration_status() + components = {row["component"] for row in status} + assert components == {"core", "tesm"} + names = {(row["component"], row["name"]) for row in status} + assert ("tesm", "baseline") in names + assert ("tesm", "seed-dhcp-options") in names + + +def test_dhcp_option_catalogue_seeded(tesm_app): + from tesm_core.db import Database + from tesm_core.extension import core + + extension = core(tesm_app) + with extension.database.session() as conn: + count = Database.value(conn, "SELECT COUNT(*) FROM dhcp_options WHERE is_standard=1") + host_name = Database.value(conn, "SELECT COUNT(*) FROM dhcp_options WHERE code=12") + assert count == 56 + # host-name (12) ist bewusst nicht im Katalog -- Kea beantwortet das ueber + # das hostname-Feld der Reservierung. + assert host_name == 0 + + +def test_role_presets_created(tesm_app): + from tesm_core.extension import core + from tesm_core.rbac import service as rbac + + extension = core(tesm_app) + with extension.database.session() as conn: + names = {group.name for group in rbac.list_groups(conn)} + assert {"Benutzer", "Betrieb", "Support", "Netzwerk", "Revision", "Administration"} <= names + + +def test_public_dashboard_hides_details(tclient): + response = tclient.get("/") + assert response.status_code == 200 + body = response.get_data(as_text=True) + assert "Statusuebersicht" in body + assert "oeffentliche Ansicht" in body + + +def test_health_endpoint(tclient): + assert tclient.get("/gesundheit").get_json()["status"] == "ok" + + +# --------------------------------------------------------------------------- # +# Seiten +# --------------------------------------------------------------------------- # + + +ADMIN_PAGES = [ + "/", + "/clients/", + "/switche/", + "/zugangsdaten/", + "/protokolle/laufend", + "/protokolle/verlauf", + "/protokolle/aenderungen", + "/protokolle/neustarts", + "/einstellungen/system", + "/einstellungen/netzwerk", + "/einstellungen/webserver", + "/einstellungen/verzeichnisdienst", + "/lizenz/", + "/sicherung/", + "/papierkorb/", + "/diagnose/", + "/verwaltung/benutzer", + "/verwaltung/gruppen", + "/verwaltung/sitzungen", + "/konto/", + "/konto/sicherheit", +] + + +@pytest.mark.parametrize("path", ADMIN_PAGES) +def test_admin_pages_render(tclient, login_admin, path): + login_admin() + response = tclient.get(path) + assert response.status_code == 200, f"{path} -> {response.status_code}" + assert "Interner Fehler" not in response.get_data(as_text=True) + + +def test_licensed_modules_are_hidden_without_license(tclient, login_admin): + """Ohne Lizenz sind DHCP, Wartung und Dateifreigaben komplett unerreichbar.""" + login_admin() + for path in ("/dhcp/", "/wartung/", "/dateifreigaben/", "/protokolle/kea"): + assert tclient.get(path).status_code == 404, path + body = tclient.get("/").get_data(as_text=True) + assert "DHCP-Server" not in body + assert "Wartung" not in body + + +# --------------------------------------------------------------------------- # +# Geraete +# --------------------------------------------------------------------------- # + + +def test_device_lifecycle(tclient, login_admin): + login_admin() + token = _token(tclient, "/clients/") + + created = tclient.post( + "/clients/neu", + data={ + "csrf_token": token, + "name": "Kamera Eingang", + "mac": "AA-BB-CC-DD-EE-FF", + "ip": "192.168.1.50", + "is_active": "1", + "auto_restart": "1", + "ssh_port": "22", + }, + follow_redirects=True, + ) + assert created.status_code == 200 + body = created.get_data(as_text=True) + assert "angelegt" in body + # MAC wird normalisiert. + assert "aa:bb:cc:dd:ee:ff" in tclient.get("/clients/").get_data(as_text=True) + + listing = tclient.get("/clients/").get_data(as_text=True) + assert "Kamera Eingang" in listing + + # Loeschen legt in den Papierkorb, MAC bleibt reserviert. + tclient.post("/clients/1/loeschen", data={"csrf_token": token}, follow_redirects=True) + trash = tclient.get("/papierkorb/").get_data(as_text=True) + assert "Kamera Eingang" in trash + + again = tclient.post( + "/clients/neu", + data={ + "csrf_token": token, + "name": "Zweite Kamera", + "mac": "aa:bb:cc:dd:ee:ff", + "ip": "192.168.1.51", + "ssh_port": "22", + }, + follow_redirects=True, + ) + assert "Papierkorb" in again.get_data(as_text=True) + + +def test_device_requires_port_when_switch_assigned(tclient, login_admin): + login_admin() + token = _token(tclient, "/switche/") + tclient.post( + "/switche/neu", + data={"csrf_token": token, "hostname": "sw01", "ip": "192.168.1.2", "ssh_port": "22"}, + follow_redirects=True, + ) + response = tclient.post( + "/clients/neu", + data={ + "csrf_token": token, + "name": "Ohne Port", + "mac": "11:22:33:44:55:66", + "ip": "192.168.1.60", + "switch_id": "1", + "ssh_port": "22", + }, + follow_redirects=True, + ) + assert "Portbezeichnung" in response.get_data(as_text=True) + + +def test_invalid_mac_rejected(tclient, login_admin): + login_admin() + token = _token(tclient, "/clients/") + response = tclient.post( + "/clients/neu", + data={"csrf_token": token, "name": "Kaputt", "mac": "xyz", "ip": "192.168.1.70"}, + follow_redirects=True, + ) + assert "hexadezimale" in response.get_data(as_text=True) + + +def test_invalid_ip_rejected(tclient, login_admin): + login_admin() + token = _token(tclient, "/clients/") + response = tclient.post( + "/clients/neu", + data={"csrf_token": token, "name": "Kaputt", "mac": "11:22:33:44:55:77", "ip": "999.1.1.1"}, + follow_redirects=True, + ) + assert "gueltige IP-Adresse" in response.get_data(as_text=True) + + +# --------------------------------------------------------------------------- # +# Zugangsdaten und Verschluesselung +# --------------------------------------------------------------------------- # + + +def test_credential_secret_is_encrypted_at_rest(tclient, tesm_app, login_admin): + from tesm_core.db import Database + from tesm_core.extension import core + + login_admin() + token = _token(tclient, "/zugangsdaten/") + tclient.post( + "/zugangsdaten/neu", + data={ + "csrf_token": token, + "name": "Switch-Admin", + "username": "admin", + "secret": "SuperGeheim-2026", + "category": "switch", + }, + follow_redirects=True, + ) + extension = core(tesm_app) + with extension.database.session() as conn: + stored = Database.value(conn, "SELECT secret_encrypted FROM credentials WHERE name=?", ("Switch-Admin",)) + assert stored + assert "SuperGeheim" not in stored + assert stored.startswith("v1.") + + +def test_credential_reveal_requires_permission(tclient, tesm_app, login_admin): + from tesm_core.extension import core + from tesm_core.security import passwords + + login_admin() + token = _token(tclient, "/zugangsdaten/") + tclient.post( + "/zugangsdaten/neu", + data={ + "csrf_token": token, + "name": "Nur-Lesen", + "username": "admin", + "secret": "Geheim-Passwort-9", + "category": "switch", + }, + follow_redirects=True, + ) + tclient.get("/logout") + + # Ein Konto mit credentials.view aber ohne credentials.secrets. + extension = core(tesm_app) + with extension.database.session() as conn, extension.database.transaction(conn): + cursor = conn.execute( + "INSERT INTO users (username, password_hash, auth_source, created_at, updated_at) " + "VALUES ('lisa',?,'local',datetime('now'),datetime('now'))", + (passwords.hash_password("Ein-gutes-Passwort-1"),), + ) + user_id = int(cursor.lastrowid or 0) + group = conn.execute( + "INSERT INTO groups (name, created_at, updated_at) " + "VALUES ('nur-lesen',datetime('now'),datetime('now'))" + ) + group_id = int(group.lastrowid or 0) + conn.executemany( + "INSERT INTO group_permissions (group_id, permission) VALUES (?,?)", + [(group_id, "monitoring.view"), (group_id, "credentials.view")], + ) + conn.execute("INSERT INTO user_groups (user_id, group_id) VALUES (?,?)", (user_id, group_id)) + + client = tclient + client.post( + "/login", + data={"username": "lisa", "password": "Ein-gutes-Passwort-1", "csrf_token": _token(client)}, + follow_redirects=True, + ) + assert client.get("/zugangsdaten/").status_code == 200 + response = client.post("/zugangsdaten/1/anzeigen", data={"csrf_token": _token(client, "/zugangsdaten/")}) + assert response.status_code == 403 + + +def test_credential_reveal_is_audited(tclient, tesm_app, login_admin): + from tesm_core import audit + from tesm_core.extension import core + + login_admin() + token = _token(tclient, "/zugangsdaten/") + tclient.post( + "/zugangsdaten/neu", + data={ + "csrf_token": token, + "name": "Geheim", + "username": "admin", + "secret": "Geheim-Passwort-9", + "category": "switch", + }, + follow_redirects=True, + ) + response = tclient.post( + "/zugangsdaten/1/anzeigen", data={"csrf_token": token}, follow_redirects=True + ) + assert response.status_code == 200 + assert "Geheim-Passwort-9" in response.get_data(as_text=True) + + extension = core(tesm_app) + with extension.database.session() as conn: + rows = audit.search(conn, action="credential.secret_revealed") + assert rows and rows[0]["severity"] == "warning" + + +# --------------------------------------------------------------------------- # +# Switche und Host-Schluessel +# --------------------------------------------------------------------------- # + + +def test_switch_created_without_trusted_host_key(tclient, login_admin): + login_admin() + token = _token(tclient, "/switche/") + response = tclient.post( + "/switche/neu", + data={"csrf_token": token, "hostname": "sw-kern", "ip": "10.0.0.2", "ssh_port": "22"}, + follow_redirects=True, + ) + assert "freigegeben werden" in response.get_data(as_text=True) + detail = tclient.get("/switche/1").get_data(as_text=True) + assert "Noch kein Schluessel hinterlegt" in detail + + +def test_switch_delete_blocked_while_devices_attached(tclient, login_admin): + login_admin() + token = _token(tclient, "/switche/") + tclient.post( + "/switche/neu", + data={"csrf_token": token, "hostname": "sw01", "ip": "10.0.0.3", "ssh_port": "22"}, + follow_redirects=True, + ) + tclient.post( + "/clients/neu", + data={ + "csrf_token": token, + "name": "Kamera", + "mac": "aa:11:22:33:44:55", + "ip": "10.0.0.50", + "switch_id": "1", + "port": "1", + "ssh_port": "22", + }, + follow_redirects=True, + ) + response = tclient.post("/switche/1/loeschen", data={"csrf_token": token}, follow_redirects=True) + assert "zugeordnet" in response.get_data(as_text=True) + + +def test_command_not_on_allowlist_rejected(tclient, login_admin): + login_admin() + token = _token(tclient, "/switche/") + tclient.post( + "/switche/neu", + data={"csrf_token": token, "hostname": "sw01", "ip": "10.0.0.4", "ssh_port": "22"}, + follow_redirects=True, + ) + response = tclient.post( + "/switche/1/kommando", + data={"csrf_token": token, "command": "erase startup-config"}, + follow_redirects=True, + ) + assert "Positivliste" in response.get_data(as_text=True) + + +# --------------------------------------------------------------------------- # +# Rechte +# --------------------------------------------------------------------------- # + + +def test_viewer_cannot_reach_admin_pages(tclient, tesm_app): + from tesm_core.extension import core + from tesm_core.rbac import service as rbac + from tesm_core.security import passwords + + extension = core(tesm_app) + with extension.database.session() as conn, extension.database.transaction(conn): + cursor = conn.execute( + "INSERT INTO users (username, password_hash, auth_source, created_at, updated_at) " + "VALUES ('gast',?,'local',datetime('now'),datetime('now'))", + (passwords.hash_password("Ein-gutes-Passwort-1"),), + ) + user_id = int(cursor.lastrowid or 0) + default = next(group for group in rbac.list_groups(conn) if group.is_default) + conn.execute( + "INSERT INTO user_groups (user_id, group_id) VALUES (?,?)", (user_id, default.id) + ) + + tclient.post( + "/login", + data={"username": "gast", "password": "Ein-gutes-Passwort-1", "csrf_token": _token(tclient)}, + follow_redirects=True, + ) + assert tclient.get("/clients/").status_code == 200 + for path in ("/zugangsdaten/", "/verwaltung/benutzer", "/einstellungen/system", "/diagnose/"): + assert tclient.get(path).status_code == 403, path + + +def test_permission_matrix_columns_per_area(tesm_app): + from tesm.permissions import PERMISSIONS + from tesm_core.rbac.model import Action + + monitoring = PERMISSIONS.area("monitoring") + assert Action.SECRETS in monitoring.columns() # Zugangsdaten + assert Action.DELETE in monitoring.columns() + + network = PERMISSIONS.area("network") + assert Action.CREATE not in network.columns() # DHCP kennt kein "Anlegen" + assert Action.SECRETS not in network.columns() + + +def test_delete_is_a_separate_right(tesm_app): + """Kernaenderung gegenueber dem Vorgaenger: Aendern deckt nicht mehr Loeschen ab.""" + from tesm.permissions import PERMISSIONS + + granted = {"monitoring.view", "devices.view", "devices.edit"} + effective = PERMISSIONS.effective(granted) + assert "devices.edit" in effective + assert "devices.delete" not in effective + + +# --------------------------------------------------------------------------- # +# Offline-Aktivierung: der Code muss sichtbar sein +# --------------------------------------------------------------------------- # + + +def test_offline_request_keeps_its_code(): + """Die Seite forderte zum Uebermitteln eines Codes auf -- und zeigte keinen. + + ``handshake`` erzeugte ihn, der Blueprint verwarf ihn, und nach der + Weiterleitung war er weg. Er gehoert deshalb zur gespeicherten Anfrage: + zwischen Anfrage und Antwort liegen in der Praxis eine E-Mail und ein + Arbeitstag. + """ + import inspect + + from tesm_core import licensing_client + + source = inspect.getsource(licensing_client.LicenseManager.handshake) + assert '"code": code,' in source + assert "code = lic.encode_code(request_payload)" in source + + +def test_the_reason_for_going_offline_is_named(): + """"War nicht erreichbar" ist bei einem Zertifikatsfehler schlicht falsch.""" + from tesm_core.licensing_client import describe_transport_error + + certificate = describe_transport_error( + Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate"), + "https://10.0.0.1:8443", + ) + assert "selbstsigniertes Zertifikat" in certificate + assert "https://10.0.0.1:8443" in certificate + + assert "aufloesen" in describe_transport_error( + Exception("Name or service not known"), "https://nope.invalid" + ) + assert "nimmt keine Verbindung an" in describe_transport_error( + Exception("Connection refused"), "https://10.0.0.1:8443" + ) + assert "rechtzeitig" in describe_transport_error( + Exception("operation timed out"), "https://10.0.0.1:8443" + ) + + +def test_license_page_shows_the_request_code(): + from pathlib import Path + + template = ( + Path(__file__).resolve().parent.parent + / "apps" / "tesm" / "src" / "tesm" / "templates" / "tesm" / "license.html" + ).read_text(encoding="utf-8") + assert "pending.code" in template + assert "Anfragecode" in template + assert 'data-copy="#offline-request"' in template + + +# --------------------------------------------------------------------------- # +# Dateifreigaben +# --------------------------------------------------------------------------- # + + +def _license_all_modules(tesm_app): + """Alle Module freischalten -- die Freigabeseiten haengen an der Lizenz.""" + from tesm_core.extension import core + + manager = core(tesm_app).license + if manager is not None: + manager.module_active = lambda _name: True # type: ignore[method-assign] + + +def test_share_setup_page_is_reachable_and_searches_groups(tesm_app, tclient, login_admin): + """Die Einrichtung war nur ueber die Adresszeile erreichbar. + + Der Menuepunkt haengte an vorhandenen Einbindungen -- die es ohne + Zuordnung nie gibt. Wer Zuordnungen pflegen darf, muss die Seite auch + finden, und der Gruppen-DN muss suchbar sein statt abtippbar. + """ + _license_all_modules(tesm_app) + login_admin() + + page = tclient.get("/dateifreigaben/zuordnungen") + assert page.status_code == 200 + body = page.get_data(as_text=True) + assert "data-group-query" in body, "Gruppensuche fehlt" + assert 'name="ad_group_dn"' in body + + nav = tclient.get("/").get_data(as_text=True) + assert "/dateifreigaben/zuordnungen" in nav, "Zuordnungen fehlen in der Navigation" + + +def test_share_mapping_round_trip(tesm_app, tclient, login_admin): + _license_all_modules(tesm_app) + login_admin() + + token = _token(tclient, "/dateifreigaben/zuordnungen") + response = tclient.post( + "/dateifreigaben/zuordnungen", + data={ + "label": "Technik", + "unc": r"\\fileserver\technik", + "ad_group_name": "GG_Technik", + "ad_group_dn": "CN=GG_Technik,OU=Gruppen,DC=firma,DC=local", + "csrf_token": token, + }, + follow_redirects=True, + ) + assert response.status_code == 200 + assert "Zuordnung gespeichert" in response.get_data(as_text=True) + + from tesm.services import fileshare + from tesm_core.extension import core + + with core(tesm_app).database.session() as conn: + assert len(fileshare.list_mappings(conn)) == 1 + # Der Anspruch entsteht ueber die Verzeichnisgruppe -- und nur darueber. + assert fileshare.shares_for_groups( + conn, ["cn=gg_technik,ou=gruppen,dc=firma,dc=local"] + ) + assert not fileshare.shares_for_groups(conn, []) + + +def test_login_triggers_share_mounting(tesm_app, tclient, login_admin, monkeypatch): + """Der Ausloeser fehlte: Zuordnungen bestanden, eingebunden wurde nie. + + Ohne diesen Haken blieb der gesamte Freigabe-Teil wirkungslos -- und weil + der Menuepunkt an Einbindungen haengt, war er auch unsichtbar. + """ + from tesm_core.extension import core + + seen: list[dict] = [] + + def fake_mount(conn, **kwargs): + seen.append(kwargs) + return [] + + _license_all_modules(tesm_app) + monkeypatch.setattr("tesm.services.fileshare.mount_for_login", fake_mount) + assert core(tesm_app).login_hooks, "kein Anmeldehaken registriert" + + login_admin() + assert seen, "die Anmeldung hat die Einbindung nicht ausgeloest" + # Das Passwort wird durchgereicht, aber nirgends abgelegt. + assert seen[0]["password"] + with tclient.session_transaction() as session: + assert not any("passw" in str(key).lower() for key in session) + + +def test_logout_releases_shares(tesm_app, tclient, login_admin, monkeypatch): + """Was die Anmeldung eingebunden hat, muss die Abmeldung wieder loesen. + + Sonst bliebe der Zugriff mit dem Konto des Benutzers offen, bis die + Zeitgrenze der Einbindung greift -- Stunden spaeter. + """ + from tesm_core.extension import core + + _license_all_modules(tesm_app) + released: list[int] = [] + monkeypatch.setattr( + "tesm.services.fileshare.mount_for_login", lambda conn, **kw: [] + ) + monkeypatch.setattr( + "tesm.services.fileshare.release_all_for_user", + lambda conn, user_id: released.append(user_id) or 1, + ) + assert core(tesm_app).logout_hooks, "kein Abmeldehaken registriert" + + login_admin() + token = _token(tclient, "/") + tclient.post("/logout", data={"csrf_token": token}) + assert released, "die Abmeldung hat die Freigaben nicht geloest" + + +@pytest.mark.parametrize( + "eingabe", + [ + r"\\dc2022\daten", + "//dc2022/daten", + r"\\dc2022/daten", + "//dc2022/daten/", + "\\\\dc2022\\daten\\", # Trenner am Ende + "//dc2022//daten", + " //dc2022/daten ", + ], +) +def test_unc_accepts_both_separators(eingabe): + """Backslash und Schraegstrich muessen beide gehen -- und zwar gleich. + + Wer den Pfad aus dem Explorer kopiert, bekommt Backslashes; wer ihn aus + einer Weboberflaeche oder von einem Linux-Kollegen bekommt, Schraegstriche. + Ueberzaehlige Trenner und einer am Ende gehoeren zum Alltag und duerfen + keine Ablehnung ausloesen. + """ + from tesm.services.fileshare import validate_unc + + assert validate_unc(eingabe) == "\\\\dc2022\\daten" + + +@pytest.mark.parametrize( + "eingabe", + [ + "/dc2022/daten", # ein Trenner: absoluter Pfad auf diesem Rechner + "//dc2022", # ohne Freigabenamen + "dc2022/daten", + "//dc 2022/daten", + ], +) +def test_unc_rejects_what_is_not_a_share(eingabe): + from tesm.services.fileshare import FileshareError, validate_unc + + with pytest.raises(FileshareError): + validate_unc(eingabe) + + +# --------------------------------------------------------------------------- # +# DHCP: Reservierungen und Optionen je Geraet +# --------------------------------------------------------------------------- # + + +def _netz(cidr: str = "192.168.80.0/24", interface: str = "eth0"): + from tesm.services.kea import InterfaceNetwork + + return InterfaceNetwork(interface=interface, cidr=cidr) + + +def _geraet(conn, name: str, mac: str, ip: str) -> int: + cur = conn.execute( + "INSERT INTO devices (name, mac, ip, is_active, created_at, updated_at) " + "VALUES (?,?,?,1,datetime('now'),datetime('now'))", + (name, mac, ip), + ) + return int(cur.lastrowid) + + +def test_reservations_only_for_existing_ranges(tesm_app): + """Eine Reservierung ausserhalb aller Bereiche liefert Kea nie aus. + + Sie trotzdem zu zeigen, hat Reservierungen versprochen, die es nicht gab -- + in der Liste standen Adressen, die in der erzeugten Konfiguration fehlten. + """ + from tesm.services import kea + from tesm_core.extension import core + + ext = core(tesm_app) + with ext.database.session() as conn, ext.database.transaction(conn): + _geraet(conn, "drinnen", "aa:bb:cc:dd:ee:01", "192.168.80.50") + _geraet(conn, "draussen", "aa:bb:cc:dd:ee:02", "10.99.0.50") + conn.execute( + "INSERT INTO dhcp_subnets (interface, range_start, range_end, enabled, " + "created_at, updated_at) VALUES ('eth0','192.168.80.100','192.168.80.200',1," + "datetime('now'),datetime('now'))" + ) + + with ext.database.session() as conn: + netze = kea.configured_networks(conn, [_netz()]) + assert [str(n) for n in netze] == ["192.168.80.0/24"] + + uebersprungen: list[dict[str, str]] = [] + eintraege = kea.collect_reservations( + conn, only_networks=netze, skipped=uebersprungen + ) + namen = {e["name"] for e in eintraege} + assert namen == {"drinnen"}, namen + assert [e["ip"] for e in uebersprungen] == ["10.99.0.50"] + + # Ohne Eingrenzung bleibt das alte Verhalten -- der Erzeuger filtert selbst. + assert len(kea.collect_reservations(conn)) == 2 + + +def test_device_option_lands_in_its_reservation(tesm_app): + """Ein Wert je Geraet gehoert in dessen Reservierung, nicht in den globalen Teil.""" + from tesm.services import kea + from tesm_core.extension import core + + ext = core(tesm_app) + with ext.database.session() as conn, ext.database.transaction(conn): + device_id = _geraet(conn, "autodarts", "4c:52:62:25:b9:e4", "192.168.80.137") + conn.execute( + "INSERT INTO dhcp_subnets (interface, range_start, range_end, enabled, " + "created_at, updated_at) VALUES ('eth0','192.168.80.100','192.168.80.200',1," + "datetime('now'),datetime('now'))" + ) + option_id = conn.execute( + "INSERT INTO dhcp_options (code, name, type, is_standard) VALUES (225,'url','string',0)" + ).lastrowid + conn.execute( + "INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,?,?)", + (option_id, device_id, "http://autodarts.local"), + ) + conn.execute( + "INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,NULL,?)", + (option_id, "http://global.local"), + ) + + with ext.database.session() as conn: + config, _ = kea.render_config(conn, networks=[_netz()], settings={}) + + subnet = config["Dhcp4"]["subnet4"][0] + reservierung = subnet["reservations"][0] + assert reservierung["ip-address"] == "192.168.80.137" + assert reservierung["option-data"] == [{"name": "url", "data": "http://autodarts.local"}] + # Der globale Wert bleibt global und wird nicht verdraengt. + assert {"name": "url", "data": "http://global.local"} in config["Dhcp4"]["option-data"] + + +def test_reservation_state_from_leases(): + """Wie im Windows-DHCP: eine Reservierung ist aktiv, wenn sie benutzt wird. + + Ohne diese Angabe sah eine nie abgeholte Reservierung genauso aus wie eine + benutzte -- und ein Geraet, das noch auf einer alten Adresse haengt und die + Reservierung deshalb gar nicht uebernimmt, fiel ueberhaupt nicht auf. + """ + import time + + from tesm.services.kea import Lease, annotate_reservations + + jetzt = int(time.time()) + benutzt = Lease("192.168.80.10", "aa:bb:cc:00:00:01", "a", jetzt + 3600, "0") + veraltet = Lease("192.168.80.99", "aa:bb:cc:00:00:03", "c", jetzt + 3600, "0") + abgelaufen = Lease("192.168.80.12", "aa:bb:cc:00:00:04", "d", jetzt - 60, "0") + + eintraege = annotate_reservations( + [ + {"mac": "aa:bb:cc:00:00:01", "ip": "192.168.80.10"}, + {"mac": "aa:bb:cc:00:00:02", "ip": "192.168.80.11"}, + {"mac": "aa:bb:cc:00:00:03", "ip": "192.168.80.30"}, + {"mac": "aa:bb:cc:00:00:04", "ip": "192.168.80.12"}, + ], + [benutzt, veraltet, abgelaufen], + ) + assert [e["state"] for e in eintraege] == [ + "aktiv", + "unbenutzt", + "andere_adresse", + "abgelaufen", + ] + # Der Hinweis erklaert den Zustand, statt ihn nur zu benennen. + assert all(e["state_hint"] for e in eintraege) + assert eintraege[2]["lease"].address == "192.168.80.99" + + +def test_lease_expiry_is_readable(): + """Der rohe Unix-Zeitstempel stand vorher unveraendert in der Tabelle.""" + import time + from datetime import datetime + + from tesm.services.kea import Lease + + jetzt = int(time.time()) + lease = Lease("192.168.80.10", "aa:bb:cc:00:00:01", "a", jetzt + 5400, "0") + erwartet = datetime.fromtimestamp(jetzt + 5400).strftime("%d.%m.%Y %H:%M") + assert lease.expires_text == erwartet + assert lease.remaining_text.startswith("in 1 h") + assert str(jetzt) not in lease.expires_text + + assert Lease("x", "aa:bb:cc:00:00:01", "a", 0, "0").expires_text == "unbegrenzt" + assert Lease("x", "aa:bb:cc:00:00:01", "a", jetzt - 5, "0").remaining_text == "abgelaufen" + + +def test_custom_option_used_only_in_reservation_is_declared(tesm_app): + """Eine eigene Option muss deklariert sein, auch ohne globalen Wert. + + Genau das hat den Dienst umgebracht: der Wert stand nur in einer + Reservierung, die ``option-def`` wurde aber nur fuer Optionen mit globalem + Wert erzeugt. Kea startet dann nicht mehr -- + "definition for the option 'dhcp4.url' does not exist" -- und weil das erst + beim naechsten Start auffiel, lag der DHCP-Server stundenlang. + """ + from tesm.services import kea + from tesm_core.extension import core + + ext = core(tesm_app) + with ext.database.session() as conn, ext.database.transaction(conn): + device_id = _geraet(conn, "autodarts", "4c:52:62:25:b9:e4", "192.168.80.137") + conn.execute( + "INSERT INTO dhcp_subnets (interface, range_start, range_end, enabled, " + "created_at, updated_at) VALUES ('eth0','192.168.80.100','192.168.80.200',1," + "datetime('now'),datetime('now'))" + ) + option_id = conn.execute( + "INSERT INTO dhcp_options (code, name, type, is_standard) " + "VALUES (225,'url','string',0)" + ).lastrowid + # Bewusst *kein* globaler Wert -- nur der Wert fuer dieses eine Geraet. + conn.execute( + "INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,?,?)", + (option_id, device_id, "https://play.autodarts.com"), + ) + + with ext.database.session() as conn: + config, _ = kea.render_config(conn, networks=[_netz()], settings={}) + + dhcp4 = config["Dhcp4"] + benutzt = dhcp4["subnet4"][0]["reservations"][0]["option-data"] + assert benutzt == [{"name": "url", "data": "https://play.autodarts.com"}] + assert "option-data" not in dhcp4, "kein globaler Wert erwartet" + + definitionen = {entry["name"]: entry for entry in dhcp4["option-def"]} + assert "url" in definitionen, "ohne option-def verweigert Kea den Start" + assert definitionen["url"] == { + "name": "url", + "code": 225, + "type": "string", + "space": "dhcp4", + } + + +def test_restart_marks_device_as_restarting(tesm_app): + """Nach einem Neustart muss die Kachel das sofort zeigen. + + Der Zustand wechselte vorher erst beim naechsten Pruefdurchlauf -- Vorgabe + fuenf Minuten. Ein Neustart ueber SSH ist in vierzig Sekunden durch: das + Geraet war weg und wieder da, ohne dass es je jemand gesehen haette. Die + Kachel blieb die ganze Zeit auf "online". + """ + from tesm.services import monitor + from tesm_core.extension import core + + ext = core(tesm_app) + with ext.database.session() as conn, ext.database.transaction(conn): + device_id = _geraet(conn, "kasse", "aa:bb:cc:00:00:07", "192.168.80.7") + conn.execute( + "INSERT INTO device_status (device_id, state, last_change_at) " + "VALUES (?, 'online', datetime('now'))", + (device_id,), + ) + monitor.record_restart( + conn, + device_id=device_id, + action="reboot", + result="ok", + trigger="manuell", + actor="test", + duration_ms=100, + detail="", + method=monitor.METHOD_SSH, + ) + + with ext.database.session() as conn: + zeile = conn.execute( + "SELECT state, restart_count FROM device_status WHERE device_id=?", (device_id,) + ).fetchone() + assert zeile["state"] == monitor.STATE_RESTARTING + assert zeile["restart_count"] == 1 + assert monitor.dashboard_counts(conn)["restarting"] == 1 + + # Solange es schweigt, bleibt es "startet neu" -- nicht "ausgefallen". + vorher, nachher = monitor.record_status( + conn, device_id, monitor.PingResult(ok=False, latency_ms=None, detail="") + ) + assert nachher == monitor.STATE_RESTARTING, "ein laufender Neustart ist kein Ausfall" + + # Antwortet es wieder, ist es online. + _, nachher = monitor.record_status( + conn, device_id, monitor.PingResult(ok=True, latency_ms=3, detail="") + ) + assert nachher == "online" + + +def test_restart_state_gives_up_after_the_grace_period(tesm_app): + """Ein Neustart, der fuenf Minuten dauert, ist keiner mehr.""" + from datetime import datetime, timedelta, timezone + + from tesm.services import monitor + from tesm_core.extension import core + + ext = core(tesm_app) + lange_her = ( + datetime.now(timezone.utc) - timedelta(seconds=monitor.RESTART_GRACE_SECONDS + 60) + ).isoformat() + with ext.database.session() as conn, ext.database.transaction(conn): + device_id = _geraet(conn, "haenger", "aa:bb:cc:00:00:08", "192.168.80.8") + conn.execute( + "INSERT INTO device_status (device_id, state, last_restart_at, last_change_at) " + "VALUES (?, ?, ?, datetime('now'))", + (device_id, monitor.STATE_RESTARTING, lange_her), + ) + + with ext.database.session() as conn: + _, nachher = monitor.record_status( + conn, device_id, monitor.PingResult(ok=False, latency_ms=None, detail="") + ) + assert nachher == "offline" diff --git a/tests/test_webserver.py b/tests/test_webserver.py new file mode 100644 index 0000000..964e046 --- /dev/null +++ b/tests/test_webserver.py @@ -0,0 +1,521 @@ +"""Die nginx-Vorlage und die Trennung mehrerer Instanzen auf einem Host. + +Zwei Fehlerklassen werden hier festgenagelt: + +1. Der alias-Fehler des Vorgaengers -- statische Dateien zeigten auf ein + Verzeichnis, das es nicht gab, und die Oberflaeche kam jahrelang ohne + Design. Die erzeugte Datei muss die richtigen Pfade enthalten. +2. Zwei Installationen derselben Anwendung auf einem Host. Solange alles + ueber den ``app_key`` benannt wurde, hat eine Testinstanz beim Speichern + der Webserver-Einstellungen die Site-Datei der Produktion ueberschrieben + -- und beide teilten sich dasselbe Sitzungscookie, weil Cookies keine + Ports unterscheiden. +""" + +from __future__ import annotations + +import re + +import pytest +from tesm_core.system import webserver +from tesm_core.sysops import HELPER_VERBS, Result, SysOps + + +def make_config(**overrides) -> webserver.WebConfig: + base = { + "app_key": "tesm", + "upstream": "127.0.0.1:5000", + "static_root": "/srv/tesm/static", + } + return webserver.WebConfig(**{**base, **overrides}) + + +# --------------------------------------------------------------------------- # +# Nur HTTP +# --------------------------------------------------------------------------- # + + +def test_http_only_has_no_tls_block(): + text = webserver.render(make_config()) + assert "listen 80 default_server;" in text + assert "ssl_certificate" not in text + assert "return 308" not in text + + +def test_static_aliases_point_into_the_installation(): + text = webserver.render(make_config()) + assert "alias /srv/tesm/static/;" in text + assert "alias /srv/tesm/static-core/;" in text + + +def test_proxy_passes_the_host_including_port(): + """``$host`` verwirft den Port -- damit scheitert die Origin-Pruefung. + + Genau daran ist die Anmeldung hinter dem Reverse Proxy auf einem + abweichenden Port real gescheitert. + """ + text = webserver.render(make_config()) + assert "proxy_set_header Host $http_host;" in text + assert "proxy_set_header Host $host;" not in text + assert "proxy_set_header X-Forwarded-Port $server_port;" in text + + +def test_acme_path_is_always_reachable(): + text = webserver.render( + make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem") + ) + # Auch bei aktiver Umleitung -- sonst kann Let's Encrypt nie erneuern. + assert "/.well-known/acme-challenge/" in text + challenge = text.index("acme-challenge") + redirect = text.index("return 308") + assert challenge < redirect + + +# --------------------------------------------------------------------------- # +# HTTPS +# --------------------------------------------------------------------------- # + + +def test_https_listens_on_443_and_redirects_80(): + text = webserver.render( + make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem") + ) + assert "listen 443 ssl default_server http2;" in text + assert "listen [::]:443 ssl default_server http2;" in text + assert "return 308 https://$host$request_uri;" in text + assert "ssl_protocols TLSv1.2 TLSv1.3;" in text + + +def test_hsts_only_when_asked(): + without = webserver.render( + make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem") + ) + assert "Strict-Transport-Security" not in without + + with_hsts = webserver.render( + make_config( + https_enabled=True, hsts_enabled=True, cert_path="/c.pem", key_path="/k.pem" + ) + ) + assert "Strict-Transport-Security" in with_hsts + # HSTS ohne TLS waere ein Selbstschuss: der Browser merkt sich, dass er die + # Seite nur noch ueber HTTPS aufrufen darf. + assert "ssl_certificate" in with_hsts + + +def test_hsts_is_ignored_without_https(): + text = webserver.render(make_config(hsts_enabled=True)) + assert "Strict-Transport-Security" not in text + + +def test_redirect_can_be_switched_off(): + text = webserver.render( + make_config( + https_enabled=True, redirect_http=False, cert_path="/c.pem", key_path="/k.pem" + ) + ) + assert "return 308" not in text + # Dann muss Port 80 die Anwendung weiter bedienen. + assert text.count("proxy_pass http://127.0.0.1:5000;") >= 2 + + +def test_deviating_ports_are_rendered_and_redirect_keeps_the_port(): + text = webserver.render( + make_config( + https_enabled=True, + cert_path="/c.pem", + key_path="/k.pem", + http_port=8080, + https_port=8443, + ) + ) + assert "listen 8080 default_server;" in text + assert "listen 8443 ssl default_server http2;" in text + # Ohne den Port im Ziel liefe die Umleitung ins Leere. + assert "return 308 https://$host:8443$request_uri;" in text + + +def test_standard_port_leaves_no_suffix(): + assert make_config(https_port=443).https_suffix == "" + assert make_config(https_port=8443).https_suffix == ":8443" + + +def test_websocket_location_only_when_configured(): + assert "proxy_set_header Upgrade" not in webserver.render(make_config()) + text = webserver.render(make_config(websocket_path="/ws/")) + assert "location /ws/ {" in text + assert "proxy_read_timeout 3600s;" in text + + +def _location_blocks(text: str) -> list[list[str]]: + """Zerlegt die erzeugte Datei in ihre ``location``-Bloecke.""" + blocks: list[list[str]] = [] + current: list[str] | None = None + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("location ") and stripped.endswith("{"): + current = [] + elif stripped == "}" and current is not None: + blocks.append(current) + current = None + elif current is not None and stripped and not stripped.startswith("#"): + current.append(stripped) + return blocks + + +@pytest.mark.parametrize( + "config", + [ + make_config(websocket_path="/ws/"), + make_config( + websocket_path="/ws/", https_enabled=True, cert_path="/c.pem", key_path="/k.pem" + ), + make_config(https_enabled=True, hsts_enabled=True, cert_path="/c.pem", key_path="/k.pem"), + ], +) +def test_no_directive_appears_twice_in_one_block(config: webserver.WebConfig): + """nginx weist eine doppelte Direktive im selben Block ab. + + Auf POETEST real passiert: der Websocket-Block erbte ``proxy_read_timeout + 120s`` aus dem gemeinsamen Teil und setzte danach ``3600s``. nginx meldete + "directive is duplicate" und liess sich nicht mehr starten -- nicht nur + fuer diese Site, sondern fuer alle. + """ + for block in _location_blocks(webserver.render(config)): + directives = [line.split()[0] for line in block if line.endswith(";")] + repeated = { + name + for name in directives + # Wiederholung ist nur bei den Direktiven zulaessig, die nginx als + # Liste versteht. + if directives.count(name) > 1 and name not in {"proxy_set_header", "add_header"} + } + assert not repeated, f"Mehrfach im selben Block: {sorted(repeated)}" + + +def test_websocket_block_keeps_the_long_timeout(): + """Die lange Wartezeit ist der Zweck des Blocks -- sie darf nicht verlorengehen.""" + blocks = { + block[0]: block + for block in _location_blocks(webserver.render(make_config(websocket_path="/ws/"))) + } + websocket = next(b for b in blocks.values() if "proxy_set_header Upgrade $http_upgrade;" in b) + assert "proxy_read_timeout 3600s;" in websocket + assert "proxy_read_timeout 120s;" not in websocket + + +def test_http2_uses_the_old_spelling_by_default(): + """``http2 on;`` kennt erst nginx 1.25.1. + + Auf POETEST (nginx 1.24) fuehrte die neue Direktive zu "unknown directive" + -- und damit zu einem Webserver, der sich nicht mehr starten liess. Die + alte Schreibweise laeuft ueberall. + """ + text = webserver.render( + make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem") + ) + assert "listen 443 ssl default_server http2;" in text + assert "http2 on;" not in text + + +def test_http2_directive_on_request(): + text = webserver.render( + make_config( + https_enabled=True, cert_path="/c.pem", key_path="/k.pem", http2_directive=True + ) + ) + assert " http2 on;" in text + assert "listen 443 ssl default_server;" in text + assert "ssl http2;" not in text + + +def test_stapling_only_with_a_real_certificate(): + """Ein selbstsigniertes Zertifikat hat keinen OCSP-Responder.""" + self_signed = webserver.render( + make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem") + ) + assert "ssl_stapling" not in self_signed + + public = webserver.render( + make_config( + https_enabled=True, + domain="tesm.example.org", + cert_path="/c.pem", + key_path="/k.pem", + ) + ) + assert "ssl_stapling on;" in public + + +# --------------------------------------------------------------------------- # +# Domain +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + "value", ["tesm.example.org", "a.b.c.de", "TESM.Example.ORG", "tesm.example.org."] +) +def test_valid_domains(value: str): + assert webserver.validate_domain(value) == value.lower().rstrip(".") + + +@pytest.mark.parametrize( + "value", ["kein-punkt", "-start.de", "ende-.de", "raum .de", "a..b", "http://x.de"] +) +def test_invalid_domains(value: str): + with pytest.raises(ValueError): + webserver.validate_domain(value) + + +def test_empty_domain_becomes_catch_all(): + assert make_config().server_name == "_" + assert make_config(domain="tesm.example.org").server_name == "tesm.example.org" + + +def test_only_the_nameless_site_claims_default_server(): + """Sonst entscheidet der Dateiname in sites-enabled, wer die Vorgabe wird. + + ``server_name _;`` ist bei nginx kein Sonderfall -- ohne + ``default_server`` gewinnt schlicht die zuerst geladene Datei. Bei zwei + Anwendungen auf einem Host waere das reiner Zufall. + """ + nameless = webserver.render(make_config()) + assert "listen 80 default_server;" in nameless + + named = webserver.render(make_config(domain="lizenz.example.org")) + assert "default_server" not in named + assert "server_name lizenz.example.org;" in named + + +# --------------------------------------------------------------------------- # +# Selbstsigniertes Zertifikat +# --------------------------------------------------------------------------- # + + +class FakeSysOps: + """Merkt sich den Aufruf, statt den Helfer wirklich zu starten.""" + + def __init__(self, result: Result) -> None: + self.result = result + self.calls: list[tuple[str, ...]] = [] + self.available = True + self.site_key = "tesm-opus" + + def issue_self_signed(self, common_name: str, *alt_names: str) -> Result: + self.calls.append((common_name, *alt_names)) + return self.result + + +def test_self_signed_returns_both_paths(): + sysops = FakeSysOps(Result(True, "/etc/tesm/certs/tesm/fullchain.pem\n/etc/tesm/certs/tesm/privkey.pem\n")) + result = webserver.create_self_signed( + sysops, common_name="poetest", alt_names=["poetest.local", "192.168.82.202"] + ) + assert result["ok"] + assert result["cert_path"].endswith("fullchain.pem") + assert result["key_path"].endswith("privkey.pem") + assert sysops.calls == [("poetest", "poetest.local", "192.168.82.202")] + + +def test_self_signed_rejects_a_hostile_name(): + """Der Name landet in einem openssl-Argument -- er wird hier geprueft.""" + sysops = FakeSysOps(Result(True, "a\nb\n")) + for hostile in ("a b", "a;rm -rf /", "$(whoami)", "../../etc", ""): + result = webserver.create_self_signed(sysops, common_name=hostile) + assert not result["ok"], hostile + assert sysops.calls == [] + + +def test_self_signed_drops_unusable_alternative_names(): + sysops = FakeSysOps(Result(True, "a\nb\n")) + webserver.create_self_signed( + sysops, common_name="poetest", alt_names=["poetest", "", "bad name", "ok.local"] + ) + assert sysops.calls == [("poetest", "ok.local")] + + +def test_self_signed_reports_a_failing_helper(): + sysops = FakeSysOps(Result(False, "", "openssl fehlt", 2)) + result = webserver.create_self_signed(sysops, common_name="poetest") + assert not result["ok"] + assert "openssl fehlt" in result["detail"] + + +def test_self_signed_rejects_an_unexpected_answer(): + sysops = FakeSysOps(Result(True, "nur eine Zeile\n")) + result = webserver.create_self_signed(sysops, common_name="poetest") + assert not result["ok"] + + +def test_certificate_info_survives_an_unreadable_file(tmp_path, monkeypatch): + """Ein unlesbares Zertifikat darf die Einstellungsseite nicht abwerfen. + + Auf POETEST real passiert: das Zertifikatsverzeichnis war 0750 root:root, + der Dienstbenutzer bekam beim blossen ``stat`` einen PermissionError -- und + die Seite "Webserver und TLS" antwortete mit HTTP 500. + """ + target = tmp_path / "fullchain.pem" + target.write_text("egal", encoding="utf-8") + + def deny(*_args, **_kwargs): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr("pathlib.Path.is_file", deny) + info = webserver.certificate_info(str(target)) + assert info["present"] is True + assert "nicht lesen" in info["error"] + + +def test_certificate_info_reports_a_missing_file(): + assert webserver.certificate_info("/gibt/es/nicht.pem") == {"present": False} + + +def test_certificate_info_reports_garbage_without_raising(tmp_path): + target = tmp_path / "fullchain.pem" + target.write_text("kein Zertifikat", encoding="utf-8") + info = webserver.certificate_info(str(target)) + assert info["present"] is True + assert info["error"] + + +def test_local_names_are_usable_in_a_certificate(): + names = webserver.local_names() + assert names, "Es sollte mindestens der Hostname gefunden werden." + for name in names: + assert re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name), name + + +# --------------------------------------------------------------------------- # +# Instanztrennung +# --------------------------------------------------------------------------- # + + +def test_apply_config_is_one_call_with_rollback(): + """Getrennte Aufrufe liessen eine abgelehnte Konfiguration aktiviert liegen. + + ``nginx-write`` verlinkt sofort nach ``sites-enabled``. Schlug danach + ``nginx -t`` fehl, blieb die kaputte Datei aktiv -- der laufende nginx + merkte nichts, aber der naechste Reload durch irgendwen legte den + Webserver lahm. Die Uebernahme muss deshalb *eine* privilegierte Operation + sein, die sich selbst zuruecknimmt. + """ + calls: list[str] = [] + + class Recording(SysOps): + def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result: + calls.append(verb) + return Result(True, "uebernommen") + + result = webserver.apply_config(Recording(app_key="tesm"), "server {}") + assert result["ok"] + assert calls == ["nginx-apply"] + assert "nginx-write" not in calls + + +def test_apply_config_reports_a_rejection_without_claiming_success(): + class Failing(SysOps): + def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result: + return Result(False, "", "unknown directive \"http2\"", 2) + + result = webserver.apply_config(Failing(app_key="tesm"), "server {}") + assert not result["ok"] + assert "alte Stand" in result["message"] + assert "http2" in result["detail"] + + +def test_sysops_writes_system_files_under_the_site_key(): + """Sonst ueberschreibt eine Testinstanz die Systemdateien der Produktion.""" + calls: list[tuple[str, ...]] = [] + + class Recording(SysOps): + def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result: + calls.append((verb, *args)) + return Result(True) + + sysops = Recording(app_key="tesm", site_key="tesm-opus") + sysops.write_nginx("x") + sysops.write_netplan("x") + sysops.write_logrotate("x") + sysops.issue_self_signed("poetest") + + assert calls == [ + ("nginx-write", "tesm-opus"), + ("netplan-write", "tesm-opus"), + ("logrotate-write", "tesm-opus"), + ("selfsigned-issue", "tesm-opus", "poetest"), + ] + + +def test_upstream_follows_the_configured_bind_address(monkeypatch): + """Die nginx-Site muss auf *diese* Instanz zeigen, nicht auf Port 5000. + + Mit fest verdrahteter 5000 erzeugte eine Instanz auf Port 5100 eine + Konfiguration, die Anfragen an die *andere* Installation weiterreichte -- + auf POETEST reproduziert: HTTPS stand, lieferte aber die alte Anwendung. + """ + from tesm_core.config import load_core_config + + monkeypatch.setenv("TESM_BIND", "127.0.0.1:5100") + config = load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_") + assert config.bind == "127.0.0.1:5100" + + text = webserver.render(make_config(upstream=config.bind)) + assert "proxy_pass http://127.0.0.1:5100;" in text + assert "127.0.0.1:5000" not in text + + +def test_bind_address_is_validated(monkeypatch): + from tesm_core.config import load_core_config + + monkeypatch.setenv("TESM_BIND", "127.0.0.1:5100; rm -rf /") + with pytest.raises(ValueError): + load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_") + + +def test_site_key_is_validated(monkeypatch): + from tesm_core.config import load_core_config + + monkeypatch.setenv("TESM_SITE_KEY", "../../etc/nginx") + with pytest.raises(ValueError): + load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_") + + +def test_site_key_defaults_to_the_app_key(): + assert SysOps(app_key="tesm").site_key == "tesm" + + +def test_self_signed_verb_is_known(): + assert "selfsigned-issue" in HELPER_VERBS + + +def test_service_state_finds_unit_without_suffix(monkeypatch): + """``list-unit-files`` braucht ein Muster mit Endung. + + Ohne sie fand die Abfrage nichts und der Zustand lautete + "installiert: nein" -- auch bei laufendem Dienst. Auf der DHCP-Seite lud + das zum Klick auf "Kea installieren" ein. + """ + import tesm_core.sysops as sysops_modul + from tesm_core.sysops import Result, SysOps + + # Ohne systemctl bricht die Abfrage vorher ab -- auf dem Entwicklungsrechner + # gibt es keines. + monkeypatch.setattr(sysops_modul.shutil, "which", lambda name: "/bin/" + name) + + gesehen: list[list[str]] = [] + + class Attrappe(SysOps): + def __init__(self): + pass + + def read_command(self, argv): + gesehen.append(list(argv)) + if "list-unit-files" in argv: + treffer = "kea-dhcp4-server.service" in argv + return Result(True, "kea-dhcp4-server.service enabled" if treffer else "", "") + return Result(True, "active", "") + + zustand = Attrappe().service_state("kea-dhcp4-server") + assert zustand["installed"] == "ja", gesehen + # Eine bereits vollstaendige Unit bleibt unveraendert. + assert Attrappe().service_state("kea-dhcp4-server.service")["installed"] == "ja" diff --git a/update.sh b/update.sh deleted file mode 100644 index c2e1681..0000000 --- a/update.sh +++ /dev/null @@ -1,126 +0,0 @@ -#!/bin/bash -# ============================================================================ -# TESM — Update/Reinstall (Bootstrapper) -# Lädt ein gepacktes Gitea-Release per HTTP herunter (kein "git clone" -# nötig) und übergibt die eigentliche Installations-/Update-Logik an -# install.sh aus dem Paket -- das erkennt selbstständig, ob unter -# /srv/tesm schon eine Installation existiert, und aktualisiert sie -# entsprechend (In-Place bei unverändertem Datenbank-Schema, sonst mit -# automatischem Backup + Migrations-Sicherheitsnetz samt Rückroll- -# Möglichkeit -- siehe install.sh für Details). -# -# Dieses Skript ist reiner Bootstrapper: Download + Entpacken + Aufruf von -# install.sh. Die frühere Sonderbehandlung zweier TESM vorausgehender -# Installationsstufen (rpi-check.service aus dem ursprünglichen reinen -# Bash-Setup, poe_web.service/poe_manager als Flask-Vorgänger von TESM) -# wurde entfernt -- diese Stufen sind auf allen aktuellen Installationen -# längst migriert, die dazugehörigen Unit-Dateien wurden dort manuell -# entfernt. Ein heute noch unmigriertes System wäre ohnehin so alt, dass -# ein manueller Blick vor dem Update angebracht ist statt automatischer -# stillschweigender Bereinigung. -# -# Auszuführen als root auf dem Zielsystem: -# sudo ./update.sh -# Optional übersteuerbar (z.B. für eine bestimmte Version statt "latest"): -# TESM_RELEASE_TAG=v1.0.0 sudo -E ./update.sh -# Für ein privates Repo wird ein Zugriffstoken benötigt: -# TESM_GITEA_USER=alientim TESM_GITEA_TOKEN= sudo -E ./update.sh -# ============================================================================ -set -e - -GITEA_BASE="${TESM_GITEA_BASE:-https://gitea.int.eertmoed.net}" -REPO_OWNER="${TESM_REPO_OWNER:-alientim}" -REPO_NAME="${TESM_REPO_NAME:-tesm}" -RELEASE_TAG="${TESM_RELEASE_TAG:-latest}" -GITEA_USER="${TESM_GITEA_USER:-}" -GITEA_TOKEN="${TESM_GITEA_TOKEN:-}" - -WORK_DIR="/tmp/tesm-update-$(date +%s)" - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -if [ "$(id -u)" -ne 0 ]; then - echo "Bitte als root ausführen (sudo ./update.sh)." >&2 - exit 1 -fi - -CURL_AUTH=() -if [ -n "$GITEA_USER" ] && [ -n "$GITEA_TOKEN" ]; then - CURL_AUTH=(-u "${GITEA_USER}:${GITEA_TOKEN}") -fi - -# ---- "latest" IMMER über die Gitea-API auflösen, nie als Literal in die -# Download-URL einsetzen ---- -# Live reproduziert: /releases/download/latest/tesm-latest.tar.gz liefert -# HTTP 200 -- aber keinen echten Release-Asset, sondern (weil kein Asset -# exakt "tesm-latest.tar.gz" heißt) Giteas automatisch generiertes -# Quellcode-Archiv des AKTUELLEN Default-Branch-Stands, kommentarlos und -# ohne Fehlermeldung. Ohne diesen Auflösungsschritt würde ein einfaches -# "sudo ./update.sh" (ohne explizites TESM_RELEASE_TAG) also lautlos einen -# unversionierten, potenziell halbfertigen Zwischenstand installieren -# statt des tatsächlich neuesten Releases. -if [ "$RELEASE_TAG" == "latest" ]; then - echo -e "${RED}→${NC} Ermittle aktuellsten Release-Tag von ${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}..." - RESOLVED_TAG="$(curl -fsSL "${CURL_AUTH[@]}" "${GITEA_BASE}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest" \ - | python3 -c "import json,sys; print(json.load(sys.stdin).get('tag_name',''))" 2>/dev/null)" - if [ -z "$RESOLVED_TAG" ]; then - echo "✖ Konnte den aktuellsten Release-Tag nicht über die Gitea-API ermitteln -- Abbruch." >&2 - echo " (Alternative: TESM_RELEASE_TAG=vX.Y.Z explizit setzen.)" >&2 - exit 1 - fi - RELEASE_TAG="$RESOLVED_TAG" - echo -e "${GREEN}✔${NC} Aktuellster Release ist ${RELEASE_TAG}." -fi - -PACKAGE_NAME="tesm-${RELEASE_TAG}.tar.gz" -DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}" -PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}" - -echo -e "${YELLOW}============================================================================${NC}" -echo -e "${YELLOW} TESM Update/Reinstall — Release \"${RELEASE_TAG}\"${NC}" -echo -e "${YELLOW}============================================================================${NC}" -echo -echo "Lädt das Release-Paket herunter und übergibt Installation/Update an" -echo "install.sh daraus -- das erkennt selbst, ob es sich um eine frische" -echo "Installation oder ein Update einer bestehenden handelt, und sichert" -echo "die Datenbank bei einer Schema-Änderung automatisch vorsorglich ab." -echo - -read -r -p "Fortfahren? [y/N] " confirm -if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then - echo "Abgebrochen — nichts wurde verändert." - exit 0 -fi - -echo -e "${RED}→${NC} Lade Paket von ${DOWNLOAD_URL}..." -mkdir -p "$WORK_DIR" -curl -fsSL "${CURL_AUTH[@]}" -o "$PACKAGE_FILE" "$DOWNLOAD_URL" -echo -e "${GREEN}✔${NC} Paket heruntergeladen ($(du -h "$PACKAGE_FILE" | cut -f1))." - -echo -e "${RED}→${NC} Entpacke Paket..." -mkdir -p "$WORK_DIR/pkg" -tar xzf "$PACKAGE_FILE" -C "$WORK_DIR/pkg" --strip-components=1 - -# Absicherung gegen genau das oben beschriebene Fallback-Verhalten: statt -# eines späten, kryptischen "No such file or directory" beim Aufruf von -# install.sh hier klar benennen, WENN das heruntergeladene Paket nicht das -# erwartete ist (z.B. weil sich Giteas Verhalten wieder ändert). -if [ ! -f "$WORK_DIR/pkg/install.sh" ]; then - echo "✖ Entpacktes Paket enthält kein install.sh -- vermutlich wurde nicht das" >&2 - echo " erwartete Release-Asset heruntergeladen (siehe ${DOWNLOAD_URL})." >&2 - exit 1 -fi -echo -e "${GREEN}✔${NC} Paket entpackt." - -echo -e "${RED}→${NC} Starte install.sh aus dem Paket..." -bash "$WORK_DIR/pkg/install.sh" - -echo -echo -e "${GREEN}============================================================================${NC}" -echo -e "${GREEN} TESM Update/Reinstall (Release \"${RELEASE_TAG}\") abgeschlossen.${NC}" -echo -e "${GREEN}============================================================================${NC}" -echo "Siehe Ausgabe von install.sh oben für Details (In-Place-Update, frische" -echo "Installation, oder Migrationsergebnis inkl. eventuellem Rückroll-Hinweis)." diff --git a/usr/local/bin/custom/poe.sh b/usr/local/bin/custom/poe.sh deleted file mode 100644 index 2cfd9c0..0000000 --- a/usr/local/bin/custom/poe.sh +++ /dev/null @@ -1,176 +0,0 @@ -#!/bin/bash -LOG_DIR="/var/log/tesm" -LOGFILE="$LOG_DIR/live.log" -mkdir -p "$LOG_DIR" -touch "$LOGFILE" - -SLEEP=$(python3 - <<'END' -import sqlite3 -conn = sqlite3.connect("/srv/tesm/sqlite.db") -row = conn.execute("SELECT value FROM settings WHERE key='check_interval'").fetchone() -conn.close() -print(row[0] if row else 300) -END -) - -SLEEP=${SLEEP:-300} - -function disable_poe() { - local switch_ip=$1 - local switch_port=$2 - local username=$3 - local password=$4 - local ssh_port=${5:-22} - expect < $" { } -} -send "configure terminal\r" -expect "(config)#" -send "interface $switch_port\r" -expect "(eth-$switch_port)#" -send "no power-over-ethernet\r" -expect "(eth-$switch_port)#" -send "exit\r" -expect "(config)#" -send "exit\r" -expect "#" -send "exit\r" -expect ">"; send "exit\r" -expect "Do you want to log out (y/n)?" { send "y\r" } -expect eof -EOF -} - -function enable_poe() { - local switch_ip=$1 - local switch_port=$2 - local username=$3 - local password=$4 - local ssh_port=${5:-22} - expect < $" { } -} -send "configure terminal\r" -expect "(config)#" -send "interface $switch_port\r" -expect "(eth-$switch_port)#" -send "power-over-ethernet\r" -expect "(eth-$switch_port)#" -send "exit\r" -expect "(config)#" -send "exit\r" -expect "#" -send "exit\r" -expect ">"; send "exit\r" -expect "Do you want to log out (y/n)?" { send "y\r" } -expect eof -EOF -} - -function check_device() { - local rpi_ip=$1 dev_name=$2 switch_ip=$3 switch_ssh_port=$4 - local switch_hostname=$5 switch_port=$6 switch_user=$7 switch_pass=$8 - local license_active=$9 - - if ping -c 1 -W 2 "$rpi_ip" &> /dev/null; then - echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name ist erreichbar!" >> "$LOGFILE" - return - fi - - echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name ist nicht erreichbar!" >> "$LOGFILE" - - if [ -z "$switch_ip" ] || [ -z "$switch_port" ] || [ "$switch_port" == "None" ]; then - return - fi - - # Automatischer PoE-Neustart bei Ausfall ist ein lizenzpflichtiges - # Feature (siehe Lizenzsystem) -- der manuelle Neustart-Button im - # Dashboard (manual_restart() weiter unten) ruft disable_poe/enable_poe - # dagegen IMMER direkt auf und bleibt davon bewusst unberührt. - if [ "$license_active" != "1" ]; then - echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name: automatischer PoE-Neustart übersprungen -- keine gültige Lizenz." >> "$LOGFILE" - return - fi - - if ! disable_poe "$switch_ip" "$switch_port" "$switch_user" "$switch_pass" "$switch_ssh_port"; then - echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name: Switch $switch_hostname nicht erreichbar oder Kommando abgelehnt — PoE-Deaktivierung fehlgeschlagen, kein Neustart durchgeführt." >> "$LOGFILE" - return - fi - echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name PoE auf Port $switch_port am Switch $switch_hostname deaktiviert." >> "$LOGFILE" - - sleep 2 - - if ! enable_poe "$switch_ip" "$switch_port" "$switch_user" "$switch_pass" "$switch_ssh_port"; then - echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name: Switch $switch_hostname nicht erreichbar oder Kommando abgelehnt — PoE-Reaktivierung fehlgeschlagen! Port bleibt ggf. deaktiviert." >> "$LOGFILE" - return - fi - echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name PoE auf Port $switch_port am Switch $switch_hostname aktiviert." >> "$LOGFILE" -} - -function manual_restart() { - local target_mac="$1" - python3 /srv/tesm/generate_ips.py | while IFS='|' read -r rpi_ip dev_name switch_ip switch_ssh_port switch_hostname switch_port switch_user switch_pass mac; do - if [[ "$mac" != "$target_mac" ]]; then - continue - fi - echo "$(date '+%Y-%m-%d %H:%M:%S') Manueller Neustart von $dev_name gestartet." >> "$LOGFILE" - if [ -n "$switch_ip" ] && [ -n "$switch_port" ] && [ "$switch_port" != "None" ]; then - if ! disable_poe "$switch_ip" "$switch_port" "$switch_user" "$switch_pass" "$switch_ssh_port"; then - echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name: Switch $switch_hostname nicht erreichbar oder Kommando abgelehnt — manueller Neustart abgebrochen." >> "$LOGFILE" - exit 1 - fi - echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name PoE deaktiviert." >> "$LOGFILE" - sleep 2 - if ! enable_poe "$switch_ip" "$switch_port" "$switch_user" "$switch_pass" "$switch_ssh_port"; then - echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name: Switch $switch_hostname nicht erreichbar oder Kommando abgelehnt — PoE-Reaktivierung fehlgeschlagen! Port bleibt ggf. deaktiviert." >> "$LOGFILE" - exit 1 - fi - echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name PoE aktiviert." >> "$LOGFILE" - echo "$(date '+%Y-%m-%d %H:%M:%S') Manueller Neustart von $dev_name abgeschlossen." >> "$LOGFILE" - else - echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name besitzt keinen PoE-Port." >> "$LOGFILE" - fi - exit 0 - done -} - -if [[ "$1" == "restart" ]]; then - if [[ -z "$2" ]]; then - echo "MAC-Adresse fehlt." - exit 1 - fi - manual_restart "$2" - exit 0 -fi - -while true; do - echo "--------------------------------------------------------------------" >> "$LOGFILE" - - # Einmal pro Schleifendurchlauf gelesen (wie SLEEP oben beim Skriptstart), - # nicht pro Gerät -- reicht als Shell-Variable an check_device() durch. - LICENSE_ACTIVE=$(python3 - <<'END' -import sys -sys.path.insert(0, "/srv/tesm") -from app import license_active -print("1" if license_active() else "0") -END -) - LICENSE_ACTIVE=${LICENSE_ACTIVE:-0} - - while IFS='|' read -r rpi_ip dev_name switch_ip switch_ssh_port switch_hostname switch_port switch_user switch_pass mac; do - check_device "$rpi_ip" "$dev_name" "$switch_ip" "$switch_ssh_port" "$switch_hostname" "$switch_port" "$switch_user" "$switch_pass" "$LICENSE_ACTIVE" & - done < <(python3 /srv/tesm/generate_ips.py) - - wait - sleep "$SLEEP" -done