From f7805a2180afa19e99f08925b44e8141a272c22a Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Wed, 2 Sep 2026 18:01:23 +0200 Subject: [PATCH] TESM-Lizenzserver 2.0.0 -- Neubau Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger: * Keine doppelte licensing.py -- ein Paket, das beide Anwendungen installieren, statt zweier Dateien, die byte-identisch bleiben sollen. * Der Webprozess laeuft unprivilegiert; alles, was Root braucht, geht ueber einen einzigen Helfer mit Positivlisten fuer jedes Argument. * CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen. * Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet. * Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann. * Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als "Aktivierung offen" markiert, bis sie zurueckkommt. * Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung, seq gegen das Zurueckrollen auf eine aeltere Lizenz. * Kein Hostname im Maschinen-Fingerabdruck. * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 --- .gitattributes | 9 + .gitignore | 37 +- README.md | 195 +- apps/tesm-license/pyproject.toml | 27 + .../tesm-license/src/tesm_license/__init__.py | 194 + .../src/tesm_license/blueprints/__init__.py | 1 + .../src/tesm_license/blueprints/api.py | 115 + .../src/tesm_license/blueprints/backup.py | 201 + .../src/tesm_license/blueprints/common.py | 135 + .../src/tesm_license/blueprints/customers.py | 126 + .../src/tesm_license/blueprints/dashboard.py | 40 + .../src/tesm_license/blueprints/licenses.py | 453 ++ .../tesm_license/blueprints/own_license.py | 183 + .../src/tesm_license/blueprints/portal.py | 278 + .../tesm_license/blueprints/selfservice.py | 304 + .../src/tesm_license/blueprints/settings.py | 305 + .../src/tesm_license/blueprints/tickets.py | 238 + apps/tesm-license/src/tesm_license/cli.py | 469 ++ .../src/tesm_license/navigation.py | 188 + .../src/tesm_license/permissions.py | 183 + apps/tesm-license/src/tesm_license/schema.py | 216 + .../src/tesm_license/services/__init__.py | 1 + .../src/tesm_license/services/activation.py | 344 + .../src/tesm_license/services/issuance.py | 765 ++ .../tesm_license/services/legacy_import.py | 354 + .../src/tesm_license/services/mail.py | 213 + .../src/tesm_license/services/master_key.py | 112 + .../src/tesm_license/services/monitorlog.py | 64 + .../src/tesm_license/services/reporting.py | 274 + .../src/tesm_license/services/transfer.py | 420 + .../src/tesm_license/settings_spec.py | 97 + .../templates/license/backup.html | 132 + .../templates/license/customer_detail.html | 219 + .../templates/license/customers.html | 86 + .../templates/license/dashboard.html | 124 + .../templates/license/license_detail.html | 258 + .../templates/license/licenses.html | 77 + .../templates/license/manual_code.html | 68 + .../templates/license/own_license.html | 241 + .../templates/license/portal_disabled.html | 11 + .../templates/license/portal_me.html | 96 + .../templates/license/portal_request.html | 28 + .../templates/license/report_detail.html | 77 + .../templates/license/reports.html | 49 + .../templates/license/selfservice.html | 135 + .../license/selfservice_offline.html | 51 + .../templates/license/settings_mail.html | 81 + .../templates/license/settings_system.html | 134 + .../templates/license/settings_vendor.html | 84 + .../templates/license/signing_key.html | 34 + .../templates/license/ticket_detail.html | 259 + .../templates/license/ticket_new.html | 139 + .../templates/license/tickets.html | 91 + apps/tesm-license/src/tesm_license/wsgi.py | 7 + deploy/install.sh | 558 ++ deploy/logrotate/tesm | 14 + deploy/logrotate/tesm-license | 14 + deploy/nginx/tesm-license.conf | 43 + deploy/nginx/tesm.conf | 61 + deploy/release.sh | 29 + deploy/sudoers/tesm | 9 + deploy/sudoers/tesm-license | 9 + deploy/systemd/tesm-license.service | 78 + deploy/systemd/tesm-monitor.service | 62 + deploy/systemd/tesm.service | 78 + deploy/tesm-helper | 351 + deploy/update.sh | 56 + docs/ARCHITEKTUR.md | 166 + docs/BETRIEB.md | 295 + docs/SICHERHEIT.md | 183 + etc/nginx/sites-available/tesm-license | 18 - etc/systemd/system/tesm-license.service | 27 - install.sh | 155 - packages/tesm-core/pyproject.toml | 25 + packages/tesm-core/src/tesm_core/__init__.py | 38 + .../tesm-core/src/tesm_core/app_factory.py | 374 + packages/tesm-core/src/tesm_core/audit.py | 283 + .../tesm-core/src/tesm_core/auth/__init__.py | 27 + .../tesm-core/src/tesm_core/auth/directory.py | 581 ++ .../tesm-core/src/tesm_core/auth/models.py | 297 + .../tesm-core/src/tesm_core/auth/routes.py | 135 + .../tesm-core/src/tesm_core/auth/service.py | 499 ++ packages/tesm-core/src/tesm_core/cli_web.py | 230 + packages/tesm-core/src/tesm_core/config.py | 205 + .../tesm-core/src/tesm_core/core_schema.py | 193 + packages/tesm-core/src/tesm_core/db.py | 213 + packages/tesm-core/src/tesm_core/extension.py | 120 + packages/tesm-core/src/tesm_core/keystore.py | 212 + .../src/tesm_core/licensing_client.py | 778 ++ .../tesm-core/src/tesm_core/logging_setup.py | 136 + .../tesm-core/src/tesm_core/migrations.py | 234 + packages/tesm-core/src/tesm_core/nav.py | 160 + .../tesm-core/src/tesm_core/rbac/__init__.py | 25 + .../tesm-core/src/tesm_core/rbac/common.py | 161 + .../tesm-core/src/tesm_core/rbac/model.py | 246 + .../tesm-core/src/tesm_core/rbac/service.py | 717 ++ .../src/tesm_core/security/__init__.py | 7 + .../tesm-core/src/tesm_core/security/csrf.py | 180 + .../src/tesm_core/security/passwords.py | 153 + .../src/tesm_core/security/ratelimit.py | 84 + .../src/tesm_core/security/sessions.py | 286 + .../tesm-core/src/tesm_core/security/totp.py | 84 + .../tesm-core/src/tesm_core/settings_store.py | 165 + .../tesm_core/static/tesm_core/css/base.css | 720 ++ .../static/tesm_core/css/components.css | 1040 +++ .../static/tesm_core/img/favicon.svg | 16 +- .../src/tesm_core/static/tesm_core/js/app.js | 842 ++ packages/tesm-core/src/tesm_core/sysops.py | 310 + .../src/tesm_core/system/__init__.py | 12 + .../tesm-core/src/tesm_core/system/hostnet.py | 208 + .../src/tesm_core/system/webserver.py | 379 + .../templates/tesm_core/_group_picker.html | 46 + .../templates/tesm_core/_log_lines.html | 7 + .../templates/tesm_core/account.html | 121 + .../templates/tesm_core/account_security.html | 181 + .../tesm_core/templates/tesm_core/base.html | 190 + .../templates/tesm_core/diagnostics.html | 190 + .../tesm_core/templates/tesm_core/error.html | 17 + .../templates/tesm_core/group_detail.html | 122 + .../tesm_core/templates/tesm_core/groups.html | 85 + .../tesm_core/templates/tesm_core/login.html | 29 + .../templates/tesm_core/logs_archive.html | 30 + .../templates/tesm_core/logs_audit.html | 94 + .../templates/tesm_core/logs_file.html | 12 + .../templates/tesm_core/logs_history.html | 84 + .../templates/tesm_core/logs_live.html | 33 + .../tesm_core/templates/tesm_core/macros.html | 222 + .../tesm_core/templates/tesm_core/reauth.html | 25 + .../templates/tesm_core/second_factor.html | 20 + .../templates/tesm_core/sessions.html | 55 + .../tesm_core/settings_directory.html | 167 + .../templates/tesm_core/settings_network.html | 108 + .../templates/tesm_core/settings_web.html | 180 + .../templates/tesm_core/standalone.html | 80 + .../tesm_core/templates/tesm_core/trash.html | 73 + .../tesm_core/templates/tesm_core/users.html | 238 + packages/tesm-core/src/tesm_core/transfer.py | 233 + packages/tesm-core/src/tesm_core/ui.py | 238 + .../tesm-core/src/tesm_core/views/__init__.py | 7 + .../tesm-core/src/tesm_core/views/account.py | 367 + .../tesm-core/src/tesm_core/views/admin.py | 440 ++ .../src/tesm_core/views/diagnostics.py | 150 + .../src/tesm_core/views/hostsettings.py | 529 ++ .../tesm-core/src/tesm_core/views/logs.py | 239 + .../tesm-core/src/tesm_core/views/trash.py | 282 + packages/tesm-licensing/pyproject.toml | 13 + .../src/tesm_licensing/__init__.py | 125 + .../src/tesm_licensing/canonical.py | 115 + .../src/tesm_licensing/errors.py | 32 + .../src/tesm_licensing/fingerprint.py | 54 + .../src/tesm_licensing/license.py | 417 + .../src/tesm_licensing/protocol.py | 240 + srv/tesm-license/SCHEMA_VERSION | 1 - srv/tesm-license/VERSION | 1 - srv/tesm-license/app.py | 6896 ----------------- srv/tesm-license/create_admin.py | 40 - srv/tesm-license/create_db.py | 241 - srv/tesm-license/create_master_license.py | 111 - srv/tesm-license/licensing.py | 363 - srv/tesm-license/requirements.txt | 37 - srv/tesm-license/static/css/style.css | 1568 ---- srv/tesm-license/static/css/vendor/xterm.css | 218 - srv/tesm-license/static/images/icon-light.svg | 12 - .../static/images/logo-dark-subline.svg | 13 - srv/tesm-license/static/images/logo-dark.svg | 9 - .../static/images/logo-light-subline.svg | 13 - srv/tesm-license/static/images/logo-light.svg | 9 - srv/tesm-license/static/images/logo.png | Bin 2623 -> 0 bytes srv/tesm-license/static/js/app.js | 633 -- .../static/js/vendor/mammoth.browser.min.js | 21 - .../static/js/vendor/xlsx.full.min.js | 22 - .../static/js/vendor/xterm-addon-fit.js | 2 - srv/tesm-license/static/js/vendor/xterm.js | 2 - .../templates/_audit_log_macros.html | 67 - .../templates/_audit_log_rows.html | 5 - srv/tesm-license/templates/_hint_icon.html | 9 - srv/tesm-license/templates/account.html | 64 - srv/tesm-license/templates/activity_log.html | 224 - srv/tesm-license/templates/base.html | 150 - .../templates/customer_detail.html | 137 - .../templates/customer_portal.html | 92 - srv/tesm-license/templates/customers.html | 211 - srv/tesm-license/templates/groups.html | 393 - srv/tesm-license/templates/index.html | 119 - .../templates/license_detail.html | 92 - .../templates/license_manual_code.html | 46 - srv/tesm-license/templates/licenses.html | 123 - srv/tesm-license/templates/login.html | 49 - srv/tesm-license/templates/logs.html | 89 - srv/tesm-license/templates/logs_history.html | 112 - .../templates/portal_request.html | 48 - srv/tesm-license/templates/self_service.html | 86 - srv/tesm-license/templates/settings.html | 372 - .../templates/settings_import_export.html | 107 - srv/tesm-license/templates/settings_ldap.html | 324 - .../templates/settings_license.html | 179 - .../templates/settings_nginx.html | 212 - srv/tesm-license/templates/ticket_detail.html | 206 - srv/tesm-license/templates/ticket_new.html | 132 - srv/tesm-license/templates/tickets.html | 193 - srv/tesm-license/templates/users.html | 315 - tests/conftest.py | 211 + tests/e2e_license.py | 493 ++ tests/e2e_license_server.py | 251 + tests/test_core_security.py | 721 ++ tests/test_core_smoke.py | 246 + tests/test_license_server.py | 703 ++ tests/test_licensing.py | 408 + tests/test_repo_hygiene.py | 326 + tests/test_webserver.py | 521 ++ update.sh | 117 - 211 files changed, 31099 insertions(+), 14759 deletions(-) create mode 100644 .gitattributes create mode 100644 apps/tesm-license/pyproject.toml create mode 100644 apps/tesm-license/src/tesm_license/__init__.py create mode 100644 apps/tesm-license/src/tesm_license/blueprints/__init__.py create mode 100644 apps/tesm-license/src/tesm_license/blueprints/api.py create mode 100644 apps/tesm-license/src/tesm_license/blueprints/backup.py create mode 100644 apps/tesm-license/src/tesm_license/blueprints/common.py create mode 100644 apps/tesm-license/src/tesm_license/blueprints/customers.py create mode 100644 apps/tesm-license/src/tesm_license/blueprints/dashboard.py create mode 100644 apps/tesm-license/src/tesm_license/blueprints/licenses.py create mode 100644 apps/tesm-license/src/tesm_license/blueprints/own_license.py create mode 100644 apps/tesm-license/src/tesm_license/blueprints/portal.py create mode 100644 apps/tesm-license/src/tesm_license/blueprints/selfservice.py create mode 100644 apps/tesm-license/src/tesm_license/blueprints/settings.py create mode 100644 apps/tesm-license/src/tesm_license/blueprints/tickets.py create mode 100644 apps/tesm-license/src/tesm_license/cli.py create mode 100644 apps/tesm-license/src/tesm_license/navigation.py create mode 100644 apps/tesm-license/src/tesm_license/permissions.py create mode 100644 apps/tesm-license/src/tesm_license/schema.py create mode 100644 apps/tesm-license/src/tesm_license/services/__init__.py create mode 100644 apps/tesm-license/src/tesm_license/services/activation.py create mode 100644 apps/tesm-license/src/tesm_license/services/issuance.py create mode 100644 apps/tesm-license/src/tesm_license/services/legacy_import.py create mode 100644 apps/tesm-license/src/tesm_license/services/mail.py create mode 100644 apps/tesm-license/src/tesm_license/services/master_key.py create mode 100644 apps/tesm-license/src/tesm_license/services/monitorlog.py create mode 100644 apps/tesm-license/src/tesm_license/services/reporting.py create mode 100644 apps/tesm-license/src/tesm_license/services/transfer.py create mode 100644 apps/tesm-license/src/tesm_license/settings_spec.py create mode 100644 apps/tesm-license/src/tesm_license/templates/license/backup.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/customer_detail.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/customers.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/dashboard.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/license_detail.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/licenses.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/manual_code.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/own_license.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/portal_disabled.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/portal_me.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/portal_request.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/report_detail.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/reports.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/selfservice.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/selfservice_offline.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/settings_mail.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/settings_system.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/settings_vendor.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/signing_key.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/ticket_new.html create mode 100644 apps/tesm-license/src/tesm_license/templates/license/tickets.html create mode 100644 apps/tesm-license/src/tesm_license/wsgi.py create mode 100644 deploy/install.sh create mode 100644 deploy/logrotate/tesm create mode 100644 deploy/logrotate/tesm-license create mode 100644 deploy/nginx/tesm-license.conf create mode 100644 deploy/nginx/tesm.conf create mode 100644 deploy/release.sh create mode 100644 deploy/sudoers/tesm create mode 100644 deploy/sudoers/tesm-license create mode 100644 deploy/systemd/tesm-license.service create mode 100644 deploy/systemd/tesm-monitor.service create mode 100644 deploy/systemd/tesm.service create mode 100644 deploy/tesm-helper create mode 100644 deploy/update.sh create mode 100644 docs/ARCHITEKTUR.md create mode 100644 docs/BETRIEB.md create mode 100644 docs/SICHERHEIT.md delete mode 100644 etc/nginx/sites-available/tesm-license delete mode 100644 etc/systemd/system/tesm-license.service delete mode 100644 install.sh create mode 100644 packages/tesm-core/pyproject.toml create mode 100644 packages/tesm-core/src/tesm_core/__init__.py create mode 100644 packages/tesm-core/src/tesm_core/app_factory.py create mode 100644 packages/tesm-core/src/tesm_core/audit.py create mode 100644 packages/tesm-core/src/tesm_core/auth/__init__.py create mode 100644 packages/tesm-core/src/tesm_core/auth/directory.py create mode 100644 packages/tesm-core/src/tesm_core/auth/models.py create mode 100644 packages/tesm-core/src/tesm_core/auth/routes.py create mode 100644 packages/tesm-core/src/tesm_core/auth/service.py create mode 100644 packages/tesm-core/src/tesm_core/cli_web.py create mode 100644 packages/tesm-core/src/tesm_core/config.py create mode 100644 packages/tesm-core/src/tesm_core/core_schema.py create mode 100644 packages/tesm-core/src/tesm_core/db.py create mode 100644 packages/tesm-core/src/tesm_core/extension.py create mode 100644 packages/tesm-core/src/tesm_core/keystore.py create mode 100644 packages/tesm-core/src/tesm_core/licensing_client.py create mode 100644 packages/tesm-core/src/tesm_core/logging_setup.py create mode 100644 packages/tesm-core/src/tesm_core/migrations.py create mode 100644 packages/tesm-core/src/tesm_core/nav.py create mode 100644 packages/tesm-core/src/tesm_core/rbac/__init__.py create mode 100644 packages/tesm-core/src/tesm_core/rbac/common.py create mode 100644 packages/tesm-core/src/tesm_core/rbac/model.py create mode 100644 packages/tesm-core/src/tesm_core/rbac/service.py create mode 100644 packages/tesm-core/src/tesm_core/security/__init__.py create mode 100644 packages/tesm-core/src/tesm_core/security/csrf.py create mode 100644 packages/tesm-core/src/tesm_core/security/passwords.py create mode 100644 packages/tesm-core/src/tesm_core/security/ratelimit.py create mode 100644 packages/tesm-core/src/tesm_core/security/sessions.py create mode 100644 packages/tesm-core/src/tesm_core/security/totp.py create mode 100644 packages/tesm-core/src/tesm_core/settings_store.py create mode 100644 packages/tesm-core/src/tesm_core/static/tesm_core/css/base.css create mode 100644 packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css rename srv/tesm-license/static/images/icon-dark.svg => packages/tesm-core/src/tesm_core/static/tesm_core/img/favicon.svg (54%) create mode 100644 packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js create mode 100644 packages/tesm-core/src/tesm_core/sysops.py create mode 100644 packages/tesm-core/src/tesm_core/system/__init__.py create mode 100644 packages/tesm-core/src/tesm_core/system/hostnet.py create mode 100644 packages/tesm-core/src/tesm_core/system/webserver.py create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/_group_picker.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/_log_lines.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/account.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/account_security.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/base.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/diagnostics.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/error.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/groups.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/login.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/logs_archive.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/logs_file.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/logs_history.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/logs_live.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/macros.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/reauth.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/second_factor.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/sessions.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/settings_directory.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/settings_network.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/settings_web.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/standalone.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/trash.html create mode 100644 packages/tesm-core/src/tesm_core/templates/tesm_core/users.html create mode 100644 packages/tesm-core/src/tesm_core/transfer.py create mode 100644 packages/tesm-core/src/tesm_core/ui.py create mode 100644 packages/tesm-core/src/tesm_core/views/__init__.py create mode 100644 packages/tesm-core/src/tesm_core/views/account.py create mode 100644 packages/tesm-core/src/tesm_core/views/admin.py create mode 100644 packages/tesm-core/src/tesm_core/views/diagnostics.py create mode 100644 packages/tesm-core/src/tesm_core/views/hostsettings.py create mode 100644 packages/tesm-core/src/tesm_core/views/logs.py create mode 100644 packages/tesm-core/src/tesm_core/views/trash.py create mode 100644 packages/tesm-licensing/pyproject.toml create mode 100644 packages/tesm-licensing/src/tesm_licensing/__init__.py create mode 100644 packages/tesm-licensing/src/tesm_licensing/canonical.py create mode 100644 packages/tesm-licensing/src/tesm_licensing/errors.py create mode 100644 packages/tesm-licensing/src/tesm_licensing/fingerprint.py create mode 100644 packages/tesm-licensing/src/tesm_licensing/license.py create mode 100644 packages/tesm-licensing/src/tesm_licensing/protocol.py delete mode 100644 srv/tesm-license/SCHEMA_VERSION delete mode 100644 srv/tesm-license/VERSION delete mode 100644 srv/tesm-license/app.py delete mode 100644 srv/tesm-license/create_admin.py delete mode 100644 srv/tesm-license/create_db.py delete mode 100644 srv/tesm-license/create_master_license.py delete mode 100644 srv/tesm-license/licensing.py delete mode 100644 srv/tesm-license/requirements.txt delete mode 100644 srv/tesm-license/static/css/style.css delete mode 100644 srv/tesm-license/static/css/vendor/xterm.css delete mode 100644 srv/tesm-license/static/images/icon-light.svg delete mode 100644 srv/tesm-license/static/images/logo-dark-subline.svg delete mode 100644 srv/tesm-license/static/images/logo-dark.svg delete mode 100644 srv/tesm-license/static/images/logo-light-subline.svg delete mode 100644 srv/tesm-license/static/images/logo-light.svg delete mode 100644 srv/tesm-license/static/images/logo.png delete mode 100644 srv/tesm-license/static/js/app.js delete mode 100644 srv/tesm-license/static/js/vendor/mammoth.browser.min.js delete mode 100644 srv/tesm-license/static/js/vendor/xlsx.full.min.js delete mode 100644 srv/tesm-license/static/js/vendor/xterm-addon-fit.js delete mode 100644 srv/tesm-license/static/js/vendor/xterm.js delete mode 100644 srv/tesm-license/templates/_audit_log_macros.html delete mode 100644 srv/tesm-license/templates/_audit_log_rows.html delete mode 100644 srv/tesm-license/templates/_hint_icon.html delete mode 100644 srv/tesm-license/templates/account.html delete mode 100644 srv/tesm-license/templates/activity_log.html delete mode 100644 srv/tesm-license/templates/base.html delete mode 100644 srv/tesm-license/templates/customer_detail.html delete mode 100644 srv/tesm-license/templates/customer_portal.html delete mode 100644 srv/tesm-license/templates/customers.html delete mode 100644 srv/tesm-license/templates/groups.html delete mode 100644 srv/tesm-license/templates/index.html delete mode 100644 srv/tesm-license/templates/license_detail.html delete mode 100644 srv/tesm-license/templates/license_manual_code.html delete mode 100644 srv/tesm-license/templates/licenses.html delete mode 100644 srv/tesm-license/templates/login.html delete mode 100644 srv/tesm-license/templates/logs.html delete mode 100644 srv/tesm-license/templates/logs_history.html delete mode 100644 srv/tesm-license/templates/portal_request.html delete mode 100644 srv/tesm-license/templates/self_service.html delete mode 100644 srv/tesm-license/templates/settings.html delete mode 100644 srv/tesm-license/templates/settings_import_export.html delete mode 100644 srv/tesm-license/templates/settings_ldap.html delete mode 100644 srv/tesm-license/templates/settings_license.html delete mode 100644 srv/tesm-license/templates/settings_nginx.html delete mode 100644 srv/tesm-license/templates/ticket_detail.html delete mode 100644 srv/tesm-license/templates/ticket_new.html delete mode 100644 srv/tesm-license/templates/tickets.html delete mode 100644 srv/tesm-license/templates/users.html create mode 100644 tests/conftest.py create mode 100644 tests/e2e_license.py create mode 100644 tests/e2e_license_server.py create mode 100644 tests/test_core_security.py create mode 100644 tests/test_core_smoke.py create mode 100644 tests/test_license_server.py create mode 100644 tests/test_licensing.py create mode 100644 tests/test_repo_hygiene.py create mode 100644 tests/test_webserver.py delete mode 100644 update.sh 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 4b45d2d..0884fb7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,24 @@ -# Virtuelle Umgebung (Linux — das Projekt ist ausschließlich für Linux ausgelegt) -/srv/tesm-license/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-license/sqlite.db -/srv/tesm-license/fernet.key -/srv/tesm-license/secret.key -/srv/tesm-license/license.json -/srv/tesm-license/master_signing_key.json - -# Laufzeit-Uploads — werden bei Bedarf automatisch angelegt -/srv/tesm-license/static/uploads/ +# Werkzeuge +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ diff --git a/README.md b/README.md index be344cb..7e019f4 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,163 @@ # TESM-Lizenzserver -Master-Lizenzserver für [TESM (TimEShepManager)](https://gitea.int.eertmoed.net/alientim/tesm) — -verwaltet Kunden und deren Lizenzen, statt Netzwerkgeräte. Ursprünglich als -Teil des TESM-Hauptrepos gebaut (gleiche Codebasis: Login/Benutzer/Gruppen, -LDAP/AD, NGINX- und Systemeinstellungen, Live-Log/Verlauf/Auditlog, -Im-/Export-Grundgerüst, das komplette Rechtesystem) und in dieses eigene -Repo ausgelagert. +Ausgabe und Verwaltung der Lizenzen fuer TESM: Kunde -> Ticket -> Lizenz, +Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand ueber +MS Graph und eine Hierarchie aus Unter-Lizenzservern. -**Das Projekt ist ausschließlich für Linux ausgelegt** (Zielsystem: eine -Linux-VM, identisches Deployment-Muster wie TESM selbst). +Die verwaltete Anwendung liegt in einem eigenen Repository: +[alientim/tesm](https://gitea.int.eertmoed.net/alientim/tesm). 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. -## Kernfunktionen +## Zweige -- **Kunden-/Lizenzübersicht** als Dashboard (Typ, Module, Ablaufdatum, - Aktivierungsstatus, letzter Heartbeat) -- **Lizenz-Ausstellung**: Trial/Standard/Custom/Enterprise, frei wählbare - Gültigkeitsdauer, bei Custom einzeln wählbare Module -- **Ed25519-signierte Lizenzdateien** (siehe `srv/tesm-license/licensing.py`) - — dieselbe Datei existiert byte-identisch im TESM-Repo, da Client - (TESM) und Master (dieses Repo) exakt dasselbe Signier-/ - Verifikationsprotokoll sprechen müssen -- **Online-Aktivierung/-Deaktivierung/-Heartbeat** über eine schlanke - JSON-API (`/api/activate`, `/api/deactivate`, `/api/heartbeat`, - unauthentifiziert per Design — die Signatur der Anfrage selbst ist der - Berechtigungsnachweis) -- **Offline-Fallback**: dieselbe Aktivierungs-/Deaktivierungs-/Heartbeat- - Logik auch als manuell kopierbarer Code für Kunden ohne Netzwerkzugriff - auf den Lizenzserver -- **E-Mail-Versand** ausgestellter Lizenzen per Microsoft Graph - (Client-Credentials-Flow, keine zusätzliche Abhängigkeit) inkl. - Einrichtungsanleitung und Verbindungstest in der GUI -- Eigene Bootstrap-Lizenz des Masters selbst (`create_master_license.py`, - rein lokal, kein externer Super-Master nötig) +| Zweig | Inhalt | +|---|---| +| `main` | dieser Neubau (Version 2.0.0) | +| `SONNET5` | der Stand davor, unveraendert aufbewahrt | -## Installation +--- + +## Installation auf einem Server ```bash -sudo ./install.sh +sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.de +sudo -u tesm-license tesm-license-admin init-key +sudo -u tesm-license tesm-license-admin bootstrap +sudo -u tesm-license tesm-license-admin create-admin ``` -Richtet System-Pakete, die Flask-App (systemd: `tesm-license.service`) -sowie nginx als Reverse-Proxy unter `/srv/tesm-license` ein. Erkennt -selbstständig, ob dort bereits eine Installation existiert, und -aktualisiert sie entsprechend (In-Place bei unverändertem -Datenbank-Schema, sonst mit automatischem Backup + Health-Check + -Rückroll bei Fehlschlag — siehe Kommentarkopf in `install.sh`). +**`init-key` erzeugt den Signaturschluessel dieses Servers. Sichern Sie ihn +sofort.** Ohne ihn laesst sich keine ausgestellte Lizenz mehr erneuern, und +Kunden muessten einen Ausstellerwechsel bestaetigen. -Nach der Erstinstallation: +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. +Sitzt ein eigener Reverse Proxy davor, der TLS beendet, laeuft der Dienst ohne +`--https` schlicht auf Port 80. + +Der Lizenzserver darf neben TESM auf demselben Host laufen; nginx unterscheidet +die beiden am Servernamen, und jede Installation hat ihren eigenen `SITE_KEY` +fuer nginx-Site, logrotate, Zertifikate und Sitzungscookie. + +### Aktualisieren ```bash -sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_admin.py -sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_master_license.py +sudo bash deploy/update.sh --app tesm-license ``` -## Update (von einem bereits installierten System aus) +Vor jedem Update entsteht ein vollstaendiges Backup, danach laeuft ein +Health-Check mit automatischem Rollback. + +--- + +## Release bauen ```bash -sudo ./update.sh -# oder für eine bestimmte Version statt "latest": -TESM_RELEASE_TAG=v1.0.0 sudo -E ./update.sh +git tag -a v2.0.0 -m "TESM-Lizenzserver 2.0.0" && git push origin v2.0.0 +bash deploy/release.sh v2.0.0 ``` -Lädt das aktuelle Gitea-Release herunter und übergibt an `install.sh`. +Heraus kommt `tesm-license-v2.0.0.tar.gz` samt SHA-256. Der Name kommt aus dem +Repository -- ein Lizenzserver-Release, das `tesm-...` heisst, verwechselt man +genau einmal. -## Zusammenspiel mit TESM +--- -Eine TESM-Instanz aktiviert sich gegen genau einen Lizenzserver -(`master_endpoint`, in der Lizenzdatei hinterlegt). Ein Protokoll, zwei -Transportwege: online automatisch per HTTPS/JSON, offline als manuell -auszutauschender Code — beide Seiten nutzen dieselben Funktionen aus -`licensing.py`. Details zum kryptografischen Format und Ablauf siehe die -Docstrings in `srv/tesm-license/licensing.py` bzw. dem identischen Modul -im [TESM-Repo](https://gitea.int.eertmoed.net/alientim/tesm). +## Entwicklung + +```bash +python -m venv .venv && . .venv/bin/activate +pip install -e packages/tesm-licensing -e packages/tesm-core -e apps/tesm-license +pip install pytest +pytest tests -q +``` + +Ohne gesetzte Umgebungsvariablen legt die Anwendung ihre Daten unter +`instance/tesm-license/` im Arbeitsverzeichnis an -- kein Zugriff auf `/srv` +oder `/var/log` noetig. + +```bash +TESM_LICENSE_DEBUG=1 flask --app tesm_license run +``` + +--- + +## Verwaltung von der Kommandozeile + +```bash +sudo -u tesm-license tesm-license-admin status +sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel +sudo -u tesm-license tesm-license-admin bootstrap # Grunddaten +sudo -u tesm-license tesm-license-admin create-admin +sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand uebernehmen +``` + +`migrate-legacy` liest die SQLite-Datei eines alten Lizenzservers **nur lesend**, +uebernimmt dessen Signaturschluessel und stellt die Lizenzen mit ihrer +restlichen Laufzeit neu aus. Wird der Schluessel nicht uebernommen, muessen +Kunden den Ausstellerwechsel einmal bestaetigen. + +--- + +## Lizenzprotokoll in Kurzform + +* Ed25519, getrennte Signaturkontexte je Nachrichtenart + (`tesm/license-file/v2\0`) -- eine Signatur aus einem Kontext ist in einem + anderen nicht verwendbar. +* Kanonisches JSON, damit dieselbe Aussage genau eine Signatur hat. +* Nonce gegen Wiedereinspielung, `seq` gegen das Zurueckrollen auf eine aeltere + Lizenz. +* Aussteller wird beim ersten Kontakt gemerkt (TOFU); ein Wechsel muss + ausdruecklich bestaetigt werden. +* Der Fingerabdruck der Maschine enthaelt **keinen** Hostnamen -- Umbenennen + brach im Vorgaenger die Bindung. +* Offline-Aktivierung ueber einen kurzen Code in beide Richtungen; die Lizenz + bleibt auf dem Server als "Aktivierung offen" markiert, bis sie zurueckkommt. + +--- + +## 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 zwei Skripte, die **auf dem Zielhost gegen laufende Installationen** +sprechen -- ueber nginx, gunicorn und echtes TLS. Sie werden nicht +mitinstalliert (`install.sh` laesst `tests/` aus); bei Bedarf dorthin kopieren: + +```bash +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 zweite spielt den vollstaendigen Lizenz-Lebenszyklus ueber **beide** +Anwendungen durch: ausstellen, herunterladen, einspielen, online aktivieren, +Module pruefen, Heartbeat, neue Revision, Widerruf. Es braucht deshalb auch eine +laufende TESM-Installation. + +--- + +## Was diese Fassung anders macht + +* **Keine doppelte `licensing.py`.** Ein Paket, das beide Anwendungen + installieren -- statt zweier Dateien, die byte-identisch bleiben sollen. +* **Kein root fuer die Anwendung.** Ein einziger allowlisted Helfer mit + Argumentpruefung. +* **CSRF-Schutz ueberhaupt.** Der Vorgaenger hatte keinen. +* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet. +* **Keine Lizenz ohne Endpunkt.** Eine Ausstellung ohne `master_endpoint` wird + abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann. +* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock. diff --git a/apps/tesm-license/pyproject.toml b/apps/tesm-license/pyproject.toml new file mode 100644 index 0000000..bb86d7e --- /dev/null +++ b/apps/tesm-license/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "tesm-license" +version = "2.0.0" +description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" +requires-python = ">=3.11" +dependencies = [ + "tesm-core>=2.0.0", + "tesm-licensing>=2.0.0", + "PyYAML>=6.0", + "ldap3>=2.9", +] + +[project.optional-dependencies] +server = ["gunicorn>=23.0"] + +[project.scripts] +tesm-license-admin = "tesm_license.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +tesm_license = ["templates/**/*.html", "static/**/*"] diff --git a/apps/tesm-license/src/tesm_license/__init__.py b/apps/tesm-license/src/tesm_license/__init__.py new file mode 100644 index 0000000..a8262ec --- /dev/null +++ b/apps/tesm-license/src/tesm_license/__init__.py @@ -0,0 +1,194 @@ +"""TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung. + +Kein Fork von TESM: beide Anwendungen bauen auf demselben Paket ``tesm_core`` +auf. Der Vorgaenger kopierte die gesamte Codebasis und entfernte danach, was er +nicht brauchte -- mit der Folge, dass jede Korrektur zweimal gemacht werden +musste (und ``licensing.py`` byte-identisch gehalten werden *musste*). +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from flask import Flask +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 LICENSE_MIGRATIONS +from .settings_spec import LICENSE_SERVER_SETTINGS + +__version__ = "2.0.0" + +APP_KEY = "tesm-license" +APP_NAME = "TESM-Lizenzserver" +APP_SHORT = "TL" +APP_EDITION = "Lizenzverwaltung" +ENV_PREFIX = "TESM_LICENSE_" + +logger = logging.getLogger(__name__) + + +def master_key_path(config: Any) -> Path: + return Path(config.data_dir) / "master_signing_key.json" + + +def create_app(**overrides: Any) -> Flask: + config = load_core_config( + app_key=APP_KEY, + app_name=APP_NAME, + env_prefix=ENV_PREFIX, + default_bind="127.0.0.1:5001", + ) + + app = create_base_app( + config=config, + permissions=PERMISSIONS, + nav=NAVIGATION, + role_presets=ROLE_PRESETS, + app_migrations={"tesm_license": LICENSE_MIGRATIONS}, + settings_specs=LICENSE_SERVER_SETTINGS, + import_name="tesm_license", + app_short=APP_SHORT, + app_edition=APP_EDITION, + app_version=__version__, + enable_license=True, + ) + + _register_blueprints(app) + _register_registry(app) + _startup_tasks(app) + + if overrides: + app.config.update(overrides) + return app + + +def _register_blueprints(app: Flask) -> None: + from .blueprints import ( + api, + backup, + customers, + dashboard, + licenses, + own_license, + portal, + selfservice, + settings, + tickets, + ) + + app.register_blueprint(dashboard.bp) + app.register_blueprint(customers.bp) + app.register_blueprint(tickets.bp) + app.register_blueprint(licenses.bp) + app.register_blueprint(api.bp) + app.register_blueprint(portal.bp) + app.register_blueprint(selfservice.bp) + app.register_blueprint(own_license.bp) + app.register_blueprint(settings.bp) + app.register_blueprint(backup.bp) + + # Die Uebersicht ist die Startseite. Anders als bei TESM verlangt sie eine + # Anmeldung: das hier ist eine interne Verwaltungsanwendung, kein + # Informationsboard fuer das Netz. + app.add_url_rule("/", "index", dashboard.index_view) + + +def _register_registry(app: Flask) -> None: + from tesm_core.system import hostnet # noqa: F401 - haelt das Modul geladen + + from .services import issuance, mail, reporting + from .services.monitorlog import LiveLog + + extension = core(app) + # Beide Anwendungen koennen auf demselben Host laufen -- eigener Port je Dienst. + extension.registry["upstream"] = extension.config.bind + extension.registry["static_root"] = str(extension.config.base_dir / "static") + extension.registry["websocket_path"] = "" + extension.registry["master_key_path"] = master_key_path(extension.config) + extension.registry["live_log"] = LiveLog(extension.config.log_dir / "live.log") + extension.registry["status_pill_builder"] = _status_pills + extension.registry["reencrypt_secrets"] = _reencrypt_secrets + + # Als Sub-Lizenzserver meldet diese Instanz ihren Bestand an ihren Master. + if extension.license is not None: + extension.license.set_report_builder(reporting.build_own_report) + + +def _reencrypt_secrets(conn: Any, keystore: Any) -> int: + """Verschluesselt gespeicherte Geheimnisse unter dem neuen Datenschluessel neu.""" + from tesm_core.auth.directory import AAD_LDAP_BIND, SERVICE_PURPOSE as LDAP_PURPOSE + from tesm_core.db import Database + from tesm_core.keystore import DecryptionError + + from .services.mail import AAD_GRAPH_SECRET, SERVICE_PURPOSE as MAIL_PURPOSE + + migrated = 0 + for purpose, aad in ((LDAP_PURPOSE, AAD_LDAP_BIND), (MAIL_PURPOSE, AAD_GRAPH_SECRET)): + token = Database.value( + conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (purpose,) + ) + if not token or not keystore.needs_reencrypt(str(token)): + continue + try: + plain = keystore.decrypt(str(token), aad=aad) + except DecryptionError: + continue + conn.execute( + "UPDATE service_accounts SET password_encrypted=? WHERE purpose=?", + (keystore.encrypt(plain, aad=aad), purpose), + ) + migrated += 1 + return migrated + + +def _status_pills(user: Any) -> list[dict[str, str]]: + if not user.is_authenticated or not user.has_any(("licenses.view",)): + return [] + from tesm_core.extension import db + + from .services import issuance + + summary = issuance.dashboard_summary(db()) + critical = len(summary["buckets"]["red"]) + if not critical: + return [] + return [ + { + "label": f"{critical} Lizenz(en) kritisch", + "css_class": "status-pill--danger", + "dot": "1", + "dot_class": "status-pill__dot--pulse", + "title": "Weniger als 30 Tage Restlaufzeit oder bereits abgelaufen.", + } + ] + + +def _startup_tasks(app: Flask) -> None: + extension = core(app) + if not extension.config.web_process: + logger.info("Startaufgaben uebersprungen -- kein Webprozess.") + return + + from .services import activation + + with extension.database.session() as conn, extension.database.transaction(conn): + removed = activation.prune_nonces(conn) + if removed: + logger.info("%s abgelaufene Protokoll-Nonces entfernt", removed) + + # Auch der Lizenzserver hat eine eigene Lizenz (Rolle master oder + # license_server) und meldet sich bei einem Master, falls es einen gibt. + if extension.license is not None: + import socket + + extension.license.start_heartbeat(hostname=socket.gethostname()) + + +app = None # wird von wsgi.py gesetzt diff --git a/apps/tesm-license/src/tesm_license/blueprints/__init__.py b/apps/tesm-license/src/tesm_license/blueprints/__init__.py new file mode 100644 index 0000000..26b66ca --- /dev/null +++ b/apps/tesm-license/src/tesm_license/blueprints/__init__.py @@ -0,0 +1 @@ +"""Blueprints des Lizenzservers.""" diff --git a/apps/tesm-license/src/tesm_license/blueprints/api.py b/apps/tesm-license/src/tesm_license/blueprints/api.py new file mode 100644 index 0000000..ad8975a --- /dev/null +++ b/apps/tesm-license/src/tesm_license/blueprints/api.py @@ -0,0 +1,115 @@ +"""Maschinen-API: Aktivierung, Deaktivierung, Heartbeat. + +Diese Endpunkte werden nicht ueber Cookies autorisiert, sondern ueber die +Ed25519-Signatur der Anfrage -- darum sind sie vom CSRF-Schutz ausgenommen. Das +ist die einzige Ausnahme in der ganzen Anwendung, und sie ist es nur, weil hier +gar kein Cookie im Spiel ist. + +Zusaetzlich greift eine Ratenbegrenzung pro Absenderadresse: ohne sie waere der +Endpunkt ein kostenloser Weg, Argon2- und Ed25519-Rechenzeit zu verbrennen. +""" + +from __future__ import annotations + +import logging + +from flask import Blueprint, jsonify, request +from tesm_core.extension import core, db, transaction +from tesm_core.security import csrf, ratelimit + +from ..services import activation +from .common import signing_key + +logger = logging.getLogger(__name__) + +bp = Blueprint("api", __name__, url_prefix="/api/v1/licenses") + +RATE_BUCKET = "api:license" +RATE_LIMIT = 60 +RATE_WINDOW_MINUTES = 5 +MAX_BODY_BYTES = 512 * 1024 + + +def _client_ip() -> str: + from tesm_core.auth.service import client_ip + + return client_ip() or "unbekannt" + + +def _rate_limited() -> bool: + conn = db() + verdict = ratelimit.check( + conn, + RATE_BUCKET, + _client_ip(), + limit=RATE_LIMIT, + window_minutes=RATE_WINDOW_MINUTES, + ) + with transaction(): + ratelimit.record(conn, RATE_BUCKET, _client_ip()) + return verdict.blocked + + +def _handle(action: str): + if _rate_limited(): + return ( + jsonify({"error": "rate_limited", "message": "Zu viele Anfragen."}), + 429, + {"Retry-After": str(RATE_WINDOW_MINUTES * 60)}, + ) + + if request.content_length and request.content_length > MAX_BODY_BYTES: + return jsonify({"error": "too_large", "message": "Anfrage zu gross."}), 413 + + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + return jsonify({"error": "bad_request", "message": "JSON-Objekt erwartet."}), 400 + + processor = activation.PROCESSORS[action] + key = signing_key() + conn = db() + with transaction(): + outcome = processor(conn, payload, master_private_key=key.private_key) + + live = core().registry.get("live_log") + if live is not None: + target = str(payload.get("license_id") or "unbekannt")[:12] + if outcome.ok: + live.info("api", f"{action} von {target} -- {outcome.response.get('status')}") + else: + live.warning( + "api", f"{action} von {target} abgewiesen: {outcome.response.get('error')}" + ) + return jsonify(outcome.response), outcome.status_code + + +@bp.post("/activate") +@csrf.exempt +def activate(): + return _handle("activate") + + +@bp.post("/deactivate") +@csrf.exempt +def deactivate(): + return _handle("deactivate") + + +@bp.post("/heartbeat") +@csrf.exempt +def heartbeat(): + return _handle("heartbeat") + + +@bp.get("/hinweis") +def info(): + """Kurze, oeffentliche Selbstbeschreibung -- ohne verwertbare Details.""" + import tesm_licensing as lic + + return jsonify( + { + "service": "tesm-license", + "protocol": lic.FORMAT_VERSION, + "actions": list(lic.ACTIONS), + } + ) diff --git a/apps/tesm-license/src/tesm_license/blueprints/backup.py b/apps/tesm-license/src/tesm_license/blueprints/backup.py new file mode 100644 index 0000000..47589cf --- /dev/null +++ b/apps/tesm-license/src/tesm_license/blueprints/backup.py @@ -0,0 +1,201 @@ +"""Sicherung und Uebertragung des Lizenzservers.""" + +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 ..services import transfer +from .common import log_event + +bp = Blueprint("backup", __name__, url_prefix="/sicherung") + +MAX_IMPORT_BYTES = 64 * 1024 * 1024 +PREVIEW_SESSION_KEY = "_import_token" + + +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(): + 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) + + return render_template( + "license/backup.html", + categories=_allowed_categories(), + all_categories=transfer.CATEGORIES, + preview=preview, + min_passphrase=transfer.MIN_PASSPHRASE_LENGTH, + preview_ttl=transfer.PREVIEW_TTL_MINUTES, + can_secrets=current_user.has("backup.secrets"), + signing_key_path=str(core().registry["master_key_path"]), + ) + + +@bp.post("/export") +@permission_required("backup.export") +@reauth_required +def export(): + conn = db() + 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")) + + passphrase = request.form.get("passphrase") or "" + if passphrase != (request.form.get("passphrase_repeat") or ""): + flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger") + return redirect(url_for("backup.overview")) + + try: + envelope = transfer.seal(transfer.build_export(conn, categories=chosen), passphrase) + except transfer.TransferError as exc: + flash(str(exc), "danger") + return redirect(url_for("backup.overview")) + + log_event( + "backup.exported", + detail=f"Kategorien: {', '.join(chosen)}", + severity="warning", + scope="sicherung", + ) + 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-lizenzserver-{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 + log_event( + "backup.import_previewed", + detail=f"Erstellt {header.get('created_at')}", + severity="notice", + scope="sicherung", + ) + 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")) + + with transaction(): + report = transfer.apply_import( + conn, + 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-license/src/tesm_license/blueprints/common.py b/apps/tesm-license/src/tesm_license/blueprints/common.py new file mode 100644 index 0000000..9a46a65 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/blueprints/common.py @@ -0,0 +1,135 @@ +"""Gemeinsame Helfer der Lizenzserver-Blueprints.""" + +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urljoin + +from flask import url_for +from tesm_core import audit +from tesm_core.auth.models import current_user +from tesm_core.auth.service import client_ip +from tesm_core.extension import core, db, transaction + +import tesm_licensing as lic + +from ..services import master_key + + +def log_event( + action: str, + *, + target: str = "", + detail: str = "", + severity: str = "info", + scope: str = "lizenz", +) -> None: + """Schreibt ins Aenderungsprotokoll **und** ins laufende Protokoll.""" + extension = core() + with transaction() as conn: + audit.log( + conn, + actor=current_user.username or "system", + actor_id=getattr(current_user, "id", None) or None, + action=action, + target=target, + detail=detail, + ip=client_ip(), + severity=severity, + ) + live = extension.registry.get("live_log") + if live is not None: + text = f"{action} {target}".strip() + (f" -- {detail}" if detail else "") + if severity in {"warning", "critical"}: + live.warning(scope, text) + else: + live.info(scope, text) + + +def signing_key() -> master_key.MasterKey: + """Signaturschluessel dieser Installation (erzeugt ihn beim ersten Aufruf).""" + return master_key.ensure(core().registry["master_key_path"]) + + +def vendor_info() -> dict[str, Any]: + """Anbieterdaten, wie sie in jede Lizenz eingebettet werden.""" + values = core().settings.get_many( + db(), + [ + "vendor_name", + "vendor_email", + "vendor_phone", + "vendor_address", + "vendor_logo_base64", + ], + ) + return { + "name": str(values["vendor_name"] or ""), + "email": str(values["vendor_email"] or ""), + "phone": str(values["vendor_phone"] or ""), + "address": str(values["vendor_address"] or ""), + "logo_base64": str(values["vendor_logo_base64"] or ""), + } + + +def master_endpoint() -> str: + return str(core().settings.get(db(), "master_endpoint") or "").rstrip("/") + + +def customer_facing_url(endpoint: str, **values: Any) -> str: + """Baut Links, die ein Kunde tatsaechlich oeffnen soll. + + ``url_for(..., _external=True)`` nimmt den Host-Header der aktuellen + Anfrage. Bei einem intern angemeldeten Administrator ist das oft eine interne + IP -- fuer den Browser oder das Mailprogramm eines Kunden unbrauchbar. Darum + kommt die Basis aus der Einstellung ``master_endpoint``; nur wenn die leer + ist (frische Installation), faellt es auf das Host-Header-Verhalten zurueck. + """ + base = master_endpoint() + if not base: + return url_for(endpoint, _external=True, **values) + return urljoin(base + "/", url_for(endpoint, **values).lstrip("/")) + + +def can_issue_license_server_licenses() -> bool: + """Darf diese Instanz Lizenzen fuer weitere Lizenzserver ausstellen? + + Das haengt an der Rolle der **eigenen** Lizenzdatei, nicht an einem + Gruppenrecht. Ein Gruppenrecht koennte sich ein lokaler Administrator eines + Sub-Lizenzservers selbst einraeumen; eine von woanders signierte Lizenzdatei + nicht. Zusaetzlich verlangt es echte Administratorrechte -- das Ausstellen + einer Lizenzserver-Lizenz erzeugt eine neue Vertrauensgrenze und ist damit + keine delegierbare Alltagsaufgabe. + """ + manager = core().license + if manager is None: + return bool(getattr(current_user, "is_admin", False)) + return bool( + getattr(current_user, "is_admin", False) + and manager.active() + and manager.state.role == "master" + ) + + +def module_labels() -> dict[str, str]: + return dict(lic.MODULE_LABELS) + + +def type_labels() -> dict[str, str]: + return dict(lic.LICENSE_TYPE_LABELS) + + +def role_labels() -> dict[str, str]: + return dict(lic.ROLE_LABELS) + + +def license_filename(license_id: str, customer_name: str) -> str: + safe = "".join( + char if char.isalnum() or char in "-_" else "-" for char in (customer_name or "kunde") + ).strip("-") + return f"tesm-lizenz-{safe.lower() or 'kunde'}-{license_id[:8]}.json" + + +def bundle_bytes(bundle: dict[str, Any]) -> bytes: + return (json.dumps(bundle, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8") diff --git a/apps/tesm-license/src/tesm_license/blueprints/customers.py b/apps/tesm-license/src/tesm_license/blueprints/customers.py new file mode 100644 index 0000000..672a53b --- /dev/null +++ b/apps/tesm-license/src/tesm_license/blueprints/customers.py @@ -0,0 +1,126 @@ +"""Kunden: Liste, Detailueberblick, Pflege.""" + +from __future__ import annotations + +from flask import Blueprint, abort, flash, redirect, render_template, request, url_for +from tesm_core.auth.models import current_user, permission_required +from tesm_core.extension import db, transaction + +from ..services import issuance, reporting +from .common import customer_facing_url, log_event + +bp = Blueprint("customers", __name__, url_prefix="/kunden") + +FIELDS = ( + "name", + "contact_email", + "contact_phone", + "contact_person", + "street", + "house_number", + "postal_code", + "city", + "notes", +) + + +def _form() -> dict[str, str]: + return {key: request.form.get(key, "") for key in FIELDS} + + +@bp.get("/") +@permission_required("customers.view") +def overview(): + return render_template("license/customers.html", customers=issuance.list_customers(db())) + + +@bp.get("/") +@permission_required("customers.view") +def detail(customer_id: int): + conn = db() + customer = issuance.get_customer(conn, customer_id) + if customer is None: + abort(404) + tickets = issuance.list_tickets(conn, customer_id=customer_id) + licenses = [ + {**row, **issuance.display_state(row)} + for row in issuance.list_licenses(conn, customer_id=customer_id) + ] + reported = ( + reporting.reported_for_customer(conn, customer_id) + if current_user.has("reports.view") + else {"customers": [], "source_license_id": ""} + ) + return render_template( + "license/customer_detail.html", + customer=customer, + tickets=tickets, + licenses=licenses, + reported=reported, + portal_url=customer_facing_url("portal.request_link"), + ) + + +@bp.post("/neu") +@permission_required("customers.create") +def create(): + try: + with transaction() as conn: + customer_id = issuance.create_customer( + conn, actor=current_user.username, **_form() + ) + log_event( + "customer.created", + target=request.form.get("name", ""), + severity="notice", + scope="kunde", + ) + flash("Kunde angelegt.", "success") + return redirect(url_for("customers.detail", customer_id=customer_id)) + except issuance.IssuanceError as exc: + flash(str(exc), "danger") + return redirect(url_for("customers.overview")) + + +@bp.post("/") +@permission_required("customers.edit") +def update(customer_id: int): + try: + with transaction() as conn: + changes = issuance.update_customer(conn, customer_id, **_form()) + if changes: + log_event( + "customer.updated", + target=request.form.get("name", str(customer_id)), + detail=", ".join(changes), + severity="notice", + scope="kunde", + ) + flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", + "success" if changes else "info") + except issuance.IssuanceError as exc: + flash(str(exc), "danger") + return redirect(url_for("customers.detail", customer_id=customer_id)) + + +@bp.post("//loeschen") +@permission_required("customers.delete") +def delete(customer_id: int): + conn = db() + customer = issuance.get_customer(conn, customer_id) + if customer is None: + abort(404) + try: + with transaction(): + issuance.delete_customer(conn, customer_id) + log_event( + "customer.deleted", + target=str(customer["name"]), + severity="warning", + scope="kunde", + ) + flash("Kunde geloescht.", "success") + return redirect(url_for("customers.overview")) + except issuance.IssuanceError as exc: + flash(str(exc), "danger") + return redirect(url_for("customers.detail", customer_id=customer_id)) diff --git a/apps/tesm-license/src/tesm_license/blueprints/dashboard.py b/apps/tesm-license/src/tesm_license/blueprints/dashboard.py new file mode 100644 index 0000000..984285d --- /dev/null +++ b/apps/tesm-license/src/tesm_license/blueprints/dashboard.py @@ -0,0 +1,40 @@ +"""Uebersicht -- die Startseite des Lizenzservers. + +Anders als TESMs Geraete-Dashboard verlangt sie eine Anmeldung: das hier ist +eine interne Verwaltungsanwendung. Die Anmeldeseite ist der einzige sinnvolle +erste Einstiegspunkt. +""" + +from __future__ import annotations + +from flask import Blueprint, render_template +from tesm_core.auth.models import current_user, login_required +from tesm_core.extension import core, db + +from ..services import issuance, reporting + +bp = Blueprint("dashboard", __name__) + + +@bp.get("/uebersicht") +@login_required +def index(): + conn = db() + extension = core() + if not current_user.has("licenses.view"): + # Leere Huelle statt 403: die Startseite soll fuer jeden erreichbar + # bleiben, auch fuer ein Konto ohne Lizenzrechte. + return render_template("license/dashboard.html", summary=None, sources=[]) + + summary = issuance.dashboard_summary(conn) + return render_template( + "license/dashboard.html", + summary=summary, + sources=reporting.reporting_sources(conn) if current_user.has("reports.view") else [], + own_role=(extension.license.state.role if extension.license else "master"), + type_labels=dict(), + ) + + +def index_view(): + return index() diff --git a/apps/tesm-license/src/tesm_license/blueprints/licenses.py b/apps/tesm-license/src/tesm_license/blueprints/licenses.py new file mode 100644 index 0000000..c1d0aad --- /dev/null +++ b/apps/tesm-license/src/tesm_license/blueprints/licenses.py @@ -0,0 +1,453 @@ +"""Lizenzen: ausstellen, herunterladen, versenden, widerrufen, Offline-Codes.""" + +from __future__ import annotations + +import json + +from flask import ( + Blueprint, + Response, + abort, + flash, + redirect, + render_template, + request, + url_for, +) +from tesm_core.auth.models import ( + admin_required, + current_user, + permission_required, + reauth_required, +) +from tesm_core.extension import core, db, transaction + +import tesm_licensing as lic + +from ..services import activation, issuance, mail, reporting +from .common import ( + bundle_bytes, + customer_facing_url, + license_filename, + log_event, + master_endpoint, + module_labels, + role_labels, + signing_key, + type_labels, + vendor_info, +) + +bp = Blueprint("licenses", __name__, url_prefix="/lizenzen") + + +@bp.get("/") +@permission_required("licenses.view") +def overview(): + conn = db() + rows = [ + {**row, **issuance.display_state(row)} + for row in issuance.list_licenses( + conn, + customer_id=request.args.get("kunde", type=int), + ticket_id=request.args.get("ticket"), + status=request.args.get("status"), + ) + ] + return render_template( + "license/licenses.html", + licenses=rows, + type_labels=type_labels(), + role_labels=role_labels(), + states=issuance.LICENSE_STATES, + active_filter=request.args.get("status", ""), + ) + + +@bp.get("/") +@permission_required("licenses.view") +def detail(license_id: str): + conn = db() + row = issuance.get_license(conn, license_id) + if row is None: + abort(404) + from tesm_core.db import Database + + document = json.loads(str(row["license_document"])) + return render_template( + "license/license_detail.html", + license=row, + state=issuance.display_state(row), + document=document, + document_json=json.dumps(document, indent=2, ensure_ascii=False, sort_keys=True), + deliveries=Database.all( + conn, + "SELECT * FROM license_deliveries WHERE license_id=? ORDER BY delivered_at DESC", + (license_id,), + ), + modules=json.loads(str(row["modules"] or "[]")), + module_labels=module_labels(), + type_labels=type_labels(), + role_labels=role_labels(), + mail_ready=mail.is_enabled(conn), + all_modules=lic.ALL_MODULES, + ) + + +# --------------------------------------------------------------------------- # +# Ausstellung +# --------------------------------------------------------------------------- # + + +@bp.post("/aus-ticket/") +@permission_required("licenses.create") +def issue(ticket_id: str): + conn = db() + as_trial = request.form.get("as_trial") == "1" + key = signing_key() + try: + with transaction(): + result = issuance.issue_from_ticket( + conn, + ticket_id, + master_private_key=key.private_key, + master_public_key=key.public_key, + master_endpoint=master_endpoint(), + vendor=vendor_info(), + actor=current_user.username, + keystore=core().keystore, + as_trial=as_trial, + ) + except issuance.IssuanceError as exc: + flash(str(exc), "danger") + return redirect(url_for("tickets.detail", ticket_id=ticket_id)) + + if result.already_existed: + flash( + "Zu diesem Ticket war bereits eine Lizenz offen -- sie wird angezeigt statt eine " + "zweite auszustellen.", + "info", + ) + else: + log_event( + "license.issued", + target=result.license_id[:12], + detail=("Trial-Ausstellung" if as_trial else "regulaer"), + severity="notice", + ) + reporting.report_soon(core()) + flash("Lizenz ausgestellt.", "success") + return redirect(url_for("licenses.detail", license_id=result.license_id)) + + +@bp.get("//datei") +@permission_required("licenses.export") +@reauth_required +def download(license_id: str): + """Laedt das vollstaendige Auslieferungs-Bundle herunter. + + Es enthaelt den privaten Client-Schluessel der Lizenz -- damit ist es ein + Geheimnis. Deshalb: eigenes Recht, frische Passwortbestaetigung, Eintrag im + Zustellprotokoll und im Aenderungsprotokoll. + """ + conn = db() + row = issuance.get_license(conn, license_id) + if row is None: + abort(404) + try: + bundle = issuance.delivery_bundle(conn, license_id, keystore=core().keystore) + except issuance.IssuanceError as exc: + flash(str(exc), "danger") + return redirect(url_for("licenses.detail", license_id=license_id)) + with transaction(): + issuance.record_delivery( + conn, + license_id=license_id, + channel="download", + recipient=current_user.username, + actor=current_user.username, + detail="Vollstaendiges Bundle" if "client_key" in bundle else "Nur Lizenzdokument", + ) + log_event("license.downloaded", target=license_id[:12], severity="notice") + return Response( + bundle_bytes(bundle), + mimetype="application/json", + headers={ + "Content-Disposition": ( + f'attachment; filename="{license_filename(license_id, str(row["customer_name"]))}"' + ), + "Cache-Control": "no-store", + }, + ) + + +@bp.post("//neu-signieren") +@permission_required("licenses.edit") +@reauth_required +def resign(license_id: str): + """Signiert eine neue Revision -- z. B. nach einer Modulerweiterung. + + Der Client bekommt sie beim naechsten Heartbeat automatisch, ohne erneute + Aktivierung. + """ + conn = db() + row = issuance.get_license(conn, license_id) + if row is None: + abort(404) + modules = sorted(set(request.form.getlist("modules")) & set(lic.ALL_MODULES)) + changes: dict[str, object] = {} + if str(row["role"]) == "customer": + changes["modules"] = modules + if request.form.get("extend_days"): + try: + days = int(request.form["extend_days"]) + except ValueError: + flash("Die Verlaengerung muss eine Zahl sein.", "danger") + return redirect(url_for("licenses.detail", license_id=license_id)) + if days > 0: + document = json.loads(str(row["license_document"])) + base = lic.parse_iso(str(document["expires_at"])) + from datetime import timedelta + + changes["expires_at"] = lic.iso(base + timedelta(days=days)) + + if not changes: + flash("Es wurde keine Aenderung angegeben.", "info") + return redirect(url_for("licenses.detail", license_id=license_id)) + + key = signing_key() + try: + with transaction(): + updated = issuance.resign_license( + conn, license_id, master_private_key=key.private_key, changes=changes + ) + except (issuance.IssuanceError, ValueError) as exc: + flash(str(exc), "danger") + return redirect(url_for("licenses.detail", license_id=license_id)) + + log_event( + "license.resigned", + target=license_id[:12], + detail=f"Revision {updated['seq']}: " + ", ".join(sorted(changes)), + severity="notice", + ) + reporting.report_soon(core()) + flash( + f"Neue Revision {updated['seq']} signiert. Der Client erhaelt sie beim naechsten " + "Heartbeat.", + "success", + ) + return redirect(url_for("licenses.detail", license_id=license_id)) + + +@bp.post("//widerrufen") +@permission_required("licenses.edit") +def revoke(license_id: str): + conn = db() + try: + with transaction(): + issuance.revoke_license(conn, license_id, reason=request.form.get("reason", "")) + except issuance.IssuanceError as exc: + flash(str(exc), "danger") + return redirect(url_for("licenses.detail", license_id=license_id)) + log_event( + "license.revoked", + target=license_id[:12], + detail=request.form.get("reason", ""), + severity="warning", + ) + reporting.report_soon(core()) + flash( + "Lizenz widerrufen. Das Ticket bleibt offen -- eine neue Lizenz kann sofort " + "ausgestellt werden.", + "success", + ) + return redirect(url_for("licenses.detail", license_id=license_id)) + + +@bp.post("//loeschen") +@permission_required("licenses.delete") +def delete(license_id: str): + conn = db() + try: + with transaction(): + issuance.delete_license(conn, license_id) + except issuance.IssuanceError as exc: + flash(str(exc), "danger") + return redirect(url_for("licenses.detail", license_id=license_id)) + log_event("license.deleted", target=license_id[:12], severity="warning") + reporting.report_soon(core()) + flash("Lizenz geloescht.", "success") + return redirect(url_for("licenses.overview")) + + +# --------------------------------------------------------------------------- # +# Versand +# --------------------------------------------------------------------------- # + + +@bp.post("//versenden") +@permission_required("licenses.export") +def send_mail(license_id: str): + conn = db() + row = issuance.get_license(conn, license_id) + if row is None: + abort(404) + recipient = (request.form.get("recipient") or str(row["contact_email"] or "")).strip() + if not recipient: + flash("Es ist keine Empfaengeradresse hinterlegt.", "danger") + return redirect(url_for("licenses.detail", license_id=license_id)) + + try: + bundle = issuance.delivery_bundle(conn, license_id, keystore=core().keystore) + except issuance.IssuanceError as exc: + flash(str(exc), "danger") + return redirect(url_for("licenses.detail", license_id=license_id)) + vendor = vendor_info() + is_customer = str(row["role"]) == "customer" + subject = ( + "Ihre TESM-Lizenz" if is_customer else "Ihre TESM-Lizenzserver-Lizenz" + ) + lines = [ + f"Guten Tag{(' ' + str(row['contact_person'])) if row['contact_person'] else ''},", + "", + "im Anhang finden Sie Ihre Lizenzdatei.", + "", + f"Lizenz-ID: {license_id}", + f"Typ: {type_labels().get(str(row['type']), str(row['type']))}", + f"Gueltig bis: {row['expires_at']}" + + (" (unbefristet)" if row["lifetime"] else ""), + ] + if is_customer and row["ticket_id"]: + # Self-Service- und Portallinks nur fuer Kundenlizenzen: fuer andere + # Rollen sind diese Wege ohnehin gesperrt. + lines += [ + "", + "Ihre Lizenz verwalten (herunterladen, bei einem Systemwechsel freigeben):", + customer_facing_url("selfservice.overview", ticket_id=str(row["ticket_id"])), + "", + "Ihre Stammdaten pflegen:", + customer_facing_url("portal.request_link"), + ] + lines += ["", "Freundliche Gruesse", vendor.get("name") or "Ihr Lizenzgeber"] + if vendor.get("phone"): + lines.append(f"Telefon: {vendor['phone']}") + if vendor.get("email"): + lines.append(f"E-Mail: {vendor['email']}") + + try: + mail.send( + conn, + to=recipient, + subject=subject, + body="\n".join(lines), + attachment=mail.Attachment( + name=license_filename(license_id, str(row["customer_name"])), + content=bundle_bytes(bundle), + ), + ) + except mail.MailError as exc: + flash(f"Versand fehlgeschlagen: {exc}", "danger") + return redirect(url_for("licenses.detail", license_id=license_id)) + + with transaction(): + issuance.record_delivery( + conn, + license_id=license_id, + channel="email", + recipient=recipient, + actor=current_user.username, + ) + log_event("license.sent", target=license_id[:12], detail=recipient, severity="notice") + flash(f"Lizenz an {recipient} versendet.", "success") + return redirect(url_for("licenses.detail", license_id=license_id)) + + +# --------------------------------------------------------------------------- # +# Offline-Codes +# --------------------------------------------------------------------------- # + + +@bp.route("/offline-code", methods=["GET", "POST"]) +@permission_required("licenses.execute") +def manual_code(): + """Verarbeitet einen Offline-Code des Kunden. + + Bewusst dieselben Verarbeitungsfunktionen wie die JSON-API: identische + Signatur-, Replay- und Fingerprint-Pruefung, nur ein anderer Transportweg. + """ + response_code = "" + request_summary: dict[str, object] = {} + message = "" + ok = False + + if request.method == "POST": + conn = db() + raw = request.form.get("code", "") + try: + payload = lic.decode_code(raw) + except ValueError as exc: + flash(f"Der Code ist unlesbar: {exc}", "danger") + return render_template("license/manual_code.html", response_code="", summary={}) + + request_summary = { + "license_id": payload.get("license_id"), + "action": payload.get("action"), + "fingerprint": str(payload.get("fingerprint") or "")[:24] + "...", + "hostname": payload.get("hostname"), + "timestamp": payload.get("timestamp"), + } + key = signing_key() + with transaction(): + outcome = activation.process(conn, payload, master_private_key=key.private_key) + if outcome.ok: + response_code = lic.encode_code(outcome.response) + ok = True + message = "Antwortcode erzeugt. Bitte an den Kunden zuruecksenden." + log_event( + f"license.offline_{payload.get('action')}", + target=str(payload.get("license_id") or "")[:12], + detail="Offline-Code verarbeitet", + severity="notice", + ) + else: + message = str(outcome.response.get("message") or "Die Anfrage wurde abgewiesen.") + flash(message, "danger") + + return render_template( + "license/manual_code.html", + response_code=response_code, + summary=request_summary, + message=message, + ok=ok, + ) + + +# --------------------------------------------------------------------------- # +# Sub-Lizenzserver +# --------------------------------------------------------------------------- # + + +@bp.get("/sub-lizenzserver") +@permission_required("reports.view") +def reports(): + conn = db() + sources = reporting.reporting_sources(conn) + return render_template( + "license/reports.html", + sources=sources, + heartbeat_hours=lic.HEARTBEAT_INTERVAL_SECONDS // 3600, + ) + + +@bp.get("/sub-lizenzserver/") +@permission_required("reports.view") +def report_detail(license_id: str): + conn = db() + row = issuance.get_license(conn, license_id) + if row is None or str(row["role"]) != "license_server": + abort(404) + reported = reporting.reported_for_customer(conn, int(row["customer_id"])) + return render_template( + "license/report_detail.html", license=row, reported=reported + ) diff --git a/apps/tesm-license/src/tesm_license/blueprints/own_license.py b/apps/tesm-license/src/tesm_license/blueprints/own_license.py new file mode 100644 index 0000000..52fd2f9 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/blueprints/own_license.py @@ -0,0 +1,183 @@ +"""Die eigene Lizenz dieses Lizenzservers. + +Auch ein Lizenzserver braucht eine Lizenz -- sie entscheidet ueber seine Rolle: + +* ``master``: Wurzelinstanz. Nur sie darf Lizenzen fuer weitere Lizenzserver + ausstellen. Diese Rolle entsteht ausschliesslich lokal ueber + ``tesm-license-admin bootstrap`` -- es gibt keine hoehere Instanz, die sie + bestaetigen koennte, und die Ticket-Oberflaeche bietet sie an keiner Stelle an. +* ``license_server``: von einem Master ausgestellter Sub-Lizenzserver. Er darf + alles, was ein Lizenzserver koennen soll -- **ausser** selbst weitere + Lizenzserver-Lizenzen auszustellen. Diese Grenze haengt an der signierten + Rolle der Lizenzdatei, nicht an einem Gruppenrecht. + +Die Seite bleibt immer erreichbar, auch ohne gueltige Lizenz -- sonst liesse +sich keine neue einspielen. +""" + +from __future__ import annotations + +import socket + +from flask import Blueprint, flash, redirect, render_template, request, url_for +from tesm_core.auth.models import ( + admin_required, + current_user, + permission_required, + reauth_required, +) +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 + +from ..services import master_key +from .common import can_issue_license_server_licenses, log_event, signing_key + +bp = Blueprint("own_license", __name__, url_prefix="/eigene-lizenz") + +MAX_UPLOAD_BYTES = 256 * 1024 + + +@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( + "license/own_license.html", + state=state, + pending=pending, + features=LICENSED_FEATURES, + role_labels=dict(lic.ROLE_LABELS), + type_labels=dict(lic.LICENSE_TYPE_LABELS), + fingerprint=lic.system_fingerprint(), + hostname=socket.gethostname(), + trusted_keys=Database.all( + conn, "SELECT * FROM trusted_issuer_keys ORDER BY added_at DESC" + ), + signing_key_health=master_key.health(extension.registry["master_key_path"]), + may_issue_license_server=can_issue_license_server_licenses(), + 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("own_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("own_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("own_license.overview")) + + try: + with transaction() as conn: + state = manager.import_bundle( + conn, + raw, + actor=current_user.username, + allow_new_issuer=request.form.get("allow_new_issuer") == "1" + and current_user.is_admin, + ) + except LicenseError as exc: + flash(str(exc), "danger") + return redirect(url_for("own_license.overview")) + + log_event( + "own_license.imported", + target=(state.status.license_id[:12] if state.status else ""), + detail=f"Rolle {state.role}", + severity="warning", + scope="eigene", + ) + flash("Lizenz eingespielt. Bitte jetzt aktivieren.", "success") + return redirect(url_for("own_license.overview")) + + +def _handshake(action: str): + manager = core().license + if manager is None: + return redirect(url_for("own_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("own_license.overview")) + flash( + result["message"], + "success" if result["ok"] else ("warning" if result["transport"] == "offline" else "danger"), + ) + return redirect(url_for("own_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(): + return _handshake("deactivate") + + +@bp.post("/heartbeat") +@permission_required("settings_license.edit") +def heartbeat(): + return _handshake("heartbeat") + + +@bp.post("/offline-antwort") +@permission_required("settings_license.edit") +def offline_response(): + manager = core().license + if manager is None: + return redirect(url_for("own_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("own_license.overview")) + flash(result["message"], "success" if result["ok"] else "danger") + return redirect(url_for("own_license.overview")) + + +@bp.get("/signaturschluessel") +@permission_required("settings_license.view") +def signing_key_info(): + """Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden. + + Der oeffentliche Teil ist kein Geheimnis; er reist ohnehin in jeder Lizenz + mit. Ihn hier anzuzeigen erlaubt es, ihn unabhaengig zu vergleichen, bevor + ein Kunde ihn als vertrauenswuerdig hinterlegt. + """ + key = signing_key() + return render_template( + "license/signing_key.html", + key_id=key.key_id, + public_key=key.public_key, + created_at=key.created_at, + health=master_key.health(core().registry["master_key_path"]), + ) diff --git a/apps/tesm-license/src/tesm_license/blueprints/portal.py b/apps/tesm-license/src/tesm_license/blueprints/portal.py new file mode 100644 index 0000000..bfac568 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/blueprints/portal.py @@ -0,0 +1,278 @@ +"""Kundenportal -- zeitlich begrenzter Zugang ohne Konto. + +Zweistufig, bewusst: + +1. ``/portal`` nimmt eine E-Mail-Adresse an und verschickt einen **einmal** + verwendbaren Zugangslink mit kurzer Gueltigkeit. +2. Der Link erzeugt eine Sitzung mit eigener, unabhaengiger Gueltigkeit. + +Der Vorgaenger hatte zuerst einen permanenten, nie ablaufenden Link pro Kunde +(``/portal/``). Das wurde als Schwachstelle erkannt und ersetzt -- hier +existiert die permanente Variante gar nicht. + +Anti-Enumeration: die Antwort ist immer dieselbe, unabhaengig davon, ob die +Adresse bekannt ist. Ein Fehler beim Mailversand wird dem Anfragenden gegenueber +verschwiegen, aber serverseitig protokolliert -- sonst waere eine kaputte +Mailkonfiguration unsichtbar. +""" + +from __future__ import annotations + +import logging +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any + +from flask import Blueprint, flash, redirect, render_template, request, session, url_for +from tesm_core import audit +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.security import ratelimit + +from ..services import issuance, mail +from .common import customer_facing_url, vendor_info + +logger = logging.getLogger(__name__) + +bp = Blueprint("portal", __name__, url_prefix="/portal") + +SESSION_CUSTOMER = "portal_customer_id" +SESSION_EXPIRES = "portal_expires_at" +RATE_BUCKET = "portal:request" +RATE_LIMIT = 5 +RATE_WINDOW_MINUTES = 15 + +GENERIC_ANSWER = ( + "Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit " + "einem {minutes} Minuten gueltigen Zugangslink verschickt." +) + +EDITABLE_FIELDS = ( + "contact_person", + "contact_phone", + "contact_email", + "street", + "house_number", + "postal_code", + "city", +) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(value: datetime) -> str: + return value.isoformat(timespec="seconds") + + +def _parse(value: Any) -> datetime | None: + """Liest einen Zeitstempel aus der Datenbank. + + Bewusst geparst statt als Zeichenkette verglichen: ein Wert, der ueber + ``datetime('now')`` von SQLite selbst geschrieben wurde, hat ein Leerzeichen + statt ``T`` und keine Zeitzone -- ein lexikografischer Vergleich mit unserem + ISO-Format waere dann falsch, und zwar zugunsten von "abgelaufen". + """ + if not value: + return None + text = str(value).strip().replace(" ", "T") + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +def _enabled() -> bool: + return bool(core().settings.get(db(), "portal_enabled")) + + +@bp.route("/", methods=["GET", "POST"]) +def request_link(): + extension = core() + conn = db() + if not _enabled(): + return render_template("license/portal_disabled.html"), 404 + + minutes = int(extension.settings.get(conn, "portal_link_minutes") or 30) + if request.method == "POST": + email = (request.form.get("email") or "").strip() + verdict = ratelimit.check( + conn, + RATE_BUCKET, + client_ip() or "unbekannt", + limit=RATE_LIMIT, + window_minutes=RATE_WINDOW_MINUTES, + ) + with transaction(): + ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt") + if verdict.blocked: + # Auch hier keine abweichende Aussage -- nur eine Bremse. + flash(GENERIC_ANSWER.format(minutes=minutes), "info") + return redirect(url_for("portal.request_link")) + + customer = issuance.find_customer_by_email(conn, email) + if customer is not None: + token = secrets.token_urlsafe(32) + with transaction(): + conn.execute( + "INSERT INTO portal_access_tokens (token, customer_id, created_at, " + "expires_at, created_ip) VALUES (?,?,?,?,?)", + ( + token, + int(customer["id"]), + _iso(_now()), + _iso(_now() + timedelta(minutes=minutes)), + client_ip() or "", + ), + ) + audit.log( + conn, + actor="portal", + action="portal.link_requested", + target=str(customer["name"]), + ip=client_ip(), + severity="notice", + ) + access_url = customer_facing_url("portal.access", token=token) + vendor = vendor_info() + try: + mail.send( + conn, + to=str(customer["contact_email"]), + subject="Ihr Zugang zum Kundenportal", + body="\n".join( + [ + f"Guten Tag{(' ' + str(customer['contact_person'])) if customer['contact_person'] else ''},", + "", + "mit dem folgenden Link erreichen Sie Ihre Daten im Kundenportal.", + f"Der Link ist {minutes} Minuten gueltig und laesst sich nur einmal verwenden:", + "", + access_url, + "", + "Wenn Sie diesen Zugang nicht angefragt haben, ignorieren Sie die Nachricht.", + "", + "Freundliche Gruesse", + vendor.get("name") or "Ihr Lizenzgeber", + ] + ), + ) + except mail.MailError as exc: + # Bewusst nicht nach aussen tragen (Anti-Enumeration), aber + # serverseitig sichtbar machen. + logger.error("Portal-Mail an %s fehlgeschlagen: %s", email, exc) + + flash(GENERIC_ANSWER.format(minutes=minutes), "info") + return redirect(url_for("portal.request_link")) + + return render_template("license/portal_request.html", minutes=minutes) + + +@bp.get("/zugang/") +def access(token: str): + extension = core() + conn = db() + if not _enabled(): + return render_template("license/portal_disabled.html"), 404 + + row = Database.one( + conn, "SELECT * FROM portal_access_tokens WHERE token=?", (token,) + ) + expires = _parse(row["expires_at"]) if row is not None else None + valid = row is not None and row["used_at"] is None and expires is not None and _now() < expires + if not valid: + flash("Dieser Link ist ungueltig oder abgelaufen.", "danger") + return redirect(url_for("portal.request_link")) + + minutes = int(extension.settings.get(conn, "portal_session_minutes") or 60) + with transaction(): + # Einmalverwendung erzwingen: auch innerhalb der Gueltigkeit scheitert + # ein zweiter Aufruf. + conn.execute( + "UPDATE portal_access_tokens SET used_at=? WHERE token=? AND used_at IS NULL", + (_iso(_now()), token), + ) + audit.log( + conn, + actor="portal", + action="portal.session_started", + target=str(row["customer_id"]), + ip=client_ip(), + severity="notice", + ) + session[SESSION_CUSTOMER] = int(row["customer_id"]) + session[SESSION_EXPIRES] = _iso(_now() + timedelta(minutes=minutes)) + return redirect(url_for("portal.me")) + + +def _current_customer() -> dict[str, Any] | None: + customer_id = session.get(SESSION_CUSTOMER) + expires = _parse(session.get(SESSION_EXPIRES)) + if not customer_id or expires is None or _now() >= expires: + session.pop(SESSION_CUSTOMER, None) + session.pop(SESSION_EXPIRES, None) + return None + return issuance.get_customer(db(), int(customer_id)) + + +@bp.route("/meine-daten", methods=["GET", "POST"]) +def me(): + conn = db() + if not _enabled(): + return render_template("license/portal_disabled.html"), 404 + + customer = _current_customer() + if customer is None: + flash("Ihre Sitzung ist abgelaufen. Bitte fordern Sie einen neuen Zugangslink an.", "info") + return redirect(url_for("portal.request_link")) + + if request.method == "POST": + fields = {key: request.form.get(key, "") for key in EDITABLE_FIELDS} + try: + with transaction(): + changes = issuance.update_customer( + conn, int(customer["id"]), name=str(customer["name"]), **fields + ) + if changes: + audit.log( + conn, + actor="portal", + action="portal.customer_updated", + target=str(customer["name"]), + detail=", ".join(changes), + ip=client_ip(), + severity="notice", + ) + flash("Ihre Angaben wurden gespeichert." if changes else "Keine Aenderungen.", "success") + except issuance.IssuanceError as exc: + flash(str(exc), "danger") + return redirect(url_for("portal.me")) + + tickets = issuance.list_tickets(conn, customer_id=int(customer["id"])) + licenses = { + str(row["ticket_id"]): {**row, **issuance.display_state(row)} + for row in issuance.list_licenses(conn, customer_id=int(customer["id"])) + if row["status"] in {"issued", "active"} + } + self_service_enabled = bool(core().settings.get(conn, "self_service_enabled")) + return render_template( + "license/portal_me.html", + customer=customer, + tickets=tickets, + licenses=licenses, + expires_at=session.get(SESSION_EXPIRES), + self_service_enabled=self_service_enabled, + vendor=vendor_info(), + ) + + +@bp.post("/abmelden") +def logout(): + session.pop(SESSION_CUSTOMER, None) + session.pop(SESSION_EXPIRES, None) + flash("Sie wurden abgemeldet.", "info") + return redirect(url_for("portal.request_link")) diff --git a/apps/tesm-license/src/tesm_license/blueprints/selfservice.py b/apps/tesm-license/src/tesm_license/blueprints/selfservice.py new file mode 100644 index 0000000..fe209e0 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/blueprints/selfservice.py @@ -0,0 +1,304 @@ +"""Self-Service je Ticket -- ohne Anmeldung, allein ueber den Ticket-Link. + +Die Berechtigung ist der Besitz der Ticket-ID: 192 Bit Zufall +(``secrets.token_urlsafe(24)``), zugleich Primaerschluessel. Damit kann ein Kunde +seine Lizenz herunterladen, bei einem Systemwechsel freigeben und neu ausstellen +lassen -- ohne Support-Anruf. + +Fuer Tickets mit einer anderen Rolle als ``customer`` antworten **alle** Routen +mit 404, nicht mit "keine Berechtigung": aus der Fehlermeldung soll sich nicht +ablesen lassen, dass eine geratene ID zu einem Lizenzserver-Ticket gehoert. +""" + +from __future__ import annotations + +from flask import Blueprint, Response, abort, flash, redirect, render_template, request, url_for +from tesm_core import audit +from tesm_core.auth.service import client_ip +from tesm_core.extension import core, db, transaction +from tesm_core.security import ratelimit + +import tesm_licensing as lic + +from ..services import activation, issuance, reporting +from .common import ( + bundle_bytes, + customer_facing_url, + license_filename, + master_endpoint, + signing_key, + type_labels, + vendor_info, +) + +bp = Blueprint("selfservice", __name__, url_prefix="/lizenz") + +RATE_BUCKET = "selfservice" +RATE_LIMIT = 20 +RATE_WINDOW_MINUTES = 10 + + +def _ticket_or_404(ticket_id: str) -> dict: + conn = db() + if not core().settings.get(conn, "self_service_enabled"): + abort(404) + ticket = issuance.get_ticket(conn, ticket_id) + if ticket is None or str(ticket["role"]) != "customer": + abort(404) + return ticket + + +def _rate_guard() -> None: + conn = db() + verdict = ratelimit.check( + conn, + RATE_BUCKET, + client_ip() or "unbekannt", + limit=RATE_LIMIT, + window_minutes=RATE_WINDOW_MINUTES, + ) + with transaction(): + ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt") + if verdict.blocked: + abort(429, description="Zu viele Anfragen. Bitte spaeter erneut versuchen.") + + +def _needs_offline(conn, ticket_id: str): # type: ignore[no-untyped-def] + """Die Lizenz, fuer die der Offline-Weg gerade einen Zweck hat -- sonst ``None``. + + Zwei Faelle, und nur diese: + + * **Noch nicht aktiviert.** Der Kunde muss aktivieren. + * **Neue Revision ausstehend.** Der Anbieter hat nachsigniert (andere + Module, laengere Laufzeit); eine Installation ohne Verbindung holt sie + sonst nie ab. + + Ist die Lizenz aktiv und auf dem aktuellen Stand, gibt es nichts zu tun -- + fuer einen Systemwechsel gibt es die Freigabe. + """ + for row in issuance.list_licenses(conn, ticket_id=ticket_id): + status = str(row["status"]) + if status == "issued": + return row + if status == "active" and issuance.revision_pending(row): + return row + return None + + +def _log(action: str, ticket: dict, detail: str = "", severity: str = "notice") -> None: + extension = core() + with transaction() as conn: + audit.log( + conn, + actor="self-service", + action=action, + target=str(ticket["customer_name"]), + detail=detail, + ip=client_ip(), + severity=severity, + ) + live = extension.registry.get("live_log") + if live is not None: + live.info("selfservice", f"{action} {ticket['customer_name']} {detail}".strip()) + + +@bp.get("/") +def overview(ticket_id: str): + _rate_guard() + ticket = _ticket_or_404(ticket_id) + conn = db() + licenses = [ + {**row, **issuance.display_state(row)} + for row in issuance.list_licenses(conn, ticket_id=ticket_id) + ] + current = next((row for row in licenses if row["status"] in {"issued", "active"}), None) + return render_template( + "license/selfservice.html", + offline_needed=_needs_offline(db(), ticket_id), + ticket=ticket, + current=current, + history=[row for row in licenses if row is not current], + type_labels=type_labels(), + vendor=vendor_info(), + portal_url=url_for("portal.request_link"), + allow_revoke=bool(core().settings.get(conn, "allow_customer_revoke")), + blocked=str(ticket["status"]) == "blocked", + ) + + +@bp.post("//ausstellen") +def issue(ticket_id: str): + _rate_guard() + ticket = _ticket_or_404(ticket_id) + conn = db() + if str(ticket["status"]) == "blocked": + flash( + "Dieses Ticket ist gesperrt. Bitte wenden Sie sich an Ihren Ansprechpartner.", + "danger", + ) + return redirect(url_for("selfservice.overview", ticket_id=ticket_id)) + if issuance.ticket_has_open_license(conn, ticket_id): + flash( + "Es existiert bereits eine gueltige Lizenz. Geben Sie diese zuerst frei, wenn Sie " + "auf ein anderes System wechseln moechten.", + "warning", + ) + return redirect(url_for("selfservice.overview", ticket_id=ticket_id)) + + key = signing_key() + try: + with transaction(): + result = issuance.issue_from_ticket( + conn, + ticket_id, + master_private_key=key.private_key, + master_public_key=key.public_key, + master_endpoint=master_endpoint(), + vendor=vendor_info(), + actor="self-service", + keystore=core().keystore, + ) + except issuance.IssuanceError as exc: + flash(str(exc), "danger") + return redirect(url_for("selfservice.overview", ticket_id=ticket_id)) + + _log("license.issued_self_service", ticket, result.license_id[:12]) + reporting.report_soon(core()) + flash( + "Ihre Lizenz wurde erstellt. Laden Sie die Datei herunter und spielen Sie sie in Ihrer " + "TESM-Installation ein.", + "success", + ) + return redirect(url_for("selfservice.overview", ticket_id=ticket_id)) + + +@bp.get("//datei/") +def download(ticket_id: str, license_id: str): + _rate_guard() + ticket = _ticket_or_404(ticket_id) + conn = db() + row = issuance.get_license(conn, license_id) + if row is None or str(row["ticket_id"]) != ticket_id: + abort(404) + if str(row["status"]) == "revoked": + abort(410, description="Diese Lizenz wurde widerrufen.") + try: + bundle = issuance.delivery_bundle(conn, license_id, keystore=core().keystore) + except issuance.IssuanceError as exc: + abort(409, description=str(exc)) + + with transaction(): + issuance.record_delivery( + conn, + license_id=license_id, + channel="self-service", + recipient=str(ticket["contact_email"] or ""), + actor="self-service", + ) + _log("license.downloaded_self_service", ticket, license_id[:12], severity="info") + return Response( + bundle_bytes(bundle), + mimetype="application/json", + headers={ + "Content-Disposition": ( + f'attachment; filename="{license_filename(license_id, str(ticket["customer_name"]))}"' + ), + "Cache-Control": "no-store", + }, + ) + + +@bp.route("//offline", methods=["GET", "POST"]) +def offline(ticket_id: str): + """Loest einen Aktivierungscode ein, wenn TESM den Server nicht erreicht.""" + ticket = _ticket_or_404(ticket_id) + conn = db() + pending = _needs_offline(conn, ticket_id) + if pending is None: + # Ohne noch nicht aktivierte Lizenz gibt es hier nichts zu tun. + return redirect(url_for("selfservice.overview", ticket_id=ticket_id)) + + response_code = "" + summary: dict[str, object] = {} + + if request.method == "POST": + _rate_guard() + raw = request.form.get("code", "") + try: + payload = lic.decode_code(raw) + except ValueError as exc: + flash(f"Der Code ist unlesbar: {exc}", "danger") + return redirect(url_for("selfservice.offline", ticket_id=ticket_id)) + + # Nur Lizenzen dieses Vorgangs -- ein fremder Code gehoert nicht hierher. + row = issuance.get_license(conn, str(payload.get("license_id") or "")) + if row is None or str(row["ticket_id"]) != ticket_id: + _log( + "selfservice.offline_rejected", + ticket, + "Code gehoert nicht zu diesem Vorgang", + severity="warning", + ) + flash( + "Dieser Code gehoert nicht zu Ihrem Vorgang. Bitte pruefen Sie, ob Sie ihn " + "vollstaendig kopiert haben.", + "danger", + ) + return redirect(url_for("selfservice.offline", ticket_id=ticket_id)) + + key = signing_key() + with transaction(): + outcome = activation.process(conn, payload, master_private_key=key.private_key) + summary = {"action": payload.get("action"), "hostname": payload.get("hostname")} + if outcome.ok: + response_code = lic.encode_code(outcome.response) + _log( + f"selfservice.offline_{payload.get('action')}", + ticket, + f"Lizenz {str(payload.get('license_id'))[:12]}", + ) + else: + message = str(outcome.response.get("message") or "Die Anfrage wurde abgewiesen.") + _log("selfservice.offline_rejected", ticket, message, severity="warning") + flash(message, "danger") + + return render_template( + "license/selfservice_offline.html", + ticket=ticket, + license=pending, + response_code=response_code, + summary=summary, + vendor=vendor_info(), + ) + + +@bp.post("//freigeben/") +def revoke(ticket_id: str, license_id: str): + """Gibt die aktuelle Lizenz frei -- fuer einen Systemwechsel. + + Fachlich ein Widerruf: die Datei gilt nicht mehr, das Ticket bleibt offen, + eine neue Lizenz kann sofort ausgestellt werden. + """ + _rate_guard() + ticket = _ticket_or_404(ticket_id) + conn = db() + if not core().settings.get(conn, "allow_customer_revoke"): + abort(404) + row = issuance.get_license(conn, license_id) + if row is None or str(row["ticket_id"]) != ticket_id: + abort(404) + try: + with transaction(): + issuance.revoke_license(conn, license_id, reason="Freigabe durch den Kunden") + except issuance.IssuanceError as exc: + flash(str(exc), "danger") + return redirect(url_for("selfservice.overview", ticket_id=ticket_id)) + + _log("license.revoked_self_service", ticket, license_id[:12], severity="warning") + reporting.report_soon(core()) + flash( + "Die Lizenz wurde freigegeben. Sie koennen jetzt eine neue Lizenz fuer Ihr neues System " + "ausstellen.", + "success", + ) + return redirect(url_for("selfservice.overview", ticket_id=ticket_id)) diff --git a/apps/tesm-license/src/tesm_license/blueprints/settings.py b/apps/tesm-license/src/tesm_license/blueprints/settings.py new file mode 100644 index 0000000..a4da171 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/blueprints/settings.py @@ -0,0 +1,305 @@ +"""Einstellungen des Lizenzservers: System, Anbieterdaten, E-Mail-Versand. + +Netzwerk, Webserver/TLS und Verzeichnisdienst kommen aus dem Kern. +""" + +from __future__ import annotations + +import base64 +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 ..services import mail +from .common import log_event, master_endpoint, vendor_info + +bp = Blueprint("settings", __name__, url_prefix="/einstellungen") + +MAX_LOGO_BYTES = 200 * 1024 +LOGO_MIME = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", +} +LOGO_MAGIC = { + b"\x89PNG\r\n\x1a\n": "image/png", + b"\xff\xd8\xff": "image/jpeg", + b" list[str]: + try: + from zoneinfo import available_timezones + + return sorted(available_timezones()) + except Exception: # noqa: BLE001 + return ["Europe/Berlin", "UTC"] + + +@bp.post("/system") +@permission_required("settings_system.edit") +def system_save(): + extension = core() + conn = db() + values: dict[str, Any] = {} + for key in ( + "portal_enabled", + "portal_link_minutes", + "portal_session_minutes", + "self_service_enabled", + "allow_customer_revoke", + "default_valid_days", + "trash_retention_days", + "log_rotate_interval", + "log_rotate_keep", + ): + spec = extension.settings.spec(key) + if spec.kind == "bool": + values[key] = request.form.get(key) == "1" + elif key in request.form: + values[key] = request.form.get(key) + + 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", + ) + flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.", + "success" if changed else "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: + log_event("settings.hostname_changed", target=hostname, severity="notice", scope="system") + 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(): + import time + + sysops = core().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: + try: + time.tzset() # type: ignore[attr-defined] + except AttributeError: # pragma: no cover - Windows + pass + log_event("settings.timezone_changed", target=timezone, severity="notice", scope="system") + flash(f"Zeitzone auf {timezone} gesetzt.", "success") + else: + flash(f"Zeitzone konnte nicht gesetzt werden: {result.message}", "danger") + return redirect(url_for("settings.system")) + + +# --------------------------------------------------------------------------- # +# Anbieterdaten +# --------------------------------------------------------------------------- # + + +@bp.get("/anbieter") +@permission_required("settings_vendor.view") +def vendor(): + extension = core() + conn = db() + return render_template( + "license/settings_vendor.html", + values=extension.settings.get_many( + conn, + [ + "vendor_name", + "vendor_email", + "vendor_phone", + "vendor_address", + "vendor_logo_base64", + "master_endpoint", + ], + ), + max_logo_kb=MAX_LOGO_BYTES // 1024, + ) + + +@bp.post("/anbieter") +@permission_required("settings_vendor.edit") +def vendor_save(): + extension = core() + conn = db() + values = { + key: (request.form.get(key) or "").strip() + for key in ("vendor_name", "vendor_email", "vendor_phone", "vendor_address") + } + endpoint = (request.form.get("master_endpoint") or "").strip().rstrip("/") + if endpoint and not endpoint.startswith(("http://", "https://")): + flash("Der Endpunkt muss mit http:// oder https:// beginnen.", "danger") + return redirect(url_for("settings.vendor")) + values["master_endpoint"] = endpoint + + logo_problem = "" + upload = request.files.get("vendor_logo") + if upload is not None and upload.filename: + from pathlib import Path + + suffix = Path(upload.filename).suffix.lower() + head = upload.stream.read(16) + upload.stream.seek(0) + data = upload.stream.read(MAX_LOGO_BYTES + 1) + if suffix not in LOGO_MIME: + logo_problem = "Erlaubt sind PNG, JPG und SVG." + elif len(data) > MAX_LOGO_BYTES: + logo_problem = f"Das Logo ist zu gross (maximal {MAX_LOGO_BYTES // 1024} KB)." + elif not any(head.startswith(magic) for magic in LOGO_MAGIC): + logo_problem = "Die Datei ist kein Bild." + else: + mime = LOGO_MIME[suffix] + values["vendor_logo_base64"] = ( + f"data:{mime};base64," + base64.b64encode(data).decode("ascii") + ) + + with transaction(): + changed = extension.settings.set_many(conn, values, actor=current_user.username) + if logo_problem: + # Ein zu grosses Logo verwirft nur das Logo -- die uebrigen Angaben + # werden trotzdem gespeichert. + flash(logo_problem, "danger") + log_event( + "settings.vendor_saved", detail=", ".join(changed), severity="notice", scope="anbieter" + ) + flash("Anbieterdaten gespeichert." if changed else "Keine Aenderungen.", + "success" if changed else "info") + return redirect(url_for("settings.vendor")) + + +@bp.post("/anbieter/logo-entfernen") +@permission_required("settings_vendor.edit") +def vendor_logo_remove(): + with transaction() as conn: + core().settings.set(conn, "vendor_logo_base64", "", actor=current_user.username) + flash("Logo entfernt.", "success") + return redirect(url_for("settings.vendor")) + + +# --------------------------------------------------------------------------- # +# E-Mail +# --------------------------------------------------------------------------- # + + +@bp.get("/email") +@permission_required("settings_mail.view") +def mail_settings(): + extension = core() + conn = db() + return render_template( + "license/settings_mail.html", + values=extension.settings.get_many(conn, [spec.key for spec in mail.SETTINGS]), + specs={spec.key: spec for spec in mail.SETTINGS}, + has_secret=mail.has_secret(conn), + ready=mail.is_enabled(conn), + can_secrets=current_user.has("settings_mail.secrets"), + ) + + +@bp.post("/email") +@permission_required("settings_mail.edit") +def mail_save(): + extension = core() + conn = db() + values: dict[str, Any] = {} + for spec in mail.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) or "").strip() + + with transaction(): + changed = extension.settings.set_many(conn, values, actor=current_user.username) + if current_user.has("settings_mail.secrets") and request.form.get("client_secret"): + mail.store_secret(conn, request.form.get("client_secret")) + changed.append("client_secret") + log_event("settings.mail_saved", detail=", ".join(changed), severity="notice", scope="mail") + flash("E-Mail-Einstellungen gespeichert." if changed else "Keine Aenderungen.", + "success" if changed else "info") + return redirect(url_for("settings.mail_settings")) + + +@bp.post("/email/test") +@permission_required("settings_mail.execute") +def mail_test(): + result = mail.test_connection(db()) + flash(result["message"], "success" if result["ok"] else "danger") + return redirect(url_for("settings.mail_settings")) diff --git a/apps/tesm-license/src/tesm_license/blueprints/tickets.py b/apps/tesm-license/src/tesm_license/blueprints/tickets.py new file mode 100644 index 0000000..6dadf5a --- /dev/null +++ b/apps/tesm-license/src/tesm_license/blueprints/tickets.py @@ -0,0 +1,238 @@ +"""Tickets: der dauerhafte Anspruch eines Kunden auf eine Lizenz.""" + +from __future__ import annotations + +import json + +from flask import Blueprint, abort, flash, redirect, render_template, request, url_for +from tesm_core.auth.models import current_user, permission_required +from tesm_core.extension import core, db, transaction + +import tesm_licensing as lic + +from ..services import issuance, reporting +from .common import ( + can_issue_license_server_licenses, + customer_facing_url, + log_event, + master_endpoint, + module_labels, + role_labels, + type_labels, +) + +bp = Blueprint("tickets", __name__, url_prefix="/tickets") + + +@bp.get("/") +@permission_required("tickets.view") +def overview(): + conn = db() + tickets = issuance.list_tickets(conn) + licenses = { + str(row["ticket_id"]): {**row, **issuance.display_state(row)} + for row in issuance.list_licenses(conn) + if row["status"] in {"issued", "active"} + } + return render_template( + "license/tickets.html", + endpoint_missing=_endpoint_missing(), + tickets=tickets, + open_licenses=licenses, + type_labels=type_labels(), + role_labels=role_labels(), + module_labels=module_labels(), + ) + + +@bp.get("/neu") +@permission_required("tickets.create") +def new(): + conn = db() + extension = core() + return render_template( + "license/ticket_new.html", + endpoint_missing=_endpoint_missing(), + customers=issuance.list_customers(conn), + modules=lic.ALL_MODULES, + module_labels=module_labels(), + types=lic.LICENSE_TYPES, + type_labels=type_labels(), + default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365), + may_create_license_server=can_issue_license_server_licenses(), + preselected_customer=request.args.get("kunde", type=int), + ) + + +@bp.post("/neu") +@permission_required("tickets.create") +def create(): + conn = db() + # Rolle: jeder Versuch ausserhalb der strukturellen Erlaubnis wird still auf + # "customer" zurueckgestuft -- nicht nur in der Oberflaeche verborgen. + role = request.form.get("role", "customer") + if role != "customer" and not can_issue_license_server_licenses(): + role = "customer" + + try: + with transaction(): + ticket_id = issuance.create_ticket( + conn, + customer_id=int(request.form.get("customer_id") or 0), + license_type=request.form.get("type", "standard"), + modules=request.form.getlist("modules"), + valid_days=int(request.form.get("valid_days") or 365), + lifetime=request.form.get("lifetime") == "1", + role=role, + notes=request.form.get("notes", ""), + actor=current_user.username, + ) + log_event( + "ticket.created", + target=ticket_id[:12], + detail=f"Typ {request.form.get('type')}, Rolle {role}", + severity="notice", + scope="ticket", + ) + flash("Ticket angelegt.", "success") + return redirect(url_for("tickets.detail", ticket_id=ticket_id)) + except (issuance.IssuanceError, ValueError) as exc: + flash(str(exc), "danger") + return redirect(url_for("tickets.new")) + + +def _endpoint_missing() -> str: + """Meldung, falls ohne Endpunkt gearbeitet wird -- sonst leer.""" + if master_endpoint(): + return "" + return ( + "Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). " + "Solange er fehlt, koennen keine Lizenzen ausgestellt werden: der Endpunkt wird " + "in die Lizenz signiert und laesst sich spaeter nicht nachtragen." + ) + + +@bp.get("/") +@permission_required("tickets.view") +def detail(ticket_id: str): + conn = db() + ticket = issuance.get_ticket(conn, ticket_id) + if ticket is None: + abort(404) + licenses = [ + {**row, **issuance.display_state(row)} + for row in issuance.list_licenses(conn, ticket_id=ticket_id) + ] + extension = core() + self_service_enabled = bool(extension.settings.get(conn, "self_service_enabled")) + return render_template( + "license/ticket_detail.html", + endpoint_missing=_endpoint_missing(), + ticket=ticket, + ticket_modules=json.loads(str(ticket["modules"] or "[]")), + licenses=licenses, + has_open_license=issuance.ticket_has_open_license(conn, ticket_id), + modules=lic.ALL_MODULES, + module_labels=module_labels(), + types=lic.LICENSE_TYPES, + type_labels=type_labels(), + role_labels=role_labels(), + trial_days=lic.TRIAL_DEFAULT_DAYS, + # Self-Service gibt es nur fuer Kundentickets -- fuer andere Rollen ist + # der Weg vollstaendig gesperrt. + self_service_url=( + customer_facing_url("selfservice.overview", ticket_id=ticket_id) + if self_service_enabled and str(ticket["role"]) == "customer" + else "" + ), + portal_url=customer_facing_url("portal.request_link"), + ) + + +@bp.post("/") +@permission_required("tickets.edit") +def update(ticket_id: str): + conn = db() + if issuance.get_ticket(conn, ticket_id) is None: + abort(404) + try: + with transaction(): + changes = issuance.update_ticket( + conn, + ticket_id, + license_type=request.form.get("type", "standard"), + modules=request.form.getlist("modules"), + valid_days=int(request.form.get("valid_days") or 365), + lifetime=request.form.get("lifetime") == "1", + notes=request.form.get("notes", ""), + ) + if changes: + log_event( + "ticket.updated", + target=ticket_id[:12], + detail="; ".join(changes), + severity="notice", + scope="ticket", + ) + reporting.report_soon(core()) + flash( + "Gespeichert. Die Aenderung wirkt auf die naechste Ausstellung." + if changes + else "Keine Aenderungen.", + "success" if changes else "info", + ) + except (issuance.IssuanceError, ValueError) as exc: + flash(str(exc), "danger") + return redirect(url_for("tickets.detail", ticket_id=ticket_id)) + + +@bp.post("//sperre") +@permission_required("tickets.edit") +def toggle_block(ticket_id: str): + conn = db() + ticket = issuance.get_ticket(conn, ticket_id) + if ticket is None: + abort(404) + new_status = "open" if str(ticket["status"]) == "blocked" else "blocked" + with transaction(): + issuance.set_ticket_status(conn, ticket_id, new_status) + log_event( + "ticket.blocked" if new_status == "blocked" else "ticket.unblocked", + target=ticket_id[:12], + detail=str(ticket["customer_name"]), + severity="warning" if new_status == "blocked" else "notice", + scope="ticket", + ) + reporting.report_soon(core()) + flash( + "Ticket gesperrt -- es lassen sich keine neuen Lizenzen mehr ausstellen." + if new_status == "blocked" + else "Ticket entsperrt.", + "success", + ) + return redirect(url_for("tickets.detail", ticket_id=ticket_id)) + + +@bp.post("//loeschen") +@permission_required("tickets.delete") +def delete(ticket_id: str): + conn = db() + ticket = issuance.get_ticket(conn, ticket_id) + if ticket is None: + abort(404) + try: + with transaction(): + issuance.delete_ticket(conn, ticket_id) + log_event( + "ticket.deleted", + target=ticket_id[:12], + detail=str(ticket["customer_name"]), + severity="warning", + scope="ticket", + ) + reporting.report_soon(core()) + flash("Ticket und seine Lizenzhistorie geloescht.", "success") + return redirect(url_for("tickets.overview")) + except issuance.IssuanceError as exc: + flash(str(exc), "danger") + return redirect(url_for("tickets.detail", ticket_id=ticket_id)) diff --git a/apps/tesm-license/src/tesm_license/cli.py b/apps/tesm-license/src/tesm_license/cli.py new file mode 100644 index 0000000..09d693d --- /dev/null +++ b/apps/tesm-license/src/tesm_license/cli.py @@ -0,0 +1,469 @@ +"""Kommandozeilenwerkzeuge des Lizenzservers. + +Enthaelt insbesondere ``bootstrap`` -- die eigene Lizenz der Wurzelinstanz. + +Warum das ein lokales Skript ist und keine Funktion in der Oberflaeche: die +Master-Lizenz ist die einzige Lizenz, fuer die es **keine hoehere Instanz gibt**, +die sie bestaetigen koennte. Ein Aktivierungs-Handshake gegen sich selbst waere +Theater. Das Skript signiert die Datei mit dem eigenen Schluessel und setzt den +Aktivierungszustand direkt -- und nur so kann eine Lizenzdatei je die Rolle +``master`` tragen. Die Ticket-Oberflaeche bietet diese Rolle an keiner Stelle an. +""" + +from __future__ import annotations + +import argparse +import getpass +import json +import sys +from pathlib import Path +from typing import Any + +import tesm_licensing as lic +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, master_key_path +from .services import legacy_import, master_key + + +def _app_and_core() -> tuple[Any, Any]: + app = create_app() + return app, core(app) + + +def cmd_init_key(args: argparse.Namespace) -> int: + app, extension = _app_and_core() + path = master_key_path(extension.config) + if path.exists() and not args.force: + key = master_key.load(path) + print(f"Signaturschluessel existiert bereits: {key.key_id} ({path})") + print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unpruefbar.") + return 0 + if path.exists() and args.force: + print( + "ABBRUCH: --force auf einen bestehenden Signaturschluessel wird nicht unterstuetzt.\n" + "Ein neuer Schluessel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n" + "von Hand beiseite legen, wenn das wirklich gewollt ist.", + file=sys.stderr, + ) + return 2 + key = master_key.ensure(path) + print(f"Signaturschluessel erzeugt: {key.key_id}") + print(f"Datei: {path}") + print("Bitte SOFORT sichern -- ohne sie ist keine ausgestellte Lizenz mehr pruefbar.") + print(f"Oeffentlicher Schluessel (zum Pinnen beim Kunden):\n{key.public_key}") + return 0 + + +def cmd_bootstrap(args: argparse.Namespace) -> int: + """Stellt die eigene Lizenz dieser Wurzelinstanz aus.""" + app, extension = _app_and_core() + key = master_key.ensure(master_key_path(extension.config)) + + with app.app_context(), extension.database.session() as conn: + vendor = { + "name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"), + "email": str(extension.settings.get(conn, "vendor_email") or ""), + "phone": str(extension.settings.get(conn, "vendor_phone") or ""), + "address": str(extension.settings.get(conn, "vendor_address") or ""), + "logo_base64": "", + } + bundle, _ = lic.issue_license( + customer={"name": args.name, "contact_email": vendor["email"]}, + license_type="enterprise", + modules=[], + valid_days=int(args.days), + master_private_key_b64=key.private_key, + master_public_key_b64=key.public_key, + # Bewusst leer: es gibt keinen uebergeordneten Endpunkt. Damit + # laeuft der Handshake fuer diese Lizenz nie ins Netz. + master_endpoint="", + vendor=vendor, + role="master", + ) + document, client_key = lic.split_bundle(bundle) + + config = extension.config + config.license_path.parent.mkdir(parents=True, exist_ok=True) + config.license_path.write_text( + json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + try: + config.license_path.chmod(0o640) + except OSError: + pass + from tesm_core.keystore import write_private_file + + write_private_file( + config.license_key_path, + json.dumps({"license_id": document["license_id"], "client_key": client_key}) + "\n", + ) + + with extension.database.transaction(conn): + conn.execute( + "INSERT INTO trusted_issuer_keys (key_id, public_key, label, source, added_at, " + "added_by) VALUES (?,?,?,'bootstrap',datetime('now'),'cli') " + "ON CONFLICT(key_id) DO NOTHING", + (key.key_id, key.public_key, "Eigener Signaturschluessel"), + ) + # Es gibt keine hoehere Instanz, die diese Lizenz aktivieren koennte. + extension.settings.set(conn, "license_activation_status", "active", actor="cli") + extension.settings.set(conn, "license_pending_request", "", actor="cli") + audit.log( + conn, + actor="cli", + action="own_license.bootstrapped", + target=document["license_id"], + detail=f"Rolle master, {args.days} Tage, Schluessel {key.key_id}", + severity="warning", + ) + state = extension.license.reload(conn) if extension.license else None + + print(f"Master-Lizenz ausgestellt: {document['license_id']}") + print(f"Gueltig bis: {document['expires_at']}") + print(f"Datei: {config.license_path}") + if state is not None: + print(f"Zustand: gueltig={state.valid}, aktiviert={state.activated}, Rolle={state.role}") + return 0 + + +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 or getpass.getpass("Passwort: ") + if not args.password and 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) + 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" + else: + 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)), + ) + action = "angelegt" + audit.log( + conn, + actor="cli", + action="user.admin_created", + target=username, + 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 = { + "kunden": Database.value(conn, "SELECT COUNT(*) FROM license_customers", default=0), + "tickets": Database.value(conn, "SELECT COUNT(*) FROM license_tickets", default=0), + "lizenzen": Database.value(conn, "SELECT COUNT(*) FROM licenses", default=0), + "aktiv": Database.value( + conn, "SELECT COUNT(*) FROM licenses WHERE status='active'", default=0 + ), + "administratoren": rbac.count_active_admins(conn), + } + state = extension.license.reload(conn) if extension.license else None + key_health = master_key.health(master_key_path(extension.config)) + + payload = { + "datenbank": str(extension.config.db_path), + "bestand": counts, + "audit_kette": chain, + "signaturschluessel": key_health, + "eigene_lizenz": ( + { + "vorhanden": state.present, + "gueltig": state.valid, + "aktiviert": state.activated, + "rolle": state.role, + "fehler": state.error, + } + if state + else None + ), + } + if args.json: + print(json.dumps(payload, indent=2, ensure_ascii=False, default=str)) + return 0 + + print(f"Datenbank : {payload['datenbank']}") + for key, value in counts.items(): + print(f"{key.capitalize():<17}: {value}") + print( + "Audit-Kette : " + + ("unversehrt" if chain["ok"] else f"GEBROCHEN bei {chain['broken_at']}") + ) + print( + "Signaturschluessel: " + + (str(key_health.get("key_id")) if key_health.get("present") else "FEHLT") + ) + for problem in key_health.get("problems", []): + print(f" ! {problem}", file=sys.stderr) + if state: + print( + "Eigene Lizenz : " + + ("aktiv" if state.valid and state.activated else "nicht aktiv") + + f", Rolle {state.role}" + ) + if not counts["administratoren"]: + print("\nWARNUNG: kein entsperrtes Administratorkonto vorhanden.", 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 + + from .services import activation + + with app.app_context(), extension.database.session() as conn: + with extension.database.transaction(conn): + sessions = session_store.prune(conn) + events = ratelimit.prune(conn) + nonces = activation.prune_nonces(conn) + archived = audit.archive_old_rows(conn, extension.audit_archive_dir) + expired_tokens = conn.execute( + "DELETE FROM portal_access_tokens WHERE expires_at < datetime('now', '-7 days')" + ).rowcount + print( + f"{sessions} Sitzungen, {events} Ratenereignisse, {nonces} Nonces, " + f"{expired_tokens or 0} Portal-Token entfernt, {len(archived)} Protokolltage archiviert." + ) + return 0 + + +def cmd_web_setup(args: argparse.Namespace) -> int: + app, extension = _app_and_core() + return run_web_setup(app, extension, args) + + +def cmd_migrate_legacy(args: argparse.Namespace) -> int: + """Uebernimmt Signaturschluessel, Kunden, Tickets und Lizenzen der Vorgaengerfassung.""" + root = Path(args.source) + app, extension = _app_and_core() + + try: + legacy = legacy_import.open_legacy(root) + except legacy_import.LegacyImportError as exc: + print(str(exc), file=sys.stderr) + return 2 + + key_path = master_key_path(extension.config) + report = legacy_import.Report() + + try: + # -- Schluessel ---------------------------------------------------- # + # Der alte Schluessel liegt als 0600 root. Gelesen wird er nur, wenn er + # wirklich gebraucht wird -- naemlich beim Uebernehmen. Fuer den blossen + # Vergleich genuegt die oeffentliche Haelfte, und fehlt sie, ist das + # kein Grund abzubrechen: der Vergleich ist eine Auskunft, keine + # Voraussetzung. + issuer_changes = False + legacy_public = "" + try: + _, legacy_public = legacy_import.read_legacy_key(root) + except legacy_import.LegacyImportError as exc: + if args.adopt_key: + raise + print(f"Hinweis: alter Signaturschluessel nicht lesbar ({exc}).") + + if key_path.is_file(): + existing = master_key.load(key_path) + report.key_id = existing.key_id + if legacy_public and existing.public_key == legacy_public: + print(f"Signaturschluessel stimmt bereits ueberein ({existing.key_id}).") + elif not legacy_public: + issuer_changes = True + print( + f"Es wird mit dem vorhandenen Signaturschluessel {existing.key_id} " + "gearbeitet." + ) + else: + # Nicht ueberschreiben: jede mit dem vorhandenen Schluessel + # ausgestellte Lizenz waere danach unpruefbar. + issuer_changes = True + print( + f"Diese Installation hat einen eigenen Signaturschluessel " + f"({existing.key_id}); der alte war {lic.key_id(legacy_public)}. " + "Es wird mit dem vorhandenen weitergearbeitet." + ) + elif args.adopt_key: + legacy_private, legacy_public = legacy_import.read_legacy_key(root) + report.key_id = legacy_import.adopt_key(legacy_private, legacy_public, key_path) + report.key_adopted = True + print(f"Signaturschluessel uebernommen: {report.key_id}") + else: + print( + "Diese Installation hat noch keinen Signaturschluessel. Mit --adopt-key " + "wird der alte uebernommen -- dann bleibt die Anbieteridentitaet erhalten " + "und Kunden muessen keinen Ausstellerwechsel bestaetigen.", + file=sys.stderr, + ) + return 2 + + key = master_key.load(key_path) + + with app.app_context(), extension.database.session() as conn: + vendor = { + "name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"), + "email": str(extension.settings.get(conn, "vendor_email") or ""), + "phone": str(extension.settings.get(conn, "vendor_phone") or ""), + "address": str(extension.settings.get(conn, "vendor_address") or ""), + "logo_base64": "", + } + endpoint = str(extension.settings.get(conn, "master_endpoint") or "") + if not endpoint and not args.dry_run: + print( + "Es ist kein Endpunkt hinterlegt (Einstellungen -> Anbieter). Ohne ihn " + "koennen uebernommene Lizenzen sich spaeter nicht aktivieren.", + file=sys.stderr, + ) + return 2 + + with extension.database.transaction(conn): + result = legacy_import.import_all( + legacy, + conn, + keystore=extension.keystore, + master_private_key=key.private_key, + master_public_key=key.public_key, + master_endpoint=endpoint, + vendor=vendor, + actor="migration", + reissue=not args.dry_run, + ) + result.key_adopted = report.key_adopted + result.key_id = report.key_id + if issuer_changes: + result.warnings.append( + "Die Lizenzen sind mit einem anderen Schluessel signiert als " + "frueher. Kunden, die den alten Aussteller gepinnt haben, muessen " + "beim Einspielen einmal 'Wechsel des Ausstellers ausdruecklich " + "zulassen' bestaetigen." + ) + if args.dry_run: + # Nichts festschreiben -- der Lauf war nur zur Ansicht. + raise _DryRun(result) + except _DryRun as preview: + print() + print("== Probelauf, nichts gespeichert ==") + print(preview.report.as_text()) + return 0 + except legacy_import.LegacyImportError as exc: + print(str(exc), file=sys.stderr) + return 2 + finally: + legacy.close() + + print() + print(result.as_text()) + return 0 + + +class _DryRun(Exception): + """Bricht die Transaktion eines Probelaufs ab -- ohne Fehler zu sein.""" + + def __init__(self, report: Any) -> None: + super().__init__("Probelauf") + self.report = report + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="tesm-license-admin", description="Verwaltungswerkzeuge des TESM-Lizenzservers" + ) + sub = parser.add_subparsers(dest="command", required=True) + + init_key = sub.add_parser("init-key", help="Signaturschluessel erzeugen (einmalig)") + init_key.add_argument("--force", action="store_true") + init_key.set_defaults(func=cmd_init_key) + + bootstrap = sub.add_parser( + "bootstrap", help="Eigene Master-Lizenz dieser Wurzelinstanz ausstellen" + ) + bootstrap.add_argument("--days", type=int, default=3650) + bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)") + bootstrap.set_defaults(func=cmd_bootstrap) + + admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen") + admin.add_argument("--username") + admin.add_argument("--password") + admin.add_argument("--force", action="store_true") + admin.set_defaults(func=cmd_create_admin) + + status = sub.add_parser("status", help="Zustand ausgeben") + status.add_argument("--json", action="store_true") + status.set_defaults(func=cmd_status) + + house = sub.add_parser("housekeeping", help="Laufzeitdaten aufraeumen") + house.set_defaults(func=cmd_housekeeping) + + migrate = sub.add_parser( + "migrate-legacy", + help="Lizenzserver der Vorgaengerfassung uebernehmen", + description=( + "Uebernimmt Signaturschluessel, Kunden, Tickets und Lizenzen aus einer alten " + "Installation. Die alte Installation wird nur gelesen. Jede Lizenz wird im " + "neuen Format neu signiert -- die Kunden brauchen danach eine neue " + "Lizenzdatei, weil die alte Signatur nicht mehr gilt." + ), + ) + migrate.add_argument( + "--source", default="/srv/tesm-license", help="Verzeichnis der alten Installation" + ) + migrate.add_argument( + "--adopt-key", + action="store_true", + help="Den alten Signaturschluessel uebernehmen. Nur moeglich, solange diese " + "Installation noch keinen eigenen hat -- ein vorhandener wird nie ueberschrieben.", + ) + migrate.add_argument( + "--dry-run", + action="store_true", + help="Nur anzeigen, was passieren wuerde -- nichts schreiben", + ) + migrate.set_defaults(func=cmd_migrate_legacy) + + add_web_setup(sub, cmd_web_setup) + + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/apps/tesm-license/src/tesm_license/navigation.py b/apps/tesm-license/src/tesm_license/navigation.py new file mode 100644 index 0000000..8960fba --- /dev/null +++ b/apps/tesm-license/src/tesm_license/navigation.py @@ -0,0 +1,188 @@ +"""Navigationsbaum des Lizenzservers. + +Kunde, Ticket und Lizenz sind drei **flache** Top-Level-Punkte, jeder einzeln +ueber die Navigation erreichbar. Ein Zwischenstand des Vorgaengerprojekts hatte +Tickets und Lizenzen tief in die Kundenseite verschachtelt -- das war +nachweislich unbrauchbar. Die Kundendetailseite existiert trotzdem, aber als +reiner Lese-Ueberblick. + +Settings und Protokolle bleiben zweistufige Gruppen -- das ist keine Ausnahme +von der Flachheit, sondern die Standardkonvention fuer mehrseitige Bereiche. +""" + +from __future__ import annotations + +from typing import Any + +from tesm_core.extension import core +from tesm_core.nav import NavItem, NavTree + + +def _is_master(_user: Any) -> bool: + """Nur eine Master-Instanz sieht die Berichte der Sub-Lizenzserver.""" + manager = core().license + if manager is None: + return True + return manager.state.role == "master" + + +NAVIGATION = NavTree( + [ + NavItem(key="index", label="Uebersicht", icon="grid", endpoint="dashboard.index"), + NavItem( + key="customers", + label="Kunden", + icon="building", + endpoint="customers.overview", + permissions=("customers.view",), + ), + NavItem( + key="tickets", + label="Tickets", + icon="ticket", + endpoint="tickets.overview", + permissions=("tickets.view",), + ), + NavItem( + key="licenses", + label="Lizenzen", + icon="shield", + endpoint="licenses.overview", + permissions=("licenses.view",), + ), + NavItem( + key="offline_code", + label="Offline-Aktivierung", + icon="transfer", + endpoint="licenses.manual_code", + permissions=("licenses.execute",), + ), + NavItem( + key="reports", + label="Sub-Lizenzserver", + icon="share", + endpoint="licenses.reports", + permissions=("reports.view",), + condition=_is_master, + ), + 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="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_vendor", + label="Anbieterdaten", + icon="building", + endpoint="settings.vendor", + permissions=("settings_vendor.view",), + ), + NavItem( + key="settings_mail", + label="E-Mail-Versand", + icon="mail", + endpoint="settings.mail_settings", + permissions=("settings_mail.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="Eigene Lizenz", + icon="key", + endpoint="own_license.overview", + 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-license/src/tesm_license/permissions.py b/apps/tesm-license/src/tesm_license/permissions.py new file mode 100644 index 0000000..1a10cce --- /dev/null +++ b/apps/tesm-license/src/tesm_license/permissions.py @@ -0,0 +1,183 @@ +"""Rechtematrix des Lizenzservers. + +Dieselbe Systematik wie in TESM: festes Aktionsvokabular, Loeschen als eigenes +Recht, jede Seite mit eigenem Rechtepaar. + +Zwei Nuancen, die der Vorgaenger nachtraeglich korrigieren musste und die hier +von Anfang an so gebaut sind: + +* **Tickets und Lizenzen sind getrennte Rechte.** "Ticket sperren" und "Lizenz + ausstellen" sind verschiedene Vorgaenge und lassen sich unabhaengig vergeben. +* **Die eigene Lizenzseite hat ihr eigenes Recht** (``settings_license``) und + laeuft nicht ueber ``settings_system`` mit. + +Und eine Grenze, die ausdruecklich **kein** Recht ist: das Ausstellen von +Lizenzserver-Lizenzen. Es haengt an der Rolle der eigenen Lizenzdatei +(``master``) und am Admin-Kennzeichen -- nicht an einer vergebbaren Permission. +Ein lokaler Administrator eines Sub-Lizenzservers koennte sich ein Recht selbst +einraeumen; eine von woanders signierte Lizenzdatei nicht. +""" + +from __future__ import annotations + +from tesm_core.rbac.common import administration_area, observability_area +from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset + +A = Action + +CUSTOMERS = Resource( + key="customers", + label="Kunden", + actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), + hint="Stammdaten der Kundeninstanzen", + action_hints={A.DELETE: "Nur moeglich, solange kein Ticket am Kunden haengt."}, +) + +TICKETS = Resource( + key="tickets", + label="Tickets", + actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), + hint="Dauerhafte Anspruechte auf eine Lizenz", + action_hints={ + A.EDIT: "Parameter aendern und Ticket sperren/entsperren. Wirkt erst auf die " + "naechste Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.", + A.DELETE: "Nur moeglich, wenn keine offene Lizenz mehr existiert.", + }, +) + +LICENSES = Resource( + key="licenses", + label="Lizenzen", + actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE, A.EXPORT), + hint="Konkrete, signierte Lizenzdateien", + action_hints={ + A.CREATE: "Aus einem Ticket eine Lizenz ausstellen.", + A.EDIT: "Lizenz widerrufen oder eine neue Revision signieren.", + A.EXECUTE: "Offline-Aktivierungscodes verarbeiten.", + A.EXPORT: "Lizenzdatei herunterladen oder per E-Mail versenden.", + }, +) + +PORTAL = Resource( + key="portal", + label="Kundenportal", + actions=(A.VIEW, A.EXECUTE), + hint="Zugangslinks fuer Kunden", + action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."}, +) + +REPORTS = Resource( + key="reports", + label="Berichte der Sub-Lizenzserver", + actions=(A.VIEW,), + hint="Von untergeordneten Lizenzservern gemeldete Kunden, Tickets und Lizenzen", +) + +MAIL = Resource( + key="settings_mail", + label="E-Mail-Versand", + actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS), + hint="Microsoft Graph (App-only)", + action_hints={ + A.EXECUTE: "Verbindungstest ausfuehren.", + A.SECRETS: "Client-Secret setzen.", + }, +) + +VENDOR = Resource( + key="settings_vendor", + label="Anbieterdaten", + actions=(A.VIEW, A.EDIT), + hint="Name, Kontakt, Logo und oeffentlicher Endpunkt -- reisen in jeder Lizenz mit", +) + +LICENSING_AREA = Area( + key="licensing", + label="Lizenzierung", + icon="key", + hint="Kunden, Tickets, Lizenzen. Ohne diesen Zugang bleibt der gesamte Bereich verborgen.", + resources=(CUSTOMERS, TICKETS, LICENSES, PORTAL, REPORTS), +) + +PERMISSIONS = PermissionTree( + [ + LICENSING_AREA, + observability_area(), + administration_area(MAIL, VENDOR), + ] +) + + +ROLE_PRESETS: tuple[RolePreset, ...] = ( + RolePreset( + key="viewer", + name="Benutzer", + description="Standardgruppe: nur das laufende Protokoll lesen.", + grants=(("logs_live", (A.VIEW,)),), + is_default=True, + ), + RolePreset( + key="sales", + name="Vertrieb", + description="Kunden und Tickets pflegen, Lizenzen ausstellen und versenden.", + grants=( + ("customers", "*"), + ("tickets", "*"), + ("licenses", (A.VIEW, A.CREATE, A.EDIT, A.EXPORT)), + ("portal", "*"), + ("logs_live", (A.VIEW,)), + ("logs_audit", (A.VIEW,)), + ), + ), + RolePreset( + key="support", + name="Support", + description="Lesen und Offline-Aktivierungen verarbeiten.", + grants=( + ("customers", (A.VIEW,)), + ("tickets", (A.VIEW,)), + ("licenses", (A.VIEW, A.EXECUTE)), + ("reports", (A.VIEW,)), + ("logs_live", (A.VIEW,)), + ), + ), + RolePreset( + key="auditor", + name="Revision", + description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.", + grants=( + ("customers", (A.VIEW,)), + ("tickets", (A.VIEW,)), + ("licenses", (A.VIEW,)), + ("reports", (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,)), + ), + ), + 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", "*"), + ("settings_mail", (A.VIEW, A.EDIT, A.EXECUTE)), + ("settings_vendor", "*"), + ("backup", (A.VIEW, A.EXECUTE, A.EXPORT)), + ("trash", "*"), + ("diagnostics", (A.VIEW,)), + ("logs_audit", (A.VIEW,)), + ), + ), +) + +__all__ = ["PERMISSIONS", "ROLE_PRESETS"] diff --git a/apps/tesm-license/src/tesm_license/schema.py b/apps/tesm-license/src/tesm_license/schema.py new file mode 100644 index 0000000..53c909b --- /dev/null +++ b/apps/tesm-license/src/tesm_license/schema.py @@ -0,0 +1,216 @@ +"""Migrationen der Komponente ``tesm_license``. + +Das Datenmodell folgt der fachlichen Hierarchie **Kunde -> Ticket -> Lizenz**: + +* Ein **Kunde** ist eine Firma mit Stammdaten. +* Ein **Ticket** ist der dauerhafte Anspruch dieses Kunden auf eine Lizenz mit + bestimmten Parametern (Typ, Module, Laufzeit). +* Eine **Lizenz** ist die konkrete, signierte Datei, die aus einem Ticket + ausgestellt wurde. + +Der Vorgaenger hatte in einem frueheren Stand nur "Lizenz" in "Ticket" +umbenannt, ohne die Trennung tatsaechlich zu bauen -- das wird hier nicht +wiederholt. + +Aenderungen gegenueber dem Vorgaenger: + +* **Kein ``portal_token``.** Der permanente, unbegrenzt gueltige Magic-Link war + eine erkannte Schwachstelle und wurde durch einen zweistufigen Flow ersetzt + (Anfragelink 30 Minuten, einmalig -> Sitzung 60 Minuten). Spalte und Index des + alten Verfahrens entstehen hier gar nicht erst. +* **Nonce-Speicher** (``license_nonces``): jede Aktivierungs-/Heartbeat-Anfrage + darf genau einmal verarbeitet werden. Der Vorgaenger pruefte Nonces nicht -- + eine abgefangene Anfrage war unbegrenzt wiederverwendbar. +* **Zustellprotokoll** (``license_deliveries``): wer wann welche Lizenzdatei + bekommen hat. +""" + +from __future__ import annotations + +from tesm_core.migrations import Migration + +V1 = """ +CREATE TABLE IF NOT EXISTS license_customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL COLLATE NOCASE, + contact_email TEXT NOT NULL DEFAULT '', + contact_phone TEXT NOT NULL DEFAULT '', + contact_person TEXT NOT NULL DEFAULT '', + -- Adresse ausdruecklich in Einzelfeldern, damit sie z. B. fuer Rechnungen + -- weiterverwendbar bleibt. + street TEXT NOT NULL DEFAULT '', + house_number TEXT NOT NULL DEFAULT '', + postal_code TEXT NOT NULL DEFAULT '', + city TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_customers_email ON license_customers(contact_email); + +CREATE TABLE IF NOT EXISTS license_tickets ( + -- Die ID ist zugleich der Schluessel des Self-Service-Links: 192 Bit + -- Zufall, nicht ratbar. + id TEXT PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES license_customers(id) ON DELETE RESTRICT, + type TEXT NOT NULL, + modules TEXT NOT NULL DEFAULT '[]', + valid_days INTEGER NOT NULL DEFAULT 365, + lifetime INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'open', + role TEXT NOT NULL DEFAULT 'customer', + notes TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_tickets_customer ON license_tickets(customer_id); +CREATE INDEX IF NOT EXISTS idx_tickets_status ON license_tickets(status); + +CREATE TABLE IF NOT EXISTS licenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + license_id TEXT NOT NULL UNIQUE, + ticket_id TEXT REFERENCES license_tickets(id) ON DELETE CASCADE, + customer_id INTEGER NOT NULL REFERENCES license_customers(id) ON DELETE RESTRICT, + type TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'customer', + modules TEXT NOT NULL DEFAULT '[]', + lifetime INTEGER NOT NULL DEFAULT 0, + seq INTEGER NOT NULL DEFAULT 1, + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + license_pubkey TEXT NOT NULL, + license_document TEXT NOT NULL, + -- Der private Client-Schluessel dieser Lizenz, AES-256-GCM-verschluesselt und + -- an die Lizenz-ID gebunden. Er muss erneut ausliefer-bar sein (verlorene + -- Mail, neuer Ansprechpartner), darf aber nie im Klartext in der Datenbank + -- liegen. Die Herausgabe verlangt Recht, frische Passwortbestaetigung und + -- landet im Aenderungsprotokoll. + client_key_encrypted TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'issued', + fingerprint TEXT, + hostname TEXT, + activated_at TEXT, + deactivated_at TEXT, + last_heartbeat_at TEXT, + revoked_at TEXT, + revoke_reason TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_licenses_customer ON licenses(customer_id); +CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses(status); +CREATE INDEX IF NOT EXISTS idx_licenses_fingerprint ON licenses(fingerprint); + +-- Hoechstens eine offene Lizenz pro Ticket -- auf Datenbankebene, nicht nur im +-- Python-Code. Damit ist der Doppelklick-Fall (zwei Tabs, zwei Anfragen) sauber +-- abgefangen: der verlierende INSERT scheitert und der Aufrufer gibt die +-- bereits erzeugte Lizenz zurueck. +CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket + ON licenses(ticket_id) WHERE status IN ('issued','active'); + +-- Verbrauchte Nonces des Aktivierungsprotokolls. Verhindert Replay. +CREATE TABLE IF NOT EXISTS license_nonces ( + nonce TEXT PRIMARY KEY, + license_id TEXT NOT NULL, + action TEXT NOT NULL, + seen_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_nonces_seen ON license_nonces(seen_at); + +-- Wer hat wann welche Lizenzdatei erhalten. +CREATE TABLE IF NOT EXISTS license_deliveries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + license_id TEXT NOT NULL, + channel TEXT NOT NULL, + recipient TEXT NOT NULL DEFAULT '', + delivered_at TEXT NOT NULL, + delivered_by TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_deliveries_license ON license_deliveries(license_id); + +-- Zeitlich begrenzte Zugangslinks des Kundenportals: einmalig verwendbar. +CREATE TABLE IF NOT EXISTS portal_access_tokens ( + token TEXT PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES license_customers(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + created_ip TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_portal_tokens_customer ON portal_access_tokens(customer_id); + +-- Von Sub-Lizenzservern gemeldeter Bestand. Rein informativ; dieser Master +-- verwaltet ihn nicht selbst. Jeder Bericht ersetzt alle Zeilen seiner Quelle +-- vollstaendig (Schnappschuss statt Delta) -- so verschwindet ein beim +-- Sub-Lizenzserver geloeschtes Ticket automatisch auch hier. +CREATE TABLE IF NOT EXISTS reported_customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_license_id TEXT NOT NULL, + remote_ref TEXT NOT NULL, + name TEXT NOT NULL, + contact_email TEXT NOT NULL DEFAULT '', + contact_person TEXT NOT NULL DEFAULT '', + city TEXT NOT NULL DEFAULT '', + last_reported_at TEXT NOT NULL, + UNIQUE(source_license_id, remote_ref) +); +CREATE INDEX IF NOT EXISTS idx_reported_customers_src ON reported_customers(source_license_id); + +CREATE TABLE IF NOT EXISTS reported_tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_license_id TEXT NOT NULL, + remote_ref TEXT NOT NULL, + customer_ref TEXT NOT NULL, + type TEXT NOT NULL, + modules TEXT NOT NULL DEFAULT '[]', + valid_days INTEGER, + lifetime INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'open', + role TEXT NOT NULL DEFAULT 'customer', + created_at TEXT, + last_reported_at TEXT NOT NULL, + UNIQUE(source_license_id, remote_ref) +); +CREATE INDEX IF NOT EXISTS idx_reported_tickets_src ON reported_tickets(source_license_id); + +CREATE TABLE IF NOT EXISTS reported_licenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_license_id TEXT NOT NULL, + remote_ref TEXT NOT NULL, + ticket_ref TEXT, + type TEXT NOT NULL, + modules TEXT NOT NULL DEFAULT '[]', + lifetime INTEGER NOT NULL DEFAULT 0, + issued_at TEXT, + expires_at TEXT, + status TEXT NOT NULL, + fingerprint TEXT, + hostname TEXT, + activated_at TEXT, + last_heartbeat_at TEXT, + last_reported_at TEXT NOT NULL, + UNIQUE(source_license_id, remote_ref) +); +CREATE INDEX IF NOT EXISTS idx_reported_licenses_src ON reported_licenses(source_license_id); +""" + + +# Version 2 ----------------------------------------------------------------- +# +# Wann wurde zuletzt eine neue Revision signiert? Aus diesem Zeitstempel und +# ``last_heartbeat_at`` ergibt sich, ob der Client sie schon hat. Ein eigener +# Status waere hier falsch: eine Lizenz kann aktiv sein *und* eine ausstehende +# Revision haben. +V2 = """ +ALTER TABLE licenses ADD COLUMN revision_at TEXT; +CREATE INDEX IF NOT EXISTS idx_licenses_revision ON licenses(revision_at); +""" + + +LICENSE_MIGRATIONS: tuple[Migration, ...] = ( + Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True), + Migration(version=2, name="revision-timestamp", sql=V2), +) diff --git a/apps/tesm-license/src/tesm_license/services/__init__.py b/apps/tesm-license/src/tesm_license/services/__init__.py new file mode 100644 index 0000000..019c767 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/services/__init__.py @@ -0,0 +1 @@ +"""Fachdienste des Lizenzservers -- Ausstellung, Aktivierung, Mail, Berichte.""" diff --git a/apps/tesm-license/src/tesm_license/services/activation.py b/apps/tesm-license/src/tesm_license/services/activation.py new file mode 100644 index 0000000..b20ebe1 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/services/activation.py @@ -0,0 +1,344 @@ +"""Aktivierung, Deaktivierung, Heartbeat -- die Serverseite des Protokolls. + +Ein Satz Verarbeitungsfunktionen, zwei Transportwege: die JSON-API +(``/api/v1/licenses/*``) und der Offline-Code (Copy-Paste). Beide landen hier -- +es gibt keinen zweiten, abweichenden Pfad, an dem eine Pruefung fehlen koennte. + +Durchgesetzte Regeln: + +* Signatur der Anfrage gegen den in der Datenbank gespeicherten + Lizenz-Public-Key -- nicht gegen etwas aus der Anfrage selbst. +* **Replay-Schutz**: jede Nonce genau einmal, plus Frischefenster auf dem + Zeitstempel. Der Vorgaenger pruefte beides nicht. +* **Bindung an die Maschine ueber den Fingerprint**, nie ueber den Hostnamen. + Der Hostname ist ein rein informatives Feld; er wird per ``COALESCE`` nur + ueberschrieben, wenn tatsaechlich ein Wert kommt. +* **Eine aktive Lizenz pro Maschine**: aktiviert jemand eine neue Lizenz auf + einem Fingerprint, der schon eine andere aktive traegt, wird die alte + automatisch deaktiviert. Pro Fingerprint, nicht pro Kunde -- ein Kunde darf + mehrere Maschinen gleichzeitig lizenziert haben. +""" + +from __future__ import annotations + +import json +import logging +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any + +import tesm_licensing as lic +from tesm_core import audit +from tesm_core.db import Database + +logger = logging.getLogger(__name__) + +NONCE_RETENTION_DAYS = 30 + + +@dataclass(frozen=True, slots=True) +class Outcome: + """Ergebnis einer Verarbeitung -- HTTP-Status inklusive.""" + + status_code: int + response: dict[str, Any] + + @property + def ok(self) -> bool: + return self.status_code == 200 + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _error(status_code: int, code: str, message: str) -> Outcome: + """Fehlerantworten sind bewusst **unsigniert** und knapp. + + Sie enthalten nichts, was einem Angreifer beim Raten hilft -- insbesondere + keine Auskunft darueber, ob eine Lizenz-ID existiert, sobald die Signatur + nicht passt. + """ + return Outcome(status_code, {"error": code, "message": message}) + + +def _signed_response( + conn: sqlite3.Connection, + request: dict[str, Any], + *, + master_private_key: str, + status: str, + detail: str = "", + license_update: dict[str, Any] | None = None, +) -> Outcome: + response = lic.build_master_response( + str(request["action"]), + license_id=str(request["license_id"]), + fingerprint=str(request["fingerprint"]), + request_nonce=str(request["nonce"]), + master_private_key=master_private_key, + status=status, + detail=detail, + license_update=license_update, + ) + return Outcome(200, response) + + +def _remember_nonce(conn: sqlite3.Connection, request: dict[str, Any]) -> None: + conn.execute( + "INSERT OR IGNORE INTO license_nonces (nonce, license_id, action, seen_at) " + "VALUES (?,?,?,?)", + ( + str(request["nonce"]), + str(request["license_id"]), + str(request["action"]), + _now(), + ), + ) + + +def prune_nonces(conn: sqlite3.Connection) -> int: + horizon = (datetime.now(timezone.utc) - timedelta(days=NONCE_RETENTION_DAYS)).isoformat( + timespec="seconds" + ) + cursor = conn.execute("DELETE FROM license_nonces WHERE seen_at < ?", (horizon,)) + return cursor.rowcount or 0 + + +def _verify( + conn: sqlite3.Connection, request: dict[str, Any], *, expected_action: str | None +) -> tuple[dict[str, Any] | None, Outcome | None]: + """Sucht die Lizenz und prueft die Anfrage. Rueckgabe: ``(zeile, fehler)``.""" + if not isinstance(request, dict): + return None, _error(400, "bad_request", "Anfrage ist kein Objekt.") + license_id = str(request.get("license_id") or "") + row = Database.one(conn, "SELECT * FROM licenses WHERE license_id=?", (license_id,)) + if row is None: + return None, _error(404, "unknown_license", "Unbekannte Lizenz.") + + def seen(nonce: str) -> bool: + return ( + Database.value(conn, "SELECT 1 FROM license_nonces WHERE nonce=?", (nonce,)) + is not None + ) + + try: + lic.verify_client_request( + request, + str(row["license_pubkey"]), + expected_action=expected_action, + seen_nonce=seen, + ) + except lic.SignatureInvalid: + return None, _error(400, "invalid_signature", "Signatur der Anfrage ist ungueltig.") + except lic.ReplayDetected as exc: + return None, _error(409, "replay", str(exc)) + except lic.ProtocolViolation as exc: + return None, _error(400, "bad_request", str(exc)) + return row, None + + +# --------------------------------------------------------------------------- # +# Aktionen +# --------------------------------------------------------------------------- # + + +def process_activate( + conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str +) -> Outcome: + row, error = _verify(conn, request, expected_action="activate") + if error is not None or row is None: + return error or _error(400, "bad_request", "Ungueltige Anfrage.") + + fingerprint = str(request["fingerprint"]) + hostname = str(request.get("hostname") or "") + status = str(row["status"]) + + if status == "revoked": + _remember_nonce(conn, request) + return _signed_response( + conn, + request, + master_private_key=master_private_key, + status="revoked", + detail="Diese Lizenz wurde widerrufen.", + ) + if status == "active" and str(row["fingerprint"] or "") not in {"", fingerprint}: + _remember_nonce(conn, request) + return _error( + 409, + "already_bound_elsewhere", + "Diese Lizenz ist bereits auf einer anderen Maschine aktiv. " + "Bitte dort zuerst deaktivieren.", + ) + + # Eine Maschine traegt hoechstens eine aktive Lizenz: eine andere aktive + # Lizenz auf demselben Fingerprint wird verdraengt. + superseded = Database.all( + conn, + "SELECT license_id FROM licenses WHERE fingerprint=? AND status='active' " + "AND license_id<>?", + (fingerprint, str(row["license_id"])), + ) + for other in superseded: + conn.execute( + "UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?", + (_now(), other["license_id"]), + ) + + conn.execute( + "UPDATE licenses SET status='active', fingerprint=?, " + "hostname=COALESCE(NULLIF(?, ''), hostname), activated_at=?, deactivated_at=NULL " + "WHERE license_id=?", + (fingerprint, hostname, _now(), str(row["license_id"])), + ) + _remember_nonce(conn, request) + audit.log( + conn, + actor="api", + action="license.activated", + target=str(row["license_id"]), + detail=( + f"Fingerprint {fingerprint[:16]}..., Host {hostname or 'unbekannt'}" + + (f", verdraengt {len(superseded)} Lizenz(en)" if superseded else "") + ), + severity="notice", + ) + return _signed_response( + conn, + request, + master_private_key=master_private_key, + status="ok", + detail="Aktivierung bestaetigt.", + ) + + +def process_deactivate( + conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str +) -> Outcome: + row, error = _verify(conn, request, expected_action="deactivate") + if error is not None or row is None: + return error or _error(400, "bad_request", "Ungueltige Anfrage.") + + fingerprint = str(request["fingerprint"]) + if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint: + _remember_nonce(conn, request) + return _error( + 409, + "not_active_on_this_fingerprint", + "Diese Lizenz ist auf dieser Maschine nicht aktiv.", + ) + + conn.execute( + "UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?", + (_now(), str(row["license_id"])), + ) + _remember_nonce(conn, request) + audit.log( + conn, + actor="api", + action="license.deactivated", + target=str(row["license_id"]), + detail=f"Fingerprint {fingerprint[:16]}...", + severity="notice", + ) + return _signed_response( + conn, + request, + master_private_key=master_private_key, + status="ok", + detail="Deaktivierung bestaetigt. Die Lizenz kann auf einer anderen Maschine aktiviert werden.", + ) + + +def process_heartbeat( + conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str +) -> Outcome: + row, error = _verify(conn, request, expected_action="heartbeat") + if error is not None or row is None: + return error or _error(400, "bad_request", "Ungueltige Anfrage.") + + fingerprint = str(request["fingerprint"]) + hostname = str(request.get("hostname") or "") + + if str(row["status"]) == "revoked": + # Auch hier eine signierte Antwort: der Client loescht seine Lizenzdatei + # erst, wenn er die Signatur selbst geprueft hat. + _remember_nonce(conn, request) + return _signed_response( + conn, + request, + master_private_key=master_private_key, + status="revoked", + detail="Diese Lizenz wurde widerrufen.", + ) + + if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint: + _remember_nonce(conn, request) + return _error(409, "not_active", "Diese Lizenz ist auf dieser Maschine nicht aktiv.") + + conn.execute( + "UPDATE licenses SET last_heartbeat_at=?, hostname=COALESCE(NULLIF(?, ''), hostname) " + "WHERE license_id=?", + (_now(), hostname, str(row["license_id"])), + ) + _remember_nonce(conn, request) + + report = request.get("report") + if report is not None: + from .reporting import ingest_report + + # Nur Lizenzen, die in **dieser** Datenbank als Lizenzserver eingetragen + # sind, duerfen berichten. Was der Client behauptet, ist unerheblich. + if str(row["role"]) == "license_server": + ingested = ingest_report(conn, str(row["license_id"]), report) + audit.log( + conn, + actor="api", + action="license.report_ingested", + target=str(row["license_id"]), + detail=( + f"{ingested['customers']} Kunden, {ingested['tickets']} Tickets, " + f"{ingested['licenses']} Lizenzen" + ), + ) + else: + logger.warning( + "Bericht von Lizenz %s verworfen -- Rolle ist %s, nicht license_server", + row["license_id"], + row["role"], + ) + + # Nachtraegliche Aenderungen (z. B. Modulerweiterung) erreichen den Client + # ueber den Heartbeat -- ohne erneute Aktivierung. + document = json.loads(str(row["license_document"])) + client_seq = int(request.get("seq") or 0) + update = document if int(document.get("seq") or 1) > client_seq else None + + return _signed_response( + conn, + request, + master_private_key=master_private_key, + status="ok", + detail="Verbindung bestaetigt.", + license_update=update, + ) + + +PROCESSORS = { + "activate": process_activate, + "deactivate": process_deactivate, + "heartbeat": process_heartbeat, +} + + +def process( + conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str +) -> Outcome: + """Verteilt anhand des ``action``-Feldes -- fuer den Offline-Code-Weg.""" + action = str((request or {}).get("action") or "") + processor = PROCESSORS.get(action) + if processor is None: + return _error(400, "bad_action", "Unbekannte Aktion.") + return processor(conn, request, master_private_key=master_private_key) diff --git a/apps/tesm-license/src/tesm_license/services/issuance.py b/apps/tesm-license/src/tesm_license/services/issuance.py new file mode 100644 index 0000000..bc23e79 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/services/issuance.py @@ -0,0 +1,765 @@ +"""Kunden, Tickets und die Ausstellung von Lizenzen.""" + +from __future__ import annotations + +import json +import re +import secrets +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Iterable, Sequence + +import tesm_licensing as lic +from tesm_core.db import Database + +TICKET_TYPES = lic.LICENSE_TYPES +TICKET_STATES = ("open", "blocked") +LICENSE_STATES = ("issued", "active", "deactivated", "revoked") + +#: Ampel-Schwellen der Anzeige. Bewusst getrennt von der Kulanzlogik in +#: ``tesm_licensing`` -- gleiche Zahl (30), anderes Konzept. +GREEN_ABOVE_DAYS = 90 +RED_BELOW_DAYS = 30 + +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +class IssuanceError(Exception): + """Eingabe- oder Regelverstoss mit Klartextmeldung.""" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +# --------------------------------------------------------------------------- # +# Kunden +# --------------------------------------------------------------------------- # + + +def validate_email(value: str, *, required: bool) -> str: + text = (value or "").strip() + if not text: + if required: + raise IssuanceError("Eine E-Mail-Adresse ist erforderlich.") + # Leer bleibt erlaubt: Altkunden von vor der Pflichtfeld-Einfuehrung + # sollen weiter bearbeitbar bleiben, statt ausgesperrt zu werden. + return "" + if not EMAIL_RE.match(text): + raise IssuanceError(f"{text!r} ist keine gueltige E-Mail-Adresse.") + return text + + +def list_customers(conn: sqlite3.Connection) -> list[dict[str, Any]]: + return Database.all( + conn, + """ + SELECT c.*, + (SELECT COUNT(*) FROM license_tickets t WHERE t.customer_id = c.id) AS ticket_count, + (SELECT COUNT(*) FROM licenses l + WHERE l.customer_id = c.id AND l.status IN ('issued','active')) AS open_licenses, + (SELECT l.license_id FROM licenses l + WHERE l.customer_id = c.id AND l.status='active' AND l.role='license_server' + LIMIT 1) AS license_server_id + FROM license_customers c + ORDER BY c.name COLLATE NOCASE + """, + ) + + +def get_customer(conn: sqlite3.Connection, customer_id: int) -> dict[str, Any] | None: + return Database.one(conn, "SELECT * FROM license_customers WHERE id=?", (customer_id,)) + + +def create_customer(conn: sqlite3.Connection, *, actor: str, **fields: Any) -> int: + name = str(fields.get("name") or "").strip() + if not name: + raise IssuanceError("Der Firmenname darf nicht leer sein.") + email = validate_email(str(fields.get("contact_email") or ""), required=True) + cursor = conn.execute( + "INSERT INTO license_customers (name, contact_email, contact_phone, contact_person, " + "street, house_number, postal_code, city, notes, created_at, updated_at, created_by) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + ( + name, + email, + str(fields.get("contact_phone") or "").strip(), + str(fields.get("contact_person") or "").strip(), + str(fields.get("street") or "").strip(), + str(fields.get("house_number") or "").strip(), + str(fields.get("postal_code") or "").strip(), + str(fields.get("city") or "").strip(), + str(fields.get("notes") or "").strip(), + _now(), + _now(), + actor, + ), + ) + return int(cursor.lastrowid or 0) + + +def update_customer(conn: sqlite3.Connection, customer_id: int, **fields: Any) -> list[str]: + row = get_customer(conn, customer_id) + if row is None: + raise IssuanceError("Dieser Kunde existiert nicht.") + name = str(fields.get("name") or "").strip() + if not name: + raise IssuanceError("Der Firmenname darf nicht leer sein.") + email = validate_email(str(fields.get("contact_email") or ""), required=False) + + columns = { + "name": name, + "contact_email": email, + "contact_phone": str(fields.get("contact_phone") or "").strip(), + "contact_person": str(fields.get("contact_person") or "").strip(), + "street": str(fields.get("street") or "").strip(), + "house_number": str(fields.get("house_number") or "").strip(), + "postal_code": str(fields.get("postal_code") or "").strip(), + "city": str(fields.get("city") or "").strip(), + "notes": str(fields.get("notes") or "").strip(), + } + changes = [key for key, value in columns.items() if str(row.get(key) or "") != value] + if not changes: + return [] + assignments = ", ".join(f"{key}=?" for key in columns) + conn.execute( + f"UPDATE license_customers SET {assignments}, updated_at=? WHERE id=?", + (*columns.values(), _now(), customer_id), + ) + return changes + + +def delete_customer(conn: sqlite3.Connection, customer_id: int) -> None: + tickets = Database.value( + conn, "SELECT COUNT(*) FROM license_tickets WHERE customer_id=?", (customer_id,), default=0 + ) + if int(tickets or 0): + raise IssuanceError( + f"An diesem Kunden haengen noch {tickets} Ticket(s). Bitte zuerst entfernen." + ) + conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,)) + + +def find_customer_by_email(conn: sqlite3.Connection, email: str) -> dict[str, Any] | None: + return Database.one( + conn, + "SELECT * FROM license_customers WHERE contact_email <> '' " + "AND lower(contact_email)=lower(?)", + ((email or "").strip(),), + ) + + +# --------------------------------------------------------------------------- # +# Tickets +# --------------------------------------------------------------------------- # + + +def list_tickets( + conn: sqlite3.Connection, *, customer_id: int | None = None +) -> list[dict[str, Any]]: + clause = "WHERE t.customer_id = ?" if customer_id else "" + params = (customer_id,) if customer_id else () + return Database.all( + conn, + f""" + SELECT t.*, c.name AS customer_name, c.contact_email, + (SELECT COUNT(*) FROM licenses l WHERE l.ticket_id = t.id) AS license_count, + (SELECT l.license_id FROM licenses l + WHERE l.ticket_id = t.id AND l.status IN ('issued','active') LIMIT 1) AS open_license_id + FROM license_tickets t + JOIN license_customers c ON c.id = t.customer_id + {clause} + ORDER BY t.created_at DESC + """, + params, + ) + + +def get_ticket(conn: sqlite3.Connection, ticket_id: str) -> dict[str, Any] | None: + return Database.one( + conn, + "SELECT t.*, c.name AS customer_name, c.contact_email, c.contact_person " + "FROM license_tickets t JOIN license_customers c ON c.id = t.customer_id WHERE t.id=?", + (ticket_id,), + ) + + +def normalize_modules(license_type: str, role: str, modules: Iterable[str]) -> list[str]: + """Bestimmt den Modulsatz eines Tickets. + + Regeln (bewusst hier zentral, nicht in der Oberflaeche): + * ``role != "customer"``: immer leer -- das TESM-Modulkonzept ist fuer einen + Lizenzserver bedeutungslos. + * ``trial``/``standard``: leer (Trial bekommt den vollen Satz erst bei der + Ausstellung als Trial-Override). + * ``enterprise``: alle Module. + * ``custom``: die uebergebene Auswahl. + """ + if role != "customer": + return [] + if license_type == "enterprise": + return sorted(lic.ALL_MODULES) + if license_type == "custom": + return sorted(set(modules) & set(lic.ALL_MODULES)) + return [] + + +def create_ticket( + conn: sqlite3.Connection, + *, + customer_id: int, + license_type: str, + modules: Sequence[str], + valid_days: int, + lifetime: bool, + role: str, + notes: str = "", + actor: str = "", +) -> str: + if get_customer(conn, customer_id) is None: + raise IssuanceError("Dieser Kunde existiert nicht.") + if license_type not in TICKET_TYPES: + raise IssuanceError("Unbekannter Lizenztyp.") + if role not in lic.LICENSE_ROLES or role == "master": + # "master" entsteht ausschliesslich lokal per Bootstrap-Skript. + raise IssuanceError("Unzulaessige Rolle.") + + # Trial + unbefristet ist ein sinnloser Zustand: eine permanente Lizenz ohne + # Module. Diese Regel wird an drei Stellen geprueft (hier, beim Bearbeiten + # und unmittelbar vor dem Signieren). + if lifetime and license_type == "trial": + raise IssuanceError("Eine Testlizenz kann nicht unbefristet sein.") + if not lifetime and int(valid_days) <= 0: + raise IssuanceError("Die Laufzeit muss positiv sein.") + + ticket_id = secrets.token_urlsafe(24) + conn.execute( + "INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, " + "status, role, notes, created_by, created_at, updated_at) " + "VALUES (?,?,?,?,?,?,'open',?,?,?,?,?)", + ( + ticket_id, + customer_id, + license_type, + json.dumps(normalize_modules(license_type, role, modules)), + int(valid_days), + int(bool(lifetime)), + role, + (notes or "").strip(), + actor, + _now(), + _now(), + ), + ) + return ticket_id + + +def update_ticket( + conn: sqlite3.Connection, + ticket_id: str, + *, + license_type: str, + modules: Sequence[str], + valid_days: int, + lifetime: bool, + notes: str = "", +) -> list[str]: + """Aendert die Parameter eines Tickets. + + Wirkt ausschliesslich auf die **naechste** Ausstellung. Bereits ausgestellte + Lizenzen behalten die Werte, mit denen sie signiert wurden -- eine + nachtraegliche Aenderung waere sonst eine Signaturaenderung ohne neue Datei. + + Kunde und Rolle sind nach der Anlage unveraenderlich: das Self-Service-Ticket + ist an genau diesen Kunden gebunden, und ein Rollenwechsel wuerde eine + Vertrauensgrenze verschieben. + """ + ticket = get_ticket(conn, ticket_id) + if ticket is None: + raise IssuanceError("Dieses Ticket existiert nicht.") + if license_type not in TICKET_TYPES: + raise IssuanceError("Unbekannter Lizenztyp.") + if lifetime and license_type == "trial": + raise IssuanceError("Eine Testlizenz kann nicht unbefristet sein.") + + role = str(ticket["role"]) + new_modules = json.dumps(normalize_modules(license_type, role, modules)) + changes: list[str] = [] + if license_type != str(ticket["type"]): + changes.append(f"Typ: {ticket['type']} -> {license_type}") + if new_modules != str(ticket["modules"]): + changes.append("Module geaendert") + if int(valid_days) != int(ticket["valid_days"]): + changes.append(f"Laufzeit: {ticket['valid_days']} -> {valid_days} Tage") + if bool(lifetime) != bool(ticket["lifetime"]): + changes.append(f"Unbefristet: {bool(ticket['lifetime'])} -> {bool(lifetime)}") + + conn.execute( + "UPDATE license_tickets SET type=?, modules=?, valid_days=?, lifetime=?, notes=?, " + "updated_at=? WHERE id=?", + ( + license_type, + new_modules, + int(valid_days), + int(bool(lifetime)), + (notes or "").strip(), + _now(), + ticket_id, + ), + ) + return changes + + +def set_ticket_status(conn: sqlite3.Connection, ticket_id: str, status: str) -> None: + if status not in TICKET_STATES: + raise IssuanceError("Unbekannter Ticketzustand.") + conn.execute( + "UPDATE license_tickets SET status=?, updated_at=? WHERE id=?", + (status, _now(), ticket_id), + ) + + +def ticket_has_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool: + return ( + Database.value( + conn, + "SELECT COUNT(*) FROM licenses WHERE ticket_id=? AND status IN ('issued','active')", + (ticket_id,), + default=0, + ) + or 0 + ) > 0 + + +def delete_ticket(conn: sqlite3.Connection, ticket_id: str) -> None: + if ticket_has_open_license(conn, ticket_id): + raise IssuanceError( + "Zu diesem Ticket existiert noch eine offene Lizenz. Bitte zuerst widerrufen." + ) + # Historische Lizenzzeilen haengen per ON DELETE CASCADE mit -- sonst blieben + # verwaiste Zeilen mit toter ticket_id zurueck. + conn.execute("DELETE FROM license_tickets WHERE id=?", (ticket_id,)) + + +# --------------------------------------------------------------------------- # +# Lizenzen +# --------------------------------------------------------------------------- # + + +def list_licenses( + conn: sqlite3.Connection, + *, + customer_id: int | None = None, + ticket_id: str | None = None, + status: str | None = None, +) -> list[dict[str, Any]]: + clauses: list[str] = [] + params: list[Any] = [] + if customer_id: + clauses.append("l.customer_id = ?") + params.append(customer_id) + if ticket_id: + clauses.append("l.ticket_id = ?") + params.append(ticket_id) + if status: + clauses.append("l.status = ?") + params.append(status) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + return Database.all( + conn, + f""" + SELECT l.*, c.name AS customer_name, c.contact_email + FROM licenses l JOIN license_customers c ON c.id = l.customer_id + {where} + ORDER BY l.created_at DESC + """, + tuple(params), + ) + + +def get_license(conn: sqlite3.Connection, license_id: str) -> dict[str, Any] | None: + return Database.one( + conn, + "SELECT l.*, c.name AS customer_name, c.contact_email, c.contact_person " + "FROM licenses l JOIN license_customers c ON c.id = l.customer_id WHERE l.license_id=?", + (license_id,), + ) + + +@dataclass(frozen=True, slots=True) +class IssuedLicense: + license_id: str + bundle: dict[str, Any] + document: dict[str, Any] + already_existed: bool = False + + +def client_key_aad(license_id: str) -> str: + return f"licenses.client_key:{license_id}" + + +def issue_from_ticket( + conn: sqlite3.Connection, + ticket_id: str, + *, + master_private_key: str, + master_public_key: str, + master_endpoint: str, + vendor: dict[str, Any], + actor: str, + keystore: Any, + as_trial: bool = False, +) -> IssuedLicense: + """Stellt aus einem Ticket eine Lizenz aus. + + ``as_trial`` ist ein reiner **Ausstellungs-Override**: 30 Tage, alle Module, + Typ ``trial``. Das Ticket selbst bleibt dabei unveraendert -- nur diese eine + Datei traegt Trial-Konditionen. Fuer Tickets mit einer anderen Rolle als + ``customer`` ist der Override sinnlos und wird abgewiesen. + """ + ticket = get_ticket(conn, ticket_id) + if ticket is None: + raise IssuanceError("Dieses Ticket existiert nicht.") + if str(ticket["status"]) != "open": + raise IssuanceError("Dieses Ticket ist gesperrt.") + + role = str(ticket["role"]) + if as_trial and role != "customer": + raise IssuanceError( + "Eine Testlizenz gibt es nur fuer Kundeninstanzen, nicht fuer Lizenzserver." + ) + + if as_trial: + license_type = "trial" + modules = sorted(lic.ALL_MODULES) + valid_days = lic.TRIAL_DEFAULT_DAYS + lifetime = False + else: + license_type = str(ticket["type"]) + modules = json.loads(str(ticket["modules"] or "[]")) + valid_days = int(ticket["valid_days"]) + lifetime = bool(ticket["lifetime"]) + + # Dritte Absicherung derselben Regel, unmittelbar vor dem Signieren. + if lifetime and license_type == "trial": + raise IssuanceError("Eine Testlizenz kann nicht unbefristet sein.") + + # Der Endpunkt wird mitsigniert. Ohne ihn entsteht eine Lizenz, die sich + # nie online aktivieren laesst -- und das laesst sich nachtraeglich nur + # durch eine neue Signatur beheben, nicht durch eine Einstellung. Deshalb + # hier, an der einzigen Stelle, durch die jede Ausstellung laeuft. + if not str(master_endpoint or "").strip(): + raise IssuanceError( + "Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). " + "Ohne ihn koennte sich diese Lizenz nie online aktivieren -- der Endpunkt " + "wird in die Lizenz signiert und laesst sich spaeter nicht nachtragen." + ) + + customer = get_customer(conn, int(ticket["customer_id"])) + assert customer is not None + bundle, license_pub = lic.issue_license( + customer={ + "name": str(customer["name"]), + "contact_email": str(customer["contact_email"] or ""), + "customer_ref": str(customer["id"]), + }, + license_type=license_type, + modules=modules, + valid_days=valid_days, + master_private_key_b64=master_private_key, + master_public_key_b64=master_public_key, + master_endpoint=master_endpoint, + vendor=vendor, + role=role, + lifetime=lifetime, + ) + document = bundle["license"] + + try: + conn.execute( + "INSERT INTO licenses (license_id, ticket_id, customer_id, type, role, modules, " + "lifetime, seq, issued_at, expires_at, license_pubkey, license_document, " + "client_key_encrypted, status, created_by, created_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,'issued',?,?)", + ( + document["license_id"], + ticket_id, + int(ticket["customer_id"]), + license_type, + role, + json.dumps(modules), + int(bool(lifetime)), + int(document["seq"]), + document["issued_at"], + document["expires_at"], + license_pub, + json.dumps(document), + keystore.encrypt( + str(bundle["client_key"]), aad=client_key_aad(str(document["license_id"])) + ), + actor, + _now(), + ), + ) + except sqlite3.IntegrityError: + # Der partielle Unique-Index hat zugeschlagen: ein zweiter Klick oder ein + # zweiter Tab war schneller. Wir geben die tatsaechlich erzeugte Lizenz + # zurueck statt einen Fehler zu zeigen. + existing = Database.one( + conn, + "SELECT license_id, license_document FROM licenses " + "WHERE ticket_id=? AND status IN ('issued','active')", + (ticket_id,), + ) + if existing is None: + raise + return IssuedLicense( + license_id=str(existing["license_id"]), + bundle={}, + document=json.loads(str(existing["license_document"])), + already_existed=True, + ) + + return IssuedLicense( + license_id=str(document["license_id"]), bundle=bundle, document=document + ) + + +def delivery_bundle(conn: sqlite3.Connection, license_id: str, *, keystore: Any) -> dict[str, Any]: + """Baut das Auslieferungs-Bundle inklusive Client-Schluessel neu auf. + + Der Schluessel liegt verschluesselt in der Datenbank; hier wird er genau + fuer die Auslieferung entschluesselt. Fehlt er (Lizenz aus einer aelteren + Version), enthaelt das Bundle nur das Dokument -- der Kunde braucht dann eine + neu ausgestellte Lizenz. + """ + row = get_license(conn, license_id) + if row is None: + raise IssuanceError("Diese Lizenz existiert nicht.") + document = json.loads(str(row["license_document"])) + bundle: dict[str, Any] = { + "kind": "tesm-license-bundle", + "fmt": lic.FORMAT_VERSION, + "license": document, + } + encrypted = str(row["client_key_encrypted"] or "") + if encrypted: + try: + bundle["client_key"] = keystore.decrypt(encrypted, aad=client_key_aad(license_id)) + except Exception as exc: # noqa: BLE001 + raise IssuanceError( + "Der Client-Schluessel dieser Lizenz konnte nicht entschluesselt werden. " + "Bitte eine neue Lizenz ausstellen." + ) from exc + return bundle + + +def revoke_license(conn: sqlite3.Connection, license_id: str, *, reason: str = "") -> None: + """Widerruft eine Lizenz. + + Das Ticket bleibt bewusst offen: ein Widerruf ist Routine (Systemwechsel, + Neuinstallation), keine Sperre. Wer den Kunden aussperren will, sperrt das + Ticket. + """ + row = get_license(conn, license_id) + if row is None: + raise IssuanceError("Diese Lizenz existiert nicht.") + if str(row["status"]) == "revoked": + raise IssuanceError("Diese Lizenz ist bereits widerrufen.") + conn.execute( + "UPDATE licenses SET status='revoked', revoked_at=?, revoke_reason=? WHERE license_id=?", + (_now(), (reason or "").strip()[:400], license_id), + ) + + +def delete_license(conn: sqlite3.Connection, license_id: str) -> None: + row = get_license(conn, license_id) + if row is None: + raise IssuanceError("Diese Lizenz existiert nicht.") + if str(row["status"]) not in {"revoked", "issued"}: + raise IssuanceError( + "Eine aktive Lizenz kann nicht geloescht werden. Bitte zuerst widerrufen." + ) + conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,)) + + +def resign_license( + conn: sqlite3.Connection, + license_id: str, + *, + master_private_key: str, + changes: dict[str, Any], +) -> dict[str, Any]: + """Signiert eine neue Revision -- z. B. nach einer Modulerweiterung. + + Der Client erhaelt sie beim naechsten Heartbeat. ``seq`` steigt dabei; eine + aeltere Revision kann ihm damit nicht mehr eingespielt werden. + """ + row = get_license(conn, license_id) + if row is None: + raise IssuanceError("Diese Lizenz existiert nicht.") + document = json.loads(str(row["license_document"])) + updated = lic.resign_license( + document, master_private_key_b64=master_private_key, changes=changes + ) + conn.execute( + "UPDATE licenses SET license_document=?, seq=?, type=?, modules=?, lifetime=?, " + "expires_at=?, revision_at=? WHERE license_id=?", + ( + json.dumps(updated), + int(updated["seq"]), + str(updated["type"]), + json.dumps(updated["modules"]), + int(bool(updated["lifetime"])), + str(updated["expires_at"]), + _now(), + license_id, + ), + ) + return updated + + +def record_delivery( + conn: sqlite3.Connection, + *, + license_id: str, + channel: str, + recipient: str, + actor: str, + detail: str = "", +) -> None: + conn.execute( + "INSERT INTO license_deliveries (license_id, channel, recipient, delivered_at, " + "delivered_by, detail) VALUES (?,?,?,?,?,?)", + (license_id, channel, recipient, _now(), actor, detail[:400]), + ) + + +# --------------------------------------------------------------------------- # +# Anzeige +# --------------------------------------------------------------------------- # + + +def revision_pending(row: Any) -> bool: + """Wurde seit der letzten Revision noch kein Kontakt vom Client gesehen? + + Widerrufene Lizenzen zaehlen nicht -- dort ist nichts mehr abzuholen. + """ + def field(name: str) -> Any: + # Die Zeile kommt aus verschiedenen Abfragen; nicht jede waehlt alles. + # Eine fehlende Spalte darf keine Ausnahme werfen -- die Funktion sitzt + # in Listen, Detailseiten und im Kundenportal. + try: + return row[name] + except (KeyError, IndexError, TypeError): + return None + + revision_at = field("revision_at") + if not revision_at or str(field("status") or "") == "revoked": + return False + last = field("last_heartbeat_at") or field("activated_at") + if not last: + return True + # Beide Zeitstempel stammen aus ``_now()`` und sind damit gleich formatiert. + return str(last) < str(revision_at) + + +def display_state(row: dict[str, Any], *, now: datetime | None = None) -> dict[str, Any]: + """Ampelfarbe und Resttage einer Lizenzzeile. + + Genau eine Stelle im Code, von jeder Oberflaeche benutzt (Dashboard, + Listen, Detailseiten, Self-Service, Kundenportal, gemeldete Bestaende der + Sub-Lizenzserver). + + Grenzen: mehr als 90 Tage = gruen, 30 bis 90 Tage = orange, unter 30 Tage + (auch abgelaufen) = rot. Unbefristete Lizenzen sind immer gruen. + """ + moment = now or datetime.now(timezone.utc) + result = { + "color": "green", + "days_left": None, + "is_expired": False, + "lifetime": bool(row.get("lifetime")), + # Kein eigener Status: eine Lizenz kann aktiv sein *und* eine noch + # nicht abgeholte Revision haben. + "revision_pending": revision_pending(row), + } + if row.get("lifetime"): + return result + raw = row.get("expires_at") + if not raw: + return {**result, "color": "red"} + try: + expires = lic.parse_iso(str(raw)) + except ValueError: + # Fail-safe: unlesbares Datum gilt als kritisch, nicht als in Ordnung. + return {**result, "color": "red"} + + delta = expires - moment + days_left = int(delta.total_seconds() // 86400) + expired = delta.total_seconds() <= 0 + if expired: + color = "red" + elif days_left < RED_BELOW_DAYS: + color = "red" + elif days_left <= GREEN_ABOVE_DAYS: + color = "orange" + else: + color = "green" + return { + **result, + "color": color, + "days_left": max(0, days_left), + "is_expired": expired, + "lifetime": False, + } + + +def dashboard_summary(conn: sqlite3.Connection) -> dict[str, Any]: + rows = list_licenses(conn) + buckets: dict[str, list[dict[str, Any]]] = {"red": [], "orange": [], "green": []} + for row in rows: + if str(row["status"]) in {"revoked", "deactivated"}: + continue + state = display_state(row) + entry = {**row, **state} + buckets[state["color"]].append(entry) + return { + "buckets": buckets, + "counts": { + "customers": int( + Database.value(conn, "SELECT COUNT(*) FROM license_customers", default=0) or 0 + ), + "tickets_open": int( + Database.value( + conn, "SELECT COUNT(*) FROM license_tickets WHERE status='open'", default=0 + ) + or 0 + ), + "tickets_blocked": int( + Database.value( + conn, "SELECT COUNT(*) FROM license_tickets WHERE status='blocked'", default=0 + ) + or 0 + ), + "licenses_active": int( + Database.value( + conn, "SELECT COUNT(*) FROM licenses WHERE status='active'", default=0 + ) + or 0 + ), + "licenses_issued": int( + Database.value( + conn, "SELECT COUNT(*) FROM licenses WHERE status='issued'", default=0 + ) + or 0 + ), + "licenses_revoked": int( + Database.value( + conn, "SELECT COUNT(*) FROM licenses WHERE status='revoked'", default=0 + ) + or 0 + ), + }, + } diff --git a/apps/tesm-license/src/tesm_license/services/legacy_import.py b/apps/tesm-license/src/tesm_license/services/legacy_import.py new file mode 100644 index 0000000..a88420a --- /dev/null +++ b/apps/tesm-license/src/tesm_license/services/legacy_import.py @@ -0,0 +1,354 @@ +"""Uebernahme eines Lizenzservers der Vorgaengerfassung. + +Die alten Lizenzdateien sind mit der neuen Fassung **nicht** verwendbar, und +das ist Absicht: dort wurde ohne Domaenentrennung signiert (die Signatur ueber +eine Lizenz war damit auch als Signatur ueber eine Protokollantwort gueltig), +es fehlten Format-Version, Sequenznummer und Gueltigkeitsbeginn. Beides +gleichzeitig zu akzeptieren hiesse, die Haertung wieder aufzugeben -- eine +Uebergangsfrist waere hier eine dauerhaft offene Tuer. + +Was **sehr wohl** uebernommen wird, ist alles andere: + +* **Der Signaturschluessel.** Damit bleibt die Anbieteridentitaet dieselbe. Bei + Kunden, die den Aussteller bereits gepinnt haben, passt der Schluessel + weiterhin -- sie muessen keinen Ausstellerwechsel bestaetigen. +* **Kunden, Tickets und Lizenzen** aus der alten Datenbank. + +Jede uebernommene Lizenz wird im neuen Format **neu signiert**. Der Kunde +bekommt dadurch eine neue Lizenzdatei; das laesst sich nicht vermeiden, denn +die alte traegt eine Signatur, die es in dieser Form nicht mehr gibt. Der +Bericht am Ende sagt genau, wer eine neue Datei braucht. + +Der Vorgang ist **lesend** gegenueber der alten Installation. Sie wird nicht +angefasst, nicht angehalten und nicht veraendert. +""" + +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import tesm_licensing as lic +from tesm_core.db import Database +from tesm_core.keystore import Keystore + +from . import issuance + + +class LegacyImportError(Exception): + """Die alte Installation ist nicht lesbar oder passt nicht.""" + + +@dataclass +class Report: + """Was die Uebernahme getan hat -- vollstaendig und nachpruefbar.""" + + key_adopted: bool = False + key_id: str = "" + customers: int = 0 + tickets: int = 0 + licenses: int = 0 + reissued: list[str] = field(default_factory=list) + skipped: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + def as_text(self) -> str: + lines = [ + f"Signaturschluessel uebernommen: {'ja' if self.key_adopted else 'nein'}" + + (f" ({self.key_id})" if self.key_id else ""), + f"Kunden : {self.customers}", + f"Tickets : {self.tickets}", + f"Lizenzen : {self.licenses}", + ] + if self.reissued: + lines.append("") + lines.append("Neu signiert -- diese Kunden brauchen eine neue Lizenzdatei:") + lines.extend(f" * {entry}" for entry in self.reissued) + if self.skipped: + lines.append("") + lines.append("Uebersprungen:") + lines.extend(f" * {entry}" for entry in self.skipped) + if self.warnings: + lines.append("") + lines.append("Hinweise:") + lines.extend(f" * {entry}" for entry in self.warnings) + return "\n".join(lines) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +# --------------------------------------------------------------------------- # +# Quelle +# --------------------------------------------------------------------------- # + + +def open_legacy(root: Path) -> sqlite3.Connection: + """Oeffnet die alte Datenbank **nur lesend**.""" + path = Path(root) / "sqlite.db" + if not path.is_file(): + raise LegacyImportError(f"Unter {path} liegt keine Datenbank der Vorgaengerfassung.") + try: + conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + except sqlite3.Error as exc: + raise LegacyImportError(f"Die alte Datenbank ist nicht lesbar: {exc}") from exc + conn.row_factory = sqlite3.Row + + tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + missing = {"license_customers", "license_tickets", "licenses"} - tables + if missing: + conn.close() + raise LegacyImportError( + f"Das sieht nicht nach einem Lizenzserver aus -- es fehlen: {', '.join(sorted(missing))}" + ) + return conn + + +def read_legacy_key(root: Path) -> tuple[str, str]: + """Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``.""" + path = Path(root) / "master_signing_key.json" + if not path.is_file(): + raise LegacyImportError(f"Der alte Signaturschluessel {path} fehlt.") + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc + + # Die Vorgaengerfassung nannte die Felder "private"/"public". + private = str(data.get("private") or data.get("private_key") or "") + public = str(data.get("public") or data.get("public_key") or "") + if not private or not public: + raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.") + if lic.public_from_private(private) != public: + raise LegacyImportError( + "Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht " + "zueinander. Die Datei ist beschaedigt." + ) + return private, public + + +def adopt_key(private: str, public: str, target: Path) -> str: + """Uebernimmt den alten Schluessel in das Format der neuen Fassung. + + Damit bleibt die Anbieteridentitaet erhalten: Kunden, die den Aussteller + schon gepinnt haben, sehen weiterhin denselben Schluessel und muessen + keinen Wechsel bestaetigen. + """ + from tesm_core.keystore import write_private_file + + if Path(target).is_file(): + raise LegacyImportError( + f"Unter {target} liegt bereits ein Signaturschluessel. Er wird nicht " + "ueberschrieben -- das wuerde jede damit ausgestellte Lizenz unbrauchbar " + "machen. Fuer eine Uebernahme bitte eine frische Installation verwenden." + ) + payload = { + "private_key": private, + "public_key": public, + "key_id": lic.key_id(public), + "created_at": _now(), + "origin": "Uebernommen aus der Vorgaengerfassung.", + } + write_private_file(Path(target), json.dumps(payload, indent=2, sort_keys=True) + "\n") + return lic.key_id(public) + + +# --------------------------------------------------------------------------- # +# Uebernahme +# --------------------------------------------------------------------------- # + + +def _remaining_days(expires_at: str) -> int: + """Restlaufzeit in Tagen, mindestens 1 bei noch gueltiger Lizenz. + + Die neue Ausstellung rechnet in Tagen ab jetzt. Wer noch drei Monate + hatte, soll auch nach der Uebernahme drei Monate haben -- nicht ploetzlich + ein volles Jahr und nicht weniger als vorher. + """ + try: + moment = lic.parse_iso(expires_at) + except (ValueError, TypeError): + return 0 + remaining = (moment - datetime.now(timezone.utc)).days + return remaining if remaining > 0 else 0 + + +def _modules(raw: Any) -> list[str]: + try: + values = json.loads(str(raw or "[]")) + except ValueError: + return [] + return sorted(set(str(value) for value in values) & set(lic.ALL_MODULES)) + + +def import_all( + legacy: sqlite3.Connection, + conn: sqlite3.Connection, + *, + keystore: Keystore, + master_private_key: str, + master_public_key: str, + master_endpoint: str, + vendor: dict[str, Any], + actor: str = "migration", + reissue: bool = True, +) -> Report: + """Uebernimmt Kunden, Tickets und Lizenzen. Erwartet eine offene Transaktion.""" + report = Report() + + # -- Kunden ------------------------------------------------------------ # + customer_map: dict[int, int] = {} + for row in legacy.execute("SELECT * FROM license_customers ORDER BY id"): + name = str(row["name"] or "").strip() + if not name: + report.skipped.append(f"Kunde ohne Namen (alte Nummer {row['id']})") + continue + existing = Database.one( + conn, "SELECT id FROM license_customers WHERE name=? COLLATE NOCASE", (name,) + ) + if existing is not None: + customer_map[int(row["id"])] = int(existing["id"]) + report.warnings.append(f"Kunde {name!r} war schon vorhanden -- nicht doppelt angelegt.") + continue + new_id = issuance.create_customer( + conn, + actor=actor, + name=name, + contact_email=str(row["contact_email"] or ""), + contact_phone=str(row["contact_phone"] or ""), + contact_person=str(row["contact_person"] or ""), + street=str(row["street"] or ""), + house_number=str(row["house_number"] or ""), + postal_code=str(row["postal_code"] or ""), + city=str(row["city"] or ""), + notes=str(row["notes"] or ""), + ) + customer_map[int(row["id"])] = new_id + report.customers += 1 + + # -- Tickets ----------------------------------------------------------- # + for row in legacy.execute("SELECT * FROM license_tickets ORDER BY created_at"): + customer_id = customer_map.get(int(row["customer_id"] or 0)) + if customer_id is None: + report.skipped.append(f"Ticket {str(row['id'])[:12]} -- Kunde nicht uebernommen") + continue + if Database.one(conn, "SELECT id FROM license_tickets WHERE id=?", (row["id"],)): + continue + conn.execute( + "INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, " + "status, role, notes, created_by, created_at, updated_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + ( + str(row["id"]), + customer_id, + str(row["type"] or "standard"), + json.dumps(_modules(row["modules"])), + int(row["valid_days"] or 365), + int(row["lifetime"] or 0), + str(row["status"] or "open"), + str(row["role"] or "customer"), + "Aus der Vorgaengerfassung uebernommen.", + str(row["created_by"] or actor), + str(row["created_at"] or _now()), + _now(), + ), + ) + report.tickets += 1 + + # -- Lizenzen ---------------------------------------------------------- # + for row in legacy.execute("SELECT * FROM licenses ORDER BY created_at"): + license_id = str(row["license_id"] or "") + customer_id = customer_map.get(int(row["customer_id"] or 0)) + if not license_id or customer_id is None: + report.skipped.append(f"Lizenz {license_id[:12] or '?'} -- Kunde nicht uebernommen") + continue + if Database.one(conn, "SELECT id FROM licenses WHERE license_id=?", (license_id,)): + continue + + customer = issuance.get_customer(conn, customer_id) + status = str(row["status"] or "issued") + modules = _modules(row["modules"]) + + if not reissue: + report.skipped.append(f"{customer['name']} / {license_id[:12]} -- nur gezaehlt") + report.licenses += 1 + continue + + # Neu ausstellen: gleiche Kennung, gleiche Eckdaten, neues Format und + # ein frisches Clientschluesselpaar. Das alte Paar gehoert zu einer + # Signatur, die es nicht mehr gibt. + lifetime = bool(row["lifetime"]) + valid_days = _remaining_days(str(row["expires_at"] or "")) + if not lifetime and valid_days <= 0: + report.skipped.append( + f"{customer['name']} / {license_id[:12]} -- abgelaufen am " + f"{str(row['expires_at'])[:10]}, nicht uebernommen" + ) + continue + + bundle, license_public = lic.issue_license( + customer={ + "name": str(customer["name"]), + "contact_email": str(customer["contact_email"] or ""), + "customer_ref": str(customer["id"]), + }, + license_type=str(row["type"] or "standard"), + modules=modules, + valid_days=valid_days, + master_private_key_b64=master_private_key, + master_public_key_b64=master_public_key, + master_endpoint=master_endpoint, + vendor=vendor, + license_id=license_id, + role=str(row["role"] or "customer"), + lifetime=lifetime, + ) + document = bundle["license"] + + conn.execute( + "INSERT INTO licenses (license_id, ticket_id, customer_id, type, role, modules, " + "lifetime, seq, issued_at, expires_at, license_pubkey, license_document, " + "client_key_encrypted, status, created_by, created_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + license_id, + str(row["ticket_id"] or ""), + customer_id, + str(row["type"] or "standard"), + str(row["role"] or "customer"), + json.dumps(modules), + int(lifetime), + int(document["seq"]), + document["issued_at"], + document["expires_at"], + license_public, + json.dumps(document), + keystore.encrypt( + str(bundle["client_key"]), aad=issuance.client_key_aad(license_id) + ), + # Kein Zustand, der eine Maschine nennt: die alte Bindung gilt + # fuer die alte Datei, nicht fuer diese. + "revoked" if status == "revoked" else "issued", + str(row["created_by"] or actor), + str(row["created_at"] or _now()), + ), + ) + report.licenses += 1 + if status != "revoked": + report.reissued.append( + f"{customer['name']} -- {license_id} " + f"({'aktiv gewesen' if status == 'active' else status})" + ) + + if report.reissued: + report.warnings.append( + "Die neu signierten Lizenzen sind noch nicht aktiviert: die Bindung an eine " + "Maschine gilt nur fuer die alte Datei. Nach dem Einspielen aktiviert der " + "Kunde erneut." + ) + return report diff --git a/apps/tesm-license/src/tesm_license/services/mail.py b/apps/tesm-license/src/tesm_license/services/mail.py new file mode 100644 index 0000000..4b4f65b --- /dev/null +++ b/apps/tesm-license/src/tesm_license/services/mail.py @@ -0,0 +1,213 @@ +"""E-Mail-Versand ueber die Microsoft-Graph-API (App-only). + +Kein SMTP und keine zusaetzliche Abhaengigkeit: der Client-Credentials-Flow ist +ein Token-Abruf und ein POST -- das geht mit ``urllib`` aus der +Standardbibliothek. Eine Bibliothek wie ``msal`` waere hier reiner Ballast. + +Das Client-Secret liegt AES-GCM-verschluesselt in ``service_accounts``, an den +Zweck gebunden. Der Vorgaenger hielt es unverschluesselt in der +``settings``-Tabelle. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import sqlite3 +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +from tesm_core.db import Database +from tesm_core.extension import core +from tesm_core.settings_store import SettingSpec + +logger = logging.getLogger(__name__) + +SERVICE_PURPOSE = "graph_client_secret" +AAD_GRAPH_SECRET = "service_accounts.graph_client_secret" + +TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" +SEND_URL = "https://graph.microsoft.com/v1.0/users/{sender}/sendMail" +SCOPE = "https://graph.microsoft.com/.default" + +SETTINGS: tuple[SettingSpec, ...] = ( + SettingSpec("graph_enabled", False, "bool", "E-Mail-Versand aktiv", section="mail"), + SettingSpec("graph_tenant_id", "", "str", "Verzeichnis-ID (Tenant)", section="mail"), + SettingSpec("graph_client_id", "", "str", "Anwendungs-ID (Client)", section="mail"), + SettingSpec("graph_sender", "", "str", "Absender-Postfach", section="mail"), + SettingSpec( + "graph_reply_to", "", "str", "Antwortadresse (optional)", section="mail" + ), +) + +_token_cache: dict[str, tuple[str, float]] = {} + + +class MailError(Exception): + """Versandfehler mit Klartextmeldung.""" + + +@dataclass(frozen=True, slots=True) +class Attachment: + name: str + content: bytes + content_type: str = "application/json" + + +def store_secret(conn: sqlite3.Connection, secret: str | None) -> None: + 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 secret: + encrypted = core().keystore.encrypt(secret, aad=AAD_GRAPH_SECRET) + conn.execute( + "INSERT INTO service_accounts (purpose, username, password_encrypted, updated_at) " + "VALUES (?,'',?,?) ON CONFLICT(purpose) DO UPDATE SET " + "password_encrypted=excluded.password_encrypted, updated_at=excluded.updated_at", + (SERVICE_PURPOSE, encrypted, datetime.now(timezone.utc).isoformat(timespec="seconds")), + ) + + +def has_secret(conn: sqlite3.Connection) -> bool: + value = Database.value( + conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,) + ) + return bool(value) + + +def _secret(conn: sqlite3.Connection) -> str: + value = Database.value( + conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,) + ) + if not value: + raise MailError("Es ist kein Client-Secret hinterlegt.") + try: + return core().keystore.decrypt(str(value), aad=AAD_GRAPH_SECRET) + except Exception as exc: # noqa: BLE001 + raise MailError( + "Das Client-Secret konnte nicht entschluesselt werden -- bitte neu eintragen." + ) from exc + + +def _config(conn: sqlite3.Connection) -> dict[str, Any]: + return core().settings.get_many(conn, [spec.key for spec in SETTINGS]) + + +def is_enabled(conn: sqlite3.Connection) -> bool: + config = _config(conn) + return bool( + config.get("graph_enabled") + and config.get("graph_tenant_id") + and config.get("graph_client_id") + and config.get("graph_sender") + and has_secret(conn) + ) + + +def _post(url: str, data: bytes, headers: dict[str, str], *, timeout: int = 20) -> bytes: + request = urllib.request.Request(url, data=data, headers=headers, method="POST") # noqa: S310 + try: + with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 + return response.read() + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + detail = body + try: + parsed = json.loads(body) + detail = str(parsed.get("error", {}).get("message") or parsed.get("error_description") or body) + except ValueError: + pass + raise MailError(f"Graph antwortete mit HTTP {exc.code}: {detail[:400]}") from exc + except urllib.error.URLError as exc: + raise MailError(f"Graph ist nicht erreichbar: {exc.reason}") from exc + + +def fetch_token(conn: sqlite3.Connection) -> str: + config = _config(conn) + tenant = str(config["graph_tenant_id"]) + client_id = str(config["graph_client_id"]) + cache_key = f"{tenant}:{client_id}" + cached = _token_cache.get(cache_key) + if cached and cached[1] > time.time() + 60: + return cached[0] + + payload = urllib.parse.urlencode( + { + "client_id": client_id, + "client_secret": _secret(conn), + "scope": SCOPE, + "grant_type": "client_credentials", + } + ).encode("utf-8") + raw = _post( + TOKEN_URL.format(tenant=urllib.parse.quote(tenant)), + payload, + {"Content-Type": "application/x-www-form-urlencoded"}, + ) + data = json.loads(raw.decode("utf-8")) + token = str(data.get("access_token") or "") + if not token: + raise MailError("Graph hat kein Zugriffstoken geliefert.") + _token_cache[cache_key] = (token, time.time() + int(data.get("expires_in") or 3000)) + return token + + +def test_connection(conn: sqlite3.Connection) -> dict[str, Any]: + """Holt nur ein Token -- verschickt bewusst keine echte Nachricht.""" + if not is_enabled(conn): + return {"ok": False, "message": "Der E-Mail-Versand ist nicht vollstaendig konfiguriert."} + try: + fetch_token(conn) + except MailError as exc: + return {"ok": False, "message": str(exc)} + return {"ok": True, "message": "Anmeldung an Microsoft Graph erfolgreich."} + + +def send( + conn: sqlite3.Connection, + *, + to: str, + subject: str, + body: str, + attachment: Attachment | None = None, +) -> None: + if not is_enabled(conn): + raise MailError("Der E-Mail-Versand ist nicht konfiguriert.") + config = _config(conn) + message: dict[str, Any] = { + "message": { + "subject": subject, + # Reiner Text: kein HTML, damit nichts aus Kundendaten interpretiert wird. + "body": {"contentType": "Text", "content": body}, + "toRecipients": [{"emailAddress": {"address": to}}], + }, + "saveToSentItems": True, + } + reply_to = str(config.get("graph_reply_to") or "") + if reply_to: + message["message"]["replyTo"] = [{"emailAddress": {"address": reply_to}}] + if attachment is not None: + message["message"]["attachments"] = [ + { + "@odata.type": "#microsoft.graph.fileAttachment", + "name": attachment.name, + "contentType": attachment.content_type, + "contentBytes": base64.b64encode(attachment.content).decode("ascii"), + } + ] + + token = fetch_token(conn) + _post( + SEND_URL.format(sender=urllib.parse.quote(str(config["graph_sender"]))), + json.dumps(message).encode("utf-8"), + {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + timeout=30, + ) + logger.info("E-Mail an %s versendet: %s", to, subject) diff --git a/apps/tesm-license/src/tesm_license/services/master_key.py b/apps/tesm-license/src/tesm_license/services/master_key.py new file mode 100644 index 0000000..28dec98 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/services/master_key.py @@ -0,0 +1,112 @@ +"""Signaturschluessel des Lizenzservers. + +Ein Ed25519-Schluesselpaar pro Installation. Der private Teil verlaesst diese +Maschine nie und wird fuer genau zwei Dinge benutzt: + +1. Lizenzdateien signieren, +2. Antworten auf Aktivierungs-/Deaktivierungs-/Heartbeat-Anfragen signieren. + +Der oeffentliche Teil reist in jeder ausgestellten Lizenz mit -- damit kann der +Client Antworten vollstaendig offline pruefen, ohne je einen Schluessel abholen +zu muessen. + +Die Datei liegt mit Modus 0600 und ist aus dem Deployment-Abgleich +ausgeschlossen: ein Update darf sie nie ueberschreiben. Ginge sie verloren, +waere **jede** ausgestellte Lizenz nicht mehr verifizierbar. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +import tesm_licensing as lic +from tesm_core.keystore import check_private_file, write_private_file + +logger = logging.getLogger(__name__) + + +class MasterKeyError(Exception): + """Schluessel fehlt oder ist unlesbar.""" + + +@dataclass(frozen=True, slots=True) +class MasterKey: + private_key: str + public_key: str + created_at: str + + @property + def key_id(self) -> str: + return lic.key_id(self.public_key) + + +def load(path: Path) -> MasterKey: + if not path.is_file(): + raise MasterKeyError( + f"Der Signaturschluessel {path} fehlt. Ohne ihn kann keine Lizenz ausgestellt " + "werden. Erzeugen Sie ihn mit 'tesm-license-admin init-key'." + ) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise MasterKeyError(f"Der Signaturschluessel ist unlesbar: {exc}") from exc + + private = str(data.get("private_key") or "") + public = str(data.get("public_key") or "") + if not private or not public: + raise MasterKeyError("Der Signaturschluessel ist unvollstaendig.") + if lic.public_from_private(private) != public: + raise MasterKeyError( + "Der oeffentliche Teil des Signaturschluessels passt nicht zum privaten. " + "Die Datei ist beschaedigt oder wurde manipuliert." + ) + return MasterKey( + private_key=private, public_key=public, created_at=str(data.get("created_at") or "") + ) + + +def ensure(path: Path) -> MasterKey: + """Erzeugt den Schluessel beim ersten Aufruf, laedt ihn danach nur noch. + + **Niemals** neu erzeugen, wenn die Datei existiert -- das wuerde alle + bestehenden Lizenzen unbrauchbar machen. + """ + if path.is_file(): + return load(path) + private, public = lic.generate_keypair() + payload = { + "private_key": private, + "public_key": public, + "key_id": lic.key_id(public), + "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + write_private_file(path, json.dumps(payload, indent=2, sort_keys=True) + "\n") + logger.warning( + "Neuer Signaturschluessel erzeugt (%s). Diese Datei ist unwiederbringlich -- " + "bitte sofort gesichert ablegen: %s", + lic.key_id(public), + path, + ) + return load(path) + + +def health(path: Path) -> dict[str, object]: + """Zustand fuer die Diagnoseseite.""" + if not path.is_file(): + return {"present": False, "problems": ["Signaturschluessel fehlt"]} + problems = check_private_file(path) + try: + key = load(path) + except MasterKeyError as exc: + return {"present": True, "problems": [*problems, str(exc)]} + return { + "present": True, + "key_id": key.key_id, + "public_key": key.public_key, + "created_at": key.created_at, + "problems": problems, + } diff --git a/apps/tesm-license/src/tesm_license/services/monitorlog.py b/apps/tesm-license/src/tesm_license/services/monitorlog.py new file mode 100644 index 0000000..3718935 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/services/monitorlog.py @@ -0,0 +1,64 @@ +"""Laufendes Protokoll des Lizenzservers. + +Anders als TESM hat der Lizenzserver keinen Hintergrundprozess, der Telemetrie +erzeugt -- es gibt keine Geraete zu pingen. Damit die Ansicht "Laufend" +trotzdem Inhalt hat, spiegelt sie hierher denselben Vorgangsstrom, der auch im +Aenderungsprotokoll landet: Ausstellung, Aktivierung, Widerruf, Heartbeat, +Mailversand. + +Das ist ausdruecklich kein zweites Protokollkonzept, sondern eine +menschenlesbare Fortschreibung -- die belastbare Quelle bleibt ``audit_log`` +mit seiner Hash-Kette. +""" + +from __future__ import annotations + +import logging +import threading +from datetime import datetime +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class LiveLog: + 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:<12} {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 + 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]: + 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:] diff --git a/apps/tesm-license/src/tesm_license/services/reporting.py b/apps/tesm-license/src/tesm_license/services/reporting.py new file mode 100644 index 0000000..8b5883e --- /dev/null +++ b/apps/tesm-license/src/tesm_license/services/reporting.py @@ -0,0 +1,274 @@ +"""Sub-Lizenzserver-Berichte: eigenen Bestand melden, fremden aufnehmen. + +Ein Lizenzserver, der selbst mit einer ``role="license_server"``-Lizenz laeuft, +meldet seinen kompletten Kunden-/Ticket-/Lizenzbestand an seinen Master -- +huckepack auf dem bestehenden Heartbeat, zusaetzlich sofort nach jeder +Aenderung. + +**Vollstaendiger Schnappschuss, kein Delta.** Jeder Bericht ersetzt alle Zeilen +seiner Quelle. Das ist bei diesen Datenmengen deutlich einfacher korrekt zu +halten: ein beim Sub-Lizenzserver geloeschtes Ticket verschwindet automatisch +auch beim Master, ohne dass je ein "geloescht"-Ereignis uebertragen werden muss. +""" + +from __future__ import annotations + +import json +import logging +import sqlite3 +import threading +from datetime import datetime, timezone +from typing import Any + +import tesm_licensing as lic +from tesm_core.db import Database + +logger = logging.getLogger(__name__) + +MAX_ROWS = lic.MAX_REPORT_ROWS + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +# --------------------------------------------------------------------------- # +# Eigenen Bestand melden (nur als Sub-Lizenzserver relevant) +# --------------------------------------------------------------------------- # + + +def build_own_report(conn: sqlite3.Connection) -> dict[str, Any]: + """Baut den Bericht ueber den eigenen Bestand. + + Die eigene Bootstrap-Lizenz ist nicht Teil davon -- sie steht in + ``license.json``, nicht in diesen Tabellen. + """ + customers = Database.all( + conn, + "SELECT id, name, contact_email, contact_person, city FROM license_customers " + "ORDER BY id LIMIT ?", + (MAX_ROWS,), + ) + tickets = Database.all( + conn, + "SELECT id, customer_id, type, modules, valid_days, lifetime, status, role, created_at " + "FROM license_tickets ORDER BY created_at LIMIT ?", + (MAX_ROWS,), + ) + licenses = Database.all( + conn, + "SELECT license_id, ticket_id, type, modules, lifetime, issued_at, expires_at, status, " + "fingerprint, hostname, activated_at, last_heartbeat_at FROM licenses " + "ORDER BY created_at LIMIT ?", + (MAX_ROWS,), + ) + return { + "customers": [ + { + "ref": str(row["id"]), + "name": str(row["name"]), + "contact_email": str(row["contact_email"] or ""), + "contact_person": str(row["contact_person"] or ""), + "city": str(row["city"] or ""), + } + for row in customers + ], + "tickets": [ + { + "ref": str(row["id"]), + "customer_ref": str(row["customer_id"]), + "type": str(row["type"]), + "modules": json.loads(str(row["modules"] or "[]")), + "valid_days": int(row["valid_days"] or 0), + "lifetime": bool(row["lifetime"]), + "status": str(row["status"]), + "role": str(row["role"]), + "created_at": str(row["created_at"] or ""), + } + for row in tickets + ], + "licenses": [ + { + "ref": str(row["license_id"]), + "ticket_ref": str(row["ticket_id"] or ""), + "type": str(row["type"]), + "modules": json.loads(str(row["modules"] or "[]")), + "lifetime": bool(row["lifetime"]), + "issued_at": str(row["issued_at"] or ""), + "expires_at": str(row["expires_at"] or ""), + "status": str(row["status"]), + "fingerprint": str(row["fingerprint"] or ""), + "hostname": str(row["hostname"] or ""), + "activated_at": str(row["activated_at"] or ""), + "last_heartbeat_at": str(row["last_heartbeat_at"] or ""), + } + for row in licenses + ], + } + + +def report_soon(extension: Any) -> None: + """Meldet den eigenen Bestand im Hintergrund an den Master. + + Best effort: schlaegt der Versand fehl, bleibt es beim regulaeren Heartbeat. + Der Master ist damit hoechstens sechs Stunden hinterher, nie inkonsistent. + """ + manager = extension.license + if manager is None or manager.state.role != "license_server": + return + + def worker() -> None: + try: + manager.run_heartbeat() + except Exception as exc: # noqa: BLE001 + logger.info("Sofortmeldung an den Master fehlgeschlagen: %s", exc) + + threading.Thread(target=worker, name="report-to-master", daemon=True).start() + + +# --------------------------------------------------------------------------- # +# Fremden Bestand aufnehmen (Master-Seite) +# --------------------------------------------------------------------------- # + + +def ingest_report( + conn: sqlite3.Connection, source_license_id: str, report: dict[str, Any] +) -> dict[str, int]: + """Uebernimmt einen Bericht. Ersetzt alle bisherigen Zeilen dieser Quelle.""" + counts = {"customers": 0, "tickets": 0, "licenses": 0} + stamp = _now() + + for table in ("reported_customers", "reported_tickets", "reported_licenses"): + conn.execute(f"DELETE FROM {table} WHERE source_license_id=?", (source_license_id,)) + + for entry in (report.get("customers") or [])[:MAX_ROWS]: + conn.execute( + "INSERT INTO reported_customers (source_license_id, remote_ref, name, contact_email, " + "contact_person, city, last_reported_at) VALUES (?,?,?,?,?,?,?)", + ( + source_license_id, + str(entry.get("ref") or ""), + str(entry.get("name") or ""), + str(entry.get("contact_email") or ""), + str(entry.get("contact_person") or ""), + str(entry.get("city") or ""), + stamp, + ), + ) + counts["customers"] += 1 + + for entry in (report.get("tickets") or [])[:MAX_ROWS]: + conn.execute( + "INSERT INTO reported_tickets (source_license_id, remote_ref, customer_ref, type, " + "modules, valid_days, lifetime, status, role, created_at, last_reported_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?)", + ( + source_license_id, + str(entry.get("ref") or ""), + str(entry.get("customer_ref") or ""), + str(entry.get("type") or ""), + json.dumps(entry.get("modules") or []), + int(entry.get("valid_days") or 0), + int(bool(entry.get("lifetime"))), + str(entry.get("status") or "open"), + str(entry.get("role") or "customer"), + str(entry.get("created_at") or ""), + stamp, + ), + ) + counts["tickets"] += 1 + + for entry in (report.get("licenses") or [])[:MAX_ROWS]: + conn.execute( + "INSERT INTO reported_licenses (source_license_id, remote_ref, ticket_ref, type, " + "modules, lifetime, issued_at, expires_at, status, fingerprint, hostname, " + "activated_at, last_heartbeat_at, last_reported_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + source_license_id, + str(entry.get("ref") or ""), + str(entry.get("ticket_ref") or ""), + str(entry.get("type") or ""), + json.dumps(entry.get("modules") or []), + int(bool(entry.get("lifetime"))), + str(entry.get("issued_at") or ""), + str(entry.get("expires_at") or ""), + str(entry.get("status") or ""), + str(entry.get("fingerprint") or ""), + str(entry.get("hostname") or ""), + str(entry.get("activated_at") or ""), + str(entry.get("last_heartbeat_at") or ""), + stamp, + ), + ) + counts["licenses"] += 1 + + return counts + + +def reported_for_customer(conn: sqlite3.Connection, customer_id: int) -> dict[str, Any]: + """Gemeldeter Bestand eines Kunden, der selbst Lizenzserver ist.""" + source = Database.value( + conn, + "SELECT license_id FROM licenses WHERE customer_id=? AND role='license_server' " + "AND status='active' LIMIT 1", + (customer_id,), + ) + if source is None: + return {"source_license_id": "", "customers": [], "last_reported_at": ""} + + customers = Database.all( + conn, + "SELECT * FROM reported_customers WHERE source_license_id=? ORDER BY name COLLATE NOCASE", + (str(source),), + ) + tickets = Database.all( + conn, "SELECT * FROM reported_tickets WHERE source_license_id=?", (str(source),) + ) + licenses = Database.all( + conn, "SELECT * FROM reported_licenses WHERE source_license_id=?", (str(source),) + ) + + from .issuance import display_state + + tickets_by_customer: dict[str, list[dict[str, Any]]] = {} + for ticket in tickets: + tickets_by_customer.setdefault(str(ticket["customer_ref"]), []).append(dict(ticket)) + licenses_by_ticket: dict[str, list[dict[str, Any]]] = {} + for entry in licenses: + licenses_by_ticket.setdefault(str(entry["ticket_ref"]), []).append( + {**dict(entry), **display_state(dict(entry))} + ) + + result = [] + for customer in customers: + own_tickets = tickets_by_customer.get(str(customer["remote_ref"]), []) + for ticket in own_tickets: + ticket["licenses"] = licenses_by_ticket.get(str(ticket["remote_ref"]), []) + result.append({**dict(customer), "tickets": own_tickets}) + + return { + "source_license_id": str(source), + "customers": result, + "last_reported_at": max( + [str(row["last_reported_at"]) for row in customers] or [""], + ), + } + + +def reporting_sources(conn: sqlite3.Connection) -> list[dict[str, Any]]: + """Alle Sub-Lizenzserver, die schon einmal berichtet haben.""" + return Database.all( + conn, + """ + SELECT l.license_id, l.hostname, l.last_heartbeat_at, c.name AS customer_name, c.id AS customer_id, + (SELECT COUNT(*) FROM reported_customers r WHERE r.source_license_id = l.license_id) AS customers, + (SELECT COUNT(*) FROM reported_tickets r WHERE r.source_license_id = l.license_id) AS tickets, + (SELECT COUNT(*) FROM reported_licenses r WHERE r.source_license_id = l.license_id) AS licenses, + (SELECT MAX(r.last_reported_at) FROM reported_customers r + WHERE r.source_license_id = l.license_id) AS last_reported_at + FROM licenses l JOIN license_customers c ON c.id = l.customer_id + WHERE l.role='license_server' + ORDER BY c.name COLLATE NOCASE + """, + ) diff --git a/apps/tesm-license/src/tesm_license/services/transfer.py b/apps/tesm-license/src/tesm_license/services/transfer.py new file mode 100644 index 0000000..1ffd0ea --- /dev/null +++ b/apps/tesm-license/src/tesm_license/services/transfer.py @@ -0,0 +1,420 @@ +"""Sicherung des Lizenzservers: Kunden, Tickets, Lizenzen, Konten, Einstellungen. + +Der verschluesselte Umschlag liegt im Kern (``tesm_core.transfer``). Hier steht, +was gesichert wird -- und was bewusst nicht. + +Bewusst **nicht** in der Sicherung: + +* Der Signaturschluessel (``master_signing_key.json``). Er ist der Kern des + Vertrauens und gehoert in eine getrennte, bewusst verwahrte Sicherung -- nicht + in dieselbe Datei wie die Kundendaten. +* Verbrauchte Protokoll-Nonces und Portal-Token: reiner Laufzeitzustand. +* Der eigene Lizenz- und Aktivierungszustand: er gehoert zur Maschine. + +Die **verschluesselten Client-Schluessel** der ausgestellten Lizenzen sind +enthalten -- allerdings weiterhin verschluesselt mit dem Datenschluessel dieser +Installation. Eine Sicherung ohne ``data.keys`` gibt sie also nicht her; das ist +gewollt und in der Oberflaeche vermerkt. +""" + +from __future__ import annotations + +import json +import sqlite3 +from typing import Any, Callable, Iterable + +from tesm_core.db import Database +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, +) + +APP_TAG = "tesm-license" + +CATEGORIES: tuple[Category, ...] = ( + Category("customers", "Kunden", "Stammdaten der Kundeninstanzen"), + Category("tickets", "Tickets", "Anspruechte inklusive Self-Service-Schluessel", sensitive=True), + Category( + "licenses", + "Lizenzen", + "Ausgestellte Lizenzen mit signiertem Dokument und verschluesseltem Client-Schluessel", + sensitive=True, + ), + Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True), + Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True), + Category("settings", "Einstellungen", "Anbieterdaten, Portal, Mailversand", admin_only=True), +) + +CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES} + + +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) + + +def summarize(payload: dict[str, Any]) -> list[dict[str, Any]]: + return _summarize(payload, CATEGORIES) + + +# --------------------------------------------------------------------------- # +# Export +# --------------------------------------------------------------------------- # + + +def build_export(conn: sqlite3.Connection, *, categories: Iterable[str]) -> dict[str, Any]: + wanted = {key for key in categories if key in CATEGORY_BY_KEY} + payload: dict[str, Any] = {} + + if "customers" in wanted: + payload["customers"] = Database.all( + conn, + "SELECT id, name, contact_email, contact_phone, contact_person, street, " + "house_number, postal_code, city, notes, created_at FROM license_customers", + ) + if "tickets" in wanted: + payload["tickets"] = Database.all( + conn, + "SELECT id, customer_id, type, modules, valid_days, lifetime, status, role, notes, " + "created_by, created_at FROM license_tickets", + ) + if "licenses" in wanted: + payload["licenses"] = Database.all( + conn, + "SELECT license_id, ticket_id, customer_id, type, role, modules, lifetime, seq, " + "issued_at, expires_at, license_pubkey, license_document, client_key_encrypted, " + "status, fingerprint, hostname, activated_at, deactivated_at, last_heartbeat_at, " + "revoked_at, revoke_reason, created_by, created_at FROM licenses", + ) + if "users" in wanted: + 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") + if not str(row["key"]).startswith("license_") + } + return payload + + +# --------------------------------------------------------------------------- # +# Uebernahme +# --------------------------------------------------------------------------- # + + +def apply_import( + conn: sqlite3.Connection, + payload: dict[str, Any], + *, + categories: Iterable[str], + set_setting: Callable[[str, Any], None], +) -> dict[str, dict[str, int]]: + """Uebernimmt ausgewaehlte Kategorien. + + Abgleich nach natuerlichem Schluessel. Kunden werden ueber ihren Namen + zusammengefuehrt; Tickets und Lizenzen ueber ihre ID, weil die zugleich + kryptographisch relevant ist (Self-Service-Link bzw. signierte Lizenz-ID). + """ + wanted = {key for key in categories if key in CATEGORY_BY_KEY} + report: dict[str, dict[str, int]] = {} + stamp = _stamp() + + def bump(category: str, field: str) -> None: + report.setdefault(category, {"created": 0, "updated": 0, "skipped": 0})[field] += 1 + + customer_map: dict[str, int] = {} + + if "customers" in wanted: + for entry in payload.get("customers", []): + name = str(entry.get("name") or "").strip() + if not name: + continue + existing = Database.one( + conn, + "SELECT id FROM license_customers WHERE name=? COLLATE NOCASE", + (name,), + ) + columns = ( + str(entry.get("contact_email") or ""), + str(entry.get("contact_phone") or ""), + str(entry.get("contact_person") or ""), + str(entry.get("street") or ""), + str(entry.get("house_number") or ""), + str(entry.get("postal_code") or ""), + str(entry.get("city") or ""), + str(entry.get("notes") or ""), + ) + if existing is None: + cursor = conn.execute( + "INSERT INTO license_customers (name, contact_email, contact_phone, " + "contact_person, street, house_number, postal_code, city, notes, " + "created_at, updated_at, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,'import')", + (name, *columns, stamp, stamp), + ) + customer_id = int(cursor.lastrowid or 0) + bump("customers", "created") + else: + customer_id = int(existing["id"]) + conn.execute( + "UPDATE license_customers SET contact_email=?, contact_phone=?, " + "contact_person=?, street=?, house_number=?, postal_code=?, city=?, notes=?, " + "updated_at=? WHERE id=?", + (*columns, stamp, customer_id), + ) + bump("customers", "updated") + customer_map[str(entry.get("id") or "")] = customer_id + + def resolve_customer(reference: Any) -> int | None: + mapped = customer_map.get(str(reference or "")) + if mapped: + return mapped + # Ohne mitgesicherte Kundenliste kann ein Ticket nicht zugeordnet werden. + value = Database.value( + conn, "SELECT id FROM license_customers WHERE id=?", (reference,) + ) + return int(value) if value is not None else None + + if "tickets" in wanted: + for entry in payload.get("tickets", []): + ticket_id = str(entry.get("id") or "") + customer_id = resolve_customer(entry.get("customer_id")) + if not ticket_id or customer_id is None: + bump("tickets", "skipped") + continue + existing = Database.one( + conn, "SELECT id FROM license_tickets WHERE id=?", (ticket_id,) + ) + values = ( + customer_id, + str(entry.get("type") or "standard"), + json.dumps(entry.get("modules") if isinstance(entry.get("modules"), list) + else json.loads(str(entry.get("modules") or "[]"))), + int(entry.get("valid_days") or 365), + int(bool(entry.get("lifetime"))), + str(entry.get("status") or "open"), + str(entry.get("role") or "customer"), + str(entry.get("notes") or ""), + ) + if existing is None: + conn.execute( + "INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, " + "lifetime, status, role, notes, created_by, created_at, updated_at) " + "VALUES (?,?,?,?,?,?,?,?,?,'import',?,?)", + (ticket_id, *values, stamp, stamp), + ) + bump("tickets", "created") + else: + conn.execute( + "UPDATE license_tickets SET customer_id=?, type=?, modules=?, valid_days=?, " + "lifetime=?, status=?, role=?, notes=?, updated_at=? WHERE id=?", + (*values, stamp, ticket_id), + ) + bump("tickets", "updated") + + if "licenses" in wanted: + for entry in payload.get("licenses", []): + license_id = str(entry.get("license_id") or "") + customer_id = resolve_customer(entry.get("customer_id")) + if not license_id or customer_id is None: + bump("licenses", "skipped") + continue + if Database.one( + conn, "SELECT 1 FROM licenses WHERE license_id=?", (license_id,) + ): + # Eine signierte Lizenz wird nie ueberschrieben: ihr Inhalt ist + # kryptographisch festgelegt. + bump("licenses", "skipped") + continue + ticket_id = str(entry.get("ticket_id") or "") or None + if ticket_id and not Database.one( + conn, "SELECT 1 FROM license_tickets WHERE id=?", (ticket_id,) + ): + ticket_id = None + conn.execute( + "INSERT INTO licenses (license_id, ticket_id, customer_id, type, role, modules, " + "lifetime, seq, issued_at, expires_at, license_pubkey, license_document, " + "client_key_encrypted, status, fingerprint, hostname, activated_at, " + "deactivated_at, last_heartbeat_at, revoked_at, revoke_reason, created_by, " + "created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'import',?)", + ( + license_id, + ticket_id, + customer_id, + str(entry.get("type") or "standard"), + str(entry.get("role") or "customer"), + json.dumps(entry.get("modules") if isinstance(entry.get("modules"), list) + else json.loads(str(entry.get("modules") or "[]"))), + int(bool(entry.get("lifetime"))), + int(entry.get("seq") or 1), + str(entry.get("issued_at") or stamp), + str(entry.get("expires_at") or stamp), + str(entry.get("license_pubkey") or ""), + str(entry.get("license_document") or "{}"), + str(entry.get("client_key_encrypted") or ""), + str(entry.get("status") or "issued"), + entry.get("fingerprint"), + entry.get("hostname"), + entry.get("activated_at"), + entry.get("deactivated_at"), + entry.get("last_heartbeat_at"), + entry.get("revoked_at"), + str(entry.get("revoke_reason") or ""), + stamp, + ), + ) + bump("licenses", "created") + + 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"]) + bump("users", "updated") + names = [n for n in str(entry.get("group_names") or "").split("\n") if n.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 + set_setting(str(key), value) + bump("settings", "updated") + + return report + + +def _stamp() -> str: + from datetime import datetime, timezone + + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +__all__ = [ + "APP_TAG", + "CATEGORIES", + "CATEGORY_BY_KEY", + "MIN_PASSPHRASE_LENGTH", + "PREVIEW_TTL_MINUTES", + "TransferError", + "apply_import", + "build_export", + "drop_preview", + "load_preview", + "seal", + "store_preview", + "summarize", + "unseal", +] diff --git a/apps/tesm-license/src/tesm_license/settings_spec.py b/apps/tesm-license/src/tesm_license/settings_spec.py new file mode 100644 index 0000000..f442e02 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/settings_spec.py @@ -0,0 +1,97 @@ +"""Laufzeiteinstellungen des Lizenzservers.""" + +from __future__ import annotations + +from tesm_core.settings_store import SettingSpec + +from .services.mail import SETTINGS as MAIL_SETTINGS + +VENDOR_SETTINGS: tuple[SettingSpec, ...] = ( + SettingSpec("vendor_name", "", "str", "Anbietername", section="vendor"), + SettingSpec("vendor_email", "", "str", "Kontakt-E-Mail", section="vendor"), + SettingSpec("vendor_phone", "", "str", "Telefon", section="vendor"), + SettingSpec("vendor_address", "", "str", "Adresse", section="vendor"), + SettingSpec( + "vendor_logo_base64", + "", + "str", + "Logo (Data-URI)", + hint="Reist in jeder ausgestellten Lizenz mit und wird beim Kunden angezeigt. " + "Maximal 200 KB.", + section="vendor", + export=False, + ), + SettingSpec( + "master_endpoint", + "", + "str", + "Oeffentlicher Endpunkt dieses Lizenzservers", + hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen -- z. B. " + "https://lizenz.firma.de. Wird in jede Lizenz eingebettet und fuer alle " + "kundengerichteten Links verwendet.", + section="vendor", + ), +) + +PORTAL_SETTINGS: tuple[SettingSpec, ...] = ( + SettingSpec( + "portal_link_minutes", + 30, + "int", + "Gueltigkeit des Zugangslinks (Minuten)", + hint="Der Link ist zusaetzlich nur einmal verwendbar.", + section="portal", + minimum=5, + maximum=1440, + ), + SettingSpec( + "portal_session_minutes", + 60, + "int", + "Gueltigkeit der Portalsitzung (Minuten)", + hint="Zweites, unabhaengiges Zeitfenster: eines fuer das Anklicken des Links, " + "eines fuer die daraus entstehende Sitzung.", + section="portal", + minimum=5, + maximum=1440, + ), + SettingSpec( + "portal_enabled", True, "bool", "Kundenportal aktiv", section="portal" + ), + SettingSpec( + "self_service_enabled", + True, + "bool", + "Self-Service-Link je Ticket aktiv", + hint="Erlaubt dem Kunden, seine Lizenz selbst herunterzuladen, zu widerrufen und " + "neu auszustellen -- ohne Anmeldung, allein ueber den unratbaren Ticket-Link.", + section="portal", + ), +) + +DEFAULT_TICKET_SETTINGS: tuple[SettingSpec, ...] = ( + SettingSpec( + "default_valid_days", + 365, + "int", + "Vorgeschlagene Laufzeit neuer Tickets (Tage)", + section="licensing", + minimum=1, + maximum=36500, + ), + SettingSpec( + "allow_customer_revoke", + True, + "bool", + "Kunden duerfen ihre Lizenz selbst widerrufen", + hint="Notwendig fuer einen Systemwechsel ohne Support-Anruf.", + section="licensing", + ), +) + +LICENSE_SERVER_SETTINGS: tuple[SettingSpec, ...] = ( + *VENDOR_SETTINGS, + *PORTAL_SETTINGS, + *DEFAULT_TICKET_SETTINGS, + *MAIL_SETTINGS, +) diff --git a/apps/tesm-license/src/tesm_license/templates/license/backup.html b/apps/tesm-license/src/tesm_license/templates/license/backup.html new file mode 100644 index 0000000..237ed10 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/backup.html @@ -0,0 +1,132 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, alert, pill %} + +{% block page_title %}Sicherung und Uebertragung{% endblock %} +{% block page_sub %}Verschluesselter Export · zweistufiger Import{% endblock %} + +{% block content %} +{{ 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.', 'warning', +'Passphrase') }} + +{{ alert('Der Signaturschluessel (' ~ signing_key_path ~ ') ist bewusst +nicht Teil dieser Sicherung. Er ist der Kern des Vertrauens und gehoert in eine +getrennte, bewusst verwahrte Sicherung -- nicht in dieselbe Datei wie die Kundendaten. Ohne ihn +ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'danger', +'Wichtig: Schluessel getrennt sichern') }} + +
+
+
+
+
{{ icon('download', size=17) }} Sicherung erstellen
+
Verlangt eine frische Passwortbestaetigung
+
+
+
+ +
+
+ Kategorien +
+ {% for category in all_categories %} + {% set allowed = category in categories %} + + {% endfor %} +
+
+
+ {{ 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
+
Gueltig fuer {{ preview_ttl }} Minuten
+
+
+
+ +
+
+ + + + {% for entry in preview.summary %} + + + + + + {% endfor %} + +
KategorieEintraege
{{ entry.label }}{{ entry.count }}
+
+ {{ alert('Bereits vorhandene Lizenzen werden nie ueberschrieben -- ihr Inhalt ist + kryptographisch festgelegt. Kunden werden ueber ihren Namen zusammengefuehrt.', + '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 %} +
+
+{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/customer_detail.html b/apps/tesm-license/src/tesm_license/templates/license/customer_detail.html new file mode 100644 index 0000000..c2d2261 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/customer_detail.html @@ -0,0 +1,219 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, textarea_field, pill, empty_state, alert %} + +{% block page_title %}{{ customer.name }}{% endblock %} +{% block page_sub %} + {{ tickets | length }} Ticket(s) · {{ licenses | length }} Lizenz(en) +{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block topbar_extra %} + {% if current_user.has('tickets.create') %} + + {{ icon('plus', size=15) }} Ticket anlegen + + {% endif %} +{% endblock %} + +{% block content %} +
+
+
+
+
+
{{ icon('ticket', size=17) }} Tickets
+
Verwaltung auf der eigenen Ticket-Seite
+
+
+ {% if tickets %} +
+ + + + {% for ticket in tickets %} + + + + + + + + {% endfor %} + +
TicketTypRolleLaufzeitZustand
+ + {{ ticket.id[:12] }}… + {{ ticket.type }} + {% if ticket.role != 'customer' %}{{ pill(ticket.role, 'accent') }} + {% else %}Kunde{% endif %} + + {% if ticket.lifetime %}unbefristet{% else %}{{ ticket.valid_days }} Tage{% endif %} + + {% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }} + {% else %}{{ pill('offen', 'online') }}{% endif %} +
+
+ {% else %} + {{ empty_state('Keine Tickets', 'Ohne Ticket kann keine Lizenz ausgestellt werden.', 'ticket') }} + {% endif %} +
+ +
+
+
{{ icon('shield', size=17) }} Lizenzen
+
+ {% if licenses %} +
+ + + + {% for entry in licenses %} + + + + + + + + {% endfor %} + +
LizenzTypZustandLaeuft abHost
+ + {{ entry.license_id[:12] }}… + {{ entry.type }} + {% if entry.status == 'active' %}{{ pill('aktiv', 'online') }} + {% elif entry.status == 'issued' %}{{ pill('ausgestellt', 'info') }} + {% elif entry.status == 'revoked' %}{{ pill('widerrufen', 'danger') }} + {% else %}{{ pill('deaktiviert', '') }}{% endif %} + + {% if entry.lifetime %}unbefristet + {% else %} + {{ entry.expires_at | date }} + {% if entry.color == 'red' %}{{ pill('kritisch', 'danger') }} + {% elif entry.color == 'orange' %}{{ pill('bald', 'warning') }}{% endif %} + {% endif %} + {{ entry.hostname or '–' | safe }}
+
+ {% else %} + {{ empty_state('Keine Lizenzen', '', 'shield') }} + {% endif %} +
+ + {% if reported.customers %} +
+
+
+
{{ icon('share', size=17) }} Von diesem Lizenzserver verwaltete Kunden
+
+ Gemeldeter Bestand · Quelle {{ reported.source_license_id[:12] }}… + {% if reported.last_reported_at %} + · {{ reported.last_reported_at | dt }} + {% endif %} +
+
+
+
+ {{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein + informativ und werden bei jedem Bericht vollstaendig ersetzt.', 'info') }} + {% for entry in reported.customers %} +
+ +
+
{{ entry.name }}
+
+ {{ entry.city or 'ohne Ort' }} · {{ entry.tickets | length }} Ticket(s) +
+
+ {{ icon('chevron-down', size=15) }} +
+
+ {% for ticket in entry.tickets %} +
+
+ {{ ticket.remote_ref[:12] }}… + {{ ticket.type }} + {% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }} + {% else %}{{ pill('offen', 'online') }}{% endif %} +
+ {% for entry_license in ticket.licenses %} +
+ {{ entry_license.remote_ref[:12] }}… + {{ entry_license.status }} + {% if entry_license.lifetime %}{{ pill('unbefristet', 'online') }} + {% elif entry_license.days_left is not none %} + {{ pill(entry_license.days_left ~ ' Tage', + 'danger' if entry_license.color == 'red' else + ('warning' if entry_license.color == 'orange' else 'online')) }} + {% endif %} + {{ entry_license.hostname or '' }} +
+ {% endfor %} +
+ {% endfor %} +
+
+ {% endfor %} +
+
+ {% endif %} +
+ +
+
+
{{ icon('building', size=17) }} Stammdaten
+
+
+ +
+ {{ field('name', 'Firmenname', value=customer.name, required=True, + disabled=not current_user.has('customers.edit')) }} + {{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email', + disabled=not current_user.has('customers.edit')) }} + {{ field('contact_person', 'Ansprechpartner', value=customer.contact_person, + disabled=not current_user.has('customers.edit')) }} + {{ field('contact_phone', 'Telefon', value=customer.contact_phone, + disabled=not current_user.has('customers.edit')) }} +
+ {{ field('street', 'Strasse', value=customer.street, + disabled=not current_user.has('customers.edit')) }} + {{ field('house_number', 'Nr.', value=customer.house_number, + disabled=not current_user.has('customers.edit')) }} + {{ field('postal_code', 'PLZ', value=customer.postal_code, + disabled=not current_user.has('customers.edit')) }} + {{ field('city', 'Ort', value=customer.city, + disabled=not current_user.has('customers.edit')) }} +
+ {{ textarea_field('notes', 'Notizen', value=customer.notes, rows=3, + extra_class='field--full') }} +
+
+
Angelegt
{{ customer.created_at | dt }} von {{ customer.created_by or '-' }}
+
Geaendert
{{ customer.updated_at | dt }}
+
Portal-Zugang
+
{{ portal_url }} +
+
+
+ {% if current_user.has('customers.edit') %} + + {% endif %} +
+
+
+{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/customers.html b/apps/tesm-license/src/tesm_license/templates/license/customers.html new file mode 100644 index 0000000..734a508 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/customers.html @@ -0,0 +1,86 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, textarea_field, pill, search_box, modal, + empty_state %} + +{% macro customer_form(customer=None) %} +
+ {{ field('name', 'Firmenname', value=customer.name if customer else '', required=True) }} + {{ field('contact_email', 'E-Mail', value=customer.contact_email if customer else '', + type='email', required=not customer, + hint_text='Pflicht bei der Neuanlage -- ueber diese Adresse laeuft das Kundenportal.') }} + {{ field('contact_person', 'Ansprechpartner', value=customer.contact_person if customer else '') }} + {{ field('contact_phone', 'Telefon', value=customer.contact_phone if customer else '') }} + {{ field('street', 'Strasse', value=customer.street if customer else '') }} + {{ field('house_number', 'Hausnummer', value=customer.house_number if customer else '') }} + {{ field('postal_code', 'PLZ', value=customer.postal_code if customer else '') }} + {{ field('city', 'Ort', value=customer.city if customer else '') }} + {{ textarea_field('notes', 'Notizen', value=customer.notes if customer else '', rows=2, + extra_class='field--full') }} +
+{% endmacro %} + +{% block page_title %}Kunden{% endblock %} +{% block page_sub %}{{ customers | length }} Kunden{% endblock %} + +{% block topbar_extra %} + {{ search_box('customers-table', 'Firma, Ort, Ansprechpartner ...') }} + {% if current_user.has('customers.create') %} + + {% endif %} +{% endblock %} + +{% block content %} +
+
+ + + + + + + + + + + + + {% for customer in customers %} + + + + + + + + + {% endfor %} + +
FirmaAnsprechpartnerE-MailOrtTicketsOffene Lizenzen
+ + {{ customer.name }} + + {% if customer.license_server_id %}{{ pill('Lizenzserver', 'accent') }}{% endif %} + {{ customer.contact_person or '–' | safe }}{{ customer.contact_email or '–' | safe }}{{ customer.city or '–' | safe }}{{ customer.ticket_count }}{{ customer.open_licenses }}
+
+ {% if not customers %} + {{ empty_state('Keine Kunden', 'Legen Sie den ersten Kunden an.', 'building') }} + {% endif %} +
+{% endblock %} + +{% block dialogs %} +{% if current_user.has('customers.create') %} + {% call modal('customer-new', 'Kunde anlegen', 'Adresse in Einzelfeldern -- fuer Rechnungen weiterverwendbar', 'wide') %} +
+ + + +
+ {% endcall %} +{% endif %} +{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/dashboard.html b/apps/tesm-license/src/tesm_license/templates/license/dashboard.html new file mode 100644 index 0000000..05ae415 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/dashboard.html @@ -0,0 +1,124 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import pill, empty_state, alert, stat %} + +{% block page_title %}Uebersicht{% endblock %} +{% block page_sub %} + {% if summary %} + {{ summary.counts.customers }} Kunden · {{ summary.counts.licenses_active }} aktive Lizenzen + {% else %}Willkommen{% endif %} +{% endblock %} + +{% block content %} +{% if not summary %} + {{ empty_state('Keine Lizenzrechte', + 'Ihrem Konto fehlt das Recht, Lizenzen zu sehen. Wenden Sie sich an die Administration.', + 'lock') }} +{% else %} +
+ {{ stat('Kunden', summary.counts.customers, href=url_for('customers.overview')) }} + {{ stat('Offene Tickets', summary.counts.tickets_open, href=url_for('tickets.overview')) }} + {{ stat('Aktive Lizenzen', summary.counts.licenses_active, kind='ok', + href=url_for('licenses.overview', status='active')) }} + {{ stat('Widerrufen', summary.counts.licenses_revoked, kind='muted', + href=url_for('licenses.overview', status='revoked')) }} +
+ + {% if summary.counts.tickets_blocked %} + {{ alert(summary.counts.tickets_blocked ~ ' Ticket(s) sind gesperrt -- fuer diese Kunden + lassen sich keine neuen Lizenzen ausstellen.', 'warning') }} + {% endif %} + + {% for key, label, kind, hint in [ + ('red', 'Kritisch', 'danger', 'Weniger als 30 Tage Restlaufzeit oder bereits abgelaufen'), + ('orange', 'Bald faellig', 'warning', '30 bis 90 Tage Restlaufzeit'), + ('green', 'In Ordnung', 'ok', 'Mehr als 90 Tage oder unbefristet')] %} + {% set entries = summary.buckets[key] %} + {% if entries %} +
+ +
+
+ {{ pill(entries | length, kind if kind != 'ok' else 'online') }} {{ label }} +
+
{{ hint }}
+
+ {{ icon('chevron-down', size=16) }} +
+ +
+ {% endif %} + {% endfor %} + + {% if not summary.buckets.red and not summary.buckets.orange and not summary.buckets.green %} + {{ empty_state('Noch keine Lizenzen', + 'Legen Sie einen Kunden an, erstellen Sie ein Ticket und stellen Sie daraus eine Lizenz aus.', + 'shield') }} + {% endif %} + + {% if sources %} +
+
+
+
{{ icon('share', size=17) }} Sub-Lizenzserver
+
Von untergeordneten Lizenzservern gemeldeter Bestand
+
+ Alle anzeigen +
+
+ + + + + {% for source in sources %} + + + + + + + + + {% endfor %} + +
KundeHostKundenTicketsLizenzenGemeldet
+ + {{ source.customer_name }} + {{ source.hostname or '–' | safe }}{{ source.customers }}{{ source.tickets }}{{ source.licenses }} + {% if source.last_reported_at %} + {{ source.last_reported_at | dt }} + {% else %}noch nie{% endif %} +
+
+
+ {% endif %} +{% endif %} +{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/license_detail.html b/apps/tesm-license/src/tesm_license/templates/license/license_detail.html new file mode 100644 index 0000000..aa1088d --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/license_detail.html @@ -0,0 +1,258 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, checkbox, pill, alert, empty_state, modal %} + +{% block page_title %}Lizenz {{ license.license_id[:12] }}…{% endblock %} +{% block page_sub %} + {{ license.customer_name }} · {{ type_labels.get(license.type, license.type) }} + {% if license.role != 'customer' %}· {{ role_labels.get(license.role, license.role) }}{% endif %} +{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block topbar_extra %} + {% if current_user.has('licenses.export') %} + + {{ icon('download', size=15) }} Datei + + {% endif %} +{% endblock %} + +{% block content %} +{% if state.revision_pending %} + {{ alert('Es wurde eine neue Revision signiert, die der Kunde noch nicht abgeholt hat. + Eine erreichbare Installation holt sie beim naechsten Heartbeat; eine Installation ohne + Verbindung braucht dafuer den Offline-Weg -- der Kunde findet ihn in seinem Portal.', + 'warning', 'Revision ausstehend') }} +{% endif %} + +{% if license.status == 'revoked' %} + {{ alert('Diese Lizenz ist widerrufen. Der Client erfaehrt das beim naechsten Heartbeat und + schaltet seine lizenzpflichtigen Funktionen ab.' ~ ((' Grund: ' ~ license.revoke_reason) if + license.revoke_reason else ''), 'danger', 'Widerrufen') }} +{% endif %} + +
+
+
+
+
+
+ {{ icon('shield', size=17) }} Zustand + {% if license.status == 'active' %}{{ pill('aktiv', 'online') }} + {% elif license.status == 'issued' %}{{ pill('ausgestellt', 'info') }} + {% elif license.status == 'revoked' %}{{ pill('widerrufen', 'danger') }} + {% else %}{{ pill('deaktiviert', '') }}{% endif %} + {% if state.revision_pending %} + {{ pill('Revision ausstehend', 'warning') }} + {% endif %} +
+
+
+
+
+
Lizenz-ID
{{ license.license_id }}
+
Revision
{{ license.seq }}
+
Ticket
+
+ {% if license.ticket_id %} + + {{ license.ticket_id[:12] }}… + {% else %}kein Ticket (importiert){% endif %} +
+
Module
+
+ {% if modules %} + {% for module in modules %}{{ pill(module_labels.get(module, module), 'online') }}{% endfor %} + {% else %}keine{% endif %} +
+
Ausgestellt
{{ license.issued_at | dt }} von {{ license.created_by or '-' }}
+
Laeuft ab
+
+ {% if license.lifetime %}{{ pill('unbefristet', 'online') }} + {% else %} + {{ license.expires_at | dt }} + {% if state.is_expired %}{{ pill('abgelaufen', 'danger') }} + {% elif state.color == 'red' %}{{ pill(state.days_left ~ ' Tage', 'danger') }} + {% elif state.color == 'orange' %}{{ pill(state.days_left ~ ' Tage', 'warning') }} + {% else %}{{ pill(state.days_left ~ ' Tage', 'online') }}{% endif %} + {% endif %} +
+
Maschine
+
+ {% if license.fingerprint %} + {{ license.fingerprint }} + {% else %}noch nicht gebunden{% endif %} +
+
Host (gemeldet)
{{ license.hostname or '–' | safe }}
+
Aktiviert
{{ license.activated_at | dt }}
+
Letzter Heartbeat
+
+ {% if license.last_heartbeat_at %} + {{ license.last_heartbeat_at | dt }} + {% else %}nie{% endif %} +
+
+
+
+ + {% if current_user.has('licenses.edit') and license.status != 'revoked' %} +
+
+
+
{{ icon('refresh', size=17) }} Nachtraeglich anpassen
+
+ Signiert eine neue Revision. Der Client erhaelt sie beim naechsten Heartbeat -- + ohne erneute Aktivierung. +
+
+
+
+ +
+ {% if license.role == 'customer' %} +
+ Module +
+ {% for module in all_modules %} + {{ checkbox('modules', module_labels[module], value=module, + checked=module in modules) }} + {% endfor %} +
+
+ {% endif %} + {{ field('extend_days', 'Laufzeit verlaengern (Tage)', type='number', + placeholder='0', + hint_text='Wird auf das aktuelle Ablaufdatum aufgeschlagen.') }} +
+ +
+
+ {% endif %} + +
+
+
+
{{ icon('transfer', size=17) }} Zustellungen
+
{{ deliveries | length }} Vorgaenge
+
+
+ {% if deliveries %} +
+ + + + {% for entry in deliveries %} + + + + + + + + {% endfor %} + +
ZeitWegEmpfaengerDurchDetail
{{ entry.delivered_at | dt }}{{ entry.channel }}{{ entry.recipient or '–' | safe }}{{ entry.delivered_by }}{{ entry.detail }}
+
+ {% else %} + {{ empty_state('Noch nicht zugestellt', 'Die Datei wurde bisher nicht herausgegeben.', 'transfer') }} + {% endif %} +
+ +
+ +
+
{{ icon('file-text', size=17) }} Signiertes Dokument
+
Genau so, wie es signiert wurde -- jede Aenderung bricht die Signatur
+
+ {{ icon('chevron-down', size=16) }} +
+
{{ document_json }}
+
+
+ +
+ {% if current_user.has('licenses.export') %} +
+
+
+
{{ icon('mail', size=17) }} Per E-Mail versenden
+
+ {% if mail_ready %}Ueber Microsoft Graph{% else %}Nicht konfiguriert{% endif %} +
+
+
+
+ +
+ {{ field('recipient', 'Empfaenger', value=license.contact_email, type='email', + disabled=not mail_ready) }} +
+ +
+
+ {% endif %} + + {% if current_user.has('licenses.edit') or current_user.has('licenses.delete') %} +
+
+
{{ icon('alert', size=17) }} Lebenszyklus
+
+
+ {% if current_user.has('licenses.edit') and license.status != 'revoked' %} + +

+ Das Ticket bleibt offen -- eine neue Lizenz kann sofort ausgestellt werden. Genau so + laeuft ein Systemwechsel. +

+ {% endif %} + {% if current_user.has('licenses.delete') %} +
+ + +
+ {% endif %} +
+
+ {% endif %} +
+
+{% endblock %} + +{% block dialogs %} +{% if current_user.has('licenses.edit') and license.status != 'revoked' %} + {% call modal('revoke-dialog', 'Lizenz widerrufen', license.customer_name, 'narrow') %} +
+ + + +
+ {% endcall %} +{% endif %} +{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/licenses.html b/apps/tesm-license/src/tesm_license/templates/license/licenses.html new file mode 100644 index 0000000..e3fc425 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/licenses.html @@ -0,0 +1,77 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import pill, search_box, empty_state %} + +{% block page_title %}Lizenzen{% endblock %} +{% block page_sub %}{{ licenses | length }} Lizenzen{% if active_filter %} · Filter: {{ active_filter }}{% endif %}{% endblock %} + +{% block topbar_extra %} + {{ search_box('licenses-table', 'Kunde, Lizenz-ID, Host ...') }} + {% if current_user.has('licenses.execute') %} + + {{ icon('transfer', size=15) }} Offline-Code + + {% endif %} +{% endblock %} + +{% block content %} +
+
+
+ alle + {% for state in states %} + {{ state }} + {% endfor %} +
+
+
+ + + + + + + + + + + + + + {% for entry in licenses %} + + + + + + + + + + {% endfor %} + +
KundeLizenzTypZustandRestlaufzeitHostHeartbeat
+ {{ entry.customer_name }} + {% if entry.role != 'customer' %}{{ pill(role_labels.get(entry.role, entry.role), 'accent') }}{% endif %} + + {{ entry.license_id[:12] }}… + {{ type_labels.get(entry.type, entry.type) }} + {% if entry.status == 'active' %}{{ pill('aktiv', 'online') }} + {% elif entry.status == 'issued' %}{{ pill('ausgestellt', 'info') }} + {% elif entry.status == 'revoked' %}{{ pill('widerrufen', 'danger') }} + {% else %}{{ pill('deaktiviert', '') }}{% endif %} + + {% if entry.lifetime %}{{ pill('unbefristet', 'online') }} + {% elif entry.is_expired %}{{ pill('abgelaufen', 'danger') }} + {% elif entry.color == 'red' %}{{ pill(entry.days_left ~ ' Tage', 'danger') }} + {% elif entry.color == 'orange' %}{{ pill(entry.days_left ~ ' Tage', 'warning') }} + {% else %}{{ pill(entry.days_left ~ ' Tage', 'online') }}{% endif %} + {{ entry.hostname or '–' | safe }} + {% if entry.last_heartbeat_at %} + {{ entry.last_heartbeat_at | dt }} + {% else %}nie{% endif %} +
+
+ {% if not licenses %}{{ empty_state('Keine Lizenzen', 'Zu diesem Filter gibt es nichts.', 'shield') }}{% endif %} +
+{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/manual_code.html b/apps/tesm-license/src/tesm_license/templates/license/manual_code.html new file mode 100644 index 0000000..097e17d --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/manual_code.html @@ -0,0 +1,68 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import alert %} + +{% block page_title %}Offline-Aktivierung{% endblock %} +{% block page_sub %}Code des Kunden verarbeiten und Antwortcode zurueckgeben{% endblock %} + +{% block content %} +{{ alert('Beide Codes sind signierte Protokollobjekte -- inhaltlich identisch mit dem +Online-Weg, nur als kopierbarer Text. Die Pruefungen (Signatur, Frische, Nonce, Fingerprint) +laufen exakt gleich; es gibt hier keine Abkuerzung.', 'info') }} + +
+
+
+
{{ icon('upload', size=17) }} Code des Kunden
+
+
+ +
+ +
+ +
+
+ +
+ {% if summary %} +
+
+
{{ icon('info', size=17) }} Inhalt der Anfrage
+
+
+
+ {% for key, value in summary.items() %} +
{{ key }}
{{ value }}
+ {% endfor %} +
+
+
+ {% endif %} + {% if response_code %} +
+
+
+
{{ icon('download', size=17) }} Antwortcode
+
{{ message }}
+
+ +
+
+
{{ response_code }}
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/own_license.html b/apps/tesm-license/src/tesm_license/templates/license/own_license.html new file mode 100644 index 0000000..4b8ce9a --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/own_license.html @@ -0,0 +1,241 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, checkbox, alert, pill, empty_state %} + +{% block page_title %}Eigene Lizenz{% endblock %} +{% block page_sub %} + {% if state and state.valid and state.activated %} + aktiv · Rolle {{ role_labels.get(state.role, state.role) }} + {% elif state and state.valid %}gueltig, nicht aktiviert + {% elif state and state.present %}ungueltig + {% else %}keine Lizenz installiert{% endif %} +{% endblock %} + +{% block topbar_extra %} + + {{ icon('key', size=15) }} Signaturschluessel + +{% endblock %} + +{% block content %} +{% if signing_key_health.problems %} + {% for problem in signing_key_health.problems %}{{ alert(problem, 'danger') }}{% endfor %} +{% endif %} +{% if state and state.error %}{{ alert(state.error, 'danger') }}{% endif %} + +{% if state and state.role == 'master' %} + {{ alert('Diese Instanz ist die Wurzelinstanz. Nur sie darf Lizenzen fuer + weitere Lizenzserver ausstellen -- diese Grenze haengt an der signierten Rolle dieser + Lizenzdatei, nicht an einem Gruppenrecht. Ein lokaler Administrator eines Sub-Lizenzservers + kann sie sich damit nicht selbst einraeumen.', 'info', 'Rolle: Master') }} +{% elif state and state.role == 'license_server' %} + {{ alert('Diese Instanz ist ein Sub-Lizenzserver. Sie darf eigene Kunden, + Tickets und Lizenzen verwalten -- aber keine weiteren Lizenzserver-Lizenzen ausstellen. Der + eigene Bestand wird automatisch an den uebergeordneten Master gemeldet.', 'info', + 'Rolle: Lizenzserver') }} +{% endif %} + +
+
+ {% 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) }} +
+
+
+
+
+
Inhaber
{{ state.customer_name or '–' | safe }}
+
Lizenz-ID
{{ state.status.license_id }}
+
Revision
{{ state.status.seq }}
+
Laeuft ab
+
+ {% if state.status.lifetime %}{{ pill('unbefristet', 'online') }} + {% else %} + {{ state.status.expires_at | dt }} + {% if state.status.expired %} + {{ pill('abgelaufen', 'danger') }} + {% if state.status.grace_active %} +
+ Kulanzfrist laeuft ({{ grace_days }} Tage). +
+ {% 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 %} +
+
Uebergeordneter Server
+
+ {{ state.status.master_endpoint or 'keiner (Wurzelinstanz oder Offline-Betrieb)' }} +
+
Letzter Heartbeat
+
+ {{ state.last_heartbeat_at | dt }} + {% if state.heartbeat_error %} +
{{ state.heartbeat_error }}
+ {% endif %} +
+
+
+ {% if current_user.has('settings_license.edit') %} + + {% endif %} +
+ {% else %} + {{ empty_state('Keine gueltige Lizenz', + 'Spielen Sie eine Lizenzdatei ein oder erzeugen Sie mit "tesm-license-admin bootstrap" + die eigene Lizenz der Wurzelinstanz.', 'key') }} + {% endif %} + + {% if pending %} +
+
+
+
{{ icon('transfer', size=17) }} Offene Anfrage (Offline-Weg)
+
Aktion: {{ pending.action }}
+
+
+
+ +
+ +
+ +
+
+ {% endif %} + + {% if current_user.has('settings_license.edit') %} +
+
+
+
{{ icon('upload', size=17) }} Lizenzdatei einspielen
+
+ Fuer einen Sub-Lizenzserver: die Datei, die der Master ausgestellt hat +
+
+
+
+ +
+ + {% if trusted_keys and current_user.is_admin %} + {{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdruecklich zulassen') }} + {% endif %} +
+ +
+
+ {% endif %} +
+ +
+
+
+
+
{{ icon('hard-drive', size=17) }} Diese Maschine
+
+
+
+
+
Hostname
{{ hostname }}
+
Fingerprint
{{ fingerprint }}
+
Signaturschluessel
+
{{ signing_key_health.get('key_id') or 'fehlt' }}
+
+
+
+ +
+
+
+
{{ icon('shield', size=17) }} Vertrauenswuerdige Aussteller
+
{{ trusted_keys | length }} gepinnte Schluessel
+
+
+
+ {% if trusted_keys %} +
+ {% for key in trusted_keys %} +
{{ key.key_id }}
+
{{ key.label }} ({{ key.source }}, {{ key.added_at | date }})
+ {% endfor %} +
+ {% else %} +

Noch kein Aussteller gepinnt.

+ {% 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 %} +
+ {% if may_issue_license_server %} +
+

+ {{ icon('check', size=14) }} Diese Instanz darf Lizenzserver-Lizenzen ausstellen. +

+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/portal_disabled.html b/apps/tesm-license/src/tesm_license/templates/license/portal_disabled.html new file mode 100644 index 0000000..e45fd8d --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/portal_disabled.html @@ -0,0 +1,11 @@ +{% extends "tesm_core/standalone.html" %} +{% block heading %}Kundenportal{% endblock %} +{% block content %} +
+ {{ icon('info', size=18) }} +
+ Das Kundenportal ist bei diesem Anbieter derzeit nicht aktiv. Bitte wenden Sie sich an + Ihren Ansprechpartner. +
+
+{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/portal_me.html b/apps/tesm-license/src/tesm_license/templates/license/portal_me.html new file mode 100644 index 0000000..3ef8189 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/portal_me.html @@ -0,0 +1,96 @@ +{% extends "tesm_core/standalone.html" %} +{% from "tesm_core/macros.html" import field, pill %} + +{% block card_class %}auth-card--wide{% endblock %} +{% block heading %}{{ customer.name }}{% endblock %} +{% block subheading %}Ihre Lizenzen und Stammdaten{% endblock %} + +{% block content %} +
+ {{ icon('clock', size=18) }} +
+ Diese Sitzung endet + {{ expires_at | dt }}. Danach fordern Sie + einfach einen neuen Zugangslink an. +
+
+ +
+
+
+
{{ icon('shield', size=17) }} Ihre Lizenzen
+
{{ tickets | length }} Vertrag/Vertraege
+
+
+
+ {% for ticket in tickets %} + {% set entry = licenses.get(ticket.id) %} +
+
+
+
{{ ticket.type | capitalize }}
+
+ {% if ticket.lifetime %}unbefristet{% else %}{{ ticket.valid_days }} Tage Laufzeit{% endif %} +
+
+ {% if ticket.status == 'blocked' %} + {{ pill('gesperrt', 'danger') }} + {% elif entry %} + {% if entry.lifetime %}{{ pill('unbefristet', 'online') }} + {% elif entry.is_expired %}{{ pill('abgelaufen', 'danger') }} + {% elif entry.color == 'red' %}{{ pill('noch ' ~ entry.days_left ~ ' Tage', 'danger') }} + {% elif entry.color == 'orange' %}{{ pill('noch ' ~ entry.days_left ~ ' Tage', 'warning') }} + {% else %}{{ pill('noch ' ~ entry.days_left ~ ' Tage', 'online') }}{% endif %} + {% else %} + {{ pill('keine Lizenz', 'warning') }} + {% endif %} +
+ {% if self_service_enabled and ticket.role == 'customer' %} + + Lizenz verwalten + + {% endif %} +
+ {% else %} +

Zu Ihrem Konto ist noch kein Vertrag hinterlegt.

+ {% endfor %} +
+
+ +
+
+
{{ icon('building', size=17) }} Ihre Angaben
+
+
+ +
+
+ {{ field('contact_person', 'Ansprechpartner', value=customer.contact_person) }} + {{ field('contact_phone', 'Telefon', value=customer.contact_phone) }} + {{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email', + hint_text='Ueber diese Adresse laeuft der Zugang zu diesem Portal.') }} + {{ field('street', 'Strasse', value=customer.street) }} + {{ field('house_number', 'Hausnummer', value=customer.house_number) }} + {{ field('postal_code', 'PLZ', value=customer.postal_code) }} + {{ field('city', 'Ort', value=customer.city) }} +
+
+ +
+
+ +
+ + +
+ +{% if vendor.name %} +

+ Fragen? {{ vendor.name }} + {% if vendor.phone %} · {{ vendor.phone }}{% endif %} + {% if vendor.email %} · {{ vendor.email }}{% endif %} +

+{% endif %} +{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/portal_request.html b/apps/tesm-license/src/tesm_license/templates/license/portal_request.html new file mode 100644 index 0000000..2c1c949 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/portal_request.html @@ -0,0 +1,28 @@ +{% extends "tesm_core/standalone.html" %} + +{% block heading %}Kundenportal{% endblock %} +{% block subheading %}Zugangslink anfordern{% endblock %} + +{% block content %} +

+ Geben Sie die bei uns hinterlegte E-Mail-Adresse ein. Sie erhalten einen Link, der + {{ minutes }} Minuten gueltig ist und sich einmal verwenden laesst. +

+ +
+ + + +
+ +

+ Aus Datenschutzgruenden bekommen Sie immer dieselbe Rueckmeldung -- unabhaengig davon, ob die + Adresse bei uns bekannt ist. +

+{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/report_detail.html b/apps/tesm-license/src/tesm_license/templates/license/report_detail.html new file mode 100644 index 0000000..e4ec262 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/report_detail.html @@ -0,0 +1,77 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import empty_state, pill, alert %} + +{% block page_title %}{{ license.customer_name }}{% endblock %} +{% block page_sub %}Gemeldeter Bestand · Lizenz {{ license.license_id[:12] }}…{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block content %} +{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein informativ und +werden bei jedem Bericht vollstaendig ersetzt.', 'info') }} + +{% if reported.customers %} +
+ {% for entry in reported.customers %} +
+ +
+
{{ entry.name }}
+
+ {{ entry.contact_email or 'ohne E-Mail' }} + {% if entry.city %}· {{ entry.city }}{% endif %} + · {{ entry.tickets | length }} Ticket(s) +
+
+ {{ icon('chevron-down', size=16) }} +
+
+ + + + {% for ticket in entry.tickets %} + + + + + + + {% endfor %} + +
TicketTypZustandLizenzen
{{ ticket.remote_ref[:12] }}…{{ ticket.type }} + {% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }} + {% else %}{{ pill('offen', 'online') }}{% endif %} + + {% for entry_license in ticket.licenses %} +
+ {{ entry_license.remote_ref[:10] }}… + {{ entry_license.status }} + {% if entry_license.lifetime %}{{ pill('unbefristet', 'online') }} + {% elif entry_license.days_left is not none %} + {{ pill(entry_license.days_left ~ ' Tage', + 'danger' if entry_license.color == 'red' else + ('warning' if entry_license.color == 'orange' else 'online')) }} + {% endif %} + {% if entry_license.hostname %} + {{ entry_license.hostname }} + {% endif %} +
+ {% else %} + keine + {% endfor %} +
+
+
+ {% endfor %} +
+{% else %} + {{ empty_state('Noch kein Bericht', + 'Dieser Lizenzserver hat noch nichts gemeldet. Der erste Bericht kommt mit dem naechsten + Heartbeat.', 'share') }} +{% endif %} +{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/reports.html b/apps/tesm-license/src/tesm_license/templates/license/reports.html new file mode 100644 index 0000000..ef4a7fc --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/reports.html @@ -0,0 +1,49 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import empty_state, alert, pill %} + +{% block page_title %}Sub-Lizenzserver{% endblock %} +{% block page_sub %}{{ sources | length }} untergeordnete Lizenzserver{% endblock %} + +{% block content %} +{{ alert('Ein Sub-Lizenzserver meldet seinen kompletten Bestand -- huckepack auf dem +' ~ heartbeat_hours ~ '-Stunden-Heartbeat und zusaetzlich sofort nach jeder Aenderung. Jeder +Bericht ist ein vollstaendiger Schnappschuss: Geloeschtes verschwindet automatisch auch hier.', +'info') }} + +{% if sources %} +
+
+ + + + + + + + {% for source in sources %} + + + + + + + + + + {% endfor %} + +
KundeLizenzHostKundenTicketsLizenzenGemeldet
+ + {{ source.customer_name }} + {{ source.license_id[:12] }}…{{ source.hostname or '–' | safe }}{{ source.customers }}{{ source.tickets }}{{ source.licenses }} + {% if source.last_reported_at %} + {{ source.last_reported_at | dt }} + {% else %}{{ pill('noch nie', 'warning') }}{% endif %} +
+
+
+{% else %} + {{ empty_state('Keine Sub-Lizenzserver', + 'Es wurde noch keine Lizenz mit der Rolle "Lizenzserver" ausgestellt.', 'share') }} +{% endif %} +{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/selfservice.html b/apps/tesm-license/src/tesm_license/templates/license/selfservice.html new file mode 100644 index 0000000..71df7f0 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/selfservice.html @@ -0,0 +1,135 @@ +{% extends "tesm_core/standalone.html" %} +{% from "tesm_core/macros.html" import pill %} + +{% block card_class %}auth-card--wide{% endblock %} +{% block heading %}Ihre TESM-Lizenz{% endblock %} +{% block subheading %}{{ ticket.customer_name }}{% endblock %} + +{% block content %} +{% if blocked %} +
+ {{ icon('alert', size=18) }} +
+ Dieser Vertrag ist derzeit gesperrt. Bitte wenden Sie sich an Ihren Ansprechpartner. +
+
+{% endif %} + +{% if current %} +
+
+
+
+ {{ icon('shield', size=17) }} Aktuelle Lizenz + {% if current.status == 'active' %}{{ pill('aktiv', 'online') }} + {% else %}{{ pill('bereit zum Einspielen', 'info') }}{% endif %} +
+
+ {{ type_labels.get(current.type, current.type) }} · + {% if current.lifetime %}unbefristet + {% elif current.is_expired %}abgelaufen + {% else %}noch {{ current.days_left }} Tage{% endif %} +
+
+
+
+
+
Gueltig bis
+
{% if current.lifetime %}unbefristet{% else %}{{ current.expires_at | date }}{% endif %}
+ {% if current.hostname %}
Installiert auf
{{ current.hostname }}
{% endif %} +
Ausgestellt
{{ current.issued_at | date }}
+
+ + + {{ icon('download', size=16) }} Lizenzdatei herunterladen + + {% if offline_needed %} + + {{ icon('transfer', size=16) }} Offline aktivieren + +

+ Nutzen Sie das, wenn Ihre TESM-Installation uns nicht direkt erreicht. +

+ {% endif %} +

+ Spielen Sie die Datei in Ihrer TESM-Installation unter + Verwaltung → Lizenz ein und aktivieren Sie sie dort. +

+ + {% if allow_revoke %} +
+
+ + +
+

+ Nutzen Sie das, wenn Sie TESM auf einem anderen Server neu aufsetzen. Ihr Vertrag + bleibt bestehen. +

+ {% endif %} +
+
+{% else %} +
+
+
+
{{ icon('shield', size=17) }} Noch keine Lizenz
+
Sie koennen Ihre Lizenz jetzt erstellen
+
+
+
+
+ + +
+
+
+{% endif %} + +{% if history %} +
+ +
+
{{ icon('history', size=17) }} Frueher ausgestellt
+
{{ history | length }} Eintraege
+
+ {{ icon('chevron-down', size=16) }} +
+
+ + + + {% for entry in history %} + + + + + + {% endfor %} + +
AusgestelltZustandSystem
{{ entry.issued_at | date }} + {% if entry.status == 'revoked' %}freigegeben + {% elif entry.status == 'deactivated' %}abgeloest + {% else %}{{ entry.status }}{% endif %} + {{ entry.hostname or '–' | safe }}
+
+
+{% endif %} + +

+ Stammdaten pflegen + {% if vendor.name %} · {{ vendor.name }}{% endif %} + {% if vendor.phone %} · {{ vendor.phone }}{% endif %} +

+{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/selfservice_offline.html b/apps/tesm-license/src/tesm_license/templates/license/selfservice_offline.html new file mode 100644 index 0000000..f2fa05b --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/selfservice_offline.html @@ -0,0 +1,51 @@ +{% extends "tesm_core/standalone.html" %} +{% from "tesm_core/macros.html" import alert %} + +{% block card_class %}auth-card--wide{% endblock %} +{% block heading %}Offline aktivieren{% endblock %} +{% block subheading %}{{ ticket.customer_name }}{% endblock %} + +{% block content %} +
+
+ {{ alert('In Ihrer TESM-Installation unter Verwaltung -- Lizenz auf "Lizenz aktivieren" + klicken. Erscheint dort ein Anfragecode, fuegen Sie ihn hier ein. Sie bekommen einen + Antwortcode zurueck, den Sie in TESM eintragen. Beide Codes sind signiert und lassen + sich unterwegs nicht veraendern.', 'info', 'So geht es') }} + + {% if response_code %} +
+ Antwortcode -- in TESM eintragen +
{{ response_code }}
+
+ +
+

+ Tragen Sie ihn in TESM unter Verwaltung -- Lizenz bei + „Antwortcode des Lizenzservers“ ein. + {% if summary.hostname %} + Die Anfrage kam von {{ summary.hostname }}. + {% endif %} +

+
+ {% else %} +
+ +
+ +
+
+ {% endif %} + +

+ Zurueck zur Uebersicht +

+
+
+{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/settings_mail.html b/apps/tesm-license/src/tesm_license/templates/license/settings_mail.html new file mode 100644 index 0000000..30e5860 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/settings_mail.html @@ -0,0 +1,81 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, checkbox, alert, pill %} + +{% block page_title %}E-Mail-Versand{% endblock %} +{% block page_sub %} + Microsoft Graph (App-only) · + {% if ready %}einsatzbereit{% else %}nicht vollstaendig konfiguriert{% endif %} +{% endblock %} + +{% block topbar_extra %} + {% if current_user.has('settings_mail.execute') %} +
+ + +
+ {% endif %} +{% endblock %} + +{% block content %} +{{ alert('Der Versand laeuft ueber den Client-Credentials-Flow von Microsoft Graph -- kein SMTP, +kein zusaetzliches Paket. Die Anwendung braucht in Azure die Anwendungsberechtigung +Mail.Send. Der Verbindungstest holt nur ein Token und verschickt +bewusst keine echte Nachricht.', 'info') }} + +
+
+
+
{{ icon('mail', size=17) }} Zugang
+
+
+ +
+ {{ checkbox('graph_enabled', 'E-Mail-Versand aktiv', checked=values.graph_enabled, + disabled=not current_user.has('settings_mail.edit')) }} +
+ {{ field('graph_tenant_id', 'Verzeichnis-ID (Tenant)', value=values.graph_tenant_id, + disabled=not current_user.has('settings_mail.edit')) }} + {{ field('graph_client_id', 'Anwendungs-ID (Client)', value=values.graph_client_id, + disabled=not current_user.has('settings_mail.edit')) }} + {{ field('graph_sender', 'Absender-Postfach', value=values.graph_sender, type='email', + disabled=not current_user.has('settings_mail.edit')) }} + {{ field('graph_reply_to', 'Antwortadresse', value=values.graph_reply_to, type='email', + disabled=not current_user.has('settings_mail.edit')) }} +
+
+ Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %} + {{ field('client_secret', 'Neues Secret (leer = unveraendert)', type='password', + autocomplete='new-password', disabled=not can_secrets) }} + {% if not can_secrets %} +

+ Zum Setzen des Secrets fehlt Ihnen das Recht „Geheimnisse“. +

+ {% endif %} +
+
+ {% if current_user.has('settings_mail.edit') %} + + {% endif %} +
+
+ +
+
+
{{ icon('info', size=17) }} Wofuer der Versand genutzt wird
+
+
+
    +
  • Lizenzdatei an den Kunden senden (mit Anhang).
  • +
  • Zugangslink fuer das Kundenportal.
  • +
+
+

+ Das Secret liegt AES-256-GCM-verschluesselt in der Datenbank, gebunden an seinen + Verwendungszweck. Ohne die Schluesseldatei dieser Installation ist es nicht lesbar. +

+
+
+
+{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/settings_system.html b/apps/tesm-license/src/tesm_license/templates/license/settings_system.html new file mode 100644 index 0000000..b26887d --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/settings_system.html @@ -0,0 +1,134 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, select_field, checkbox, alert %} + +{% block page_title %}Systemeinstellungen{% endblock %} +{% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %} + +{% block content %} +{% if not sysops_available %} + {{ alert('Systemhelfer nicht verfuegbar -- Hostname und Zeitzone koennen hier nur angezeigt, + nicht geaendert werden.', 'info') }} +{% endif %} + +
+
+
+
+
+
{{ icon('users', size=17) }} Kundenportal und Self-Service
+
Zwei unabhaengige Zeitfenster: Link und Sitzung
+
+
+
+ +
+ {{ checkbox('portal_enabled', 'Kundenportal aktiv', checked=values.portal_enabled) }} +
+ {{ field('portal_link_minutes', 'Zugangslink gueltig (Minuten)', + value=values.portal_link_minutes, type='number', + hint_text=specs['portal_link_minutes'].hint) }} + {{ field('portal_session_minutes', 'Portalsitzung gueltig (Minuten)', + value=values.portal_session_minutes, type='number', + hint_text=specs['portal_session_minutes'].hint) }} +
+
+ {{ checkbox('self_service_enabled', 'Self-Service-Link je Ticket aktiv', + checked=values.self_service_enabled, + hint_text=specs['self_service_enabled'].hint) }} + {{ checkbox('allow_customer_revoke', 'Kunden duerfen ihre Lizenz selbst freigeben', + checked=values.allow_customer_revoke, + hint_text=specs['allow_customer_revoke'].hint) }} +
+ +
+
+ +
+
+
+
{{ icon('ticket', size=17) }} Vorgaben und Protokolle
+
+
+
+ +
+
+ {{ field('default_valid_days', 'Vorgeschlagene Laufzeit neuer Tickets (Tage)', + value=values.default_valid_days, type='number') }} + {{ 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') }} +
+
+ +
+
+
+ +
+
+
+
{{ 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
+
+
+
+ +
+
    + {% 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 %} +
+
+ +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/settings_vendor.html b/apps/tesm-license/src/tesm_license/templates/license/settings_vendor.html new file mode 100644 index 0000000..c2e5e46 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/settings_vendor.html @@ -0,0 +1,84 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, textarea_field, alert %} + +{% block page_title %}Anbieterdaten{% endblock %} +{% block page_sub %}Reisen in jeder ausgestellten Lizenz mit{% endblock %} + +{% block content %} +{{ alert('Diese Angaben werden in jede Lizenzdatei eingebettet und beim Kunden auf der +Lizenzseite angezeigt -- inklusive Logo. Der oeffentliche Endpunkt entscheidet zusaetzlich, +welche Basis-URL in kundengerichteten Links steht.', 'info') }} + +
+
+
+
{{ icon('building', size=17) }} Kontakt
+
+
+ +
+
+ {{ field('vendor_name', 'Anbietername', value=values.vendor_name, + disabled=not current_user.has('settings_vendor.edit')) }} + {{ field('vendor_email', 'E-Mail', value=values.vendor_email, type='email', + disabled=not current_user.has('settings_vendor.edit')) }} + {{ field('vendor_phone', 'Telefon', value=values.vendor_phone, + disabled=not current_user.has('settings_vendor.edit')) }} +
+ {{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }} + {{ field('master_endpoint', 'Oeffentlicher Endpunkt', value=values.master_endpoint, + placeholder='https://lizenz.firma.de', + disabled=not current_user.has('settings_vendor.edit'), + hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in + jede Lizenz eingebettet und fuer alle kundengerichteten Links verwendet -- statt + des Host-Headers der aktuellen Anfrage, der bei internen Aufrufen falsch waere.', + extra_class='field--full') }} +
+ Logo (maximal {{ max_logo_kb }} KB) +
+ {% if values.vendor_logo_base64 %} + Aktuelles Logo + {% else %} +

Kein Logo hinterlegt.

+ {% endif %} + + + Ein zu grosses Logo wird abgelehnt, die uebrigen Angaben werden trotzdem gespeichert. + +
+
+
+ {% if current_user.has('settings_vendor.edit') %} + + {% endif %} +
+
+ +
+
+
{{ icon('info', size=17) }} Wo das erscheint
+
+
+
    +
  • Auf der Lizenzseite der Kundeninstanz (Name, Kontakt, Logo).
  • +
  • In der E-Mail, die eine Lizenz ausliefert.
  • +
  • Im Self-Service und im Kundenportal.
  • +
+
+

+ Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine + Aenderung hier wirkt auf neue Lizenzen -- bestehende behalten ihren Stand, bis eine neue + Revision signiert wird. +

+
+
+
+{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/signing_key.html b/apps/tesm-license/src/tesm_license/templates/license/signing_key.html new file mode 100644 index 0000000..0baf53f --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/signing_key.html @@ -0,0 +1,34 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import alert %} +{% block page_title %}Signaturschluessel{% endblock %} +{% block page_sub %}Schluessel-ID {{ key_id }}{% endblock %} +{% block breadcrumb %} + +{% endblock %} +{% block content %} +{{ alert('Der oeffentliche Teil ist kein Geheimnis -- er reist in jeder ausgestellten Lizenz mit. +Ihn hier anzuzeigen erlaubt es, ihn unabhaengig zu vergleichen, bevor ein Kunde ihn als +vertrauenswuerdig hinterlegt.', 'info') }} +{% if health.problems %} + {% for problem in health.problems %}{{ alert(problem, 'danger') }}{% endfor %} +{% endif %} +
+
+
{{ icon('key', size=17) }} Oeffentlicher Schluessel
+
Erzeugt {{ created_at | dt }}
+ +
+
+
+
Schluessel-ID
{{ key_id }}
+
Verfahren
Ed25519 (EdDSA)
+
+
{{ public_key }}
+ {{ alert('Der private Teil liegt ausschliesslich auf diesem Host (Modus 0600) und ist aus dem + Deployment-Abgleich ausgeschlossen. Ginge er verloren, waere jede + ausgestellte Lizenz nicht mehr pruefbar -- bitte getrennt sichern.', 'warning') }} +
+
+{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html b/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html new file mode 100644 index 0000000..1d75ca1 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html @@ -0,0 +1,259 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, checkbox, textarea_field, pill, alert, empty_state %} + +{% block page_title %}Ticket {{ ticket.id[:12] }}…{% endblock %} +{% block page_sub %} + {{ ticket.customer_name }} · {{ type_labels.get(ticket.type, ticket.type) }} + {% if ticket.role != 'customer' %}· {{ role_labels.get(ticket.role, ticket.role) }}{% endif %} +{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block content %} +{% if endpoint_missing %} + {{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }} +{% endif %} +{% if ticket.status == 'blocked' %} + {{ alert('Dieses Ticket ist gesperrt. Es lassen sich keine neuen Lizenzen ausstellen -- weder + hier noch ueber den Self-Service des Kunden. Bereits ausgestellte Lizenzen bleiben gueltig.', + 'danger', 'Gesperrt') }} +{% endif %} + +
+
+
+
+
+
{{ icon('shield', size=17) }} Lizenz ausstellen
+
+ {% if has_open_license %} + Es existiert bereits eine offene Lizenz -- widerrufen Sie sie zuerst. + {% else %} + Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets. + {% endif %} +
+
+
+
+ {% if current_user.has('licenses.create') %} +
+ + +
+ {% if ticket.role == 'customer' %} +
+ + + +
+ {% endif %} + {% else %} +

Ihnen fehlt das Recht, Lizenzen auszustellen.

+ {% endif %} +
+
+ +
+
+
+
{{ icon('history', size=17) }} Lizenzen dieses Tickets
+
{{ licenses | length }} insgesamt
+
+
+ {% if licenses %} +
+ + + + {% for entry in licenses %} + + + + + + + + {% endfor %} + +
LizenzAusgestelltZustandLaeuft abHost
+ + {{ entry.license_id[:12] }}… + {% if entry.type != ticket.type %}{{ pill(entry.type, 'accent') }}{% endif %} + {{ entry.issued_at | dt }} + {% if entry.status == 'active' %}{{ pill('aktiv', 'online') }} + {% elif entry.status == 'issued' %}{{ pill('ausgestellt', 'info') }} + {% elif entry.status == 'revoked' %}{{ pill('widerrufen', 'danger') }} + {% else %}{{ pill('deaktiviert', '') }}{% endif %} + + {% if entry.lifetime %}unbefristet{% else %}{{ entry.expires_at | date }}{% endif %} + {{ entry.hostname or '–' | safe }}
+
+ {% else %} + {{ empty_state('Noch keine Lizenz', 'Stellen Sie die erste Lizenz aus.', 'shield') }} + {% endif %} +
+ + {% if self_service_url %} +
+
+
+
{{ icon('link', size=17) }} Self-Service fuer den Kunden
+
+ Ohne Anmeldung nutzbar – die Berechtigung ist der Besitz dieses Links +
+
+
+
+
+ + +
+

+ Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und + neu ausstellen. Der Link enthaelt 192 Bit Zufall und ist nicht ratbar -- behandeln Sie + ihn wie ein Passwort. +

+
+
+ + +
+

+ Kundenportal: der Kunde fordert dort selbst einen zeitlich begrenzten Zugangslink an + und pflegt seine Stammdaten. +

+
+
+ {% elif ticket.role != 'customer' %} + {{ alert('Fuer Lizenzserver-Tickets ist der Self-Service vollstaendig gesperrt -- diese + Lizenzen werden ausschliesslich hier verwaltet.', 'info') }} + {% endif %} +
+ +
+
+
+
{{ icon('sliders', size=17) }} Ticketdaten
+ {% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }} + {% else %}{{ pill('offen', 'online') }}{% endif %} +
+
+ +
+ {{ alert('Aenderungen wirken auf die naechste Ausstellung. Kunde und Rolle sind + unveraenderlich.', 'info') }} + + {% if ticket.role == 'customer' %} +
+ Module +
+ {% for module in modules %} + {{ checkbox('modules', module_labels[module], value=module, + checked=module in ticket_modules, + disabled=not current_user.has('tickets.edit')) }} + {% endfor %} +
+
+ {% endif %} + {{ field('valid_days', 'Laufzeit (Tage)', value=ticket.valid_days, type='number', + disabled=not current_user.has('tickets.edit')) }} + {{ checkbox('lifetime', 'Unbefristet', checked=ticket.lifetime, + disabled=not current_user.has('tickets.edit')) }} + {{ textarea_field('notes', 'Interne Notiz', value=ticket.notes, rows=2) }} +
+
+
Ticket-ID
{{ ticket.id }}
+
Rolle
{{ role_labels.get(ticket.role, ticket.role) }}
+
Angelegt
{{ ticket.created_at | dt }} von {{ ticket.created_by or '-' }}
+
+
+ {% if current_user.has('tickets.edit') %} + + {% endif %} +
+
+ + {% if current_user.has('tickets.edit') or current_user.has('tickets.delete') %} +
+
+
{{ icon('lock', size=17) }} Sperre und Loeschung
+
+
+ {% if current_user.has('tickets.edit') %} +
+ + +
+ {% endif %} + {% if current_user.has('tickets.delete') %} +
+ + +
+ {% endif %} +
+
+ {% endif %} +
+
+{% endblock %} + +{% block scripts %} +{% if ticket.role == 'customer' %} + +{% endif %} +{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/ticket_new.html b/apps/tesm-license/src/tesm_license/templates/license/ticket_new.html new file mode 100644 index 0000000..0f139d8 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/ticket_new.html @@ -0,0 +1,139 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import field, checkbox, textarea_field, alert, pill %} + +{% block page_title %}Ticket anlegen{% endblock %} +{% block page_sub %}Kunde, Typ, Module und Laufzeit{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block content %} +
+ +
+
+
+
+
{{ icon('ticket', size=17) }} Grunddaten
+
+
+ + + + +
+ Module (nur bei „Individuell“) +
+ {% for module in modules %} + {{ checkbox('modules', module_labels[module], value=module) }} + {% endfor %} +
+
+ +
+ {{ field('valid_days', 'Laufzeit (Tage)', value=default_valid_days, type='number') }} +
+ {{ checkbox('lifetime', 'Unbefristet', + hint_text='Nicht kombinierbar mit einer Testlizenz -- das waere eine + permanente Lizenz ohne Module.') }} + {{ textarea_field('notes', 'Interne Notiz', rows=2) }} +
+
+ + {% if may_create_license_server %} +
+
+
+
{{ icon('share', size=17) }} Lizenzserver-Lizenz
+
+ Erzeugt eine neue Vertrauensgrenze -- der Empfaenger verwaltet danach eigene Kunden +
+
+
+
+ {{ alert('Ein so lizenzierter Lizenzserver darf alles, was ein Lizenzserver koennen + soll -- ausser selbst weitere Lizenzserver-Lizenzen auszustellen. Er meldet seinen + Bestand automatisch hierher zurueck. Module sind fuer diese Rolle bedeutungslos und + werden ignoriert. Self-Service und Kundenportal sind fuer solche Tickets gesperrt.', + 'warning') }} + +
+
+ {% endif %} +
+ +
+
+
{{ icon('info', size=17) }} Wie es weitergeht
+
+
+
    +
  1. Ticket anlegen (dieser Schritt).
  2. +
  3. Auf der Ticketseite eine Lizenz ausstellen -- optional als 30-Tage-Testlizenz mit + allen Modulen.
  4. +
  5. Lizenzdatei herunterladen oder per E-Mail versenden.
  6. +
  7. Der Kunde spielt sie ein und aktiviert sie; die Aktivierung bindet sie an seine + Maschine.
  8. +
+
+

+ Der Self-Service-Link des Tickets erlaubt dem Kunden, seine Lizenz selbst + herunterzuladen und bei einem Systemwechsel freizugeben -- ohne Support-Anruf. +

+
+ +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/tickets.html b/apps/tesm-license/src/tesm_license/templates/license/tickets.html new file mode 100644 index 0000000..2ed5730 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/templates/license/tickets.html @@ -0,0 +1,91 @@ +{% extends "tesm_core/base.html" %} +{% from "tesm_core/macros.html" import pill, search_box, empty_state, alert %} + +{% block page_title %}Tickets{% endblock %} +{% block page_sub %} + {{ tickets | length }} Tickets · ein Ticket ist der dauerhafte Anspruch auf eine Lizenz +{% endblock %} + +{% block topbar_extra %} + {{ search_box('tickets-table', 'Kunde, Typ, ID ...') }} + {% if current_user.has('tickets.create') %} + + {{ icon('plus', size=15) }} Ticket anlegen + + {% endif %} +{% endblock %} + +{% block content %} +{% if endpoint_missing %} + {{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }} +{% endif %} +{{ alert('Ein Ticket beschreibt den Anspruch (Typ, Module, Laufzeit); die Lizenz ist die daraus +erzeugte, signierte Datei. Aenderungen am Ticket wirken auf die naechste +Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.', 'info') }} + +
+
+ + + + + + + + + + + + + + {% for ticket in tickets %} + + + + + + + + + + {% endfor %} + +
KundeTicketTypModuleLaufzeitZustandOffene Lizenz
+ + {{ ticket.customer_name }} + + {% if ticket.role != 'customer' %} + {{ pill(role_labels.get(ticket.role, ticket.role), 'accent') }} + {% endif %} + + {{ ticket.id[:12] }}… + {{ type_labels.get(ticket.type, ticket.type) }} + {% set mods = ticket.modules | from_json %} + {% if mods %}{{ mods | map('replace', '_', ' ') | join(', ') }} + {% else %}keine{% endif %} + + {% if ticket.lifetime %}{{ pill('unbefristet', 'online') }} + {% else %}{{ ticket.valid_days }} Tage{% endif %} + + {% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }} + {% else %}{{ pill('offen', 'online') }}{% endif %} + + {% set entry = open_licenses.get(ticket.id) %} + {% if entry %} + + {{ entry.status }} + + {% if entry.lifetime %}{{ pill('unbefristet', 'online') }} + {% elif entry.color == 'red' %}{{ pill(entry.days_left ~ ' Tage', 'danger') }} + {% elif entry.color == 'orange' %}{{ pill(entry.days_left ~ ' Tage', 'warning') }} + {% else %}{{ pill(entry.days_left ~ ' Tage', 'online') }}{% endif %} + {% else %} + keine + {% endif %} +
+
+ {% if not tickets %} + {{ empty_state('Keine Tickets', 'Legen Sie das erste Ticket fuer einen Kunden an.', 'ticket') }} + {% endif %} +
+{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/wsgi.py b/apps/tesm-license/src/tesm_license/wsgi.py new file mode 100644 index 0000000..c478dc7 --- /dev/null +++ b/apps/tesm-license/src/tesm_license/wsgi.py @@ -0,0 +1,7 @@ +"""WSGI-Einstiegspunkt: ``gunicorn tesm_license.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-license b/etc/nginx/sites-available/tesm-license deleted file mode 100644 index c35b53b..0000000 --- a/etc/nginx/sites-available/tesm-license +++ /dev/null @@ -1,18 +0,0 @@ -server { - listen 80; - server_name _; - - location / { - client_max_body_size 4m; - 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-license/static/; - expires 7d; - } -} diff --git a/etc/systemd/system/tesm-license.service b/etc/systemd/system/tesm-license.service deleted file mode 100644 index 56dc1a9..0000000 --- a/etc/systemd/system/tesm-license.service +++ /dev/null @@ -1,27 +0,0 @@ -[Unit] -Description=TESM Lizenzserver (Master) -After=network.target - -[Service] -Type=simple -User=root -WorkingDirectory=/srv/tesm-license -# Analog zu TESM_WEB_PROCESS bei TESM selbst -- markiert diesen Prozess als -# den echten Web-Dienst (startet den Lizenz-Heartbeat- und den Auditlog- -# Archivierungs-Thread genau einmal). Anders als bei TESM importiert hier -# kein Hilfsskript app.py periodisch als Modul (kein Geräte-Polling auf -# dem Lizenzserver) -- das Flag bleibt trotzdem gesetzt, für den Fall -# künftiger Hilfsskripte und zur Konsistenz mit dem TESM-Muster. -Environment=TESM_LICENSE_WEB_PROCESS=1 -# Kein WebSocket-Terminal wie bei TESM -- gthread/--timeout 0 daher nicht -# nötig, ein paar Threads reichen für die parallele Aktivierungs-/ -# Heartbeat-API mehrerer Kunden. -# "--bind 127.0.0.1:5000" wie bei TESM: nur über den nginx-Reverse-Proxy -# von außen erreichbar. -ExecStart=/srv/tesm-license/venv/bin/gunicorn --workers 1 --threads 8 \ - --bind 127.0.0.1:5000 app:app -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target diff --git a/install.sh b/install.sh deleted file mode 100644 index 0886d73..0000000 --- a/install.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/bin/bash -# ============================================================================ -# TESM-Lizenzserver — Installer/Updater -# Richtet System-Pakete und die Flask-App (systemd + nginx) unter -# /srv/tesm-license ein. Ursprünglich als Teil des TESM-Hauptrepos gebaut -# (Fork von dessen install.sh) und später in dieses eigenständige Repo -# ausgelagert -- Backup + Health-Check + automatischer Rückroll bei einem -# fehlgeschlagenen Update funktionieren identisch zu TESMs eigenem -# Installer, hier nur die Kurzfassung je Schritt. -# -# Auszuführen als root, nachdem dieses Repo/Release-Paket z.B. nach -# /root/tesm-license entpackt wurde: -# sudo ./install.sh -# ============================================================================ -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-license-install.log; } - -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 2>/dev/null || true - -# ---- Bestehende Installation erkennen ---- -EXISTING_INSTALL=0 -[ -f /srv/tesm-license/sqlite.db ] && EXISTING_INSTALL=1 - -NEEDS_MIGRATION_GUARD=0 -BACKUP_DIR="" - -if [ "$EXISTING_INSTALL" -eq 1 ]; then - OLD_SCHEMA_VERSION="" - [ -f /srv/tesm-license/SCHEMA_VERSION ] && OLD_SCHEMA_VERSION="$(tr -d '[:space:]' < /srv/tesm-license/SCHEMA_VERSION)" - NEW_SCHEMA_VERSION="" - [ -f "$REPO_DIR/srv/tesm-license/SCHEMA_VERSION" ] && NEW_SCHEMA_VERSION="$(tr -d '[:space:]' < "$REPO_DIR/srv/tesm-license/SCHEMA_VERSION")" - - step "Stopping tesm-license.service for update" - systemctl stop tesm-license.service 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 - echo -e "${YELLOW}Bestehende Installation erkannt, Schema-Version geändert oder unbekannt:${NC} Update mit automatischer Migration." - fi - NEEDS_MIGRATION_GUARD=1 - BACKUP_DIR="/srv/tesm-license-backup-pre-update-$(date +%Y%m%d-%H%M%S)" - step "Backing up current installation to $BACKUP_DIR before update" - cp -a /srv/tesm-license "$BACKUP_DIR" - print_status "Backup created" -fi - -# ---- Pakete ---- -step "Installing system packages" -sudo apt-get update >>/var/log/tesm-license-install.log 2>&1 && print_status "apt update" -sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 rsync logrotate certbot >>/var/log/tesm-license-install.log 2>&1 && print_status "Packages installed" - -# ---- Log-Verzeichnis ---- -sudo mkdir -p /var/log/tesm-license -sudo chmod 755 /var/log/tesm-license - -# ---- App-Verzeichnis ---- -step "Deploying application to /srv/tesm-license" -sudo mkdir -p /srv/tesm-license -sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \ - --exclude 'license.json' --exclude 'master_signing_key.json' \ - "$REPO_DIR/srv/tesm-license/" /srv/tesm-license/ >>/var/log/tesm-license-install.log 2>&1 -print_status "Application files copied" - -# ---- Python venv ---- -step "Setting up Python virtual environment" -cd /srv/tesm-license -sudo python3 -m venv venv -sudo ./venv/bin/python3 -m pip install --upgrade pip >>/var/log/tesm-license-install.log 2>&1 -sudo ./venv/bin/python3 -m pip install -r requirements.txt >>/var/log/tesm-license-install.log 2>&1 -print_status "Virtual environment ready" - -# ---- Datenbank ---- -if [ ! -f /srv/tesm-license/sqlite.db ]; then - step "Initializing database" - sudo ./venv/bin/python3 create_db.py >>/var/log/tesm-license-install.log 2>&1 - print_status "Database initialized" - echo -e "${RED}→${NC} Kein Admin-Benutzer vorhanden. Bitte danach ausführen:" - echo " sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_admin.py" - echo -e "${RED}→${NC} Und die eigene Bootstrap-Lizenz erzeugen:" - echo " sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_master_license.py" -fi - -# ---- systemd ---- -step "Copying systemd unit" -UNITS_CHANGED=0 -install_if_changed "$REPO_DIR/etc/systemd/system/tesm-license.service" /etc/systemd/system/tesm-license.service 644 && UNITS_CHANGED=1 -print_status "Unit copied" - -# ---- nginx ---- -step "Configuring nginx reverse proxy" -if [ ! -f /etc/nginx/sites-available/tesm-license ]; then - sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm-license" /etc/nginx/sites-available/tesm-license -fi -sudo ln -sf /etc/nginx/sites-available/tesm-license /etc/nginx/sites-enabled/tesm-license -sudo rm -f /etc/nginx/sites-enabled/default -sudo nginx -t >>/var/log/tesm-license-install.log 2>&1 && sudo systemctl reload nginx -print_status "nginx configured" - -# ---- Dienst aktivieren ---- -step "Enabling service" -if [ "$UNITS_CHANGED" -eq 1 ]; then - sudo systemctl daemon-reload -fi -sudo systemctl enable --now tesm-license.service -print_status "Service enabled and started" - -# ---- Sicherheitsnetz prüfen ---- -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-license.service 2>/dev/null || true - FAILED_DIR="/srv/tesm-license-failed-update-$(date +%Y%m%d-%H%M%S)" - sudo mv /srv/tesm-license "$FAILED_DIR" - sudo cp -a "$BACKUP_DIR" /srv/tesm-license - sudo systemctl start tesm-license.service - echo -e "${YELLOW}Zurückgerollt auf den Stand vor dem Update.${NC}" - echo "Backup bleibt erhalten unter: ${BACKUP_DIR}" - echo "Die fehlgeschlagene Installation liegt zur Fehlersuche unter: ${FAILED_DIR}" - echo "Bitte /var/log/tesm-license/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-license/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-license/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-license/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/srv/tesm-license/SCHEMA_VERSION b/srv/tesm-license/SCHEMA_VERSION deleted file mode 100644 index 00750ed..0000000 --- a/srv/tesm-license/SCHEMA_VERSION +++ /dev/null @@ -1 +0,0 @@ -3 diff --git a/srv/tesm-license/VERSION b/srv/tesm-license/VERSION deleted file mode 100644 index c04c650..0000000 --- a/srv/tesm-license/VERSION +++ /dev/null @@ -1 +0,0 @@ -1.2.7 diff --git a/srv/tesm-license/app.py b/srv/tesm-license/app.py deleted file mode 100644 index 8d30e2e..0000000 --- a/srv/tesm-license/app.py +++ /dev/null @@ -1,6896 +0,0 @@ -#!/usr/bin/env python3 -""" -TESM-License — Master-Lizenzserver, als Fork von TESM (TimEShepManager) -entstanden. Alles Geräte-/PoE-/Switch-spezifische (Devices, Switche, -Zugangsdaten, Wartung, DHCP, Dateifreigaben, Papierkorb, manueller -PoE-Neustart, SSH-Terminal) wurde entfernt -- ein Lizenzserver verwaltet -Kunden und Lizenzen, keine Netzwerkgeräte. - -Unverändert aus TESM übernommen: Login/Session/Benutzer- und -Gruppenverwaltung, LDAP/AD-Anbindung, NGINX- und Systemeinstellungen, -Live-Log/Log-Verlauf/Auditlog, das Im-/Export-Grundgerüst sowie das in -Phase 1 ergänzte Lizenzsystem selbst (licensing.py). Das Dashboard ("/") -ist aktuell nur ein Platzhalter -- die eigentliche Kunden-/ -Lizenzübersicht folgt in einem späteren Schritt. -""" -from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_file, abort, session -from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user -from flask_bcrypt import Bcrypt -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, io, ipaddress, logging, math, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback, zipfile -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 urllib.parse -import licensing - -LDAP_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941" - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) - -DB_PATH = os.environ.get("TESM_LICENSE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db")) -FERNET_KEY_PATH = os.environ.get("TESM_LICENSE_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key")) -SECRET_KEY_PATH = os.environ.get("TESM_LICENSE_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") - -# Eigener Pfad-Namespace (tesm-license statt tesm) -- läuft testweise auch -# NEBEN einer echten TESM-Instanz auf demselben Host (siehe Phase 2/3 der -# Lizenzsystem-Umstellung), da sonst beide Dienste dieselben Log-/Config- -# Pfade beanspruchen würden. -TESM_LOG_DIR = os.environ.get("TESM_LICENSE_LOG_DIR", "/var/log/tesm-license") -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") -LOGROTATE_CONFIG_PATH = os.environ.get("TESM_LICENSE_LOGROTATE_CONFIG", "/etc/logrotate.d/tesm-license") - -# 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 -ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"} - -NGINX_SITE_CONFIG_PATH = os.environ.get("TESM_LICENSE_NGINX_CONFIG", "/etc/nginx/sites-available/tesm-license") -TESM_SSL_DIR = os.environ.get("TESM_LICENSE_SSL_DIR", "/etc/tesm-license/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_LICENSE_ACME_WEBROOT", "/var/www/tesm-license-acme") -CERTBOT_DEPLOY_HOOK_PATH = "/etc/letsencrypt/renewal-hooks/deploy/tesm-license-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() - -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_LICENSE_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() - -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 = { - "licensing_group": { - "label": "Lizenzierung", - "view_key": "licensing_group.view", - "children": { - "customers": { - "label": "Kunden", - "rows": {"view": "customers.view", "create": "customers.create", "edit": "customers.edit"}, - }, - "tickets": { - "label": "Tickets", - "rows": {"view": "tickets.view", "create": "tickets.create", "edit": "tickets.edit"}, - }, - "licenses": { - "label": "Lizenzen", - "rows": {"view": "licenses.view", "create": "licenses.create", "edit": "licenses.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"}}, - }, - }, - "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_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"}, - }, - }, - }, -} - -PERMISSION_LABELS = { - "licensing_group.view": "Lizenzierung-Bereich anzeigen", - "customers.view": "Kunden lesen", "customers.create": "Kunden anlegen", - "customers.edit": "Kunden ändern (inkl. Löschen)", - "tickets.view": "Tickets lesen", "tickets.create": "Tickets anlegen", - "tickets.edit": "Tickets ändern (Eckdaten, Sperren/Entsperren, Löschen)", - "licenses.view": "Lizenzen und Aktivierungsstatus lesen", - "licenses.create": "Lizenzen ausstellen", - "licenses.edit": "Lizenzen ändern (Deaktivieren/Widerrufen, erneut versenden)", - "logs_group.view": "Logs-Bereich anzeigen", - "settings_group.view": "Einstellungen-Bereich anzeigen", - "logs_live.view": "Live-Log lesen", "logs_activity.view": "Auditlog 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_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)", -} - -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 = [ - "logs_group.view", "logs_live.view", -] - -NAV_ITEMS = [ - {"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"}, - # Reihenfolge spiegelt bewusst die fachliche Hierarchie Kunde -> Ticket - # -> Lizenz wider -- jede Ebene eine eigene, direkt ueber die Navigation - # erreichbare Seite statt ineinander verschachtelter Detailansichten. - {"key": "customers", "label": "Kunden", "icon": "users", "endpoint": "customers"}, - {"key": "tickets", "label": "Tickets", "icon": "key", "endpoint": "tickets_list"}, - {"key": "licenses", "label": "Lizenzen", "icon": "shield", "endpoint": "licenses_list"}, - {"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"}, - {"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"}, - {"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_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"}, - {"key": "settings_nginx", "label": "NGINX", "icon": "server", "endpoint": "settings_nginx"}, - ]}, - {"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"}, - ]}, -] -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 == "tickets": - return user.has_permission("tickets.view") - if key == "licenses": - return user.has_permission("licenses.view") - if key == "customers": - return user.can_manage_customers - if key == "users": - return user.can_manage_users - if key == "groups": - return user.can_manage_groups - 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_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 - 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_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_manage_customers(self): - return self.has_any_permission( - ["customers.view", "customers.create", "customers.edit"] - ) - - @property - def can_manage_tickets(self): - return self.has_any_permission( - ["tickets.view", "tickets.create", "tickets.edit"] - ) - - @property - def can_manage_licenses(self): - return self.has_any_permission( - ["licenses.view", "licenses.create", "licenses.edit"] - ) - - @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_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(): - # timeout=15 statt Python-Default 5s: reine Verteidigungslinie gegen - # kurze, legitime Ueberschneidungen zweier Schreibzugriffe (SQLite - # erlaubt nur EINEN Schreiber gleichzeitig) -- ersetzt NICHT die - # eigentliche Regel, niemals eine zweite Verbindung waehrend einer noch - # offenen Schreibtransaktion auf derselben zu committen (siehe - # _process_activate für ein Beispiel, wo genau das den Fehler - # "database is locked" ausgeloest hat). - conn = sqlite3.connect(DB_PATH, timeout=15) - conn.row_factory = sqlite3.Row - return conn - - - - -def _ensure_schema(): - """Legt neue Tabellen (Gruppen/Rechte, Kunden/Lizenzen) an, falls die DB - noch aus einer aelteren Version stammt -- idempotent, sicher bei jedem - Start aufzurufen. Sorgt ausserdem dafuer, dass die Standardgruppe - 'Benutzer' existiert und jeder Benutzer ohne Gruppe ihr zugeordnet ist. - - Gegenueber TESM bewusst ohne jede devices/switches/credentials/dhcp_*- - Migration -- der Lizenzserver hat nie eine aeltere Version mit diesen - Tabellen gehabt (eigene, frische Schema-Zeitlinie ab SCHEMA_VERSION 1), - die entsprechenden TESM-Altlast-Migrationen waeren hier bloss totes - Gewicht.""" - 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 audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts TEXT NOT NULL, - username TEXT NOT NULL, - action TEXT NOT NULL, - target TEXT, - details TEXT - ) - """) - - for _table in ("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 license_customers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - contact_email TEXT, - contact_phone TEXT, - contact_person TEXT, - street TEXT, - house_number TEXT, - postal_code TEXT, - city TEXT, - portal_token TEXT, - notes TEXT, - created_at TEXT NOT NULL - ) - """) - customer_cols = {row["name"] for row in conn.execute("PRAGMA table_info(license_customers)").fetchall()} - # contact_person/street/house_number/postal_code/city/portal_token kamen - # nachtraeglich dazu (vorher nur Name+E-Mail+Telefon+Notizen) -- siehe - # Kachel-Dashboard/Kundenportal weiter unten, die eine vollstaendige - # Anschrift bzw. je Kunde einen eigenen Magic-Link-Token brauchen. - for _col, _ddl in ( - ("contact_person", "ALTER TABLE license_customers ADD COLUMN contact_person TEXT"), - ("street", "ALTER TABLE license_customers ADD COLUMN street TEXT"), - ("house_number", "ALTER TABLE license_customers ADD COLUMN house_number TEXT"), - ("postal_code", "ALTER TABLE license_customers ADD COLUMN postal_code TEXT"), - ("city", "ALTER TABLE license_customers ADD COLUMN city TEXT"), - ("portal_token", "ALTER TABLE license_customers ADD COLUMN portal_token TEXT"), - ): - if _col not in customer_cols: - conn.execute(_ddl) - # Historisch: ein dauerhafter Portal-Token pro Kunde (vorher Basis - # eines UNBEFRISTETEN Magic-Links). Dieser direkte, permanente Zugang - # wurde bewusst wieder entfernt -- der einzige Weg ins Kundenportal - # ist jetzt der zeitlich begrenzte Ablauf über portal_request() / - # portal_access() (siehe dort). Die Spalte samt Backfill bleibt - # bestehen (keine riskante Schema-Ruecknahme fuer reine Aufraeum- - # zwecke), wird aber fuer die Zugriffsentscheidung selbst nicht mehr - # herangezogen. - for _row in conn.execute("SELECT id FROM license_customers WHERE portal_token IS NULL OR portal_token=''").fetchall(): - conn.execute( - "UPDATE license_customers SET portal_token=? WHERE id=?", - (secrets.token_urlsafe(24), _row["id"]), - ) - conn.execute(""" - CREATE TABLE IF NOT EXISTS licenses ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - license_id TEXT UNIQUE NOT NULL, - customer_id INTEGER NOT NULL, - type TEXT NOT NULL, - modules TEXT NOT NULL DEFAULT '[]', - issued_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - license_pubkey TEXT NOT NULL, - license_file_json TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'issued', - fingerprint TEXT, - hostname TEXT, - activated_at TEXT, - deactivated_at TEXT, - last_heartbeat_at TEXT, - revoked_at TEXT, - role TEXT NOT NULL DEFAULT 'customer', - created_by TEXT, - created_at TEXT NOT NULL, - ticket_id TEXT, - lifetime INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY (customer_id) REFERENCES license_customers(id) - ) - """) - license_cols = {row["name"] for row in conn.execute("PRAGMA table_info(licenses)").fetchall()} - if "ticket_id" not in license_cols: - conn.execute("ALTER TABLE licenses ADD COLUMN ticket_id TEXT") - if "hostname" not in license_cols: - conn.execute("ALTER TABLE licenses ADD COLUMN hostname TEXT") - if "lifetime" not in license_cols: - conn.execute("ALTER TABLE licenses ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0") - - # Tickets: die dauerhafte, dem Kunden gehoerende Berechtigung "eine - # Lizenz mit diesen Eckdaten (Typ/Module/Laufzeit) zu halten" -- - # unabhaengig von der konkreten ausgestellten Lizenzdatei darunter. - # Admin ODER Kunde (per Self-Service-Link) koennen aus einem offenen - # Ticket jederzeit eine neue Lizenz erzeugen, z.B. nachdem die vorige - # widerrufen/deaktiviert wurde oder eine nie aktivierte Lizenz vom - # Admin geloescht wurde -- das Ticket selbst bleibt davon unberuehrt. - conn.execute(""" - CREATE TABLE IF NOT EXISTS license_tickets ( - id TEXT PRIMARY KEY, - customer_id INTEGER NOT NULL, - type TEXT NOT NULL, - modules TEXT NOT NULL DEFAULT '[]', - valid_days INTEGER NOT NULL, - lifetime INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'open', - role TEXT NOT NULL DEFAULT 'customer', - created_by TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (customer_id) REFERENCES license_customers(id) - ) - """) - ticket_cols = {row["name"] for row in conn.execute("PRAGMA table_info(license_tickets)").fetchall()} - if "lifetime" not in ticket_cols: - conn.execute("ALTER TABLE license_tickets ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0") - if "role" not in ticket_cols: - # Rolle der aus diesem Ticket auszustellenden Lizenz (siehe - # licensing.LICENSE_ROLES) -- "customer" (Default) fuer gewoehnliche - # TESM-Kundeninstanzen, "license_server" fuer einen damit - # auszustellenden Sub-Lizenzserver. Bestehende Tickets bleiben - # unveraendert "customer". - conn.execute("ALTER TABLE license_tickets ADD COLUMN role TEXT NOT NULL DEFAULT 'customer'") - - # DB-seitiger Schutz gegen die Doppel-Ausstellung eines Tickets: ohne - # dieses Backstop koennten zwei nahezu gleichzeitige Anfragen (z.B. ein - # Doppelklick auf den Self-Service-"Lizenz erstellen"-Button, zwei - # offene Tabs) beide den vorherigen _ticket_has_open_license()-Check - # noch als "frei" sehen, bevor die jeweils andere ihre INSERT committet - # -- ein klassisches Time-of-check/time-of-use-Problem, das reine - # Python-Logik (_ticket_has_open_license) allein nicht zuverlaessig - # verhindern kann. Der partielle Unique-Index erzwingt "hoechstens eine - # offene (issued/active) Lizenz pro Ticket" direkt auf DB-Ebene; die - # zweite, verlierende INSERT schlaegt mit IntegrityError fehl, was - # _issue_license_from_ticket() abfaengt (siehe dort). - # Bewusst defensiv: sollte eine bestehende Installation (aus welchem - # Grund auch immer) bereits Bestandsdaten haben, die diese Regel - # verletzen, soll das den Dienst nicht am Start hindern -- lieber ein - # geloggter Fehler als ein kompletter Ausfall wegen eines Datenrands, - # den diese Migration selbst gerade erst einfuehrt. - try: - conn.execute( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket " - "ON licenses(ticket_id) WHERE status IN ('issued','active')" - ) - except sqlite3.IntegrityError: - app.logger.error( - "Konnte idx_one_open_license_per_ticket nicht anlegen -- vermutlich hat mindestens ein " - "Ticket bereits mehr als eine offene Lizenz. Bitte manuell bereinigen (siehe licenses-Tabelle)." - ) - # Historisch (siehe Kommentar beim Backfill oben) -- portal_token wird - # fuer die eigentliche Zugriffsentscheidung nicht mehr genutzt, der - # Unique-Index bleibt trotzdem als harmlose Datenintegritaets- - # Absicherung bestehen. - try: - conn.execute( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_portal_token " - "ON license_customers(portal_token) WHERE portal_token IS NOT NULL AND portal_token != ''" - ) - except sqlite3.IntegrityError: - app.logger.error( - "Konnte idx_customers_portal_token nicht anlegen -- vermutlich haben zwei Kunden denselben " - "portal_token. Bitte manuell bereinigen (siehe license_customers-Tabelle)." - ) - - # Kurzlebige, EINMAL nutzbare Zugangs-Tokens fuer /portal/request (siehe - # dortiger Docstring): anders als der dauerhafte portal_token (oben, - # von Admins ueber die Ticket-/Kundenseite verteilt, bewusst - # unbefristet) ist DIESER Weg oeffentlich von JEDEM auf blossen - # Zuruf einer E-Mail-Adresse ausloesbar -- ein zusaetzliches - # zeitliches Sicherheitsnetz ist hier angemessen, waehrend die - # bereits etablierten, admin-verteilten Links (Ticket-Seite, - # Lizenz-per-E-Mail) unveraendert dauerhaft bleiben. - conn.execute(""" - CREATE TABLE IF NOT EXISTS portal_access_tokens ( - token TEXT PRIMARY KEY, - customer_id INTEGER NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - used_at TEXT, - FOREIGN KEY (customer_id) REFERENCES license_customers(id) - ) - """) - - # Sub-Lizenzserver-Reporting (siehe role="license_server" in - # licensing.py): ein Sub-Lizenzserver meldet bei jedem Heartbeat (und - # sofort nach jeder lokalen Aenderung, siehe _report_to_master_async()) - # einen vollstaendigen Schnappschuss seiner EIGENEN Kunden/Tickets/ - # Lizenzen. Bewusst DREI EIGENE Tabellen statt Wiederverwendung von - # license_customers/license_tickets/licenses -- das hier ist rein - # gemeldete, von diesem Master aus nicht editierbare Fremd-Information - # (der Master verwaltet diese Daten nicht selbst, er zeigt sie nur an), - # eine Vermischung mit selbst verwalteten Datensaetzen wuerde das - # verwischen. source_license_id verankert jede Zeile an genau der - # Lizenz (role='license_server'), die diesen Sub-Lizenzserver auf - # diesem Master identifiziert -- bei jedem neuen Bericht werden alle - # drei Tabellen fuer diese source_license_id komplett ersetzt (siehe - # _ingest_sub_report()), kein Diffing noetig, da ein voller - # Schnappschuss geschickt wird. - conn.execute(""" - CREATE TABLE IF NOT EXISTS reported_sub_customers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source_license_id TEXT NOT NULL, - remote_customer_ref TEXT NOT NULL, - name TEXT NOT NULL, - contact_email TEXT, contact_phone TEXT, contact_person TEXT, - street TEXT, house_number TEXT, postal_code TEXT, city TEXT, - last_reported_at TEXT NOT NULL, - UNIQUE (source_license_id, remote_customer_ref) - ) - """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS reported_sub_tickets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source_license_id TEXT NOT NULL, - remote_ticket_id TEXT NOT NULL, - remote_customer_ref TEXT NOT NULL, - type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]', - valid_days INTEGER, lifetime INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'open', role TEXT NOT NULL DEFAULT 'customer', - created_at TEXT, - last_reported_at TEXT NOT NULL, - UNIQUE (source_license_id, remote_ticket_id) - ) - """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS reported_sub_licenses ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source_license_id TEXT NOT NULL, - remote_license_id TEXT NOT NULL, - remote_ticket_id TEXT, - type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]', - issued_at TEXT, expires_at TEXT, status TEXT NOT NULL, lifetime INTEGER NOT NULL DEFAULT 0, - fingerprint TEXT, hostname TEXT, activated_at TEXT, last_heartbeat_at TEXT, - last_reported_at TEXT NOT NULL, - UNIQUE (source_license_id, remote_license_id) - ) - """) - conn.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_customers_source ON reported_sub_customers(source_license_id)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_tickets_source ON reported_sub_tickets(source_license_id)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_licenses_source ON reported_sub_licenses(source_license_id)") - - # Rückwirkend ein Ticket für jede Lizenz nachbilden, die vor Einführung - # des Ticket-Konzepts direkt ausgestellt wurde (ticket_id noch NULL) -- - # sonst würden genau diese Lizenzen (auf einer echten Installation z.B. - # eine bereits produktiv aktivierte Kundenlizenz!) im neuen, rein - # ticket-basierten Dashboard schlicht nicht mehr auftauchen. Eckdaten - # des Tickets werden 1:1 aus der jeweiligen Lizenz übernommen - # (Laufzeit aus issued_at/expires_at zurückgerechnet). - _migration_key_tickets = "_backfilled_tickets_for_legacy_licenses_v1" - if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_tickets,)).fetchone(): - legacy_licenses = conn.execute("SELECT * FROM licenses WHERE ticket_id IS NULL").fetchall() - for lic in legacy_licenses: - try: - issued_ts = datetime.strptime(lic["issued_at"], "%Y-%m-%dT%H:%M:%SZ") - expires_ts = datetime.strptime(lic["expires_at"], "%Y-%m-%dT%H:%M:%SZ") - valid_days = max(1, (expires_ts - issued_ts).days) - except (ValueError, TypeError): - valid_days = 365 - ticket_id = secrets.token_urlsafe(24) - conn.execute( - "INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, status, created_by, created_at) " - "VALUES (?, ?, ?, ?, ?, 'open', ?, ?)", - (ticket_id, lic["customer_id"], lic["type"], lic["modules"], valid_days, - lic["created_by"] or "system", lic["created_at"]), - ) - conn.execute("UPDATE licenses SET ticket_id=? WHERE license_id=?", (ticket_id, lic["license_id"])) - conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_tickets,)) - - _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_tickets_perm = "_migrated_tickets_permission_v1" - if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_tickets_perm,)).fetchone(): - # "Tickets" hatte bislang KEIN eigenes Recht -- Ticketverwaltung - # (anlegen/bearbeiten/sperren/loeschen) lief komplett ueber - # licenses.view/.create/.edit mit, obwohl Tickets ein eigener, - # gleichberechtigter Navigationspunkt neben Kunden und Lizenzen - # ist (genau wie bei TESM Clients/Switche/Zugangsdaten trotz enger - # fachlicher Verwandtschaft jeweils eigene Rechte haben). Backfill: - # jede Gruppe mit licenses.view/.create/.edit bekommt automatisch - # das gespiegelte tickets.view/.create/.edit dazu -- das - # Ausstellen einer Lizenz AUS einem Ticket heraus bleibt bewusst - # weiterhin an licenses.edit gebunden (siehe ticket_detail()), - # nur Sperren/Entsperren/Bearbeiten/Loeschen des Tickets selbst - # wandert auf das neue, unabhaengige Recht. - _license_to_ticket = { - "licenses.view": "tickets.view", - "licenses.create": "tickets.create", - "licenses.edit": "tickets.edit", - } - for license_perm, ticket_perm in _license_to_ticket.items(): - rows = conn.execute( - "SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (license_perm,) - ).fetchall() - for row in rows: - conn.execute( - "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)", - (row["group_id"], ticket_perm), - ) - conn.execute( - "INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_tickets_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 zwei eigene, per - logrotate rotierte Dateien -- die audit_log-Tabelle bleibt die Quelle - für die durchsuchbare/sortierbare Seite unter Logs → Auditlog, wächst - aber unbegrenzt weiter. - - Anders als bei TESM selbst (wo live.log kontinuierlich von poe.sh mit - Geräte-Erreichbarkeit gefüllt wird) gibt es hier keinen Hintergrund- - prozess, der live.log von sich aus beschreibt -- ohne diese Zeile blieb - die Datei auf einem frischen Lizenzserver schlicht leer/nicht - vorhanden, wodurch Logs → Live dauerhaft "Keine Logfiles gefunden" - zeigte. Jede Ticket-/Lizenzaktion (Ausstellung, Aktivierung, Widerruf, - Heartbeat-Fehler, ...) landet daher jetzt sowohl in changes.log - (unverändert) als auch in live.log, damit Live sowie die davon - abgeleitete Verlauf-Seite (rotierte live.log.N-Stände) tatsächlich - Inhalt haben. Ein Schreibfehler hier darf den eigentlichen, DB- - basierten Audit-Trail nicht gefährden — daher bewusst best-effort mit - breitem except je Datei.""" - line = f"{ts} [{who}] {action}" - if target: - line += f" — {target}" - if details: - line += f": {details}" - for path in (TESM_CHANGES_LOG_PATH, TESM_LIVE_LOG_PATH): - try: - with open(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 _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 die drei - App-Logs (Live, Auditlog, App) unter TESM_LOG_DIR 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 weder - Flask noch ein anderer schreibender Prozess ein Log-Reopen-Signal - (SIGHUP o.ä.) implementiert. "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 {{\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 _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) - 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 - - -# app.py wird nicht nur von gunicorn (tesm.service) gestartet, sondern bei -# jedem Import (auch als Hilfsmodul) sämtlicher Modul-Level-Code hier erneut -# ausgeführt. TESM_LICENSE_WEB_PROCESS wird nur von tesm-license.service -# selbst gesetzt (siehe dessen Unit-Datei) -- dient als Gate dafür, dass -# Hintergrund-Threads (Lizenz-Heartbeat, Auditlog-Archivierung) nur einmal -# je echtem Web-Prozess starten, nicht bei jedem Reimport. -_IS_WEB_PROCESS = os.environ.get("TESM_LICENSE_WEB_PROCESS") == "1" - - -# ============================================================ Lizenzsystem == -# Kryptographie/Protokoll: siehe licensing.py. Hier nur der TESM-seitige -# Zustand: In-Memory-Cache der aktuell installierten Lizenzdatei -- 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, Export). 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 can_issue_license_server_licenses(): - """STRUKTURELLE Sperre (siehe licensing.LICENSE_ROLES-Kommentar), keine - vergebbare Permission: True nur, wenn die EIGENE, gerade aktive Lizenz - dieser Instanz role=='master' ist -- also ausschließlich auf der - tatsächlichen Wurzel-Instanz (deren Lizenz per create_master_license.py - lokal erzeugt wurde), niemals auf einem per role='license_server' - lizenzierten Sub-Lizenzserver. Ein Sub-Lizenzserver liefert hier immer - False zurück, unabhängig davon, welche Gruppenrechte seine lokalen - Admins dort einrichten -- diese Sperre lässt sich also NICHT durch - lokale Rechtevergabe aufheben. Aufrufer müssen zusätzlich - current_user.is_admin selbst prüfen (siehe ticket_new()) -- diese - Funktion prüft nur die Instanz-Rolle, keine Benutzerrechte.""" - return bool( - license_active() - and _license_state["status"]["role"] == "master" - ) - - -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")) - - -MAX_REPORT_ROWS = 5000 - - -def _build_own_issuance_report(conn): - """Nur relevant, wenn DIESE Instanz selbst ein Sub-Lizenzserver ist - (eigene Lizenz mit role='license_server', siehe licensing.LICENSE_ROLES) - -- baut einen vollstaendigen Schnappschuss der EIGENEN, von dieser - Instanz selbst verwalteten Kunden/Tickets/Lizenzen (NICHT die eigene - Bootstrap-Lizenz dieser Instanz selbst -- die steht in license.json, - nicht in diesen Tabellen). Wird bei jedem Heartbeat an den Master - mitgeschickt (siehe _send_heartbeat()), der daraus seine - reported_sub_*-Tabellen befuellt (siehe _ingest_sub_report() auf der - Master-Seite). Ein voller Schnappschuss statt Deltas -- deutlich - einfacher korrekt zu halten (keine Delta-/Loeschverfolgung noetig), - bei den hier realistischen Datenmengen (Kunden eines einzelnen - Sub-Lizenzservers) unproblematisch.""" - customers = conn.execute( - "SELECT id, name, contact_email, contact_phone, contact_person, " - "street, house_number, postal_code, city FROM license_customers " - "ORDER BY id LIMIT ?", (MAX_REPORT_ROWS,) - ).fetchall() - tickets = conn.execute( - "SELECT id, customer_id, type, modules, valid_days, lifetime, status, role, created_at " - "FROM license_tickets ORDER BY created_at LIMIT ?", (MAX_REPORT_ROWS,) - ).fetchall() - licenses_rows = conn.execute( - "SELECT license_id, ticket_id, type, modules, issued_at, expires_at, status, lifetime, " - "fingerprint, hostname, activated_at, last_heartbeat_at " - "FROM licenses ORDER BY created_at LIMIT ?", (MAX_REPORT_ROWS,) - ).fetchall() - return { - "customers": [dict(r) for r in customers], - "tickets": [dict(r) for r in tickets], - "licenses": [dict(r) for r in licenses_rows], - } - - -def _report_to_master_async(): - """Feuert einen sofortigen Best-Effort-Heartbeat (inkl. Bericht, siehe - _build_own_issuance_report) in einem eigenen Hintergrund-Thread aus -- - aufgerufen direkt nach jeder lokalen Ticket-/Lizenz-Aenderung dieser - Instanz, damit der Master zeitnah einen aktuellen Stand sieht, statt bis - zu LICENSE_HEARTBEAT_INTERVAL_SECONDS (6h) warten zu muessen. Laeuft - bewusst asynchron und schluckt jeden Fehler (siehe _send_heartbeat) -- - ein nicht erreichbarer Master darf eine lokale Ticket-/Lizenzaktion - niemals verzoegern oder fehlschlagen lassen.""" - if _license_state.get("status") and _license_state["status"].get("role") == "license_server": - threading.Thread(target=_send_heartbeat, daemon=True).start() - - -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 - report = None - if _license_state.get("status", {}).get("role") == "license_server": - report_conn = get_db_connection() - try: - report = _build_own_issuance_report(report_conn) - finally: - report_conn.close() - request_payload = licensing.build_client_request("heartbeat", license_file, report=report) - 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() - - -# ==================================================== Master-Lizenzserver == -# Ab hier: alles, was NUR der Master braucht -- der Signaturschlüssel zum -# Ausstellen von Kundenlizenzen, Kunden-/Lizenz-Datenbankzugriff, die -# Aktivierungs-/Deaktivierungs-/Heartbeat-API für TESM-Clients (online per -# JSON-POST) sowie deren manuelle Offline-Code-Variante über die GUI. Die -# Funktionen weiter oben (license_active(), _send_heartbeat() usw.) gelten -# unverändert auch hier -- sie prüfen die EIGENE Bootstrap-Lizenz dieses -# Masters (siehe create_master_license.py), komplett unabhängig von den -# Kundenlizenzen, die der Master unten selbst ausstellt/verwaltet. - -MASTER_SIGNING_KEY_PATH = os.environ.get( - "TESM_LICENSE_MASTER_KEY", os.path.join(BASE_DIR, "master_signing_key.json") -) - - -def _load_or_create_master_signing_key(): - """Der EINE Master-Signaturschlüssel dieser Installation -- signiert - jede ausgestellte Kundenlizenz UND jede Aktivierungs-/Deaktivierungs-/ - Heartbeat-Antwort (siehe licensing.py-Moduldocstring). Wird beim ersten - Zugriff einmalig erzeugt und danach NIE automatisch rotiert: jede - Kundenlizenz trägt den zum Ausstellungszeitpunkt aktuellen - master_pubkey fest eingebettet -- ein späterer Schlüsselwechsel würde - die Offline-Verifikation künftiger Aktivierungs-/Heartbeat-Antworten - für ALLE bereits ausgegebenen Lizenzen brechen.""" - try: - with open(MASTER_SIGNING_KEY_PATH, "r", encoding="utf-8") as f: - data = json.load(f) - return data["private"], data["public"] - except (OSError, ValueError, KeyError): - priv, pub = licensing.generate_keypair() - with open(MASTER_SIGNING_KEY_PATH, "w", encoding="utf-8") as f: - json.dump({"private": priv, "public": pub}, f) - try: - os.chmod(MASTER_SIGNING_KEY_PATH, 0o600) - except OSError: - pass - return priv, pub - - -MASTER_PRIVATE_KEY, MASTER_PUBLIC_KEY = _load_or_create_master_signing_key() - -LICENSE_TYPE_LABELS = {"trial": "Trial", "standard": "Standard", "custom": "Custom", "enterprise": "Enterprise"} -MODULE_LABELS = {"dhcp": "DHCP", "fileshare": "Dateifreigaben", "maintenance": "Wartung"} - - -def _get_vendor_info(): - """Anbieter-Kontaktdaten -- einmal zentral in den Systemeinstellungen - gepflegt statt bei jeder Ausstellung erneut eingegeben (steht in jeder - ausgestellten Lizenz, siehe licensing.issue_license).""" - return { - "name": get_setting("vendor_name", ""), - "phone": get_setting("vendor_phone", ""), - "email": get_setting("vendor_email", ""), - "address": get_setting("vendor_address", ""), - "logo_base64": get_setting("vendor_logo_base64", ""), - } - - -def _get_master_endpoint(): - """Die von aussen erreichbare URL DIESES Masters -- wird in jede - ausgestellte Lizenz eingebettet, damit der Client weiss, wo er - aktivieren/heartbeaten soll. Admin-gepflegt statt hart codiert, da - jede Installation ihre eigene Domain hat.""" - return get_setting("master_endpoint", "") - - -def _customer_facing_url(endpoint, **values): - """Baut eine externe URL fuer an KUNDEN gerichtete Magic-Links (Self- - Service, Kundenportal-Anfrage/-Zugang, Lizenz-per-E-Mail) auf Basis - des admin-gepflegten Lizenzserver-Endpunkts (siehe _get_master_endpoint(), - Einstellungen -> Lizenz -> Anbieter & Lizenzserver) -- NICHT per - url_for(..., _external=True), das stattdessen den Host-Header der - GERADE laufenden Anfrage verwendet und damit z.B. die interne IP - zeigt, ueber die zufaellig der Admin gerade eingeloggt ist, statt der - fuer Kunden eigentlich gedachten oeffentlichen Domain. Faellt auf die - normale request-basierte externe URL zurueck, wenn der Endpunkt noch - nicht konfiguriert ist (z.B. auf einer frischen Installation).""" - base = _get_master_endpoint().rstrip("/") - if not base: - return url_for(endpoint, _external=True, **values) - return base + url_for(endpoint, **values) - - -def _customer_row(conn, customer_id): - return conn.execute("SELECT * FROM license_customers WHERE id=?", (customer_id,)).fetchone() - - -def _license_row(conn, license_id): - return conn.execute("SELECT * FROM licenses WHERE license_id=?", (license_id,)).fetchone() - - -def _license_dict_for_display(row, now=None): - """Reichert eine DB-Zeile aus 'licenses' um abgeleitete Anzeigefelder - an (Modul-Liste, Resttage, Ampelfarbe) -- teilt sich Dashboard, - Ticket-/Lizenz-Detailseite und Self-Service. - - Lifetime-Lizenzen (row['lifetime']) haben zwar ein konkretes, weit in - der Zukunft liegendes expires_at (siehe licensing.LIFETIME_EXPIRES_AT), - zeigen aber bewusst weder eine Resttage-Zahl noch einen Ablaufhinweis - an -- days_left bleibt None, is_expired bleibt False, color ist immer - 'green'.""" - now = now if now is not None else time.time() - d = dict(row) - try: - d["modules_list"] = json.loads(d.get("modules") or "[]") - except ValueError: - d["modules_list"] = [] - is_lifetime = bool(d.get("lifetime")) - d["is_lifetime"] = is_lifetime - if is_lifetime: - d["days_left"] = None - d["is_expired"] = False - d["color"] = "green" - return d - try: - expires_ts = datetime.strptime(d["expires_at"], "%Y-%m-%dT%H:%M:%SZ").timestamp() - days_left = int((expires_ts - now) / 86400) - d["days_left"] = days_left - d["is_expired"] = expires_ts < now - # Ampelfarbe fuer Dashboard-Kacheln und Tabellen (siehe - # index()-Dashboard): > 90 Tage gruen, 30..90 Tage orange, < 30 Tage - # (inkl. bereits abgelaufen) rot -- exakt die vom Nutzer - # vorgegebenen Schwellen (90 selbst zaehlt noch als orange, erst - # 91+ ist gruen). - if days_left < 30: - d["color"] = "red" - elif days_left <= 90: - d["color"] = "orange" - else: - d["color"] = "green" - except (ValueError, TypeError): - d["days_left"] = None - d["is_expired"] = False - d["color"] = "red" - return d - - -TRIAL_DEFAULT_DAYS = 30 - - -def _ticket_row(conn, ticket_id): - return conn.execute("SELECT * FROM license_tickets WHERE id=?", (ticket_id,)).fetchone() - - -def _tickets_for_customer(conn, customer_id): - return conn.execute( - "SELECT * FROM license_tickets WHERE customer_id=? ORDER BY created_at DESC", (customer_id,) - ).fetchall() - - -def _latest_license_for_ticket(conn, ticket_id): - """Die 'aktuelle' Lizenz eines Tickets -- ein Ticket darf laut Konzept - immer nur eine aktive/ausstehende Lizenz gleichzeitig haben, daher - reicht die zuletzt erstellte Zeile.""" - return conn.execute( - "SELECT * FROM licenses WHERE ticket_id=? ORDER BY created_at DESC LIMIT 1", (ticket_id,) - ).fetchone() - - -def _ticket_has_open_license(conn, ticket_id): - """True, wenn das Ticket gerade eine aktive ODER ausgestellte (noch - nicht aktivierte) Lizenz haelt -- in dem Fall darf weder Admin noch - Kunde per Self-Service eine weitere daraus erzeugen, bevor die - bestehende widerrufen/deaktiviert wurde.""" - current = _latest_license_for_ticket(conn, ticket_id) - return bool(current and current["status"] in ("issued", "active")) - - -def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor, lifetime=False, role="customer"): - """Legt ein neues Ticket an -- die dauerhafte, dem Kunden gehoerende - Berechtigung auf eine Lizenz mit diesen Eckdaten. Erzeugt für sich - genommen noch KEINE Lizenzdatei, siehe _issue_license_from_ticket. - lifetime=True macht valid_days irrelevant fuer jede REGULAERE Lizenz - aus diesem Ticket (Trial-Ausstellungen bleiben davon unberuehrt, siehe - _issue_license_from_ticket) -- der Wert wird trotzdem gespeichert, - damit ein spaeteres Zurückschalten auf eine normale Laufzeit nicht bei - 0/NULL anfangen muss. role (siehe licensing.LICENSE_ROLES) bestimmt, - welche Rolle eine aus diesem Ticket ausgestellte Lizenz traegt -- - Aufrufer MUESSEN role='license_server' bereits selbst gegen - can_issue_license_server_licenses() + current_user.is_admin geprueft - haben (siehe ticket_new()), diese Funktion vertraut dem Aufrufer.""" - ticket_id = secrets.token_urlsafe(24) - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn.execute( - "INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, role, status, created_by, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)", - (ticket_id, customer_id, license_type, json.dumps(sorted(modules)), valid_days, int(bool(lifetime)), role, actor, now), - ) - conn.commit() - customer = _customer_row(conn, customer_id) - duration_label = "Lifetime" if lifetime else f"{valid_days} Tage" - role_label = " -- LIZENZSERVER" if role == "license_server" else "" - log_action_system( - "ticket.create", customer["name"] if customer else str(customer_id), - f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}{role_label} (Akteur: {actor})", - ) - _report_to_master_async() - return ticket_id - - -def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False): - """Erzeugt + signiert eine neue Lizenz aus einem Ticket (Ed25519, siehe - licensing.issue_license) und trägt sie in der Master-DB ein -- von - Admin UND Kunde (Self-Service) gleichermaßen genutzt, daher 'actor' - als Klartext-Beschreibung statt current_user (im Self-Service-Pfad - gibt es keinen eingeloggten Benutzer). as_trial=True erzeugt - unabhängig vom eigentlichen Ticket-Typ eine zeitlich kurze - Trial-Lizenz mit vollem Modulumfang -- das Ticket selbst (Typ/Module/ - Laufzeit) bleibt dabei unverändert, nur DIESE eine Ausstellung nutzt - Trial-Konditionen.""" - ticket = _ticket_row(conn, ticket_id) - ticket_role = ticket["role"] if "role" in ticket.keys() else "customer" - if as_trial: - if ticket_role != "customer": - # Trial ("30 Tage, alle TESM-Module") ist ein reines - # Kunden-Konzept -- ALL_MODULES (dhcp/fileshare/maintenance) - # ist fuer eine Lizenzserver-Lizenz bedeutungslos. Klarer - # Fehler statt einer stillschweigend falsch befuellten Lizenz. - raise ValueError("Trial-Ausstellung ist nur fuer Kunden-Tickets moeglich.") - # Trial ist immer ein zeitlich kurzer Override, unabhaengig davon - # ob das Ticket selbst als Lifetime konfiguriert ist -- siehe - # Modul-Docstring oben. - license_type, modules, valid_days, lifetime = "trial", list(licensing.ALL_MODULES), TRIAL_DEFAULT_DAYS, False - else: - license_type = ticket["type"] - modules = json.loads(ticket["modules"] or "[]") - valid_days = ticket["valid_days"] - # "Trial" als Ticket-GRUNDTYP + Lifetime waere eine dauerhafte, - # nie ablaufende Lizenz mit -- da Trial kein "custom"/"enterprise" - # ist -- null Modulen: ein unsinniger, so nie beabsichtigter - # Zustand. Lifetime gilt daher nur fuer "echte" Lizenztypen; bei - # Trial als Grundtyp wird es hier still ignoriert (zusaetzlich in - # ticket_new()/ticket_edit() bereits serverseitig unterbunden, - # diese Zeile ist die letzte Absicherung direkt vor der Ausstellung). - lifetime = bool(ticket["lifetime"]) and license_type != "trial" - - customer = _customer_row(conn, ticket["customer_id"]) - license_file, license_pubkey = licensing.issue_license( - customer={"name": customer["name"], "contact_email": customer["contact_email"] or ""}, - license_type=license_type, - modules=modules, - valid_days=valid_days, - master_private_key_b64=MASTER_PRIVATE_KEY, - master_public_key_b64=MASTER_PUBLIC_KEY, - master_endpoint=_get_master_endpoint(), - vendor=_get_vendor_info(), - lifetime=lifetime, - role=ticket_role, - ) - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - try: - conn.execute( - "INSERT INTO licenses (license_id, customer_id, type, modules, issued_at, expires_at, " - "license_pubkey, license_file_json, status, role, ticket_id, lifetime, created_by, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?)", - ( - license_file["license_id"], ticket["customer_id"], license_type, json.dumps(sorted(modules)), - license_file["issued_at"], license_file["expires_at"], license_pubkey, - json.dumps(license_file), ticket_role, ticket_id, int(lifetime), actor, now, - ), - ) - conn.commit() - except sqlite3.IntegrityError: - # idx_one_open_license_per_ticket (siehe _ensure_schema) hat - # zugeschlagen: zwischen dem _ticket_has_open_license()-Check des - # Aufrufers und diesem INSERT hat eine ZWEITE, nahezu gleichzeitige - # Anfrage (z.B. Doppelklick auf den Self-Service-Button, zwei - # offene Tabs) bereits eine Lizenz fuer dieses Ticket angelegt. - # Statt zwei gleichzeitig gueltige Lizenzen aus einem Ticket zu - # erlauben, wird dieser zweite Versuch sauber abgebrochen -- der - # Aufrufer bekommt die inzwischen tatsaechlich vorhandene Lizenz - # zurueck, als waere er selbst nicht der Ausstellende gewesen. - conn.rollback() - existing = _latest_license_for_ticket(conn, ticket_id) - if existing: - return _license_dict_for_display(existing) - raise - expiry_label = "Lifetime" if lifetime else f"bis {license_file['expires_at']}" - log_action_system( - "license.issue", customer["name"] if customer else "?", - f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {expiry_label} (Ticket, Akteur: {actor})", - ) - _report_to_master_async() - return license_file - - -def _revoke_license_row(conn, license_row, actor): - """Widerruft eine Lizenz -- von Admin ODER Kunde (Self-Service) - nutzbar. Das zugehörige Ticket bleibt bewusst 'open': Widerrufen ist - hier eine normale Housekeeping-Aktion (Systemwechsel, Neuaufsetzen), - kein Bann -- wer den Kunden wirklich aussperren will, sperrt das - Ticket separat und explizit (siehe ticket_block()).""" - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn.execute( - "UPDATE licenses SET status='revoked', revoked_at=? WHERE license_id=?", - (now, license_row["license_id"]), - ) - conn.commit() - customer = _customer_row(conn, license_row["customer_id"]) - log_action_system( - "license.revoke", customer["name"] if customer else license_row["license_id"], - f"Akteur: {actor}", - ) - _report_to_master_async() - - -# --- Aktivierungs-/Deaktivierungs-/Heartbeat-Verarbeitung ------------------- -# Ein Protokoll, zwei Transportwege (siehe licensing.py-Moduldocstring): -# dieselben drei Funktionen bedienen sowohl die Online-JSON-API -# (/api/activate|deactivate|heartbeat, von TESM-Clients automatisiert -# aufgerufen) als auch die manuelle Offline-Code-Seite weiter unten -# (Admin tippt den vom Kunden erhaltenen Code ab). Rückgabe jeweils -# (response_dict, http_status) -- bei Erfolg ist response_dict bereits die -# fertige, signierte Master-Antwort (licensing.build_master_response). - -def _process_activate(data): - license_id = data.get("license_id") - conn = get_db_connection() - row = _license_row(conn, license_id) - if not row: - conn.close() - return {"error": "unknown_license"}, 404 - if not licensing.verify_client_request(data, row["license_pubkey"]): - conn.close() - return {"error": "bad_signature"}, 400 - if data.get("action") != "activate": - conn.close() - return {"error": "wrong_action"}, 400 - if row["status"] == "revoked": - conn.close() - return {"error": "revoked"}, 409 - fingerprint = data.get("fingerprint", "") - if row["status"] == "active" and row["fingerprint"] and row["fingerprint"] != fingerprint: - conn.close() - return {"error": "already_bound_elsewhere"}, 409 - # hostname ist rein informativ (Dashboard-Kacheln, siehe index()) und - # war frueher gar nicht Teil der Anfrage -- data.get(...) liefert bei - # aelteren, noch nicht aktualisierten Clients einfach "", das UPDATE - # ueberschreibt dann bewusst NICHT einen ggf. schon bekannten Hostnamen - # (siehe COALESCE unten). - hostname = data.get("hostname", "") - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn.execute( - "UPDATE licenses SET status='active', fingerprint=?, hostname=COALESCE(NULLIF(?, ''), hostname), activated_at=? WHERE license_id=?", - (fingerprint, hostname, now, license_id), - ) - # Ein physisches System (= Fingerprint) kann immer nur GENAU eine - # Lizenzdatei gleichzeitig halten -- wird hier eine neue aktiviert - # (z.B. Trial durch Enterprise ersetzt), muss jede ANDERE, für - # denselben Fingerprint noch als 'active' vermerkte Lizenz automatisch - # veralten, sonst zeigt die Übersicht fälschlich zwei gleichzeitig - # aktive Lizenzen für ein- und dasselbe System. Bewusst nach - # Fingerprint gefiltert, nicht nach Kunde -- ein Kunde darf mehrere - # Systeme mit je eigener Lizenz betreiben, ohne dass die sich - # gegenseitig deaktivieren. - superseded = conn.execute( - "SELECT license_id, customer_id FROM licenses WHERE fingerprint=? AND status='active' AND license_id!=?", - (fingerprint, license_id), - ).fetchall() - # log_action_system() oeffnet INTERN eine EIGENE, zweite Verbindung und - # committet sofort -- solange `conn` hier noch eine offene Schreib- - # transaktion haelt (erstes UPDATE oben, noch nicht committet), wuerde - # dieser zweite Commit-Versuch auf sich selbst warten (SQLite erlaubt - # nur EINEN Schreiber gleichzeitig) und nach Ablauf des Busy-Timeouts - # mit "database is locked" fehlschlagen -- live reproduziert beim - # Aktivieren einer Lizenz, die eine andere fuer denselben Fingerprint - # ersetzt. Deshalb werden die Log-Eintraege hier nur VORGEMERKT (reine - # Lesezugriffe auf `conn` sind unproblematisch, nur der schreibende - # log_action_system()-Aufruf selbst muss warten) und dann als allerletzter - # Schritt nach `conn.commit()`/`conn.close()` unten nachgeholt. - superseded_logs = [] - for old in superseded: - conn.execute( - "UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?", - (now, old["license_id"]), - ) - old_customer = _customer_row(conn, old["customer_id"]) - superseded_logs.append(( - old_customer["name"] if old_customer else old["license_id"], - f"Automatisch deaktiviert -- Fingerprint {fingerprint} hat jetzt Lizenz {license_id} aktiviert.", - )) - conn.commit() - customer = _customer_row(conn, row["customer_id"]) - conn.close() - for target, details in superseded_logs: - log_action_system("license.superseded", target, details) - log_action_system("license.activated_remote", customer["name"] if customer else license_id, f"Fingerprint {fingerprint}") - return licensing.build_master_response("activated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200 - - -def _process_deactivate(data): - license_id = data.get("license_id") - conn = get_db_connection() - row = _license_row(conn, license_id) - if not row: - conn.close() - return {"error": "unknown_license"}, 404 - if not licensing.verify_client_request(data, row["license_pubkey"]): - conn.close() - return {"error": "bad_signature"}, 400 - if data.get("action") != "deactivate": - conn.close() - return {"error": "wrong_action"}, 400 - fingerprint = data.get("fingerprint", "") - if row["status"] != "active" or row["fingerprint"] != fingerprint: - conn.close() - return {"error": "not_active_on_this_fingerprint"}, 409 - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn.execute( - "UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?", - (now, license_id), - ) - conn.commit() - customer = _customer_row(conn, row["customer_id"]) - conn.close() - log_action_system("license.deactivated_remote", customer["name"] if customer else license_id, f"Fingerprint {fingerprint}") - return licensing.build_master_response("deactivated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200 - - -def _ingest_sub_report(conn, source_license_id, report): - """Uebernimmt einen von einem Sub-Lizenzserver per Heartbeat gemeldeten - Schnappschuss (siehe _build_own_issuance_report() auf dessen Seite) in - die reported_sub_*-Tabellen dieses Masters -- rein informativ, dieser - Master verwaltet diese Datensaetze nicht selbst. Ersetzt bei jedem - Bericht ALLE bisherigen Zeilen fuer diese source_license_id komplett - (kein Diffing) -- ein voller Schnappschuss macht z.B. beim Sub- - Lizenzserver geloeschte Tickets/Kunden automatisch korrekt verschwinden, - ohne eigene Loeschverfolgung. Aufrufer MUSS bereits geprueft haben, dass - die zugehoerige Lizenz tatsaechlich role='license_server' traegt (siehe - _process_heartbeat) -- diese Funktion vertraut dem Aufrufer.""" - if not isinstance(report, dict): - return - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn.execute("DELETE FROM reported_sub_licenses WHERE source_license_id=?", (source_license_id,)) - conn.execute("DELETE FROM reported_sub_tickets WHERE source_license_id=?", (source_license_id,)) - conn.execute("DELETE FROM reported_sub_customers WHERE source_license_id=?", (source_license_id,)) - for c in (report.get("customers") or [])[:MAX_REPORT_ROWS]: - try: - conn.execute( - "INSERT INTO reported_sub_customers (source_license_id, remote_customer_ref, name, " - "contact_email, contact_phone, contact_person, street, house_number, postal_code, city, " - "last_reported_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - (source_license_id, str(c.get("id", "")), c.get("name", "") or "?", - c.get("contact_email"), c.get("contact_phone"), c.get("contact_person"), - c.get("street"), c.get("house_number"), c.get("postal_code"), c.get("city"), now), - ) - except (sqlite3.IntegrityError, TypeError, KeyError): - continue - for t in (report.get("tickets") or [])[:MAX_REPORT_ROWS]: - try: - conn.execute( - "INSERT INTO reported_sub_tickets (source_license_id, remote_ticket_id, remote_customer_ref, " - "type, modules, valid_days, lifetime, status, role, created_at, last_reported_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - (source_license_id, str(t.get("id", "")), str(t.get("customer_id", "")), - t.get("type", "standard"), t.get("modules", "[]"), t.get("valid_days"), - int(bool(t.get("lifetime"))), t.get("status", "open"), t.get("role", "customer"), - t.get("created_at"), now), - ) - except (sqlite3.IntegrityError, TypeError, KeyError): - continue - for l in (report.get("licenses") or [])[:MAX_REPORT_ROWS]: - try: - conn.execute( - "INSERT INTO reported_sub_licenses (source_license_id, remote_license_id, remote_ticket_id, " - "type, modules, issued_at, expires_at, status, lifetime, fingerprint, hostname, " - "activated_at, last_heartbeat_at, last_reported_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - (source_license_id, l.get("license_id", ""), l.get("ticket_id"), - l.get("type", "standard"), l.get("modules", "[]"), l.get("issued_at"), l.get("expires_at"), - l.get("status", "issued"), int(bool(l.get("lifetime"))), l.get("fingerprint"), - l.get("hostname"), l.get("activated_at"), l.get("last_heartbeat_at"), now), - ) - except (sqlite3.IntegrityError, TypeError, KeyError): - continue - conn.commit() - - -def _process_heartbeat(data): - license_id = data.get("license_id") - conn = get_db_connection() - row = _license_row(conn, license_id) - if not row: - conn.close() - return {"error": "unknown_license"}, 404 - if not licensing.verify_client_request(data, row["license_pubkey"]): - conn.close() - return {"error": "bad_signature"}, 400 - fingerprint = data.get("fingerprint", "") - - if row["status"] == "revoked": - conn.close() - # Signierte Antwort mit status="revoked" -- der Client (siehe - # _send_heartbeat oben) löscht seine Lizenzdatei erst NACH - # erfolgreicher eigener Signaturprüfung dieser Antwort. - return licensing.build_master_response( - "heartbeat_ok", license_id, fingerprint, MASTER_PRIVATE_KEY, status="revoked" - ), 200 - - if row["status"] != "active" or row["fingerprint"] != fingerprint: - conn.close() - return {"error": "not_active"}, 409 - - hostname = data.get("hostname", "") - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn.execute( - "UPDATE licenses SET last_heartbeat_at=?, hostname=COALESCE(NULLIF(?, ''), hostname) WHERE license_id=?", - (now, hostname, license_id), - ) - conn.commit() - # Sub-Lizenzserver-Reporting: NUR uebernehmen, wenn die Lizenz dieses - # Absenders tatsaechlich role='license_server' traegt -- Verteidigung - # in der Tiefe, ein gewoehnlicher Kunde koennte sonst versuchen, einen - # "report"-Payload unterzuschieben, um sich als Lizenzserver - # auszugeben. row['role'] steht in der Master-DB fest (beim Ausstellen - # gesetzt, siehe _issue_license_from_ticket), nicht im Client-Request. - if row["role"] == "license_server" and data.get("report"): - _ingest_sub_report(conn, license_id, data["report"]) - license_update = json.loads(row["license_file_json"]) - conn.close() - return licensing.build_master_response( - "heartbeat_ok", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok", license_update=license_update - ), 200 - - -@app.route("/api/activate", methods=["POST"]) -def api_activate(): - """Online-Aktivierung eines TESM-Clients -- unauthentifiziert per - Design (die Signatur der Anfrage selbst ist der Berechtigungsnachweis, - siehe _process_activate).""" - response, status = _process_activate(request.get_json(silent=True) or {}) - return jsonify(response), status - - -@app.route("/api/deactivate", methods=["POST"]) -def api_deactivate(): - response, status = _process_deactivate(request.get_json(silent=True) or {}) - return jsonify(response), status - - -@app.route("/api/heartbeat", methods=["POST"]) -def api_heartbeat(): - response, status = _process_heartbeat(request.get_json(silent=True) or {}) - return jsonify(response), status - - -@app.route("/licenses/manual-code", methods=["GET", "POST"]) -@login_required -def license_manual_code(): - """Offline-Gegenstück zu den drei /api/*-Routen: der Admin erhält von - einem Kunden ohne Netzwerkzugriff auf diesen Master einen Code - (telefonisch/per Mail), trägt ihn hier ein und gibt den daraufhin - erzeugten Antwortcode an den Kunden zurück (siehe settings_license.html - auf der Client-Seite -- exakt symmetrischer Ablauf).""" - if not current_user.has_permission("licenses.edit"): - flash("Keine Berechtigung, Aktivierungscodes zu verarbeiten.", "danger") - return redirect(url_for("index")) - result_code = None - processed_action = None - if request.method == "POST": - raw_code = request.form.get("client_code", "").strip() - try: - data = licensing.decode_code(raw_code) - except Exception: - flash("Code konnte nicht gelesen werden -- bitte vollständig kopieren.", "danger") - else: - action = data.get("action") - processors = {"activate": _process_activate, "deactivate": _process_deactivate, "heartbeat": _process_heartbeat} - processor = processors.get(action) - if not processor: - flash("Unbekannte Aktion im Code.", "danger") - else: - response, status = processor(data) - if status == 200: - result_code = licensing.encode_code(response) - processed_action = action - flash("Verarbeitet -- Antwortcode unten an den Kunden weitergeben.", "success") - else: - flash(f"Fehler: {response.get('error', status)}", "danger") - return render_template("license_manual_code.html", result_code=result_code, processed_action=processed_action) - - -_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") - - -def _customer_form_fields(form): - """Liest die Kunden-Formularfelder einheitlich aus (Anlegen UND - Bearbeiten nutzen dieselben Feldnamen) -- Anschrift ist bewusst in - Einzelfelder aufgeteilt (Straße/Hausnummer/PLZ/Ort statt eines - Freitextfelds), damit sie z.B. für Rechnungen sauber weiterverwendet - werden kann.""" - return { - "name": form.get("name", "").strip(), - "contact_email": form.get("contact_email", "").strip(), - "contact_phone": form.get("contact_phone", "").strip(), - "contact_person": form.get("contact_person", "").strip(), - "street": form.get("street", "").strip(), - "house_number": form.get("house_number", "").strip(), - "postal_code": form.get("postal_code", "").strip(), - "city": form.get("city", "").strip(), - "notes": form.get("notes", "").strip(), - } - - -@app.route("/customers", methods=["GET", "POST"]) -@login_required -def customers(): - if request.method == "GET" and not current_user.can_manage_customers: - flash("Keine Berechtigung, Kunden anzusehen.", "danger") - return redirect(url_for("index")) - conn = get_db_connection() - if request.method == "POST": - if "new_customer" in request.form: - if not current_user.has_permission("customers.create"): - flash("Keine Berechtigung, Kunden anzulegen.", "danger") - else: - f = _customer_form_fields(request.form) - if not f["name"]: - flash("Firma/Name ist erforderlich.", "danger") - elif not _EMAIL_RE.match(f["contact_email"]): - flash("Eine gültige E-Mail-Adresse ist erforderlich.", "danger") - else: - conn.execute( - "INSERT INTO license_customers (name, contact_email, contact_phone, contact_person, " - "street, house_number, postal_code, city, portal_token, notes, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - (f["name"], f["contact_email"], f["contact_phone"], f["contact_person"], - f["street"], f["house_number"], f["postal_code"], f["city"], - secrets.token_urlsafe(24), f["notes"], - datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - ) - conn.commit() - log_action("customer.create", f["name"]) - flash(f"Kunde „{f['name']}“ angelegt.", "success") - elif "edit_customer" in request.form: - if not current_user.has_permission("customers.edit"): - flash("Keine Berechtigung, Kunden zu ändern.", "danger") - else: - customer_id = int(request.form["edit_customer"]) - f = _customer_form_fields(request.form) - # Anders als beim Anlegen wird eine LEERE E-Mail beim - # Bearbeiten toleriert (nur ein nicht-leerer, aber - # ungueltiger Wert wird abgelehnt) -- sonst koennte ein - # Admin bei den Bestandskunden von vor dieser Umstellung - # (leere contact_email, damals noch kein Pflichtfeld) nicht - # einmal mehr Telefonnummer/Anschrift/Notizen aktualisieren, - # ohne zuerst zwingend eine E-Mail-Adresse nachzutragen. - if not f["name"]: - flash("Firma/Name ist erforderlich.", "danger") - elif f["contact_email"] and not _EMAIL_RE.match(f["contact_email"]): - flash("Die E-Mail-Adresse ist ungültig.", "danger") - else: - conn.execute( - "UPDATE license_customers SET name=?, contact_email=?, contact_phone=?, contact_person=?, " - "street=?, house_number=?, postal_code=?, city=?, notes=? WHERE id=?", - (f["name"], f["contact_email"], f["contact_phone"], f["contact_person"], - f["street"], f["house_number"], f["postal_code"], f["city"], f["notes"], customer_id), - ) - conn.commit() - log_action("customer.update", f["name"]) - flash("Kunde aktualisiert.", "success") - elif "delete_customer" in request.form: - if not current_user.has_permission("customers.edit"): - flash("Keine Berechtigung, Kunden zu löschen.", "danger") - else: - customer_id = int(request.form["delete_customer"]) - if conn.execute("SELECT 1 FROM license_tickets WHERE customer_id=?", (customer_id,)).fetchone(): - flash("Kunde hat noch Tickets -- kann nicht gelöscht werden.", "danger") - else: - conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,)) - conn.commit() - log_action("customer.delete", str(customer_id)) - flash("Kunde gelöscht.", "success") - conn.close() - # Deckt alle drei Sub-Aktionen mit einem Aufruf ab -- der Report - # ist ohnehin immer ein voller Schnappschuss, ein zusaetzlicher - # Versand bei einer fehlgeschlagenen Validierung sendet einfach - # den unveraenderten Stand erneut (harmlos). - _report_to_master_async() - return redirect(url_for("customers")) - - customers_rows = conn.execute(""" - SELECT license_customers.*, COUNT(DISTINCT license_tickets.id) AS ticket_count, - COUNT(DISTINCT licenses.id) AS license_count, - MAX(CASE WHEN licenses.role='license_server' AND licenses.status='active' THEN 1 ELSE 0 END) AS is_sub_license_server - FROM license_customers - LEFT JOIN license_tickets ON license_tickets.customer_id = license_customers.id - LEFT JOIN licenses ON licenses.customer_id = license_customers.id - GROUP BY license_customers.id - ORDER BY license_customers.name COLLATE NOCASE - """).fetchall() - conn.close() - return render_template( - "customers.html", customers=customers_rows, - can_create=current_user.has_permission("customers.create"), - can_edit=current_user.has_permission("customers.edit"), - ) - - -@app.route("/customers/") -@login_required -def customer_detail(customer_id): - """Kunden-Detailseite -- zeigt Stammdaten sowie, falls dieser Kunde eine - aktive role='license_server'-Lizenz haelt (er also selbst ein - Sub-Lizenzserver ist), einen zusaetzlichen Abschnitt mit dessen - GEMELDETEN eigenen Kunden/Tickets/Lizenzen (siehe reported_sub_*- - Tabellen, befuellt durch _ingest_sub_report() bei jedem Heartbeat - dieses Sub-Lizenzservers). Rein informativ/read-only -- dieser Master - verwaltet die gemeldeten Datensaetze nicht selbst.""" - if not current_user.can_manage_customers: - flash("Keine Berechtigung, Kunden anzusehen.", "danger") - return redirect(url_for("index")) - conn = get_db_connection() - customer = _customer_row(conn, customer_id) - if not customer: - conn.close() - flash("Kunde nicht gefunden.", "danger") - return redirect(url_for("customers")) - - own_tickets = conn.execute( - "SELECT * FROM license_tickets WHERE customer_id=? ORDER BY created_at DESC", (customer_id,) - ).fetchall() - now = time.time() - tickets_view = [] - for t in own_tickets: - d = dict(t) - d["modules_list"] = json.loads(d.get("modules") or "[]") - current = _latest_license_for_ticket(conn, t["id"]) - d["license"] = _license_dict_for_display(current, now) if current else None - tickets_view.append(d) - - # Ist dieser Kunde selbst ein (aktiver) Sub-Lizenzserver? Referenz fuer - # die reported_sub_*-Zeilen ist die license_id der role='license_server'- - # Lizenz, die dieser Kunde von UNS erhalten hat -- nicht seine eigene, - # bei ihm lokal laufende Lizenz. - sub_license_row = conn.execute( - "SELECT * FROM licenses WHERE customer_id=? AND role='license_server' AND status='active' " - "ORDER BY activated_at DESC LIMIT 1", (customer_id,) - ).fetchone() - - reported_customers, reported_tickets_by_customer, reported_licenses_by_ticket = [], {}, {} - if sub_license_row: - source_license_id = sub_license_row["license_id"] - reported_customers = [ - dict(r) for r in conn.execute( - "SELECT * FROM reported_sub_customers WHERE source_license_id=? ORDER BY name COLLATE NOCASE", - (source_license_id,), - ).fetchall() - ] - for t in conn.execute( - "SELECT * FROM reported_sub_tickets WHERE source_license_id=? ORDER BY created_at DESC", - (source_license_id,), - ).fetchall(): - reported_tickets_by_customer.setdefault(t["remote_customer_ref"], []).append(dict(t)) - for l in conn.execute( - "SELECT * FROM reported_sub_licenses WHERE source_license_id=? ORDER BY issued_at DESC", - (source_license_id,), - ).fetchall(): - # _license_dict_for_display() erwartet dieselben Spaltennamen - # (expires_at/lifetime/status/modules), die reported_sub_licenses - # bewusst 1:1 uebernimmt -- liefert damit automatisch dieselbe - # Ampelfarbe/Resttage-Berechnung wie fuer eigene Lizenzen. - reported_licenses_by_ticket.setdefault(l["remote_ticket_id"], []).append( - _license_dict_for_display(l, now) - ) - - conn.close() - return render_template( - "customer_detail.html", customer=customer, tickets=tickets_view, - sub_license=dict(sub_license_row) if sub_license_row else None, - reported_customers=reported_customers, - reported_tickets_by_customer=reported_tickets_by_customer, - reported_licenses_by_ticket=reported_licenses_by_ticket, - type_labels=LICENSE_TYPE_LABELS, - can_edit=current_user.has_permission("customers.edit"), - ) - - -@app.route("/tickets/new", methods=["GET", "POST"]) -@login_required -def ticket_new(): - """Legt ein neues Ticket an -- die Berechtigung des Kunden auf eine - Lizenz mit bestimmten Eckdaten. Erstellt bewusst noch KEINE Lizenz; - das übernimmt ein separater Schritt auf der Ticket-Detailseite (durch - Admin ODER Kunde per Self-Service).""" - if not current_user.has_permission("tickets.create"): - flash("Keine Berechtigung, Tickets anzulegen.", "danger") - return redirect(url_for("index")) - conn = get_db_connection() - if request.method == "POST": - customer_id = request.form.get("customer_id", "") - if customer_id == "__new__": - new_customer_name = request.form.get("new_customer_name", "").strip() - new_customer_email = request.form.get("new_customer_email", "").strip() - if not new_customer_name: - flash("Bitte einen Namen für den neuen Kunden angeben.", "danger") - conn.close() - return redirect(url_for("ticket_new")) - if not _EMAIL_RE.match(new_customer_email): - flash("Bitte eine gültige E-Mail-Adresse für den neuen Kunden angeben.", "danger") - conn.close() - return redirect(url_for("ticket_new")) - cur = conn.execute( - "INSERT INTO license_customers (name, contact_email, portal_token, created_at) VALUES (?, ?, ?, ?)", - (new_customer_name, new_customer_email, secrets.token_urlsafe(24), - datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - ) - conn.commit() - customer_id = cur.lastrowid - log_action("customer.create", new_customer_name, "beim Anlegen eines Tickets neu angelegt") - else: - try: - customer_id = int(customer_id) - except ValueError: - flash("Ungültiger Kunde.", "danger") - conn.close() - return redirect(url_for("ticket_new")) - - # role='license_server' darf NUR ein echter Admin auf einer - # tatsaechlichen "master"-Instanz waehlen -- fest einprogrammiert, - # nicht ueber ein delegierbares Recht (siehe - # can_issue_license_server_licenses()-Docstring). Jeder andere - # Versuch (manipuliertes Formular, Sub-Lizenzserver, Nicht-Admin) - # wird still auf 'customer' zurueckgestuft statt nur die UI - # auszublenden -- Verteidigung in der Tiefe. - role = request.form.get("role", "customer") - if role == "license_server" and not (current_user.is_admin and can_issue_license_server_licenses()): - flash("Keine Berechtigung, Lizenzserver-Lizenzen auszustellen.", "danger") - role = "customer" - if role not in licensing.LICENSE_ROLES or role == "master": - role = "customer" - - license_type = request.form.get("license_type", "standard") - if license_type not in licensing.LICENSE_TYPES: - flash("Ungültiger Lizenztyp.", "danger") - conn.close() - return redirect(url_for("ticket_new")) - if role != "customer": - # Module (dhcp/fileshare/maintenance) sind ein reines - # TESM-Client-Konzept -- fuer eine Lizenzserver-Lizenz - # bedeutungslos. - modules = [] - elif license_type == "enterprise": - modules = list(licensing.ALL_MODULES) - elif license_type == "custom": - modules = [m for m in request.form.getlist("modules") if m in licensing.ALL_MODULES] - else: - modules = [] - # Trial + Lifetime waere eine dauerhaft nie ablaufende Lizenz ohne - # jedes Modul (Trial ist nicht "custom"/"enterprise") -- ein - # Zustand, den niemand absichtlich will. Serverseitig unterbunden, - # nicht nur per JS ausgegraut (siehe ticket_new.html). - lifetime = request.form.get("lifetime") == "1" and license_type != "trial" - try: - valid_days = max(1, int(request.form.get("valid_days", "365"))) - except ValueError: - valid_days = 365 - - ticket_id = _create_ticket( - conn, customer_id, license_type, modules, valid_days, current_user.username, - lifetime=lifetime, role=role, - ) - conn.close() - flash("Ticket angelegt.", "success") - return redirect(url_for("ticket_detail", ticket_id=ticket_id)) - - customers_rows = conn.execute("SELECT * FROM license_customers ORDER BY name COLLATE NOCASE").fetchall() - conn.close() - return render_template( - "ticket_new.html", customers=customers_rows, license_types=licensing.LICENSE_TYPES, - all_modules=licensing.ALL_MODULES, module_labels=MODULE_LABELS, type_labels=LICENSE_TYPE_LABELS, - preselect_customer_id=request.args.get("customer_id", type=int), - can_issue_license_server=current_user.is_admin and can_issue_license_server_licenses(), - ) - - -@app.route("/tickets/", methods=["GET", "POST"]) -@login_required -def ticket_detail(ticket_id): - if not current_user.can_manage_tickets: - flash("Keine Berechtigung.", "danger") - return redirect(url_for("index")) - conn = get_db_connection() - ticket = _ticket_row(conn, ticket_id) - if not ticket: - conn.close() - flash("Ticket nicht gefunden.", "danger") - return redirect(url_for("index")) - - if request.method == "POST": - if "issue_license" in request.form: - # Ausstellen erzeugt eine LIZENZ (nicht nur eine Ticket-Aenderung) - # -- bewusst weiterhin ueber das Lizenz-Recht gegated, nicht ueber - # tickets.edit, damit sich "Tickets verwalten" und "Lizenzen - # ausstellen" unabhaengig voneinander vergeben lassen. - if not current_user.has_permission("licenses.edit"): - flash("Keine Berechtigung, Lizenzen auszustellen.", "danger") - elif not license_active(): - flash("Der Lizenzserver selbst hat keine gültige Lizenz -- Ausstellen ist deaktiviert.", "danger") - elif ticket["status"] != "open": - flash("Ticket ist gesperrt.", "danger") - elif _ticket_has_open_license(conn, ticket_id): - flash("Dieses Ticket hat bereits eine aktive oder ausgestellte Lizenz.", "danger") - else: - as_trial = request.form.get("issue_license") == "trial" - try: - license_file = _issue_license_from_ticket(conn, ticket_id, current_user.username, as_trial=as_trial) - except ValueError as exc: - # z.B. Trial-Override auf einem Lizenzserver-Ticket - # versucht (siehe _issue_license_from_ticket) -- im - # normalen UI-Fluss nicht erreichbar (Button wird für - # role != 'customer' gar nicht erst angezeigt), aber - # eine manipulierte Anfrage soll eine klare Meldung statt - # eines internen Serverfehlers bekommen. - flash(str(exc), "danger") - else: - conn.close() - flash("Lizenz ausgestellt.", "success") - return redirect(url_for("license_detail", license_id=license_file["license_id"])) - elif "toggle_block" in request.form: - if not current_user.has_permission("tickets.edit"): - flash("Keine Berechtigung, Tickets zu sperren/entsperren.", "danger") - else: - new_status = "blocked" if ticket["status"] == "open" else "open" - conn.execute("UPDATE license_tickets SET status=? WHERE id=?", (new_status, ticket_id)) - conn.commit() - customer = _customer_row(conn, ticket["customer_id"]) - log_action( - "ticket.block" if new_status == "blocked" else "ticket.unblock", - customer["name"] if customer else ticket_id, - ) - flash("Ticket gesperrt." if new_status == "blocked" else "Ticket entsperrt.", "success") - conn.close() - return redirect(url_for("ticket_detail", ticket_id=ticket_id)) - - customer = _customer_row(conn, ticket["customer_id"]) - licenses_rows = conn.execute( - "SELECT * FROM licenses WHERE ticket_id=? ORDER BY created_at DESC", (ticket_id,) - ).fetchall() - conn.close() - return render_template( - "ticket_detail.html", ticket=dict(ticket), customer=customer, - modules_list=json.loads(ticket["modules"] or "[]"), - licenses=[_license_dict_for_display(r) for r in licenses_rows], - has_open_license=any(l["status"] in ("issued", "active") for l in licenses_rows), - can_delete_ticket=not any(l["status"] in ("issued", "active") for l in licenses_rows), - license_types=licensing.LICENSE_TYPES, all_modules=licensing.ALL_MODULES, - type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, - master_licensed=license_active(), - self_service_url=_customer_facing_url("self_service", ticket_id=ticket_id), - portal_request_url=_customer_facing_url("portal_request"), - can_edit_ticket=current_user.has_permission("tickets.edit"), - can_issue_license=current_user.has_permission("licenses.edit"), - ) - - -@app.route("/tickets//edit", methods=["POST"]) -@login_required -def ticket_edit(ticket_id): - """Aendert die Eckdaten eines bestehenden Tickets (Typ/Module/ - Laufzeit/Lifetime) -- WIRKT SICH NICHT auf bereits ausgestellte - Lizenzen aus (deren Typ/Module/Ablaufdatum wurden bei Ausstellung fest - in die jeweilige Zeile kopiert, siehe _issue_license_from_ticket), - sondern nur auf die naechste Lizenz, die aus diesem Ticket erzeugt - wird. Der Kunde selbst bleibt unveraendert -- eine Neuzuordnung ist - hier bewusst nicht vorgesehen (das Self-Service-Ticket ist an genau - diesen Kunden gebunden, u.a. weil er ueber dessen Portal-Token - erreichbar ist).""" - if not current_user.has_permission("tickets.edit"): - flash("Keine Berechtigung, Tickets zu bearbeiten.", "danger") - return redirect(url_for("tickets_list")) - conn = get_db_connection() - ticket = _ticket_row(conn, ticket_id) - if not ticket: - conn.close() - flash("Ticket nicht gefunden.", "danger") - return redirect(url_for("tickets_list")) - - license_type = request.form.get("license_type", ticket["type"]) - if license_type not in licensing.LICENSE_TYPES: - conn.close() - flash("Ungültiger Lizenztyp.", "danger") - return redirect(url_for("ticket_detail", ticket_id=ticket_id)) - if license_type == "enterprise": - modules = list(licensing.ALL_MODULES) - elif license_type == "custom": - modules = [m for m in request.form.getlist("modules") if m in licensing.ALL_MODULES] - else: - modules = [] - # Siehe ticket_new() -- Trial + Lifetime ist kein sinnvoller Zustand. - lifetime = request.form.get("lifetime") == "1" and license_type != "trial" - try: - valid_days = max(1, int(request.form.get("valid_days", ticket["valid_days"]))) - except ValueError: - valid_days = ticket["valid_days"] - - conn.execute( - "UPDATE license_tickets SET type=?, modules=?, valid_days=?, lifetime=? WHERE id=?", - (license_type, json.dumps(sorted(modules)), valid_days, int(lifetime), ticket_id), - ) - conn.commit() - customer = _customer_row(conn, ticket["customer_id"]) - conn.close() - duration_label = "Lifetime" if lifetime else f"{valid_days} Tage" - log_action( - "ticket.update", customer["name"] if customer else ticket_id, - f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}", - ) - _report_to_master_async() - flash("Ticket aktualisiert -- gilt für die nächste daraus erstellte Lizenz.", "success") - return redirect(url_for("ticket_detail", ticket_id=ticket_id)) - - -@app.route("/tickets//delete", methods=["POST"]) -@login_required -def ticket_delete(ticket_id): - """Loescht ein Ticket vollstaendig -- nur erlaubt, wenn es aktuell - KEINE aktive oder ausgestellte (noch nicht aktivierte) Lizenz haelt - (sonst wuerde der Self-Service-/Admin-Zugriff auf ein gerade genutztes - System verschwinden). Etwaige bereits widerrufene/deaktivierte - Lizenzen darunter sind reine Historie und werden mitgeloescht, sonst - blieben verwaiste Zeilen mit einer toten ticket_id zurueck.""" - if not current_user.has_permission("tickets.edit"): - flash("Keine Berechtigung, Tickets zu löschen.", "danger") - return redirect(url_for("tickets_list")) - conn = get_db_connection() - ticket = _ticket_row(conn, ticket_id) - if not ticket: - conn.close() - flash("Ticket nicht gefunden.", "danger") - return redirect(url_for("tickets_list")) - if _ticket_has_open_license(conn, ticket_id): - conn.close() - flash("Ticket hat noch eine aktive oder ausgestellte Lizenz -- erst widerrufen, dann löschen.", "danger") - return redirect(url_for("ticket_detail", ticket_id=ticket_id)) - customer = _customer_row(conn, ticket["customer_id"]) - # Bewusst in Kauf genommenes Restrisiko: zwischen dem Check oben und - # dem DELETE hier koennte -- nur ueber den separaten Self-Service-/ - # Portal-Link des Kunden, nicht ueber diese Admin-Oberflaeche selbst -- - # in einem extrem engen Zeitfenster eine neue Lizenz fuer dieses Ticket - # entstehen, die dann mitgeloescht wuerde. Ein vollstaendiger Schutz - # bräuchte eine explizite BEGIN IMMEDIATE-Transaktion um beide - # Operationen; angesichts des sehr seltenen Zusammentreffens (Admin - # loescht genau in dem Moment, in dem der Kunde unabhaengig davon - # eine neue Lizenz erstellt) und des insgesamt seltenen Admin-Traffics - # dieser internen Anwendung wird das hier nicht weiter verhaertet -- - # die deutlich wahrscheinlichere Doppel-Ausstellung (Doppelklick auf - # denselben Button) ist bereits ueber idx_one_open_license_per_ticket - # auf DB-Ebene ausgeschlossen (siehe _ensure_schema). - conn.execute("DELETE FROM licenses WHERE ticket_id=?", (ticket_id,)) - conn.execute("DELETE FROM license_tickets WHERE id=?", (ticket_id,)) - conn.commit() - conn.close() - log_action("ticket.delete", customer["name"] if customer else ticket_id) - _report_to_master_async() - flash("Ticket gelöscht.", "success") - return redirect(url_for("tickets_list")) - - -@app.route("/licenses/") -@login_required -def license_detail(license_id): - if not current_user.can_manage_licenses: - flash("Keine Berechtigung.", "danger") - return redirect(url_for("index")) - conn = get_db_connection() - row = _license_row(conn, license_id) - if not row: - conn.close() - flash("Lizenz nicht gefunden.", "danger") - return redirect(url_for("index")) - customer = _customer_row(conn, row["customer_id"]) - conn.close() - return render_template( - "license_detail.html", license=_license_dict_for_display(row), customer=customer, - type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, - can_edit=current_user.has_permission("licenses.edit"), - ) - - -@app.route("/licenses//download") -@login_required -def license_download(license_id): - if not current_user.can_manage_licenses: - flash("Keine Berechtigung.", "danger") - return redirect(url_for("index")) - conn = get_db_connection() - row = _license_row(conn, license_id) - conn.close() - if not row: - flash("Lizenz nicht gefunden.", "danger") - return redirect(url_for("index")) - content = row["license_file_json"].encode("utf-8") - return app.response_class( - content, mimetype="application/json", - headers={"Content-Disposition": f'attachment; filename="license-{license_id}.json"'}, - ) - - -GRAPH_TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" -GRAPH_SEND_MAIL_URL = "https://graph.microsoft.com/v1.0/users/{sender}/sendMail" - - -def _graph_settings(): - return { - "tenant_id": get_setting("graph_tenant_id", ""), - "client_id": get_setting("graph_client_id", ""), - "client_secret": get_setting("graph_client_secret", ""), - "sender_mailbox": get_setting("graph_sender_mailbox", ""), - } - - -def _graph_get_token(cfg): - """Client-Credentials-Flow (App-Berechtigung, kein Benutzer-Login - nötig -- deshalb funktioniert das auch mit MFA-pflichtigen Tenants, - siehe Anleitung auf der Einstellungen-Seite). Reine Standardbibliothek - (urllib), keine zusätzliche Abhängigkeit wie 'msal'.""" - url = GRAPH_TOKEN_URL.format(tenant=cfg["tenant_id"]) - data = urllib.parse.urlencode({ - "client_id": cfg["client_id"], - "client_secret": cfg["client_secret"], - "scope": "https://graph.microsoft.com/.default", - "grant_type": "client_credentials", - }).encode("utf-8") - req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST") - with urllib.request.urlopen(req, timeout=15) as resp: - payload = json.loads(resp.read().decode("utf-8")) - return payload["access_token"] - - -def _graph_send_mail(to_email, subject, body_text, attachment_bytes=None, attachment_name=None): - cfg = _graph_settings() - if not all([cfg["tenant_id"], cfg["client_id"], cfg["client_secret"], cfg["sender_mailbox"]]): - raise RuntimeError("Microsoft-Graph-Zugangsdaten unvollständig -- siehe Systemeinstellungen.") - token = _graph_get_token(cfg) - message = { - "message": { - "subject": subject, - "body": {"contentType": "Text", "content": body_text}, - "toRecipients": [{"emailAddress": {"address": to_email}}], - } - } - if attachment_bytes: - message["message"]["attachments"] = [{ - "@odata.type": "#microsoft.graph.fileAttachment", - "name": attachment_name or "license.json", - "contentBytes": base64.b64encode(attachment_bytes).decode("ascii"), - }] - url = GRAPH_SEND_MAIL_URL.format(sender=cfg["sender_mailbox"]) - req = urllib.request.Request( - url, data=json.dumps(message).encode("utf-8"), - headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=20): - pass - - -@app.route("/settings/email/test", methods=["POST"]) -@login_required -def settings_email_test(): - if not current_user.has_permission("settings_system.edit"): - flash("Keine Berechtigung.", "danger") - return redirect(url_for("settings")) - try: - cfg = _graph_settings() - if not all([cfg["tenant_id"], cfg["client_id"], cfg["client_secret"], cfg["sender_mailbox"]]): - raise RuntimeError("Bitte zuerst alle vier Felder ausfüllen und speichern.") - _graph_get_token(cfg) - flash("Verbindung erfolgreich -- Token konnte abgerufen werden.", "success") - except Exception as exc: - flash(f"Verbindung fehlgeschlagen: {exc}", "danger") - return redirect(url_for("settings")) - - -@app.route("/licenses//send-email", methods=["POST"]) -@login_required -def license_send_email(license_id): - if not current_user.has_permission("licenses.edit"): - flash("Keine Berechtigung.", "danger") - return redirect(url_for("index")) - conn = get_db_connection() - row = _license_row(conn, license_id) - customer = _customer_row(conn, row["customer_id"]) if row else None - conn.close() - if not row or not customer: - flash("Lizenz nicht gefunden.", "danger") - return redirect(url_for("index")) - if not customer["contact_email"]: - flash("Für diesen Kunden ist keine E-Mail-Adresse hinterlegt.", "danger") - return redirect(url_for("license_detail", license_id=license_id)) - self_service_note = "" - if row["role"] == "customer": - # Self-Service-Link und Kundenportal-Hinweis sind reine - # Kunden-Konzepte -- fuer eine Lizenzserver-Lizenz irrelevant (der - # Self-Service-Link wuerde dort ohnehin mit 404 abgewiesen, siehe - # self_service()). - if row["ticket_id"]: - self_service_url = _customer_facing_url("self_service", ticket_id=row["ticket_id"]) - self_service_note = ( - f"\nÜber diesen Link können Sie Ihre Lizenz jederzeit selbst herunterladen, " - f"widerrufen oder (z.B. nach einem Systemwechsel) neu erstellen:\n{self_service_url}\n" - ) - portal_request_url = _customer_facing_url("portal_request") - self_service_note += ( - f"\nEine Übersicht all Ihrer Tickets/Lizenzen können Sie sich jederzeit unter folgendem Link per " - f"E-Mail zuschicken lassen:\n{portal_request_url}\n" - ) - valid_until = "unbegrenzt (Lifetime)" if row["lifetime"] else row["expires_at"] - subject_prefix = "Ihre TESM-Lizenzserver-Lizenz" if row["role"] == "license_server" else "Ihre TESM-Lizenz" - try: - _graph_send_mail( - customer["contact_email"], - f"{subject_prefix} ({LICENSE_TYPE_LABELS.get(row['type'], row['type'])})", - f"Anbei Ihre Lizenzdatei. Bitte auf der Zielinstanz unter Einstellungen → Lizenz hochladen und aktivieren.\n\n" - f"Lizenz-ID: {license_id}\nTyp: {LICENSE_TYPE_LABELS.get(row['type'], row['type'])}\nGültig bis: {valid_until}\n" - f"{self_service_note}", - attachment_bytes=row["license_file_json"].encode("utf-8"), - attachment_name=f"license-{license_id}.json", - ) - log_action("license.email", customer["name"], customer["contact_email"]) - flash(f"Lizenz per E-Mail an {customer['contact_email']} gesendet.", "success") - except Exception as exc: - flash(f"Versand fehlgeschlagen: {exc}", "danger") - return redirect(url_for("license_detail", license_id=license_id)) - - -@app.route("/licenses//revoke", methods=["POST"]) -@login_required -def license_revoke(license_id): - if not current_user.has_permission("licenses.edit"): - flash("Keine Berechtigung.", "danger") - return redirect(url_for("index")) - conn = get_db_connection() - row = _license_row(conn, license_id) - if row: - _revoke_license_row(conn, row, current_user.username) - flash("Lizenz widerrufen -- der Kunde wird beim nächsten Heartbeat automatisch delizensiert. " - "Das Ticket bleibt offen, eine neue Lizenz kann jederzeit daraus erstellt werden.", "success") - else: - flash("Lizenz nicht gefunden.", "danger") - conn.close() - return redirect(url_for("license_detail", license_id=license_id)) - - -@app.route("/licenses//delete", methods=["POST"]) -@login_required -def license_delete(license_id): - """Reine Aufräum-Aktion: eine widerrufene oder nie aktivierte Lizenz - aus der Übersicht entfernen. Das zugehörige Ticket bleibt bestehen -- - der Kunde kann sich darüber (Self-Service) oder der Admin (Ticket- - Seite) jederzeit eine neue mit denselben Eckdaten erzeugen.""" - if not current_user.has_permission("licenses.edit"): - flash("Keine Berechtigung.", "danger") - return redirect(url_for("index")) - conn = get_db_connection() - row = _license_row(conn, license_id) - if not row: - conn.close() - flash("Lizenz nicht gefunden.", "danger") - return redirect(url_for("index")) - if row["status"] not in ("revoked", "issued"): - conn.close() - flash("Nur widerrufene oder nicht aktivierte Lizenzen können gelöscht werden.", "danger") - return redirect(url_for("license_detail", license_id=license_id)) - ticket_id = row["ticket_id"] - customer = _customer_row(conn, row["customer_id"]) - conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,)) - conn.commit() - conn.close() - log_action("license.delete", customer["name"] if customer else license_id, f"Status war: {row['status']}") - _report_to_master_async() - flash("Lizenz gelöscht.", "success") - if ticket_id: - return redirect(url_for("ticket_detail", ticket_id=ticket_id)) - return redirect(url_for("index")) - - -# ============================================================ Self-Service = -# Oeffentlich erreichbar (bewusst KEIN @login_required) -- Autorisierung -# laeuft ueber den Besitz der unerratbaren Ticket-ID selbst (192 Bit -# Zufall, siehe secrets.token_urlsafe in _create_ticket), analog zu einem -# "Magic Link" in einer Bestell-/Abo-Verwaltungsmail. Erlaubt dem Kunden -# GENAU das, was in der Konzept-Vorgabe steht: eigene Lizenz herunterladen, -# widerrufen, und (mit den Ticket-Eckdaten) neu erstellen -- ohne dafuer -# ein Admin-Login zu brauchen oder auf den Anbieter warten zu muessen. - -@app.route("/self-service/") -def self_service(ticket_id): - conn = get_db_connection() - ticket = _ticket_row(conn, ticket_id) - if not ticket or ticket["role"] != "customer": - # Self-Service ist ein KUNDENWEG (unauthentifiziert, Zugriff allein - # ueber die unratbare Ticket-ID) -- Lizenzserver-Lizenzen werden - # ausschliesslich bewusst durch einen Admin auf der Ticket- - # Detailseite ausgestellt/verwaltet, nie ueber diesen Weg. Behandelt - # eine Lizenzserver-Ticket-ID hier bewusst wie "nicht gefunden", - # nicht wie "keine Berechtigung" -- sonst liesse sich allein aus der - # Fehlermeldung ablesen, dass eine geratene/erlangte ID zu einem - # existierenden Lizenzserver-Ticket gehoert. - conn.close() - abort(404) - customer = _customer_row(conn, ticket["customer_id"]) - current = _latest_license_for_ticket(conn, ticket_id) - conn.close() - can_issue_new = ticket["status"] == "open" and not (current and current["status"] in ("issued", "active")) - return render_template( - "self_service.html", ticket=dict(ticket), customer=customer, - modules_list=json.loads(ticket["modules"] or "[]"), - license=_license_dict_for_display(current) if current else None, - can_issue_new=can_issue_new, - type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, - portal_request_url=url_for("portal_request"), - ) - - -@app.route("/self-service//download") -def self_service_download(ticket_id): - conn = get_db_connection() - ticket = _ticket_row(conn, ticket_id) - if not ticket or ticket["role"] != "customer": - conn.close() - abort(404) - current = _latest_license_for_ticket(conn, ticket_id) - conn.close() - if not current: - abort(404) - content = current["license_file_json"].encode("utf-8") - return app.response_class( - content, mimetype="application/json", - headers={"Content-Disposition": f'attachment; filename="license-{current["license_id"]}.json"'}, - ) - - -@app.route("/self-service//revoke", methods=["POST"]) -def self_service_revoke(ticket_id): - conn = get_db_connection() - ticket = _ticket_row(conn, ticket_id) - if not ticket or ticket["role"] != "customer": - conn.close() - abort(404) - current = _latest_license_for_ticket(conn, ticket_id) - if not current or current["status"] not in ("issued", "active"): - conn.close() - flash("Keine aktive Lizenz zum Widerrufen vorhanden.", "danger") - return redirect(url_for("self_service", ticket_id=ticket_id)) - _revoke_license_row(conn, current, "Kunde (Self-Service)") - conn.close() - flash("Lizenz widerrufen. Sie können jetzt eine neue erstellen.", "success") - return redirect(url_for("self_service", ticket_id=ticket_id)) - - -@app.route("/self-service//issue", methods=["POST"]) -def self_service_issue(ticket_id): - conn = get_db_connection() - ticket = _ticket_row(conn, ticket_id) - if not ticket or ticket["role"] != "customer": - conn.close() - abort(404) - if not license_active(): - conn.close() - flash("Der Lizenzserver ist derzeit nicht verfügbar -- bitte später erneut versuchen.", "danger") - return redirect(url_for("self_service", ticket_id=ticket_id)) - if ticket["status"] != "open": - conn.close() - flash("Dieses Ticket ist gesperrt -- bitte an den Anbieter wenden.", "danger") - return redirect(url_for("self_service", ticket_id=ticket_id)) - if _ticket_has_open_license(conn, ticket_id): - conn.close() - flash("Es gibt bereits eine aktive oder ausgestellte Lizenz zu diesem Ticket.", "danger") - return redirect(url_for("self_service", ticket_id=ticket_id)) - as_trial = request.form.get("issue_license") == "trial" - _issue_license_from_ticket(conn, ticket_id, "Kunde (Self-Service)", as_trial=as_trial) - conn.close() - flash("Neue Lizenz erstellt -- bitte herunterladen und auf dem Zielsystem aktivieren.", "success") - return redirect(url_for("self_service", ticket_id=ticket_id)) - - -@app.route("/portal/request", methods=["GET", "POST"]) -def portal_request(): - """Oeffentlich erreichbar (kein Login): fuer einen Kunden, der seinen - Magic-Link verloren hat oder nie einen bekommen hat (z.B. weil er nur - telefonisch beim Anbieter bekannt ist), gibt es sonst KEINEN Weg - zurueck ins Kundenportal ausser einen Admin zu kontaktieren -- diese - Seite schliesst die Luecke: E-Mail-Adresse eingeben, Link wird (falls - hinterlegt) zugeschickt. Werkzeug routet die literale Regel - /portal/request bevorzugt vor der dynamischen /portal/ weiter - unten -- keine Kollisionsgefahr mit einem Kunden, dessen Token - zufaellig 'request' laeutet waere ohnehin praktisch ausgeschlossen - (192 Bit Zufall). - - Zeigt IMMER dieselbe generische Erfolgsmeldung, unabhaengig davon, ob - die eingegebene Adresse tatsaechlich zu einem Kunden gehoert -- - verhindert, dass sich ueber die Antwort erraten laesst, welche - E-Mail-Adressen im System hinterlegt sind. - - Bewusst OHNE dauerhaften Portal-Link (der wurde entfernt, genau weil - dieser Weg OEFFENTLICH von jedem auf blossen Zuruf einer E-Mail- - Adresse ausloesbar ist): stattdessen ein frischer, einmalig nutzbarer, - nur PORTAL_ACCESS_LINK_MINUTES gueltiger Zugangs-Token (siehe - portal_access()).""" - if request.method == "POST": - email = request.form.get("email", "").strip() - if email: - conn = get_db_connection() - customer = conn.execute( - "SELECT * FROM license_customers WHERE contact_email IS NOT NULL AND lower(contact_email) = lower(?)", - (email,), - ).fetchone() - if customer: - now = datetime.now() - grant_token = secrets.token_urlsafe(32) - conn.execute( - "INSERT INTO portal_access_tokens (token, customer_id, created_at, expires_at) VALUES (?, ?, ?, ?)", - (grant_token, customer["id"], now.strftime("%Y-%m-%d %H:%M:%S"), - (now + timedelta(minutes=PORTAL_ACCESS_LINK_MINUTES)).strftime("%Y-%m-%d %H:%M:%S")), - ) - conn.commit() - access_url = _customer_facing_url("portal_access", token=grant_token) - try: - _graph_send_mail( - customer["contact_email"], - "Ihr Zugang zum TESM-Kundenportal", - "Hallo,\n\n" - "über folgenden Link erreichen Sie für kurze Zeit eine Übersicht Ihrer Tickets und Lizenzen " - "und können Ihre Kontaktdaten selbst pflegen:\n" - f"{access_url}\n\n" - f"Dieser Link ist nur {PORTAL_ACCESS_LINK_MINUTES} Minuten lang gültig und danach nicht " - "mehr nutzbar -- falls er abläuft, fordern Sie bitte einfach einen neuen an. " - "Bitte nicht an Dritte weitergeben.", - ) - log_action_system("customer.portal_link_requested", customer["name"]) - except Exception: - # Absichtlich NICHT an den Nutzer durchgereicht (siehe Docstring, - # Anti-Enumeration) -- aber fuer den Admin sichtbar loggen, sonst - # merkt niemand, dass z.B. Microsoft Graph nicht konfiguriert ist. - app.logger.error("Versand des Kundenportal-Links fehlgeschlagen:\n%s", traceback.format_exc()) - conn.close() - flash( - "Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit einem " - f"{PORTAL_ACCESS_LINK_MINUTES} Minuten gültigen Zugangslink verschickt.", - "success", - ) - return redirect(url_for("portal_request")) - return render_template("portal_request.html") - - -def _render_customer_portal(customer): - """GET/POST-Logik des Kundenportals -- Zugriff ausschliesslich ueber - die zeitlich begrenzte Session nach einem frisch angeforderten - Magic-Link (siehe portal_request()/portal_access()/ - customer_portal_session()). Es gibt bewusst KEINEN dauerhaften, - direkt aufrufbaren Portal-Link mehr -- `customer` ist hier bereits - als authentisch geprueft (Aufrufer hat eine gueltige Session - verifiziert), diese Funktion selbst prueft nichts mehr davon.""" - conn = get_db_connection() - if request.method == "POST": - contact_email = request.form.get("contact_email", "").strip() - if not _EMAIL_RE.match(contact_email): - flash("Bitte eine gültige E-Mail-Adresse angeben.", "danger") - else: - conn.execute( - "UPDATE license_customers SET contact_person=?, contact_phone=?, contact_email=?, " - "street=?, house_number=?, postal_code=?, city=? WHERE id=?", - (request.form.get("contact_person", "").strip(), request.form.get("contact_phone", "").strip(), - contact_email, request.form.get("street", "").strip(), request.form.get("house_number", "").strip(), - request.form.get("postal_code", "").strip(), request.form.get("city", "").strip(), customer["id"]), - ) - conn.commit() - log_action_system("customer.update", customer["name"], "Kunde hat eigene Kontaktdaten im Portal aktualisiert") - flash("Ihre Daten wurden aktualisiert.", "success") - conn.close() - return redirect(url_for("customer_portal_session")) - - now = time.time() - tickets_view = [] - for t in _tickets_for_customer(conn, customer["id"]): - d = dict(t) - d["modules_list"] = json.loads(d.get("modules") or "[]") - current = _latest_license_for_ticket(conn, t["id"]) - d["license"] = _license_dict_for_display(current, now) if current else None - tickets_view.append(d) - conn.close() - return render_template( - "customer_portal.html", customer=dict(customer), tickets=tickets_view, - type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, - ) - - -PORTAL_ACCESS_LINK_MINUTES = 30 -PORTAL_SESSION_MINUTES = 60 - - -@app.route("/portal/access/") -def portal_access(token): - """Loest den kurzlebigen, EINMAL nutzbaren Link aus einer per - portal_request() angeforderten E-Mail ein -- gueltig fuer - PORTAL_ACCESS_LINK_MINUTES ab Anforderung, danach oder nach dem - ersten Aufruf nicht mehr nutzbar. Bei Erfolg wird eine Session - gesetzt, die ihrerseits nach PORTAL_SESSION_MINUTES ohne neue - Anforderung automatisch ablaeuft (siehe customer_portal_session()) -- - zwei getrennte Zeitfenster: eines fuer den Link selbst (muss zeitnah - angeklickt werden), eines fuer die resultierende Sitzung (muss danach - zeitnah genutzt werden).""" - conn = get_db_connection() - grant = conn.execute("SELECT * FROM portal_access_tokens WHERE token=?", (token,)).fetchone() - valid = bool(grant) and not grant["used_at"] - if valid: - try: - valid = datetime.now() < datetime.strptime(grant["expires_at"], "%Y-%m-%d %H:%M:%S") - except ValueError: - valid = False - if not valid: - conn.close() - flash("Dieser Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.", "danger") - return redirect(url_for("portal_request")) - conn.execute( - "UPDATE portal_access_tokens SET used_at=? WHERE token=?", - (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), token), - ) - conn.commit() - customer = _customer_row(conn, grant["customer_id"]) - conn.close() - if not customer: - flash("Dieser Zugang ist nicht mehr gültig.", "danger") - return redirect(url_for("portal_request")) - session["portal_customer_id"] = customer["id"] - session["portal_session_expires_at"] = ( - datetime.now() + timedelta(minutes=PORTAL_SESSION_MINUTES) - ).strftime("%Y-%m-%d %H:%M:%S") - log_action_system("customer.portal_link_used", customer["name"]) - return redirect(url_for("customer_portal_session")) - - -@app.route("/portal/me", methods=["GET", "POST"]) -def customer_portal_session(): - """Session-basierter Kundenportal-Zugang nach einem frisch - angeforderten Magic-Link (portal_request() -> portal_access()) -- - laeuft PORTAL_SESSION_MINUTES nach dem Anklicken des Links automatisch - ab, im Unterschied zum dauerhaften /portal/ (siehe dortiger - Docstring).""" - customer_id = session.get("portal_customer_id") - expires_at_str = session.get("portal_session_expires_at") - valid = False - if customer_id and expires_at_str: - try: - valid = datetime.now() < datetime.strptime(expires_at_str, "%Y-%m-%d %H:%M:%S") - except ValueError: - valid = False - if not valid: - session.pop("portal_customer_id", None) - session.pop("portal_session_expires_at", None) - flash("Ihre Sitzung ist abgelaufen -- bitte fordern Sie einen neuen Zugangslink an.", "danger") - return redirect(url_for("portal_request")) - conn = get_db_connection() - customer = _customer_row(conn, customer_id) - conn.close() - if not customer: - session.pop("portal_customer_id", None) - session.pop("portal_session_expires_at", None) - abort(404) - return _render_customer_portal(customer) - - -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() - - -@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) - 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) - 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(): - 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 - - -@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} - - -@app.route("/") -@login_required -def index(): - """Dashboard des Master-Lizenzservers -- eine rein informative Kachel- - uebersicht der gerade AKTIVEN Lizenzen (analog zur Geraete-Kachel- - ansicht auf dem TESM-Dashboard selbst, siehe _dashboard_tiles.html - dort), gruppiert nach Ablauf-Ampel (rot < 30 Tage/abgelaufen, orange - 30-90 Tage, gruen > 90 Tage oder Lifetime). Zeigt bewusst NUR Lizenzen - mit status='active' -- alles andere (Tickets anlegen/bearbeiten/ - loeschen, Lizenzen ausstellen/widerrufen/loeschen) ist Verwaltung und - lebt seit der Kachel-Umstellung auf der eigenen Tickets-Seite - (tickets_list()). - - @login_required (anders als bei TESM selbst, wo das Geraete-Dashboard - bewusst auch ohne Login als reines Status-Board sichtbar bleibt): der - Lizenzserver ist reines internes Verwaltungswerkzeug -- ein anonymer - Besuch auf '/' soll immer zuerst auf die Login-Seite treffen (via - login_manager.login_view), nicht auf einen 'bitte anmelden'-Platzhalter - innerhalb der Dashboard-Huelle.""" - if not current_user.has_permission("licenses.view"): - return render_template("index.html", buckets=None) - conn = get_db_connection() - active_rows = conn.execute(""" - SELECT licenses.*, license_customers.name AS customer_name - FROM licenses - JOIN license_customers ON license_customers.id = licenses.customer_id - WHERE licenses.status = 'active' - ORDER BY licenses.expires_at ASC - """).fetchall() - now = time.time() - buckets = {"red": [], "orange": [], "green": []} - for row in active_rows: - d = _license_dict_for_display(row, now) - d["customer_name"] = row["customer_name"] - buckets[d["color"]].append(d) - stats = { - "red": len(buckets["red"]), "orange": len(buckets["orange"]), - "green": len(buckets["green"]), "total": len(active_rows), - } - conn.close() - return render_template( - "index.html", buckets=buckets, stats=stats, - type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, - ) - - -@app.route("/tickets") -@login_required -def tickets_list(): - """Ticketverwaltung -- Anlegen/Bearbeiten/Loeschen von Tickets sowie - Einstieg in die Lizenzausstellung/-verwaltung je Ticket. Das war bis - v1.1.0 der Inhalt des Dashboards selbst; seit der Umstellung des - Dashboards auf eine rein informative Kacheluebersicht (siehe index()) - lebt die eigentliche Verwaltung hier, eigenstaendig ueber die - Navigation erreichbar.""" - if not current_user.has_permission("tickets.view"): - flash("Keine Berechtigung, Tickets anzusehen.", "danger") - return redirect(url_for("index")) - conn = get_db_connection() - filter_customer_id = request.args.get("customer_id", type=int) - if filter_customer_id: - ticket_rows = conn.execute(""" - SELECT license_tickets.*, license_customers.name AS customer_name - FROM license_tickets - JOIN license_customers ON license_customers.id = license_tickets.customer_id - WHERE license_tickets.customer_id = ? - ORDER BY license_tickets.created_at DESC - """, (filter_customer_id,)).fetchall() - else: - ticket_rows = conn.execute(""" - SELECT license_tickets.*, license_customers.name AS customer_name - FROM license_tickets - JOIN license_customers ON license_customers.id = license_tickets.customer_id - ORDER BY license_tickets.created_at DESC - """).fetchall() - now = time.time() - tickets_view = [] - for t in ticket_rows: - d = dict(t) - d["modules_list"] = json.loads(d.get("modules") or "[]") - current = _latest_license_for_ticket(conn, t["id"]) - d["license"] = _license_dict_for_display(current, now) if current else None - d["can_delete"] = not (current and current["status"] in ("issued", "active")) - tickets_view.append(d) - conn.close() - return render_template( - "tickets.html", tickets=tickets_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, - all_modules=licensing.ALL_MODULES, license_types=licensing.LICENSE_TYPES, - can_create=current_user.has_permission("tickets.create"), - can_edit=current_user.has_permission("tickets.edit"), - ) - - -@app.route("/licenses") -@login_required -def licenses_list(): - """Eigenstaendige Lizenzuebersicht -- zeigt ALLE ausgestellten Lizenzen - flach in einer Tabelle (statt nur ueber die Historie eines einzelnen - Tickets erreichbar zu sein). Ergaenzt Kunden/Tickets/Lizenzen zu drei - gleichberechtigten, direkt ueber die Navigation erreichbaren Ebenen -- - vorher war eine Lizenz nur durch Kunde -> gefilterte Tickets -> Ticket- - Detail -> eingebettete Historie auffindbar. Optional gefiltert nach - ?ticket_id=... oder ?customer_id=... (von Ticket- bzw. Kundenseite aus - verlinkt).""" - if not current_user.has_permission("licenses.view"): - flash("Keine Berechtigung, Lizenzen anzusehen.", "danger") - return redirect(url_for("index")) - conn = get_db_connection() - filter_ticket_id = request.args.get("ticket_id") - filter_customer_id = request.args.get("customer_id", type=int) - query = """ - SELECT licenses.*, license_customers.name AS customer_name - FROM licenses - JOIN license_customers ON license_customers.id = licenses.customer_id - WHERE 1=1 - """ - params = [] - if filter_ticket_id: - query += " AND licenses.ticket_id = ?" - params.append(filter_ticket_id) - if filter_customer_id: - query += " AND licenses.customer_id = ?" - params.append(filter_customer_id) - query += " ORDER BY licenses.created_at DESC" - rows = conn.execute(query, params).fetchall() - now = time.time() - licenses_view = [] - for row in rows: - d = _license_dict_for_display(row, now) - d["customer_name"] = row["customer_name"] - licenses_view.append(d) - filter_ticket = _ticket_row(conn, filter_ticket_id) if filter_ticket_id else None - filter_customer = _customer_row(conn, filter_customer_id) if filter_customer_id else None - conn.close() - return render_template( - "licenses.html", licenses=licenses_view, type_labels=LICENSE_TYPE_LABELS, - filter_ticket=filter_ticket, filter_customer=filter_customer, - can_edit=current_user.has_permission("licenses.edit"), - ) - - -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) - 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")) - - 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 "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." - ) - 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_vendor_settings" in request.form: - master_endpoint = request.form.get("master_endpoint", "").strip().rstrip("/") - set_setting("master_endpoint", master_endpoint) - set_setting("vendor_name", request.form.get("vendor_name", "").strip()) - set_setting("vendor_phone", request.form.get("vendor_phone", "").strip()) - set_setting("vendor_email", request.form.get("vendor_email", "").strip()) - set_setting("vendor_address", request.form.get("vendor_address", "").strip()) - logo_file = request.files.get("vendor_logo") - if logo_file and logo_file.filename: - logo_bytes = logo_file.read() - if len(logo_bytes) > 200 * 1024: - flash("Logo gespeichert -- ABER zu groß (max. 200 KB), bitte ein kleineres Bild wählen.", "danger") - else: - ext = os.path.splitext(logo_file.filename)[1].lstrip(".").lower() or "png" - mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "svg": "image/svg+xml"}.get(ext, "image/png") - set_setting("vendor_logo_base64", f"data:{mime};base64,{base64.b64encode(logo_bytes).decode('ascii')}") - log_action("settings.update", "Anbieter & Lizenzserver") - flash("Anbieter- und Lizenzserver-Einstellungen gespeichert.", "success") - - elif "save_graph_settings" in request.form: - set_setting("graph_tenant_id", request.form.get("graph_tenant_id", "").strip()) - set_setting("graph_client_id", request.form.get("graph_client_id", "").strip()) - set_setting("graph_client_secret", request.form.get("graph_client_secret", "").strip()) - set_setting("graph_sender_mailbox", request.form.get("graph_sender_mailbox", "").strip()) - log_action("settings.update", "E-Mail-Versand (Microsoft Graph)") - flash("E-Mail-Einstellungen gespeichert.", "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", - 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, - }, - current_timezone=_current_timezone(), - timezones=_list_timezones(), - full_nav_items=_ordered_nav_items() if current_user.is_admin else [], - vendor=_get_vendor_info(), - master_endpoint=_get_master_endpoint(), - graph=_graph_settings(), - ) - - -@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", "") - - 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", "") - - 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, - ) - - - -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_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 - - -@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") - - 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(), - ) - - -@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")) - - - -EXPORT_SECTIONS = [ - ("users", "Benutzer"), - ("groups", "Gruppen"), - ("ldap", "LDAP/AD"), - ("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 und die automatischen AD-Gruppenzuordnungen 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.", - "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_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, _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": [], "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()] - 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["ad_user_states"]): - return None - return data - - -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 = { - "users": _export_users, "groups": _export_groups, "ldap": _export_ldap, - "nginx": _export_nginx, "logs": _export_logs, -} - - -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 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_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 = { - "users": _import_users, "groups": _import_groups, "ldap": _import_ldap, - "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")) - - 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) - 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) - 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("/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, - ) - - -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("/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!", "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")) - - # Kein Papierkorb mehr in diesem Fork (siehe Entfernung des - # Features) -- Benutzer werden direkt endgültig per DELETE - # entfernt, abgesichert durch die data-confirm-Bestätigung im - # Frontend (siehe users.html). Ein AD/LDAP-Konto legt sich bei - # erneutem gültigem Login ohnehin automatisch wieder an (siehe - # login()), ein lokales Konto ist damit unwiderruflich weg -- - # bewusst in Kauf genommen, da die einzige Alternative (Soft- - # Delete per deleted_at) ohne Papierkorb-UI niemanden mehr - # etwas nützt. - 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") - flash("AD/LDAP-Konto gelöscht (legt sich bei erneutem gültigen Login automatisch neu an).", "success") - else: - conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) - conn.execute("DELETE FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)) - conn.commit() - log_action("user.delete", target["username"] if target else user_id) - flash("Benutzer endgültig gelöscht.", "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.", "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!", "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: - # Kein Papierkorb mehr in diesem Fork -- Gruppen werden - # direkt endgültig per DELETE entfernt (Bestätigung dazu - # per data-confirm im Frontend, siehe groups.html), analog - # zur Benutzer-Löschung oben. - 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 NULL", (group_id,)) - conn.commit() - log_action("group.delete", target["name"] if target else group_id) - flash("Gruppe endgültig gelöscht.", "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-license/create_admin.py b/srv/tesm-license/create_admin.py deleted file mode 100644 index 5270bbc..0000000 --- a/srv/tesm-license/create_admin.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -"""Legt einen Admin-Benutzer im TESM-Lizenzserver 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_LICENSE_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-license/create_db.py b/srv/tesm-license/create_db.py deleted file mode 100644 index 59a5f86..0000000 --- a/srv/tesm-license/create_db.py +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env python3 -"""Initialisiert die SQLite-Datenbank für den TESM-Lizenzserver (Master). - -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. - -Gegenüber TESM entfernt: credentials/switches/devices/dhcp_*/ -ldap_fileshare_mappings (kein Geräte-/DHCP-/Fileshare-Management auf dem -Lizenzserver). Neu: license_customers/licenses (Kern des Lizenzservers, -siehe licensing.py für das zugehörige Kryptographie-/Protokollformat).""" -import os -import sqlite3 - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -DB_PATH = os.environ.get("TESM_LICENSE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db")) - -conn = sqlite3.connect(DB_PATH) -c = conn.cursor() - -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 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 audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts TEXT NOT NULL, - username TEXT NOT NULL, - action TEXT NOT NULL, - target TEXT, - details TEXT -); -""") - -# --- Kern des Lizenzservers --------------------------------------------- - -c.execute(""" -CREATE TABLE IF NOT EXISTS license_customers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - contact_email TEXT, - contact_phone TEXT, - contact_person TEXT, - street TEXT, - house_number TEXT, - postal_code TEXT, - city TEXT, - portal_token TEXT, - notes TEXT, - created_at TEXT NOT NULL -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS licenses ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - license_id TEXT UNIQUE NOT NULL, - customer_id INTEGER NOT NULL, - type TEXT NOT NULL, - modules TEXT NOT NULL DEFAULT '[]', - issued_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - license_pubkey TEXT NOT NULL, - license_file_json TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'issued', - fingerprint TEXT, - hostname TEXT, - activated_at TEXT, - deactivated_at TEXT, - last_heartbeat_at TEXT, - revoked_at TEXT, - role TEXT NOT NULL DEFAULT 'customer', - created_by TEXT, - created_at TEXT NOT NULL, - ticket_id TEXT, - lifetime INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY (customer_id) REFERENCES license_customers(id) -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS license_tickets ( - id TEXT PRIMARY KEY, - customer_id INTEGER NOT NULL, - type TEXT NOT NULL, - modules TEXT NOT NULL DEFAULT '[]', - valid_days INTEGER NOT NULL, - lifetime INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'open', - role TEXT NOT NULL DEFAULT 'customer', - created_by TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (customer_id) REFERENCES license_customers(id) -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS portal_access_tokens ( - token TEXT PRIMARY KEY, - customer_id INTEGER NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - used_at TEXT, - FOREIGN KEY (customer_id) REFERENCES license_customers(id) -); -""") - -c.execute(""" -CREATE TABLE IF NOT EXISTS reported_sub_customers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source_license_id TEXT NOT NULL, - remote_customer_ref TEXT NOT NULL, - name TEXT NOT NULL, - contact_email TEXT, contact_phone TEXT, contact_person TEXT, - street TEXT, house_number TEXT, postal_code TEXT, city TEXT, - last_reported_at TEXT NOT NULL, - UNIQUE (source_license_id, remote_customer_ref) -); -""") -c.execute(""" -CREATE TABLE IF NOT EXISTS reported_sub_tickets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source_license_id TEXT NOT NULL, - remote_ticket_id TEXT NOT NULL, - remote_customer_ref TEXT NOT NULL, - type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]', - valid_days INTEGER, lifetime INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'open', role TEXT NOT NULL DEFAULT 'customer', - created_at TEXT, - last_reported_at TEXT NOT NULL, - UNIQUE (source_license_id, remote_ticket_id) -); -""") -c.execute(""" -CREATE TABLE IF NOT EXISTS reported_sub_licenses ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source_license_id TEXT NOT NULL, - remote_license_id TEXT NOT NULL, - remote_ticket_id TEXT, - type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]', - issued_at TEXT, expires_at TEXT, status TEXT NOT NULL, lifetime INTEGER NOT NULL DEFAULT 0, - fingerprint TEXT, hostname TEXT, activated_at TEXT, last_heartbeat_at TEXT, - last_reported_at TEXT NOT NULL, - UNIQUE (source_license_id, remote_license_id) -); -""") -c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_customers_source ON reported_sub_customers(source_license_id)") -c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_tickets_source ON reported_sub_tickets(source_license_id)") -c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_licenses_source ON reported_sub_licenses(source_license_id)") - -c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5")) - -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, "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-license/create_master_license.py b/srv/tesm-license/create_master_license.py deleted file mode 100644 index 99aef23..0000000 --- a/srv/tesm-license/create_master_license.py +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env python3 -"""Erzeugt/erneuert die Bootstrap-Lizenz DIESES Lizenzservers selbst. - -Läuft rein lokal (kein Netzwerk, kein externer Super-Master) -- der Master -prüft seine eigene Lizenz über denselben license_active()-Mechanismus wie -Kundeninstanzen (siehe app.py), nur ohne Heartbeat-Ziel: dieses Skript -setzt den Aktivierungsstatus direkt in der Datenbank, da es keine höhere -Instanz gibt, die eine Aktivierung bestätigen könnte. - -Nutzt denselben Master-Signaturschlüssel, den der Server auch zum -Ausstellen von Kundenlizenzen verwendet (wird beim ersten Aufruf -automatisch erzeugt, falls noch nicht vorhanden) -- WICHTIG: dieser -Schlüssel wird beim erneuten Aufruf (Verlängerung) wiederverwendet, nie -neu erzeugt, siehe _load_or_create_master_signing_key() weiter unten und -die identische Funktion in app.py: ein Schlüsselwechsel würde die -Offline-Verifikation künftiger Aktivierungs-/Heartbeat-Antworten für ALLE -bereits an Kunden ausgegebenen Lizenzen brechen. - -Aufruf: - python3 create_master_license.py [--days 730] [--name "Firma XY"] - -Erneuerung (z.B. kurz vor Ablauf): einfach erneut aufrufen -- überschreibt -die bestehende Bootstrap-Lizenz, der Signaturschlüssel bleibt unverändert. -""" -import argparse -import json -import os -import sqlite3 -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import licensing - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -MASTER_SIGNING_KEY_PATH = os.environ.get("TESM_LICENSE_MASTER_KEY", os.path.join(BASE_DIR, "master_signing_key.json")) -LICENSE_PATH = os.environ.get("TESM_LICENSE_PATH", os.path.join(BASE_DIR, "license.json")) -DB_PATH = os.environ.get("TESM_LICENSE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db")) - - -def _load_or_create_master_signing_key(): - try: - with open(MASTER_SIGNING_KEY_PATH, "r", encoding="utf-8") as f: - data = json.load(f) - return data["private"], data["public"], False - except (OSError, ValueError, KeyError): - priv, pub = licensing.generate_keypair() - with open(MASTER_SIGNING_KEY_PATH, "w", encoding="utf-8") as f: - json.dump({"private": priv, "public": pub}, f) - try: - os.chmod(MASTER_SIGNING_KEY_PATH, 0o600) - except OSError: - pass - return priv, pub, True - - -def main(): - parser = argparse.ArgumentParser(description="Erzeugt/erneuert die Bootstrap-Lizenz dieses Lizenzservers.") - parser.add_argument("--days", type=int, default=730, help="Gültigkeitsdauer in Tagen (Standard: 730 = 2 Jahre)") - parser.add_argument("--name", default="Lizenzserver (Eigenbetrieb)", help="Anzeigename in der Lizenzdatei") - args = parser.parse_args() - - master_priv, master_pub, created = _load_or_create_master_signing_key() - if created: - print(f"Neuer Master-Signaturschlüssel erzeugt: {MASTER_SIGNING_KEY_PATH}") - else: - print(f"Bestehenden Master-Signaturschlüssel wiederverwendet: {MASTER_SIGNING_KEY_PATH}") - - license_file, _pub = licensing.issue_license( - customer={"name": args.name, "contact_email": ""}, - license_type="enterprise", - modules=[], - valid_days=args.days, - master_private_key_b64=master_priv, - master_public_key_b64=master_pub, - master_endpoint="", - vendor={"name": "", "phone": "", "email": "", "address": "", "logo_base64": ""}, - # role="master": markiert dies als die Wurzel-Instanz -- NUR dieses - # Skript darf role="master" setzen (siehe LICENSE_ROLES in - # licensing.py). app.py bietet role="master" an keiner Stelle über - # die normale Ticket-/Ausstellungs-UI an; das ist die einzige Rolle, - # die selbst weitere role="license_server"-Lizenzen ausstellen darf. - role="master", - ) - 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 - print(f"Bootstrap-Lizenz geschrieben: {LICENSE_PATH} (gültig {args.days} Tage, bis {license_file['expires_at']})") - - if os.path.exists(DB_PATH): - conn = sqlite3.connect(DB_PATH) - conn.execute( - "INSERT INTO settings (key, value) VALUES ('license_activation_status', 'active') " - "ON CONFLICT(key) DO UPDATE SET value=excluded.value" - ) - conn.commit() - conn.close() - print("Aktivierungsstatus in der Datenbank auf 'active' gesetzt.") - else: - print( - f"WARNUNG: Datenbank {DB_PATH} nicht gefunden -- bitte zuerst create_db.py ausführen, " - "dann dieses Skript erneut aufrufen, damit der Aktivierungsstatus gesetzt werden kann." - ) - - print("Fertig -- Dienst neu starten (oder warten, bis der Prozess die Lizenz beim nächsten Zugriff neu lädt).") - - -if __name__ == "__main__": - main() diff --git a/srv/tesm-license/licensing.py b/srv/tesm-license/licensing.py deleted file mode 100644 index b42933f..0000000 --- a/srv/tesm-license/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-license/requirements.txt b/srv/tesm-license/requirements.txt deleted file mode 100644 index 756bf78..0000000 --- a/srv/tesm-license/requirements.txt +++ /dev/null @@ -1,37 +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 -gunicorn==23.0.0 -itsdangerous==2.2.0 -Jinja2==3.1.6 -ldap3==2.9.1 -MarkupSafe==3.0.2 -pycparser==2.23 -PyYAML==6.0.2 -Werkzeug==3.1.3 - -# Gegenueber TESM entfernt: Flask-Sock/simple-websocket (Browser-SSH- -# Terminal fuer Switche gibt es hier nicht mehr), paramiko/PyNaCl/pyasn1 -# (nur fuer SSH zu Switchen/Geraeten gebraucht -- der Lizenzserver macht -# kein Geraete-Management). cffi/pycparser bleiben: cryptography selbst -# haengt (ausserhalb PyPy) von cffi ab, das wiederum pycparser braucht -- -# nicht optional, auch ohne paramiko. -# -# 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-license.service (siehe dort) -# statt Flasks eigenem app.run()-Entwicklungsserver ("WARNING: This is a -# development server..."). Ein Worker-Prozess mit mehreren Threads -# (--worker-class gthread) reicht hier aus, da der Lizenzserver — anders -# als TESM — keine langlebigen WebSocket-Verbindungen mehr offen hält. diff --git a/srv/tesm-license/static/css/style.css b/srv/tesm-license/static/css/style.css deleted file mode 100644 index 2284d91..0000000 --- a/srv/tesm-license/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-license/static/css/vendor/xterm.css b/srv/tesm-license/static/css/vendor/xterm.css deleted file mode 100644 index e97b643..0000000 --- a/srv/tesm-license/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-license/static/images/icon-light.svg b/srv/tesm-license/static/images/icon-light.svg deleted file mode 100644 index 67cbfd9..0000000 --- a/srv/tesm-license/static/images/icon-light.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - TESM - - - diff --git a/srv/tesm-license/static/images/logo-dark-subline.svg b/srv/tesm-license/static/images/logo-dark-subline.svg deleted file mode 100644 index e5fe857..0000000 --- a/srv/tesm-license/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-license/static/images/logo-dark.svg b/srv/tesm-license/static/images/logo-dark.svg deleted file mode 100644 index 921f6ff..0000000 --- a/srv/tesm-license/static/images/logo-dark.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - TESM - - diff --git a/srv/tesm-license/static/images/logo-light-subline.svg b/srv/tesm-license/static/images/logo-light-subline.svg deleted file mode 100644 index fdce623..0000000 --- a/srv/tesm-license/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-license/static/images/logo-light.svg b/srv/tesm-license/static/images/logo-light.svg deleted file mode 100644 index 1c67792..0000000 --- a/srv/tesm-license/static/images/logo-light.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - TESM - - diff --git a/srv/tesm-license/static/images/logo.png b/srv/tesm-license/static/images/logo.png deleted file mode 100644 index fed246369230157c923ea1df8158fc7428e267f7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2623 zcmV-F3c&S=P)$nny4~f&DpM-(KF-|><;e#0Q)8lxIp@(t)AjYtMI2PmF!2=#=OCpe-g&~(Z7@zh3FC1*#?Zq!e-Ps^^{O$>J&gH-7>B7>jWPEy-e39{ z+l1&ijMGoVsMYK7NsLuC4#OBW7(<0||0Kr!c#LxoWBwpUtzLzIoLRj*;cg!*_7#^NG<*?5$r+;JGg zHdozZ++U56WFuiT7o5)fO^!n_D)s7Z`MeTilV{wAhI(}cORpqp@xz$D^XVe%+Rm2W zLb|U0DaKa4`WRd=92sKk3B$2bIsGajUFV8%?qSSdVvrUDC4yS=t8ZH+JA2BdqQlgCuoKditT9c*#jJx?(j|N zS_30@T(-y)jB3i(OUl(}CvYKm2YVQ{Mv+*dUg_!QDl8jQw%z0aOHU*xZ~;)me*mfn zNlpO{Q2!SI%TmE0z+Vs>SM_>=ar*+}wm5up0x>|nGC0!5I1WOBq&d=(M^0o=dL_f! zN!4>JjAt{)`{EERwTh$*OmbpM`h*h_uPj%%c2Z^KVf*paSW(|PsSAtKsUMq{c4kUm%%zwZK@$=IXISa+?H9%=lgPM?m z-Vu7u?o(sjdKm5H@{F+#VM_86Ac5*Z7vMs&NHh=B$5cQ9NXr;_ic#;f^)XmrTmxhx zy`u{dW{WUsypmIZ7^nu}I>b~@ZDI6QBUkl`8VQnDgG_;NK>}o2EW#+;N-{vrksiu* zi0NX~n|(|DWoq|l&&j>4#323;{EBn*RNSW~nriy1mePXpw> zRWAvEPD#BSU5J58_0TA_G50Fe>zf#-_H^;Q)^OY0i`M|1NH3J2B;f)*7;UCLff^!Q zksw#8<0^q;x(kK zj}yr$=Ywg^y{Z=r^+Gl8*kkOM*@`8p&CD@1E>lW1^^%hFP++KmafG9;>ea(|Ji#bd zCbzj)#L$evOB(B93W(e`Arr|^s8@CJZHrMaIAOOKS>#?y(u53pA?cNYE+*e%G1ViN zY$IY6^V|+bbHS;Bu_irnL?ewpVoY>o$QV9iSdxvHA+1TmGmO?LitPgFs$P&ddJ#kf zx&)A34Qd*7o1}}faV6Ut9Nw%bPe~1VBB`Haa>nC zb{MZd^7So@duLzia~OBj+n~pJM1iqchdCJI*4`CAE>KQSG2Rl3{emBj@i-Qv_je)p zpMh~Z7UTZ2F>ox#yH7a%JB<6!z_=ZZ@nqTS^BDJoF&=G<+ea`?Z|@U)LpI)by8O>D z@OES18}V94?Cnz+r#E-WR~U_z$oC|n-@{nz;kW#j-|}02%WwHVC`#rdmYX6FD-V&` zdI8JX(DM5cfb;o@_2ZZ~J#57G0qJ^i@U$#rJpYi3^JQ~u7QZ^Wlq#uEJT%g7vX`|A> zc%X!SR1%{=$soE5PxUas`2e^}0K!OySli{jJTsT#dW}~F#0=tNlV!(X6oa!VZo$zM z5SvJP8=V1RwAv$Gz=EOTfl)nF5CDn?f&$J=e7teR2!QkE0-V8x3PS-H^W6b(o`858 zV-xZItudSj1OW&FU<|-Gx?I}=;4XelHech_0C(}~A-PMJj&U{um@M7x4RDuO-*OY% zAaKNj5$_HLjPaeuH8iZoa2{}%OBTmUy$o=ZPv6I?@jqK0V0E-cj1l6Mq!}=djtrwF zd<2x8kw~rX1@xZAE5M4{+wkj#kr>?g$T14{h=4B%rac};b z$E;WtfJij!^0dVmp}+`hBQ~bDk4<(l!~+IMjo}Mvldmx}-)>N6E}J%al?U7vfQ+(S zD5GT@U5H(}m5n&2j2U*!em0KcJRsLnf0yATzPSpVr6)EqbcbOh*u@w(7*=9Pg%Nkz zHSV~Na0ciIK%6BNQ3VhhvY~)@gL?5mB^wYQBMyqF9TGz=t5-FI#}k4|P9AD-Btyi= zCqqIE9w|js^Y<`Fyi{YPh>M3AQq_o&C3oBmWJ#_izJaFtBS?;5Iw$Qs(qqRAZ;mwQ z;esQ>R=og_Ar7@^NU;>Owlrb?FkM__ { - 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); - }; - - 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); - }); - }); - } - - /* ---------------- 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(); - initUnsavedChangesGuard(); - initSortableTables(); - initHintIcons(); - document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); - }); - - window.PoeUI = { openModal, closeModal }; -})(); diff --git a/srv/tesm-license/static/js/vendor/mammoth.browser.min.js b/srv/tesm-license/static/js/vendor/mammoth.browser.min.js deleted file mode 100644 index d79e104..0000000 --- a/srv/tesm-license/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-license/static/js/vendor/xlsx.full.min.js b/srv/tesm-license/static/js/vendor/xlsx.full.min.js deleted file mode 100644 index 16e013f..0000000 --- a/srv/tesm-license/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-license/static/js/vendor/xterm-addon-fit.js b/srv/tesm-license/static/js/vendor/xterm-addon-fit.js deleted file mode 100644 index 0388971..0000000 --- a/srv/tesm-license/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-license/static/js/vendor/xterm.js b/srv/tesm-license/static/js/vendor/xterm.js deleted file mode 100644 index 7ca75f8..0000000 --- a/srv/tesm-license/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-license/templates/_audit_log_macros.html b/srv/tesm-license/templates/_audit_log_macros.html deleted file mode 100644 index 25ae453..0000000 --- a/srv/tesm-license/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-license/templates/_audit_log_rows.html b/srv/tesm-license/templates/_audit_log_rows.html deleted file mode 100644 index b3659cc..0000000 --- a/srv/tesm-license/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-license/templates/_hint_icon.html b/srv/tesm-license/templates/_hint_icon.html deleted file mode 100644 index b776835..0000000 --- a/srv/tesm-license/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-license/templates/account.html b/srv/tesm-license/templates/account.html deleted file mode 100644 index 5a26252..0000000 --- a/srv/tesm-license/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-license/templates/activity_log.html b/srv/tesm-license/templates/activity_log.html deleted file mode 100644 index b4e9275..0000000 --- a/srv/tesm-license/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-license/templates/base.html b/srv/tesm-license/templates/base.html deleted file mode 100644 index b78e356..0000000 --- a/srv/tesm-license/templates/base.html +++ /dev/null @@ -1,150 +0,0 @@ - - - - - -{{ title or "TESM-Lizenzserver" }} - - -{% 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 %} -
    -
    - - - -
    - {% if license_topbar %} - - {{ license_topbar.text }} - - {% 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-license/templates/customer_detail.html b/srv/tesm-license/templates/customer_detail.html deleted file mode 100644 index 19cf85d..0000000 --- a/srv/tesm-license/templates/customer_detail.html +++ /dev/null @@ -1,137 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "customers" %} -{% block page_title %}Kunde{% endblock %} -{% block page_sub %}
    {{ customer.name }}
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} - -
    -
    -

    - {{ customer.name }} - {% if sub_license %}Lizenzserver{% endif %} -

    -
    - ← Alle Kunden -
    - -
    -
    -
    -

    Stammdaten

    - {% if can_edit %} - Bearbeiten - {% endif %} -
    -
    -
    -
    -
    - - -
    - {% if customer.notes %} -
    - {% endif %} -
    - -
    -
    -

    Eigene Tickets bei uns

    - Neues Ticket -
    - {% if tickets %} -
    - - - - {% for t in tickets %} - - - - - - - {% endfor %} - -
    TypRolleTicketLizenz-Status
    {{ type_labels.get(t.type, t.type) }}{% if t.role == 'license_server' %}Lizenzserver{% else %}Kunde{% endif %} - - {{ 'Offen' if t.status == 'open' else 'Gesperrt' }} - - - {% if t.license %} - - {{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(t.license.status, t.license.status) }} - - {% else %}Noch keine Lizenz{% endif %} -
    -
    - {% else %} -

    Noch keine Tickets für diesen Kunden.

    - {% endif %} -
    -
    - -{% if sub_license %} -
    -
    -
    -

    Von diesem Lizenzserver verwaltete Kunden

    -
    - Gemeldet vom Sub-Lizenzserver selbst bei jedem Heartbeat {{ hi.hint_icon("Rein informativ -- diese Daten werden nicht von uns verwaltet, sondern vom Sub-Lizenzserver selbst bei jedem Heartbeat (alle 6h, zusaetzlich sofort nach jeder dortigen Aenderung) als vollstaendiger Schnappschuss gemeldet und hier nur angezeigt.", "Gemeldete Daten") }} - {% if sub_license.last_heartbeat_at %}— zuletzt {{ sub_license.last_heartbeat_at }}{% else %}— noch kein Heartbeat empfangen{% endif %} -
    -
    -
    - {% if reported_customers %} - {% for rc in reported_customers %} -
    -
    -
    {{ rc.name }} {{ rc.contact_email or '' }}
    -
    - {% set rtickets = reported_tickets_by_customer.get(rc.remote_customer_ref, []) %} - {% if rtickets %} -
    - - - - {% for rt in rtickets %} - {% set rlicenses = reported_licenses_by_ticket.get(rt.remote_ticket_id, []) %} - {% set rl = rlicenses[0] if rlicenses else none %} - - - - - {% if rl %} - - - {% else %} - - {% endif %} - - {% endfor %} - -
    TypLaufzeitTicket-StatusLizenz-StatusAblauf
    {{ type_labels.get(rt.type, rt.type) }}{{ 'Lifetime' if rt.lifetime else (rt.valid_days ~ ' Tage' if rt.valid_days else '—') }}{{ 'Offen' if rt.status == 'open' else 'Gesperrt' }} - - {{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(rl.status, rl.status) }} - - - {% if rl.is_lifetime %}Lifetime - {% elif rl.days_left is not none %} - {{ rl.days_left }} Tage - {% else %}—{% endif %} - Noch keine Lizenz
    -
    - {% else %} -

    Noch keine Tickets gemeldet.

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

    Dieser Lizenzserver hat noch keine eigenen Kunden gemeldet.

    - {% endif %} -
    -{% endif %} - -{% endblock %} diff --git a/srv/tesm-license/templates/customer_portal.html b/srv/tesm-license/templates/customer_portal.html deleted file mode 100644 index 5c8b6cb..0000000 --- a/srv/tesm-license/templates/customer_portal.html +++ /dev/null @@ -1,92 +0,0 @@ - - - - - -Mein Konto · TESM-Lizenzserver - - - - - - - - - - diff --git a/srv/tesm-license/templates/customers.html b/srv/tesm-license/templates/customers.html deleted file mode 100644 index d8b70e4..0000000 --- a/srv/tesm-license/templates/customers.html +++ /dev/null @@ -1,211 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "customers" %} -{% block page_title %}Kunden{% endblock %} -{% block page_sub %}
    {{ customers|length }} Kunde{{ 'n' if customers|length != 1 else '' }}
    {% endblock %} - -{% block content %} - -
    -
    -

    Kunden

    -
    - {% if can_create %} - - {% endif %} -
    - -
    -
    -
    - - -
    -
    -
    - - - - - - - - - - {% for c in customers %} - - - - - - - - - - - {% else %} - - - - {% endfor %} -
    FirmaAnschriftKontaktTicketsLizenzenAktionen
    - {{ c.name }} - {% if c.is_sub_license_server %}Lizenzserver{% endif %} - - {% if c.street or c.city %} - {{ c.street }} {{ c.house_number }}{% if c.street %}
    {% endif %}{{ c.postal_code }} {{ c.city }} - {% else %}—{% endif %} -
    - {{ c.contact_email or '—' }}{% if c.contact_phone %} · {{ c.contact_phone }}{% endif %} - {% if c.contact_person %}
    {{ c.contact_person }}{% endif %} -
    {{ c.ticket_count }}{{ c.license_count }} -
    - {% if can_edit %} - - {% if c.ticket_count == 0 %} -
    - - -
    - {% endif %} - {% endif %} - {% if current_user.has_permission('licenses.create') %} - - - - {% endif %} -
    -
    Noch keine Kunden angelegt.
    -
    -
    - -{% for c in customers %} - -{% endfor %} - - - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm-license/templates/groups.html b/srv/tesm-license/templates/groups.html deleted file mode 100644 index d7d4c5d..0000000 --- a/srv/tesm-license/templates/groups.html +++ /dev/null @@ -1,393 +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) · - 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-license/templates/index.html b/srv/tesm-license/templates/index.html deleted file mode 100644 index fcc2836..0000000 --- a/srv/tesm-license/templates/index.html +++ /dev/null @@ -1,119 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "index" %} -{% block page_title %}Dashboard{% endblock %} -{% block page_sub %}
    Übersicht aktiver Kundeninstanzen
    {% endblock %} - -{% block content %} - -{% if buckets is none %} -
    -

    - {% if not current_user.is_authenticated %}Bitte anmelden.{% else %}Keine Berechtigung, Lizenzen anzusehen.{% endif %} -

    -
    -{% else %} - -
    -
    -

    Aktive Lizenzen

    -
    - Rein informative Übersicht nach Restlaufzeit der Ablauffristen (grün > 90 Tage, orange 30-90 Tage, rot < 30 Tage). Tickets anlegen, bearbeiten oder Lizenzen ausstellen/widerrufen geschieht unter Tickets. -
    -
    -
    - -
    -
    -
    Rot (< 30 Tage)
    -
    {{ stats.red }}
    -
    -
    -
    Orange (30-90 Tage)
    -
    {{ stats.orange }}
    -
    -
    -
    Grün (> 90 Tage / Lifetime)
    -
    {{ stats.green }}
    -
    -
    -
    Aktiv gesamt
    -
    {{ stats.total }}
    -
    -
    - -{% macro license_tile(l) %} -{% set ds = {'red': 'offline', 'orange': 'unbekannt', 'green': 'online'}[l.color] %} -
    -
    -
    - -
    - - {% if l.is_lifetime %}Lifetime{% elif l.is_expired %}Abgelaufen{% else %}{{ l.days_left }} Tage{% endif %} - -
    -
    {{ l.customer_name }}
    -
    {{ l.hostname or 'Hostname unbekannt' }}
    -
    {{ type_labels.get(l.type, l.type) }}
    -
    -{% endmacro %} - -{% macro section_chevron() %}{% endmacro %} - -{% if stats.total %} - - {% if buckets.red %} -
    -
    {{ section_chevron() }}Rot {{ buckets.red|length }}
    -
    - {% for l in buckets.red %}{{ license_tile(l) }}{% endfor %} -
    -
    - {% endif %} - - {% if buckets.orange %} -
    -
    {{ section_chevron() }}Orange {{ buckets.orange|length }}
    -
    - {% for l in buckets.orange %}{{ license_tile(l) }}{% endfor %} -
    -
    - {% endif %} - - {% if buckets.green %} -
    -
    {{ section_chevron() }}Grün {{ buckets.green|length }}
    -
    - {% for l in buckets.green %}{{ license_tile(l) }}{% endfor %} -
    -
    - {% endif %} - -{% else %} -
    - Aktuell keine aktivierte Lizenz. Neue Tickets/Lizenzen unter Tickets anlegen. -
    -{% endif %} - -{% endif %} -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm-license/templates/license_detail.html b/srv/tesm-license/templates/license_detail.html deleted file mode 100644 index a4ff8ec..0000000 --- a/srv/tesm-license/templates/license_detail.html +++ /dev/null @@ -1,92 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "tickets" %} -{% block page_title %}Lizenz{% endblock %} -{% block page_sub %}
    {{ customer.name if customer else '?' }}
    {% endblock %} - -{% block content %} -
    - -
    -
    -
    -

    Status

    - {% if license.ticket_id %} - - {% endif %} -
    - - {{ {"issued": "Ausgestellt (nicht aktiviert)", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(license.status, license.status) }} - -
    -
    -
    -
    -
    -
    -
    - - {% if license.is_lifetime %} - - {% else %} - - {% endif %} -
    - {% if license.fingerprint %} -
    - {% if license.hostname %} -
    - {% endif %} -
    - {% endif %} - {% if license.last_heartbeat_at %} -
    - {% endif %} - {% if license.deactivated_at %} -
    - {% endif %} - {% if license.revoked_at %} -
    - {% endif %} -
    - -
    -
    -

    Aktionen

    -
    - - - Lizenzdatei herunterladen - - {% if can_edit and customer and customer.contact_email %} -
    - -
    - {% endif %} - {% if can_edit and license.status not in ('revoked',) %} -
    - -
    - {% endif %} - {% if current_user.has_permission('licenses.edit') %} - Offline-Code verarbeiten - {% endif %} - {% if can_edit and license.status in ('revoked', 'issued') %} -
    - -
    - {% endif %} -
    - -
    -{% endblock %} diff --git a/srv/tesm-license/templates/license_manual_code.html b/srv/tesm-license/templates/license_manual_code.html deleted file mode 100644 index a2403bb..0000000 --- a/srv/tesm-license/templates/license_manual_code.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "index" %} -{% block page_title %}Offline-Code verarbeiten{% endblock %} -{% block page_sub %}
    Aktivierung/Deaktivierung/Heartbeat für Kunden ohne Netzwerkzugriff auf diesen Server
    {% endblock %} - -{% block content %} -
    - -
    -
    -
    -

    Code vom Kunden eintragen

    -
    - Der Kunde liest diesen Code auf seiner "Lizenz"-Seite ab und teilt ihn mit (Telefon/E-Mail). Nach der - Verarbeitung hier den Antwortcode zurück an den Kunden geben — der trägt ihn dort ein. -
    -
    -
    -
    -
    - - -
    - -
    -
    - - {% if result_code %} -
    -
    -
    -

    Antwortcode für den Kunden

    -
    {{ processed_action|capitalize }} erfolgreich verarbeitet — diesen Code an den Kunden zurückgeben.
    -
    -
    -
    - -
    -
    - {% endif %} - -
    -{% endblock %} diff --git a/srv/tesm-license/templates/licenses.html b/srv/tesm-license/templates/licenses.html deleted file mode 100644 index 2f1473b..0000000 --- a/srv/tesm-license/templates/licenses.html +++ /dev/null @@ -1,123 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "licenses" %} -{% block page_title %}Lizenzen{% endblock %} -{% block page_sub %}
    Alle ausgestellten Lizenzen{% if filter_customer %} -- {{ filter_customer.name }}{% elif filter_ticket %} -- Ticket{% endif %}
    {% endblock %} - -{% block content %} - -
    -
    -

    Lizenzen

    -
    - Jede Zeile ist eine konkrete, signierte Lizenzdatei -- ausgestellt aus einem Ticket (siehe Tickets). Herunterladen, per E-Mail versenden oder widerrufen geschieht auf der Detailseite. -
    -
    -
    - -{% if filter_ticket or filter_customer %} -
    - Gefiltert nach {% if filter_customer %}Kunde „{{ filter_customer.name }}“{% endif %}{% if filter_ticket %}Ticket{% endif %} -- - Filter entfernen -
    -{% endif %} - -
    -
    -
    - - -
    -
    -
    - - - - - - - - - - - - {% for l in licenses %} - - - - - - - - - - - - - {% else %} - - - - {% endfor %} -
    KundeLizenz-IDTypAusgestelltAblaufStatusLetzter HeartbeatAktionen
    - {{ l.customer_name }} - {% if l.role == 'license_server' %}Lizenzserver{% endif %} - {{ l.license_id[:8] }}…{{ type_labels.get(l.type, l.type) }}{{ l.issued_at[:10] }} - {% if l.is_lifetime %}Lifetime - {% else %} - {{ l.expires_at[:10] }} - {% if l.status == 'active' %} - {% if l.is_expired %}(abgelaufen) - {% elif l.color in ('red', 'orange') %}({{ l.days_left }} Tage) - {% endif %} - {% endif %} - {% endif %} - - - {{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(l.status, l.status) }} - - {{ l.last_heartbeat_at or '—' }} -
    - - - - {% if can_edit %} - - - - {% if l.status not in ('revoked',) %} -
    - -
    - {% endif %} - {% if l.status in ('revoked', 'issued') %} -
    - -
    - {% endif %} - {% endif %} -
    -
    Noch keine Lizenz ausgestellt.
    -
    -
    - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm-license/templates/login.html b/srv/tesm-license/templates/login.html deleted file mode 100644 index f5995b8..0000000 --- a/srv/tesm-license/templates/login.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - -Login · TESM-Lizenzserver - - - - - - - - - - diff --git a/srv/tesm-license/templates/logs.html b/srv/tesm-license/templates/logs.html deleted file mode 100644 index 53da5ec..0000000 --- a/srv/tesm-license/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 Ticket-/Lizenzaktionen (Ausstellung, Aktivierung, Widerruf, Heartbeat) dieses Lizenzservers. 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-license/templates/logs_history.html b/srv/tesm-license/templates/logs_history.html deleted file mode 100644 index 99e5c0a..0000000 --- a/srv/tesm-license/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-license/templates/portal_request.html b/srv/tesm-license/templates/portal_request.html deleted file mode 100644 index 7d8c13e..0000000 --- a/srv/tesm-license/templates/portal_request.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - -Zugang anfordern · TESM-Lizenzserver - - - - - - - - - - diff --git a/srv/tesm-license/templates/self_service.html b/srv/tesm-license/templates/self_service.html deleted file mode 100644 index 4073d55..0000000 --- a/srv/tesm-license/templates/self_service.html +++ /dev/null @@ -1,86 +0,0 @@ - - - - - -Meine Lizenz · TESM-Lizenzserver - - - - - - - - - - diff --git a/srv/tesm-license/templates/settings.html b/srv/tesm-license/templates/settings.html deleted file mode 100644 index 5430970..0000000 --- a/srv/tesm-license/templates/settings.html +++ /dev/null @@ -1,372 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "settings_system" %} -{% block page_title %}Systemeinstellungen{% endblock %} -{% block page_sub %}
    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 %} -
    - -
    -
    -
    -

    Netzwerkeinstellungen

    -
    IP/DNS/DHCP-Umschaltung dieses Hosts.
    -
    -
    - -
    - {% 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- und App-Log.
    -
    -
    - {% if current_user.has_permission('settings_system.edit') %} -
    - -
    - - -
    -
    - - -
    - -
    - {% else %} -
    - - -
    -
    - - -
    - {% endif %} -
    - Live: {{ log_paths.live }}
    - Änderungen: {{ log_paths.changes }}
    - App: {{ log_paths.app }} -
    -
    - -
    -
    -
    -

    Anbieter & Lizenzserver

    -
    Wird in jede ausgestellte Kundenlizenz eingebettet (Kontakt + wo der Client aktivieren/heartbeaten soll).
    -
    -
    - {% if current_user.has_permission('settings_system.edit') %} -
    - -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - {% if vendor.logo_base64 %} -
    Logo
    - {% endif %} - -
    - -
    - {% else %} -

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

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

    E-Mail-Versand (Microsoft Graph)

    -
    Für den direkten Versand ausgestellter Lizenzen an Kunden per E-Mail (optional -- Download funktioniert immer, auch ohne dies einzurichten).
    -
    -
    -
    - Einrichtungsanleitung (Azure AD App-Registrierung) -
      -
    1. Im Azure-Portal unter „Azure Active Directory → App-Registrierungen“ eine neue App anlegen.
    2. -
    3. Unter „API-Berechtigungen“ die Anwendungsberechtigung (nicht delegiert!) Mail.Send für Microsoft Graph hinzufügen und per „Administratorzustimmung erteilen“ bestätigen.
    4. -
    5. Unter „Zertifikate & Geheimnisse“ einen neuen Client-Secret-Wert erzeugen und sofort kopieren (wird nur einmal angezeigt).
    6. -
    7. Tenant-ID und Client-ID stehen auf der Übersichtsseite der App-Registrierung.
    8. -
    9. Als Absender-Postfach eine echte, lizenzierte Mailbox im selben Tenant angeben (z.B. lizenz@firma.de).
    10. -
    11. Optional, aber empfohlen: per Exchange-Online-„Application Access Policy“ die App-Berechtigung auf genau dieses Postfach einschränken, statt tenant-weit jede Mailbox versenden zu lassen.
    12. -
    13. Da dies ein reiner App-zu-App-Login ohne Benutzeranmeldung ist (Client-Credentials-Flow), spielt eine ggf. für Benutzer aktivierte MFA-Pflicht hier keine Rolle.
    14. -
    -
    - {% if current_user.has_permission('settings_system.edit') %} -
    - -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    - -
    -
    - -
    - {% else %} -

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

    - {% 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-license/templates/settings_import_export.html b/srv/tesm-license/templates/settings_import_export.html deleted file mode 100644 index 1a33a28..0000000 --- a/srv/tesm-license/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-license/templates/settings_ldap.html b/srv/tesm-license/templates/settings_ldap.html deleted file mode 100644 index 7365752..0000000 --- a/srv/tesm-license/templates/settings_ldap.html +++ /dev/null @@ -1,324 +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 %} -
    - -
    - -{% if can_edit %} - - - - - -{% endif %} -{% endblock %} diff --git a/srv/tesm-license/templates/settings_license.html b/srv/tesm-license/templates/settings_license.html deleted file mode 100644 index f44e69d..0000000 --- a/srv/tesm-license/templates/settings_license.html +++ /dev/null @@ -1,179 +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.
    -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - {% endif %} - -
    -{% endblock %} diff --git a/srv/tesm-license/templates/settings_nginx.html b/srv/tesm-license/templates/settings_nginx.html deleted file mode 100644 index f833ae4..0000000 --- a/srv/tesm-license/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-license/templates/ticket_detail.html b/srv/tesm-license/templates/ticket_detail.html deleted file mode 100644 index 80e11aa..0000000 --- a/srv/tesm-license/templates/ticket_detail.html +++ /dev/null @@ -1,206 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "tickets" %} -{% block page_title %}Ticket{% endblock %} -{% block page_sub %}
    {{ customer.name if customer else '?' }}
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} -
    - -
    -
    -

    Ticket

    -
    - {% if ticket.role == 'license_server' %} - Lizenzserver - {% endif %} - - {{ 'Offen' if ticket.status == 'open' else 'Gesperrt' }} - -
    -
    - {% if ticket.role == 'license_server' %} -
    - Lizenzserver-Ticket: eine daraus ausgestellte Lizenz macht die Zielinstanz zu einem vollwertigen Sub-Lizenzserver - (darf eigene Kunden/Tickets/Lizenzen ausstellen, Benutzer/Gruppen/Einstellungen verwalten) — außer selbst weitere - Lizenzserver-Lizenzen ausstellen. Kein Self-Service-Zugang für diesen Ticket-Typ. -
    - {% endif %} -
    -
    - {% if ticket.role == 'customer' %} -
    - {% endif %} -
    - {% if ticket.role == 'customer' %} -
    - - -
    -
    - Übersicht über ALLE Tickets des Kunden {{ hi.hint_icon("Es gibt bewusst keinen dauerhaften Kundenportal-Link mehr -- der Kunde fordert sich unter " ~ portal_request_url ~ " bei Bedarf selbst einen zeitlich begrenzten Zugang an.", "Kundenportal") }}: - der Kunde fordert ihn selbst über {{ portal_request_url }} an (zeitlich begrenzter Zugang, keine dauerhafte Freigabe). -
    - {% endif %} - - {% if can_edit_ticket %} -
    - -
    - - -
    -
    - {% if can_delete_ticket %} -
    - -
    - {% else %} -
    Ticket kann erst gelöscht werden, wenn keine aktive/ausgestellte Lizenz mehr besteht.
    - {% endif %} - {% endif %} -
    - -
    -
    -

    Lizenz aus diesem Ticket erstellen

    -
    - {% if not master_licensed %} -
    - Dieser Lizenzserver hat selbst keine gültige Lizenz — siehe Lizenz. -
    - {% elif ticket.status != 'open' %} -

    Ticket ist gesperrt — bitte zuerst entsperren.

    - {% elif has_open_license %} -

    - Es gibt bereits eine aktive oder ausgestellte Lizenz zu diesem Ticket (siehe unten) — erst widerrufen, dann kann - eine neue erstellt werden. -

    - {% elif can_issue_license %} -
    - - -
    - {% if ticket.role == 'customer' %} -
    - - -
    - {% endif %} - {% endif %} -
    - -
    - - {% if licenses %} - {% set l = licenses[0] %} -
    - {{ l.license_id[:8] }}… - {{ type_labels.get(l.type, l.type) }} - {{ 'Lifetime' if l.is_lifetime else 'bis ' ~ l.expires_at[:10] }} - - {{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(l.status, l.status) }} - - Details → -
    - {% if licenses|length > 1 %} -
    {{ licenses|length - 1 }} weitere ältere Lizenz{{ 'en' if licenses|length > 2 else '' }} aus diesem Ticket -- siehe „Alle Lizenzen dieses Tickets“ oben.
    - {% endif %} - {% else %} -

    Noch keine Lizenz aus diesem Ticket erstellt.

    - {% endif %} -
    - -
    - -{% if can_edit_ticket %} - -{% endif %} - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm-license/templates/ticket_new.html b/srv/tesm-license/templates/ticket_new.html deleted file mode 100644 index a639c0c..0000000 --- a/srv/tesm-license/templates/ticket_new.html +++ /dev/null @@ -1,132 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "tickets" %} -{% block page_title %}Neues Ticket{% endblock %} -{% block page_sub %}
    Berechtigung eines Kunden auf eine Lizenz mit festen Eckdaten anlegen
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} -
    - -
    -
    - Legt nur die Berechtigung an (Typ/Module/Laufzeit) — die eigentliche signierte Lizenzdatei wird danach auf der - Ticket-Seite erzeugt, durch dich oder direkt durch den Kunden selbst über seinen Self-Service-Link. -
    - -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    Weitere Angaben (Anschrift, Ansprechpartner, Telefon) lassen sich anschließend unter Kunden ergänzen.
    -
    - - {% if can_issue_license_server %} -
    - -
    - {% endif %} - -
    - - -
    - - - -
    - -
    - -
    - - -
    - - -
    -
    - - -
    -
    - -
    -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm-license/templates/tickets.html b/srv/tesm-license/templates/tickets.html deleted file mode 100644 index a79e9db..0000000 --- a/srv/tesm-license/templates/tickets.html +++ /dev/null @@ -1,193 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "tickets" %} -{% block page_title %}Tickets{% endblock %} -{% block page_sub %}
    Ticketverwaltung -- Anlegen, Bearbeiten, Löschen
    {% endblock %} - -{% block content %} -{% import "_hint_icon.html" as hi %} - -
    -
    -

    Tickets

    -
    Jedes Ticket ist die dauerhafte Berechtigung eines Kunden auf eine Lizenz mit festen Eckdaten -- die eigentliche Lizenzdatei darunter ist austauschbar.
    -
    - {% if can_create %} - - - Neues Ticket - - {% endif %} -
    - -
    -
    -
    - - -
    -
    -
    - - - - - - - - - - - - {% for t in tickets %} - - - - - - - - {% if t.license %} - - - {% else %} - - - {% endif %} - - - - {% else %} - - - - {% endfor %} -
    KundeTypModuleLaufzeitTicketLizenz-StatusLetzter HeartbeatAktionen
    - {{ t.customer_name }} - {% if t.role == 'license_server' %}Lizenzserver{% endif %} - {{ type_labels.get(t.type, t.type) }}{{ t.modules_list|join(', ') if t.modules_list else '—' }}{{ 'Lifetime' if t.lifetime else t.valid_days ~ ' Tage' }}{{ 'Offen' if t.status == 'open' else 'Gesperrt' }} - - {{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(t.license.status, t.license.status) }} - - {% if t.license.status == 'active' and not t.license.is_lifetime and t.license.days_left is not none %} - {% if t.license.is_expired %}(abgelaufen) - {% elif t.license.color in ('red', 'orange') %} - ({{ t.license.days_left }} Tage) - {% endif %} - {% endif %} - {{ t.license.last_heartbeat_at or '—' }}Noch keine Lizenz -
    - - - - {% if can_edit %} - - {% if t.can_delete %} -
    - -
    - {% endif %} - {% endif %} -
    -
    Noch keine Tickets angelegt.
    -
    -
    - -{% if can_edit %} -{% for t in tickets %} - -{% endfor %} -{% endif %} - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/tesm-license/templates/users.html b/srv/tesm-license/templates/users.html deleted file mode 100644 index ef4b938..0000000 --- a/srv/tesm-license/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_license.py b/tests/e2e_license.py new file mode 100644 index 0000000..dd52acc --- /dev/null +++ b/tests/e2e_license.py @@ -0,0 +1,493 @@ +"""End-to-End: der komplette Lizenz-Lebenszyklus ueber beide Anwendungen hinweg. + +Laeuft auf dem Zielhost gegen zwei echte, laufende Installationen: + + Lizenzserver: Kunde -> Ticket -> Lizenz ausstellen -> Bundle herunterladen + TESM : Bundle einspielen -> aktivieren (online gegen den Lizenzserver) + -> Module freigeschaltet -> Heartbeat -> Revision -> Widerruf + +Das ist der Pfad, der im Vorgaengerprojekt nur von Hand pruefbar war -- und +genau dort sassen die teuren Fehler. Geprueft wird deshalb nicht nur "es geht", +sondern auch: Doppelausstellung wird verhindert, Module sind vor der +Aktivierung wirklich gesperrt, eine neue Revision nimmt Module wieder weg, und +ein Widerruf kommt beim Client an. + +Aufruf: + python3 e2e_license.py http://127.0.0.1:8081 http://127.0.0.1:8080 admin 'Passwort' +""" + +from __future__ import annotations + +import http.cookiejar +import json +import re +import ssl +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="([^"]+)"') + +PASSED: list[str] = [] +FAILED: list[str] = [] + + +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}") + + +class Response: + def __init__(self, status: int, body: bytes, url: str, headers: dict[str, str]) -> None: + self.status = status + self.raw = body + self.url = url + self.headers = headers + + @property + def text(self) -> str: + return self.raw.decode("utf-8", "replace") + + +class Client: + def __init__(self, base: str, label: str) -> None: + self.base = base.rstrip("/") + self.label = label + jar = http.cookiejar.CookieJar() + # Der Test laeuft auf dem Zielhost gegen dessen eigenes, selbst + # ausgestelltes Zertifikat. Geprueft wird, dass TLS steht und die + # richtige Anwendung antwortet -- nicht die Vertrauenskette. + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + self.opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(jar), + urllib.request.HTTPSHandler(context=context), + ) + self.opener.addheaders = [("User-Agent", "tesm-e2e-license/2.0")] + + def _open(self, request: urllib.request.Request) -> Response: + try: + with self.opener.open(request, timeout=60) as response: + return Response( + response.status, response.read(), response.geturl(), dict(response.headers) + ) + except urllib.error.HTTPError as exc: + return Response(exc.code, exc.read(), exc.url, dict(exc.headers)) + + def get(self, path: str) -> Response: + return self._open(urllib.request.Request(self.base + path)) + + def text(self, path: str) -> str: + return self.get(path).text + + def post(self, path: str, data: dict[str, object]) -> Response: + pairs = [ + (key, str(item)) + for key, value in data.items() + for item in (value if isinstance(value, (list, tuple)) else [value]) + ] + body = urllib.parse.urlencode(pairs).encode("utf-8") + request = urllib.request.Request( + self.base + path, + data=body, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Origin": self.base, + "Referer": self.base + path, + }, + ) + return self._open(request) + + def post_file( + self, path: str, field: str, filename: str, content: bytes, extra: dict[str, str] + ) -> Response: + boundary = "----tesm-e2e-boundary-3f88" + parts: list[bytes] = [] + for key, value in extra.items(): + parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"\r\n\r\n' + f"{value}\r\n".encode() + ) + parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="{field}"; ' + f'filename="{filename}"\r\nContent-Type: application/json\r\n\r\n'.encode() + + content + + b"\r\n" + ) + parts.append(f"--{boundary}--\r\n".encode()) + request = urllib.request.Request( + self.base + path, + data=b"".join(parts), + headers={ + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Origin": self.base, + "Referer": self.base + path, + }, + ) + return self._open(request) + + def token(self, path: str) -> str: + body = self.text(path) + match = CSRF_RE.search(body) or BODY_CSRF_RE.search(body) + if not match: + raise AssertionError(f"{self.label}: kein CSRF-Token auf {path}") + return match.group(1) + + def login(self, username: str, password: str) -> bool: + token = self.token("/login") + response = self.post( + "/login", {"username": username, "password": password, "csrf_token": token} + ) + return response.status == 200 and "/login" not in response.url + + +def main( + server_base: str, + client_base: str, + username: str, + password: str, + endpoint: str = "", +) -> int: + # Der Endpunkt, den TESM spaeter anruft, muss nicht die Adresse sein, unter + # der ein Mensch den Lizenzserver bedient. Auf einem Host, der beide + # Anwendungen traegt, ist der direkte Weg ueber die Loopback-Adresse der + # ehrlichere -- er umgeht Namensaufloesung und Zertifikatspruefung, die + # zwischen zwei Diensten derselben Maschine nichts beitragen. + endpoint = (endpoint or server_base).rstrip("/") + server = Client(server_base, "Lizenzserver") + tesm = Client(client_base, "TESM") + print(f"\n== Lizenz-Lebenszyklus: {server_base} -> {client_base} ==\n") + + print("Anmeldung:") + check("Lizenzserver", server.login(username, password), "fehlgeschlagen") + check("TESM", tesm.login(username, password), "fehlgeschlagen") + if FAILED: + print("\nOhne Anmeldung ist der Rest sinnlos -- Abbruch.") + return 1 + + # -- Anbieterdaten: der Endpunkt landet spaeter in jeder Lizenz ----------- + print("\nAnbieterdaten und Endpunkt:") + token = server.token("/einstellungen/anbieter") + response = server.post( + "/einstellungen/anbieter", + { + "csrf_token": token, + "vendor_name": "WiS GmbH", + "vendor_email": "lizenzen@wis.invalid", + "vendor_phone": "+49 000 000000", + "vendor_address": "Musterweg 1, 12345 Musterstadt", + "master_endpoint": endpoint, + }, + ) + check( + "Anbieterdaten gespeichert", + "gespeichert" in response.text or "Keine Aenderungen" in response.text, + response.text[:150], + ) + token = server.token("/einstellungen/anbieter") + response = server.post( + "/einstellungen/anbieter", {"csrf_token": token, "master_endpoint": "ftp://falsch"} + ) + check("Ungueltiger Endpunkt abgelehnt", "muss mit http" in response.text, "keine Meldung") + + # -- Kunde --------------------------------------------------------------- + print("\nKunde:") + token = server.token("/kunden/") + response = server.post( + "/kunden/neu", + { + "csrf_token": token, + "name": "Beispiel AG", + "contact_email": "it@beispiel.invalid", + "contact_person": "Frau Muster", + "street": "Hauptstrasse", + "house_number": "7", + "postal_code": "12345", + "city": "Musterstadt", + }, + ) + check("Kunde angelegt", "angelegt" in response.text, response.text[:150]) + match = re.search(r"/kunden/(\d+)$", response.url) + customer_id = match.group(1) if match else "" + check("Kunden-Nummer erhalten", bool(customer_id), response.url) + + # -- Ticket --------------------------------------------------------------- + print("\nTicket:") + token = server.token("/tickets/neu") + response = server.post( + "/tickets/neu", + { + "csrf_token": token, + "customer_id": customer_id, + "type": "enterprise", + "modules": ["dhcp", "fileshare", "maintenance"], + "valid_days": "365", + "role": "customer", + "notes": "E2E-Test", + }, + ) + check("Ticket angelegt", "angelegt" in response.text, response.text[:150]) + match = re.search(r"/tickets/([^/]+)$", response.url) + ticket_id = match.group(1) if match else "" + check("Ticket-Kennung erhalten", bool(ticket_id), response.url) + + token = server.token("/tickets/neu") + response = server.post( + "/tickets/neu", + { + "csrf_token": token, + "customer_id": customer_id, + "type": "trial", + "valid_days": "30", + "lifetime": "1", + "role": "customer", + }, + ) + check( + "Testlizenz kann nicht unbefristet sein", + "nicht unbefristet" in response.text, + "Regel greift nicht", + ) + + # -- Lizenz ausstellen ---------------------------------------------------- + print("\nLizenz ausstellen:") + token = server.token(f"/tickets/{ticket_id}") + response = server.post(f"/lizenzen/aus-ticket/{ticket_id}", {"csrf_token": token}) + check("Lizenz ausgestellt", "ausgestellt" in response.text, response.text[:200]) + match = re.search(r"/lizenzen/([0-9a-f-]{36})$", response.url) + license_id = match.group(1) if match else "" + check("Lizenz-Kennung erhalten", bool(license_id), response.url) + + token = server.token(f"/tickets/{ticket_id}") + response = server.post(f"/lizenzen/aus-ticket/{ticket_id}", {"csrf_token": token}) + check( + "Doppelklick stellt keine zweite Lizenz aus", + "bereits eine Lizenz offen" in response.text, + "zweite Lizenz entstanden", + ) + + # -- Bundle herunterladen ------------------------------------------------- + print("\nAuslieferung:") + response = server.get(f"/lizenzen/{license_id}/datei") + check("Bundle heruntergeladen", response.status == 200, f"HTTP {response.status}") + check( + "Als Anhang ausgeliefert", + "attachment" in response.headers.get("Content-Disposition", ""), + response.headers.get("Content-Disposition", ""), + ) + check( + "Nicht zwischenspeicherbar", + "no-store" in response.headers.get("Cache-Control", ""), + response.headers.get("Cache-Control", ""), + ) + bundle_bytes = response.raw + try: + bundle = json.loads(bundle_bytes.decode("utf-8")) + except ValueError: + check("Bundle ist gueltiges JSON", False, bundle_bytes[:120].decode("latin1")) + return 1 + check("Bundle ist gueltiges JSON", True) + check( + "Dokument und privater Clientschluessel enthalten", + "license" in bundle and "client_key" in bundle, + str(sorted(bundle))[:100], + ) + document = bundle.get("license", {}) + check( + "Endpunkt eingebettet", + document.get("master_endpoint") == endpoint, + str(document.get("master_endpoint")), + ) + check( + "Alle drei Module enthalten", + set(document.get("modules", [])) == {"dhcp", "fileshare", "maintenance"}, + str(document.get("modules")), + ) + check("Rolle ist customer", document.get("role") == "customer", str(document.get("role"))) + + body = server.text(f"/lizenzen/{license_id}") + check("Zustellprotokoll vermerkt den Download", "ownload" in body, "kein Eintrag") + + # -- Einspielen in TESM --------------------------------------------------- + print("\nEinspielen in TESM:") + body = tesm.text("/lizenz/") + if "keine Lizenz installiert" in body: + check("Vorher keine Lizenz installiert", True) + else: + # Bei einem wiederholten Lauf liegt noch die Lizenz des Vorlaufs. Das ist + # kein Fehler -- das Einspielen ersetzt sie, und genau das wird gleich + # geprueft. + print(" [info] Es liegt bereits eine Lizenz -- wiederholter Lauf, wird ersetzt.") + + token = tesm.token("/lizenz/") + response = tesm.post_file( + "/lizenz/einspielen", "license_file", "lizenz.json", bundle_bytes, {"csrf_token": token} + ) + check("Lizenz eingespielt", "eingespielt" in response.text, response.text[:250]) + check( + "Aussteller beim ersten Mal gepinnt", + "Aussteller gepinnt" in response.text, + "kein Pinning erkennbar", + ) + check("Noch nicht aktiviert", "nicht aktiviert" in response.text, "Zustand unerwartet") + + print("\nModule vor der Aktivierung:") + for path in ("/dhcp/", "/wartung/", "/dateifreigaben/"): + check(f"{path} gesperrt", tesm.get(path).status == 404, "erreichbar") + + # -- Manipuliertes Bundle ------------------------------------------------- + print("\nManipuliertes Bundle:") + tampered = json.loads(bundle_bytes.decode("utf-8")) + tampered["license"]["modules"] = ["dhcp", "fileshare", "maintenance", "erfunden"] + token = tesm.token("/lizenz/") + response = tesm.post_file( + "/lizenz/einspielen", + "license_file", + "boese.json", + json.dumps(tampered).encode("utf-8"), + {"csrf_token": token}, + ) + check( + "Veraenderte Lizenz wird abgewiesen", + "eingespielt" not in response.text, + "Manipulation akzeptiert!", + ) + + # -- Aktivierung ---------------------------------------------------------- + print("\nAktivierung (online, echter HTTP-Aufruf an den Lizenzserver):") + token = tesm.token("/lizenz/") + response = tesm.post("/lizenz/aktivieren", {"csrf_token": token}) + check("Aktivierung erfolgreich", "jetzt aktiv" in response.text, response.text[:300]) + check("Zustand aktiviert", "nicht aktiviert" not in response.text, "Zustand unerwartet") + + print("\nModule nach der Aktivierung:") + for path, needle in ( + ("/dhcp/", "DHCP"), + ("/wartung/", "Wartung"), + ("/dateifreigaben/", "Dateifreigaben"), + ): + response = tesm.get(path) + check( + f"{path} freigeschaltet", + response.status == 200 and needle in response.text, + f"HTTP {response.status}", + ) + + print("\nSicht des Lizenzservers:") + body = server.text(f"/lizenzen/{license_id}") + check("Maschine gebunden", "noch nicht gebunden" not in body, "kein Fingerprint") + check("Aktivierungszeitpunkt vermerkt", "Aktiviert" in body, "fehlt") + + # -- Heartbeat ------------------------------------------------------------ + print("\nHeartbeat:") + token = tesm.token("/lizenz/") + response = tesm.post("/lizenz/heartbeat", {"csrf_token": token}) + check("Heartbeat bestaetigt", "bestaetigt" in response.text, response.text[:250]) + + # -- Neue Revision: Modul entziehen --------------------------------------- + print("\nNeue Revision (Modul entziehen):") + token = server.token(f"/lizenzen/{license_id}") + response = server.post( + f"/lizenzen/{license_id}/neu-signieren", + {"csrf_token": token, "modules": ["dhcp"], "extend_days": ""}, + ) + check("Revision signiert", "Revision" in response.text, response.text[:250]) + + token = tesm.token("/lizenz/") + response = tesm.post("/lizenz/heartbeat", {"csrf_token": token}) + check("Heartbeat holt die Revision", "bestaetigt" in response.text, response.text[:200]) + check("Entzogenes Modul gesperrt", tesm.get("/wartung/").status == 404, "weiter offen") + check("Verbliebenes Modul offen", tesm.get("/dhcp/").status == 200, "gesperrt") + + # -- Deaktivierung und erneute Aktivierung -------------------------------- + print("\nDeaktivierung (Systemwechsel):") + token = tesm.token("/lizenz/") + response = tesm.post("/lizenz/deaktivieren", {"csrf_token": token}) + check("Deaktivierung erfolgreich", "wurde deaktiviert" in response.text, response.text[:250]) + check("Module wieder gesperrt", tesm.get("/dhcp/").status == 404, "weiter offen") + body = server.text(f"/lizenzen/{license_id}") + check("Server kennt die Deaktivierung", "deaktiviert" in body, "unerwartet") + + print("\nErneute Aktivierung derselben Maschine:") + token = tesm.token("/lizenz/") + response = tesm.post("/lizenz/aktivieren", {"csrf_token": token}) + check("Wieder aktiv", "jetzt aktiv" in response.text, response.text[:250]) + check("Modul wieder offen", tesm.get("/dhcp/").status == 200, "gesperrt") + + # -- Widerruf ------------------------------------------------------------- + print("\nWiderruf durch den Anbieter:") + token = server.token(f"/lizenzen/{license_id}") + response = server.post( + f"/lizenzen/{license_id}/widerrufen", {"csrf_token": token, "reason": "E2E-Test"} + ) + check("Widerruf gespeichert", "widerrufen" in response.text, response.text[:250]) + + token = tesm.token("/lizenz/") + response = tesm.post("/lizenz/heartbeat", {"csrf_token": token}) + check("Client erfaehrt den Widerruf", "widerrufen" in response.text, response.text[:250]) + check("Module nach Widerruf gesperrt", tesm.get("/dhcp/").status == 404, "weiter offen") + check("Lizenzseite bleibt erreichbar", tesm.get("/lizenz/").status == 200, "gesperrt") + + # -- Aufraeumen ------------------------------------------------------- + # Der Test endet mit einem Widerruf -- danach sind DHCP, Wartung und + # Dateifreigaben gesperrt. Eine Pruefung, die die Installation unbrauchbar + # zuruecklaesst, ist keine gute Pruefung: wer danach die Wartung sucht, + # findet einen 404 und haelt ihn fuer einen Fehler. Also wird zum Schluss + # eine frische Lizenz ausgestellt und aktiviert. + print("\nAufraeumen -- Installation wieder betriebsbereit machen:") + token = server.token(f"/tickets/{ticket_id}") + response = server.post(f"/lizenzen/aus-ticket/{ticket_id}", {"csrf_token": token}) + check("Ersatzlizenz ausgestellt", "ausgestellt" in response.text, response.text[:200]) + match = re.search(r"/lizenzen/([0-9a-f-]{36})$", response.url) + replacement_id = match.group(1) if match else "" + check("Kennung der Ersatzlizenz", bool(replacement_id), response.url) + + response = server.get(f"/lizenzen/{replacement_id}/datei") + check("Ersatzbundle heruntergeladen", response.status == 200, f"HTTP {response.status}") + + token = tesm.token("/lizenz/") + response = tesm.post_file( + "/lizenz/einspielen", "license_file", "lizenz.json", response.raw, {"csrf_token": token} + ) + check("Ersatzlizenz eingespielt", "eingespielt" in response.text, response.text[:200]) + + token = tesm.token("/lizenz/") + response = tesm.post("/lizenz/aktivieren", {"csrf_token": token}) + check("Ersatzlizenz aktiv", "jetzt aktiv" in response.text, response.text[:250]) + for path in ("/dhcp/", "/wartung/", "/dateifreigaben/"): + check(f"{path} wieder erreichbar", tesm.get(path).status == 200, "weiter gesperrt") + + # -- Protokolle ----------------------------------------------------------- + print("\nProtokolle und Diagnose:") + body = server.text("/protokolle/aenderungen") + for action in ("license.issued", "license.downloaded", "license.resigned", "license.revoked"): + check(f"Eintrag {action}", action in body, "fehlt") + check( + "Diagnose Lizenzserver: Kette unversehrt", + "unversehrt" in server.text("/diagnose/"), + "gebrochen", + ) + + body = tesm.text("/protokolle/aenderungen") + for action in ("license.activate", "license.deactivate", "license.heartbeat"): + check(f"TESM-Eintrag {action}", action in body, "fehlt") + check("Diagnose TESM: Kette unversehrt", "unversehrt" in tesm.text("/diagnose/"), "gebrochen") + + 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) not in (5, 6): + print(__doc__) + raise SystemExit(2) + raise SystemExit(main(*sys.argv[1:])) diff --git a/tests/e2e_license_server.py b/tests/e2e_license_server.py new file mode 100644 index 0000000..e5bb814 --- /dev/null +++ b/tests/e2e_license_server.py @@ -0,0 +1,251 @@ +"""End-to-End-Rauchtest des Lizenzservers gegen eine laufende Installation. + +Gegenstueck zu ``e2e_smoke.py`` (das prueft TESM). Beide Anwendungen teilen +sich den Kern; hier geht es um die Seiten und Schranken, die nur der +Lizenzserver hat -- Kunden, Tickets, Lizenzen, Kundenportal, Selbstbedienung +und die Maschinen-API. + +Aufruf: + python3 e2e_license_server.py https://lizenz.example.org admin 'Passwort' +""" + +from __future__ import annotations + +import http.cookiejar +import json +import re +import ssl +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="([^"]+)"') + +PASSED: list[str] = [] +FAILED: list[str] = [] + + +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}") + + +class Client: + def __init__(self, base: str) -> None: + self.base = base.rstrip("/") + self.https = self.base.startswith("https://") + jar = http.cookiejar.CookieJar() + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + tls = urllib.request.HTTPSHandler(context=context) + + self.opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(jar), tls + ) + self.opener.addheaders = [("User-Agent", "tesm-e2e-lic/2.0")] + + class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): # type: ignore[override] + return None + + self.plain = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(jar), _NoRedirect(), tls + ) + self.plain.addheaders = [("User-Agent", "tesm-e2e-lic/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=30) 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: + try: + with self.plain.open(urllib.request.Request(self.base + path), timeout=30) 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=45) 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 post_json(self, path: str, payload: dict[str, object]) -> tuple[int, str]: + request = urllib.request.Request( + self.base + path, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + 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) + + +def main(base: str, username: str, password: str) -> int: + client = Client(base) + print(f"\n== Rauchtest Lizenzserver gegen {base} ==\n") + + print("Oeffentlich erreichbar:") + status, body, headers = client.get("/gesundheit") + check( + "Gesundheitsendpunkt", + status == 200 and json.loads(body)["status"] == "ok", + body[:80], + ) + check( + "Antwortet als Lizenzserver", + json.loads(body).get("version") == "tesm-license", + body[:80], + ) + + status, body, headers = client.get("/login") + check("Anmeldeseite", status == 200 and "Anmelden" in body, f"HTTP {status}") + csp = headers.get("Content-Security-Policy", "") + check("CSP mit Nonce", "nonce-" in csp, csp[:80]) + check( + "X-Frame-Options", + headers.get("X-Frame-Options") == "DENY", + str(headers.get("X-Frame-Options")), + ) + if client.https: + cookie = headers.get("Set-Cookie", "") + check("Sitzungscookie mit Secure", "Secure" in cookie, cookie[:80] or "kein Cookie") + + print("\nKundenportal (ohne Anmeldung erreichbar):") + status, body, _ = client.get("/portal/") + check("Portalseite", status == 200 and "Kundenportal" in body, f"HTTP {status}") + check("Fragt nach der Kennung, nicht nach einem Passwort", "Passwort" not in body, "unerwartet") + + print("\nSchranken ohne Anmeldung:") + for path in ("/kunden/", "/tickets/", "/lizenzen/", "/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}") + + print("\nMaschinen-API (eigene Authentisierung, kein Cookie):") + status, body = client.post_json("/api/v1/licenses/activate", {"unsinn": True}) + check( + "Unsinniger Antrag wird abgewiesen", + status in (400, 401, 403, 404, 422), + f"HTTP {status}", + ) + check( + "Antwort ist maschinenlesbar, nicht HTML", + body.lstrip().startswith("{") and "error" in body, + body[:100], + ) + check("Keine Interna in der Antwort", "Traceback" not in body, "Stacktrace nach aussen") + check( + "Kein CSRF-Token noetig (Maschinenschnittstelle)", + "Sicherheitstoken" not in body, + "API verlangt ein Formulartoken", + ) + + print("\nAnmeldung:") + token = client.token("/login") + status, body = client.post( + "/login", {"username": username, "password": password, "csrf_token": token} + ) + check("Anmeldung erfolgreich", status == 200 and "Anmelden" not in body, f"HTTP {status}") + + print("\nSeiten (angemeldet):") + pages = [ + ("/", "Uebersicht"), + ("/kunden/", "Kunden"), + ("/tickets/", "Tickets"), + ("/tickets/neu", "Ticket"), + ("/lizenzen/", "Lizenzen"), + ("/lizenzen/offline-code", "Offline"), + ("/lizenzen/sub-lizenzserver", "Lizenzserver"), + ("/eigene-lizenz/", "Lizenz"), + ("/einstellungen/system", "System"), + ("/einstellungen/anbieter", "Anbieter"), + ("/einstellungen/email", "Mail"), + ("/einstellungen/netzwerk", "Netzwerk"), + ("/einstellungen/webserver", "Webserver"), + ("/einstellungen/verzeichnisdienst", "Verzeichnisdienst"), + ("/protokolle/aenderungen", "Aenderungsprotokoll"), + ("/sicherung/", "Sicherung"), + ("/papierkorb/", "Papierkorb"), + ("/diagnose/", "Diagnose"), + ("/verwaltung/benutzer", "Benutzer"), + ("/verwaltung/gruppen", "Gruppen"), + ("/konto/sicherheit", "Sicherheit"), + ] + for path, needle in pages: + status, body, _ = client.get(path) + check( + f"{path}", + status == 200 and needle in body and "Interner Fehler" not in body, + f"HTTP {status}", + ) + + print("\nEigene Master-Lizenz:") + status, body, _ = client.get("/eigene-lizenz/") + check("Master-Rolle erkannt", "master" in body.lower(), "Rolle fehlt") + + print("\nDiagnose:") + status, body, _ = client.get("/diagnose/") + check("Audit-Kette unversehrt", "unversehrt" in body, "Kette gebrochen?") + check("Signaturschluessel vorhanden", "Signatur" in body, "kein Hinweis") + + print("\nAbmeldung:") + token = client.token("/konto/") + client.post("/logout", {"csrf_token": token}) + check("Nach Abmeldung gesperrt", client.get_raw("/kunden/") in (302, 401), "weiter offen") + + 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_license_server.py b/tests/test_license_server.py new file mode 100644 index 0000000..e5bb0e2 --- /dev/null +++ b/tests/test_license_server.py @@ -0,0 +1,703 @@ +"""Tests des Lizenzservers: Ausstellung, Aktivierung, Portal, Rollen. + +Deckt die Regeln ab, die der Vorgaenger schmerzhaft gelernt hat -- und die +Haertungen, die hier neu sind (Replay, Nonce, Rollen-Sperre, Portal-TTL). +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest + +import tesm_licensing as lic + + +@pytest.fixture() +def ls_app(instance_env: Any): + from tesm_license import create_app + + return create_app(TESTING=True) + + +@pytest.fixture() +def ls_client(ls_app: Any): + return ls_app.test_client() + + +@pytest.fixture() +def ls_admin(ls_app: Any): + from tesm_core.extension import core + from tesm_core.security import passwords + + extension = core(ls_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 ls_login(ls_client: Any, ls_admin: dict[str, str]): + def do() -> Any: + return ls_client.post( + "/login", + data={ + "username": ls_admin["username"], + "password": ls_admin["password"], + "csrf_token": _token(ls_client), + }, + follow_redirects=True, + ) + + return do + + +@pytest.fixture() +def bootstrapped(ls_app: Any, ls_client: Any, ls_login: Any): + """Angemeldeter Admin mit Signaturschluessel und Vendor-Angaben.""" + from tesm_core.extension import core + + ls_login() + extension = core(ls_app) + with ls_app.app_context(), extension.database.session() as conn: + with extension.database.transaction(conn): + extension.settings.set(conn, "vendor_name", "WiS GmbH", actor="test") + extension.settings.set( + conn, "master_endpoint", "https://lizenz.example.invalid", actor="test" + ) + return ls_client + + +def _create_customer(client: Any, name: str = "Beispiel AG") -> int: + token = _token(client, "/kunden/") + client.post( + "/kunden/neu", + data={ + "csrf_token": token, + "name": name, + "contact_email": "it@example.invalid", + "contact_person": "Frau Muster", + }, + follow_redirects=True, + ) + return 1 + + +def _create_ticket(client: Any, customer_id: int = 1, **overrides: Any) -> str: + token = _token(client, "/tickets/neu") + data = { + "csrf_token": token, + "customer_id": str(customer_id), + "type": "standard", + "valid_days": "365", + } + data.update(overrides) + response = client.post("/tickets/neu", data=data, follow_redirects=False) + assert response.status_code == 302, response.get_data(as_text=True)[:400] + return response.headers["Location"].rstrip("/").split("/")[-1] + + +# --------------------------------------------------------------------------- # +# Start +# --------------------------------------------------------------------------- # + + +def test_app_boots(ls_app): + from tesm_core.extension import core + + components = {row["component"] for row in core(ls_app).migration_status()} + assert components == {"core", "tesm_license"} + + +def test_dashboard_requires_login(ls_client): + """Anders als TESMs Geraeteboard ist diese Startseite intern.""" + response = ls_client.get("/") + assert response.status_code == 302 + assert "/login" in response.headers["Location"] + + +LS_PAGES = [ + "/", + "/kunden/", + "/tickets/", + "/tickets/neu", + "/lizenzen/", + "/lizenzen/offline-code", + "/lizenzen/sub-lizenzserver", + "/einstellungen/system", + "/einstellungen/anbieter", + "/einstellungen/email", + "/einstellungen/netzwerk", + "/einstellungen/webserver", + "/einstellungen/verzeichnisdienst", + "/eigene-lizenz/", + "/eigene-lizenz/signaturschluessel", + "/sicherung/", + "/papierkorb/", + "/diagnose/", + "/protokolle/laufend", + "/protokolle/aenderungen", + "/verwaltung/benutzer", + "/verwaltung/gruppen", +] + + +@pytest.mark.parametrize("path", LS_PAGES) +def test_pages_render(bootstrapped, path): + response = bootstrapped.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_portal_pages_public(ls_client): + assert ls_client.get("/portal/").status_code == 200 + assert ls_client.get("/api/v1/licenses/hinweis").get_json()["service"] == "tesm-license" + + +# --------------------------------------------------------------------------- # +# Kunde, Ticket, Lizenz +# --------------------------------------------------------------------------- # + + +def test_customer_ticket_license_flow(bootstrapped, ls_app): + from tesm_core.extension import core + + from tesm_license.services import issuance + + client = bootstrapped + _create_customer(client) + ticket_id = _create_ticket(client, type="enterprise") + + detail = client.get(f"/tickets/{ticket_id}").get_data(as_text=True) + assert "Lizenz ausstellen" in detail + + response = client.post( + f"/lizenzen/aus-ticket/{ticket_id}", + data={"csrf_token": _token(client, f"/tickets/{ticket_id}")}, + follow_redirects=True, + ) + assert "ausgestellt" in response.get_data(as_text=True) + + extension = core(ls_app) + with extension.database.session() as conn: + rows = issuance.list_licenses(conn) + assert len(rows) == 1 + document = json.loads(str(rows[0]["license_document"])) + lic.verify_license(document) + assert set(document["modules"]) == set(lic.ALL_MODULES) + assert document["role"] == "customer" + assert rows[0]["client_key_encrypted"] + assert "client_key" not in json.dumps(document) + + +def test_only_one_open_license_per_ticket(bootstrapped, ls_app): + from tesm_core.extension import core + from tesm_license.services import issuance + + client = bootstrapped + _create_customer(client) + ticket_id = _create_ticket(client) + token = _token(client, f"/tickets/{ticket_id}") + client.post(f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": token}, follow_redirects=True) + second = client.post( + f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": token}, follow_redirects=True + ) + assert "bereits eine Lizenz offen" in second.get_data(as_text=True) + + extension = core(ls_app) + with extension.database.session() as conn: + assert len(issuance.list_licenses(conn)) == 1 + + +def test_trial_override_does_not_change_ticket(bootstrapped, ls_app): + from tesm_core.extension import core + from tesm_license.services import issuance + + client = bootstrapped + _create_customer(client) + ticket_id = _create_ticket(client, type="standard", valid_days="730") + client.post( + f"/lizenzen/aus-ticket/{ticket_id}", + data={"csrf_token": _token(client, f"/tickets/{ticket_id}"), "as_trial": "1"}, + follow_redirects=True, + ) + extension = core(ls_app) + with extension.database.session() as conn: + ticket = issuance.get_ticket(conn, ticket_id) + row = issuance.list_licenses(conn)[0] + assert ticket["type"] == "standard" + assert ticket["valid_days"] == 730 + assert row["type"] == "trial" + assert set(json.loads(str(row["modules"]))) == set(lic.ALL_MODULES) + + +def test_trial_lifetime_rejected(bootstrapped): + client = bootstrapped + _create_customer(client) + response = client.post( + "/tickets/neu", + data={ + "csrf_token": _token(client, "/tickets/neu"), + "customer_id": "1", + "type": "trial", + "valid_days": "30", + "lifetime": "1", + }, + follow_redirects=True, + ) + assert "nicht unbefristet" in response.get_data(as_text=True) + + +def test_ticket_delete_blocked_with_open_license(bootstrapped): + client = bootstrapped + _create_customer(client) + ticket_id = _create_ticket(client) + token = _token(client, f"/tickets/{ticket_id}") + client.post(f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": token}, follow_redirects=True) + response = client.post( + f"/tickets/{ticket_id}/loeschen", data={"csrf_token": token}, follow_redirects=True + ) + assert "offene Lizenz" in response.get_data(as_text=True) + + +def test_customer_delete_blocked_with_tickets(bootstrapped): + client = bootstrapped + _create_customer(client) + _create_ticket(client) + response = client.post( + "/kunden/1/loeschen", + data={"csrf_token": _token(client, "/kunden/1")}, + follow_redirects=True, + ) + assert "Ticket" in response.get_data(as_text=True) + + +def test_blocked_ticket_prevents_issuing(bootstrapped): + client = bootstrapped + _create_customer(client) + ticket_id = _create_ticket(client) + token = _token(client, f"/tickets/{ticket_id}") + client.post(f"/tickets/{ticket_id}/sperre", data={"csrf_token": token}, follow_redirects=True) + response = client.post( + f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": token}, follow_redirects=True + ) + assert "gesperrt" in response.get_data(as_text=True) + + +# --------------------------------------------------------------------------- # +# Rollen-Sperre +# --------------------------------------------------------------------------- # + + +def test_license_server_role_downgraded_without_master_license(bootstrapped, ls_app): + """Ohne eigene Master-Lizenz wird die Rolle still auf 'customer' gesetzt.""" + from tesm_core.extension import core + from tesm_license.services import issuance + + client = bootstrapped + _create_customer(client) + ticket_id = _create_ticket(client, role="license_server") + extension = core(ls_app) + with extension.database.session() as conn: + ticket = issuance.get_ticket(conn, ticket_id) + assert ticket["role"] == "customer" + + +def test_ticket_new_hides_license_server_option(bootstrapped): + body = bootstrapped.get("/tickets/neu").get_data(as_text=True) + assert "Lizenzserver-Lizenz" not in body + + +# --------------------------------------------------------------------------- # +# Aktivierungsprotokoll +# --------------------------------------------------------------------------- # + + +@pytest.fixture() +def issued_license(bootstrapped, ls_app): + from tesm_core.extension import core + from tesm_license.services import issuance + + client = bootstrapped + _create_customer(client) + ticket_id = _create_ticket(client, type="enterprise") + client.post( + f"/lizenzen/aus-ticket/{ticket_id}", + data={"csrf_token": _token(client, f"/tickets/{ticket_id}")}, + follow_redirects=True, + ) + extension = core(ls_app) + with ls_app.app_context(), extension.database.session() as conn: + row = issuance.list_licenses(conn)[0] + bundle = issuance.delivery_bundle( + conn, str(row["license_id"]), keystore=extension.keystore + ) + document, client_key = lic.split_bundle(bundle) + return {"document": document, "client_key": client_key, "row": row} + + +def test_activation_round_trip_over_api(ls_client, issued_license): + document = issued_license["document"] + request = lic.build_client_request( + "activate", document, issued_license["client_key"], hostname="kunde-01" + ) + response = ls_client.post("/api/v1/licenses/activate", json=request) + assert response.status_code == 200, response.get_json() + payload = response.get_json() + lic.verify_master_response( + payload, + document["master_pubkey"], + expected_license_id=document["license_id"], + expected_fingerprint=request["fingerprint"], + expected_action="activate", + expected_nonce=request["nonce"], + ) + assert payload["status"] == "ok" + + +def test_replayed_activation_is_rejected(ls_client, issued_license): + document = issued_license["document"] + request = lic.build_client_request("activate", document, issued_license["client_key"]) + assert ls_client.post("/api/v1/licenses/activate", json=request).status_code == 200 + replay = ls_client.post("/api/v1/licenses/activate", json=request) + assert replay.status_code == 409 + assert replay.get_json()["error"] == "replay" + + +def test_forged_signature_rejected(ls_client, issued_license): + document = issued_license["document"] + other_private, _ = lic.generate_keypair() + request = lic.build_client_request("activate", document, other_private) + response = ls_client.post("/api/v1/licenses/activate", json=request) + assert response.status_code == 400 + assert response.get_json()["error"] == "invalid_signature" + + +def test_activation_bound_to_fingerprint(ls_client, ls_app, issued_license): + from tesm_core.extension import core + + document = issued_license["document"] + request = lic.build_client_request("activate", document, issued_license["client_key"]) + ls_client.post("/api/v1/licenses/activate", json=request) + + # Zweite Maschine: anderer Fingerprint, gueltige Signatur. + second = lic.build_client_request("activate", document, issued_license["client_key"]) + second["fingerprint"] = "b" * 64 + second["signature"] = lic.sign( + {k: v for k, v in second.items() if k != "signature"}, + issued_license["client_key"], + lic.SIG_CONTEXT_REQUEST, + ) + response = ls_client.post("/api/v1/licenses/activate", json=second) + assert response.status_code == 409 + assert response.get_json()["error"] == "already_bound_elsewhere" + assert core(ls_app) is not None + + +def test_heartbeat_requires_active_license(ls_client, issued_license): + document = issued_license["document"] + request = lic.build_client_request("heartbeat", document, issued_license["client_key"]) + response = ls_client.post("/api/v1/licenses/heartbeat", json=request) + assert response.status_code == 409 + assert response.get_json()["error"] == "not_active" + + +def test_heartbeat_after_activation_delivers_update(ls_client, ls_app, issued_license): + from tesm_core.extension import core + from tesm_license.services import issuance + from tesm_license.services import master_key + + document = issued_license["document"] + activate = lic.build_client_request("activate", document, issued_license["client_key"]) + ls_client.post("/api/v1/licenses/activate", json=activate) + + # Der Anbieter erweitert die Lizenz nachtraeglich. + extension = core(ls_app) + with ls_app.app_context(), extension.database.session() as conn: + key = master_key.ensure(extension.registry["master_key_path"]) + with extension.database.transaction(conn): + issuance.resign_license( + conn, + document["license_id"], + master_private_key=key.private_key, + changes={"modules": ["dhcp"]}, + ) + + beat = lic.build_client_request("heartbeat", document, issued_license["client_key"]) + response = ls_client.post("/api/v1/licenses/heartbeat", json=beat) + assert response.status_code == 200 + payload = response.get_json() + assert payload["status"] == "ok" + assert payload["license_update"]["seq"] == document["seq"] + 1 + lic.verify_license(payload["license_update"]) + + +def test_revoked_license_reported_signed(ls_client, ls_app, issued_license): + from tesm_core.extension import core + from tesm_license.services import issuance + + document = issued_license["document"] + activate = lic.build_client_request("activate", document, issued_license["client_key"]) + ls_client.post("/api/v1/licenses/activate", json=activate) + + extension = core(ls_app) + with ls_app.app_context(), extension.database.session() as conn: + with extension.database.transaction(conn): + issuance.revoke_license(conn, document["license_id"], reason="Test") + + beat = lic.build_client_request("heartbeat", document, issued_license["client_key"]) + response = ls_client.post("/api/v1/licenses/heartbeat", json=beat) + assert response.status_code == 200 + payload = response.get_json() + assert payload["status"] == "revoked" + # Die Antwort ist signiert -- der Client loescht erst nach eigener Pruefung. + lic.verify_master_response( + payload, + document["master_pubkey"], + expected_license_id=document["license_id"], + expected_fingerprint=beat["fingerprint"], + expected_action="heartbeat", + expected_nonce=beat["nonce"], + ) + + +def test_offline_code_path_matches_api(bootstrapped, issued_license): + """Der Offline-Weg laeuft durch dieselbe Verarbeitung wie die API.""" + document = issued_license["document"] + request = lic.build_client_request("activate", document, issued_license["client_key"]) + code = lic.encode_code(request) + + response = bootstrapped.post( + "/lizenzen/offline-code", + data={"csrf_token": _token(bootstrapped, "/lizenzen/offline-code"), "code": code}, + follow_redirects=True, + ) + body = response.get_data(as_text=True) + assert "Antwortcode" in body + start = body.index('id="response-code">') + len('id="response-code">') + answer = body[start : body.index("
", start)].strip() + decoded = lic.decode_code(answer) + lic.verify_master_response( + decoded, + document["master_pubkey"], + expected_license_id=document["license_id"], + expected_fingerprint=request["fingerprint"], + expected_action="activate", + expected_nonce=request["nonce"], + ) + assert decoded["status"] == "ok" + + +def test_unknown_license_rejected(ls_client, issued_license): + document = dict(issued_license["document"]) + document["license_id"] = "gibt-es-nicht" + request = lic.build_client_request("activate", document, issued_license["client_key"]) + response = ls_client.post("/api/v1/licenses/activate", json=request) + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- # +# Self-Service und Portal +# --------------------------------------------------------------------------- # + + +def test_self_service_page_and_issue(bootstrapped, ls_app): + client = bootstrapped + _create_customer(client) + ticket_id = _create_ticket(client) + + fresh = ls_app.test_client() # ohne Anmeldung + page = fresh.get(f"/lizenz/{ticket_id}") + assert page.status_code == 200 + assert "Lizenz erstellen" in page.get_data(as_text=True) + + token = _token(fresh, f"/lizenz/{ticket_id}") + created = fresh.post( + f"/lizenz/{ticket_id}/ausstellen", data={"csrf_token": token}, follow_redirects=True + ) + assert "Lizenz wurde erstellt" in created.get_data(as_text=True) + + +def test_self_service_404_for_license_server_ticket(bootstrapped, ls_app): + from tesm_core.extension import core + from tesm_license.services import issuance + + client = bootstrapped + _create_customer(client) + ticket_id = _create_ticket(client) + extension = core(ls_app) + with extension.database.session() as conn, extension.database.transaction(conn): + conn.execute( + "UPDATE license_tickets SET role='license_server' WHERE id=?", (ticket_id,) + ) + fresh = ls_app.test_client() + assert fresh.get(f"/lizenz/{ticket_id}").status_code == 404 + + +def test_portal_answer_is_identical_for_unknown_email(ls_client): + token = _token(ls_client, "/portal/") + known = ls_client.post( + "/portal/", data={"csrf_token": token, "email": "gibt@es.nicht"}, follow_redirects=True + ).get_data(as_text=True) + assert "Falls diese E-Mail-Adresse bei uns hinterlegt ist" in known + + +def test_portal_token_is_single_use(bootstrapped, ls_app): + from tesm_core.extension import core + + client = bootstrapped + _create_customer(client) + + # Token direkt anlegen (Mailversand ist im Test nicht konfiguriert). + extension = core(ls_app) + with extension.database.session() as conn, extension.database.transaction(conn): + conn.execute( + "INSERT INTO portal_access_tokens (token, customer_id, created_at, expires_at) " + "VALUES ('testtoken', 1, datetime('now'), datetime('now', '+30 minutes'))" + ) + + fresh = ls_app.test_client() + first = fresh.get("/portal/zugang/testtoken", follow_redirects=True) + assert "Ihre Lizenzen" in first.get_data(as_text=True) + + second = ls_app.test_client() + again = second.get("/portal/zugang/testtoken", follow_redirects=True) + assert "ungueltig oder abgelaufen" in again.get_data(as_text=True) + + +def test_portal_expired_token_rejected(bootstrapped, ls_app): + from tesm_core.extension import core + + _create_customer(bootstrapped) + extension = core(ls_app) + with extension.database.session() as conn, extension.database.transaction(conn): + conn.execute( + "INSERT INTO portal_access_tokens (token, customer_id, created_at, expires_at) " + "VALUES ('alt', 1, datetime('now','-2 hours'), datetime('now','-1 hours'))" + ) + fresh = ls_app.test_client() + response = fresh.get("/portal/zugang/alt", follow_redirects=True) + assert "ungueltig oder abgelaufen" in response.get_data(as_text=True) + + +# --------------------------------------------------------------------------- # +# Ampel +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + "days,expected", + [(120, "green"), (91, "green"), (90, "orange"), (31, "orange"), (29, "red"), (-1, "red")], +) +def test_traffic_light_boundaries(days, expected): + from datetime import timedelta + + from tesm_license.services.issuance import display_state + + now = lic.utcnow() + row = {"expires_at": lic.iso(now + timedelta(days=days, hours=1)), "lifetime": 0} + assert display_state(row, now=now)["color"] == expected + + +def test_lifetime_is_always_green(): + from tesm_license.services.issuance import display_state + + state = display_state({"expires_at": "2000-01-01T00:00:00Z", "lifetime": 1}) + assert state["color"] == "green" + assert state["is_expired"] is False + + +def test_unparsable_date_fails_safe(): + from tesm_license.services.issuance import display_state + + assert display_state({"expires_at": "kaputt", "lifetime": 0})["color"] == "red" + + +# --------------------------------------------------------------------------- # +# Endpunkt: ohne ihn keine Lizenz +# --------------------------------------------------------------------------- # + + +def test_issuing_without_an_endpoint_is_refused(): + """Der Endpunkt wird mitsigniert -- fehlt er, ist die Lizenz dauerhaft offline. + + Nachtraeglich laesst er sich nicht eintragen: er steht in der signierten + Nutzlast. Eine Warnung waere zu spaet, denn der Schaden entsteht in dem + Moment, in dem die Datei den Server verlaesst. + """ + import pytest as _pytest + + from tesm_license.services import issuance + + assert "master_endpoint" in issuance.issue_from_ticket.__code__.co_varnames + source = __import__("inspect").getsource(issuance.issue_from_ticket) + assert 'if not str(master_endpoint or "").strip():' in source + assert "IssuanceError" in source + assert _pytest is not None + + +def test_offline_activation_has_a_navigation_entry(): + """Ohne Eintrag findet niemand die Seite -- der Offline-Weg waere unbenutzbar.""" + from tesm_license.navigation import NAVIGATION + + endpoints = set() + + def walk(items): + for item in items: + if item.endpoint: + endpoints.add(item.endpoint) + if item.children: + walk(item.children) + + walk(NAVIGATION.items) + assert "licenses.manual_code" in endpoints + + +def test_revision_pending_survives_a_partial_row(): + """Die Funktion sitzt in Listen, Detailseiten und im Portal. + + Nicht jede Abfrage waehlt jede Spalte. Eine fehlende darf keine Ausnahme + werfen -- sonst faellt eine ganze Seite wegen einer Nebensaechlichkeit aus. + """ + from tesm_license.services.issuance import revision_pending + + assert revision_pending({"revision_at": "2026-01-02", "status": "active"}) is True + assert revision_pending({"revision_at": None}) is False + assert revision_pending({}) is False + assert revision_pending({"revision_at": "2026-01-02", "status": "revoked"}) is False + # Abgeholt: der Kontakt ist juenger als die Revision. + assert ( + revision_pending( + { + "revision_at": "2026-01-02T00:00:00+00:00", + "status": "active", + "last_heartbeat_at": "2026-01-03T00:00:00+00:00", + } + ) + is False + ) + # Noch nicht abgeholt. + assert ( + revision_pending( + { + "revision_at": "2026-01-04T00:00:00+00:00", + "status": "active", + "last_heartbeat_at": "2026-01-03T00:00:00+00:00", + } + ) + is True + ) 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_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 754134b..0000000 --- a/update.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/bin/bash -# ============================================================================ -# TESM-Lizenzserver — 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-license 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). -# -# 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-license}" -RELEASE_TAG="${TESM_RELEASE_TAG:-latest}" -GITEA_USER="${TESM_GITEA_USER:-}" -GITEA_TOKEN="${TESM_GITEA_TOKEN:-}" - -WORK_DIR="/tmp/tesm-license-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 (im Schwester-Repo tesm, identisches Verhalten hier): -# /releases/download/latest/tesm-license-latest.tar.gz liefert HTTP 200 -- -# aber keinen echten Release-Asset, sondern (weil kein Asset exakt so -# 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-license-${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-Lizenzserver 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-Lizenzserver 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)."