Compare commits

...
16 Commits
Author SHA1 Message Date
alientim e70c6e27c1 Version 2.2.1: pdf.js unter .js -- .mjs kennt kein Webserver 2026-09-09 22:06:17 +02:00
alientim e96e4837f1 Version 2.2.0: Vorschau mit Zoom und PDF-Formularen, eine Uploadgrenze 2026-09-09 21:48:07 +02:00
alientim 119293e5e3 Version 2.1.2: Wurzelinstanz auch an der Lizenz erkennen 2026-09-09 00:27:01 +02:00
alientim 36d9cb248e Version 2.1.1: update.sh mit --yes, ohne Terminal kein stiller Ausstieg 2026-09-09 00:20:32 +02:00
alientim cb056b4872 release.sh nimmt den Namen des Repositoriums 2026-09-09 00:13:00 +02:00
alientim ed84e8343e Version 2.1.0 2026-09-09 00:10:40 +02:00
alientim 157a244900 Ausgabe fuer Sub-Lizenzserver: kein Weg zur Rolle master 2026-09-09 00:07:34 +02:00
alientimandClaude Opus 5 fd333f60ff sudoers ohne requiretty, und ein Rueckweg fuer fehlgeschlagene Upgrades
Auf einem frisch aufgesetzten Server brach das Upgrade mitten drin ab:

    /tmp/tmp.XXXX:8:24: syntax error: unknown setting: 'requiretty'
    visudo: invalid sudoers file
     !! sudoers-Datei ist fehlerhaft

"Defaults:<user> !requiretty" stand in beiden sudoers-Dateien. Auf Debian und
Ubuntu ist requiretty nie gesetzt -- die Zeile war von Anfang an wirkungslos --
und ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
Datei ab. Sie ist entfernt; ein Hygienetest haelt fest, dass sie nicht
zurueckkommt, und prueft gleich mit, dass in diesen Dateien genau eine Regel
steht und die auf den Helfer zeigt.

Schlimmer als der Fehler war die Folge: der alte Dienst war zu diesem Zeitpunkt
gestoppt und deaktiviert, das Verzeichnis teils ersetzt, der neue Dienst nicht
vorhanden. upgbrade.sh faengt den Fehlschlag jetzt ab und dreht den Lauf
zurueck -- und mit "--rollback" auch spaeter noch, aus einer vorhandenen
Sicherung.

Dafuer muss die Sicherung mehr enthalten als das Verzeichnis. Bei einem Upgrade
in dasselbe Verzeichnis schreibt install.sh auch systemd-Unit, nginx-Site,
logrotate und Banner neu und ersetzt das venv; ohne Kopie davon liesse sich der
alte Stand nicht herstellen. Ein Manifest haelt Quelle und Art des Umzugs fest,
damit ein spaeterer Rueckweg nicht raten muss.

Beim Nachstellen des Fehlschlags fiel ein zweiter Fund auf, gefaehrlicher als
der erste: die Liste der "alten Units" war fest verdrahtet -- fuer --app tesm
immer tesm.service. Nach einem rename.sh heisst so aber die *laufende*
Installation. Wer die archivierte 1.x in eine eigene Instanz hochzieht und
--keep-old-running vergisst, haette damit den Produktionsdienst gestoppt und
deaktiviert. Die Units werden jetzt daran erkannt, dass sie in genau das
Quellverzeichnis zeigen; Timer und Hilfsdienste kommen ueber den Namen der so
gefundenen Unit mit.

Am System geprueft, nicht behauptet: Fehlschlag nachgestellt (1.2.8 mit eigener
Unit, eigener Site und erkennbarem venv), Rueckweg lief -- VERSION 1.2.8, das
alte venv und die alte Unit wieder da, die neue Struktur weg, die laufende
Produktion unberuehrt. Danach derselbe Lauf mit heiler sudoers-Datei bis zum
Ende (4 Clients, 2 Zugangsdaten, DHCP), und zuletzt "--rollback" als eigener
Aufruf auf dieselbe Installation.

Version 2.0.10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:13:45 +02:00
alientimandClaude Opus 5 b9342d384c upgrade.sh und update.sh genuegen als einzelne Datei
Auf einer 1.2.8 liegt nichts von dieser Fassung -- upgrade.sh verlangte aber
ein install.sh daneben und brach sonst ab. Das war die falsche Voraussetzung
fuer genau den Fall, fuer den das Skript gedacht ist. Beide Skripte holen sich
das Release jetzt selbst:

    curl -fsSLO .../raw/branch/main/deploy/upgrade.sh
    sudo bash upgrade.sh --app tesm

Liegt das Skript in einem entpackten Release, wird dessen install.sh benutzt
statt eines Downloads: wer ein Paket ausgepackt hat, will nicht, dass ungefragt
ein anderes geladen wird. Mit --tag laesst sich eine Version festlegen.

Der Bootstrap steht wortgleich in beiden Dateien -- zwei eigenstaendige
Skripte koennen sich keine Datei teilen, und beide sollen allein genuegen. Ein
Hygienetest vergleicht die Bloecke, ein zweiter prueft, dass upgrade.sh kein
entpacktes Release mehr verlangt, ein dritter fuehrt den Block mit leerem PATH
aus und erwartet, dass er das fehlende Werkzeug benennt.

update.sh ruft install.sh nicht mehr per exec auf: dadurch lief die
Aufraeumfalle nie, und jedes Update liess ein entpacktes Release unter /tmp
liegen.

Ausserdem "opus" aus allen Beispielen genommen. Das war der Name der
Testinstanz neben der Fassung 1.x; seit dem Umbenennen heissen die
Installationen wie ihre Anwendung, und in einer Hilfe, die jemand anders liest,
ist "opus" ein Raetsel. In BETRIEB.md stand dabei zweimal /opt statt /srv.

Geprueft am System, nicht behauptet: die einzelne upgrade.sh hat eine echte
1.2.8 (Kopie des Archivs) **in dasselbe Verzeichnis** auf 2.0.8 gebracht --
Release geladen, 4 Clients, 2 Zugangsdaten und die DHCP-Konfiguration
uebernommen, Dienst laeuft, /login 200. Die Sicherung enthaelt die 1.2.8 samt
sqlite.db und fernet.key (Verzeichnis 0700, Passphrase 0600), und /tmp bleibt
leer. Danach dieselbe Probe mit der einzelnen update.sh.

Version 2.0.9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:18:54 +02:00
alientimandClaude Opus 5 f062314a0a upgrade.sh findet die Fassung 1.x auch im Archiv
Nach einem Umbenennen liegt unter /srv/<app> die laufende Installation und die
Fassung 1.x unter /srv/<app>-sonnet5 -- dorthin verschiebt rename.sh sie. Die
Vorgabe von upgrade.sh zeigte danach auf die neue Installation und brach ab
(sauber, aber unbrauchbar). Gesucht wird jetzt an beiden Orten; erkannt wird
eine 1.x an der sqlite.db im Wurzelverzeichnis.

Den Weg selbst habe ich gegen die echten Altdaten geprueft, nicht behauptet:
aus /srv/tesm-sonnet5 (Version 1.2.8) kamen 4 Clients, 2 Zugangsdaten und die
DHCP-Konfiguration in eine Probeinstanz, aus /srv/tesm-license-sonnet5
(Version 1.2.7) 2 Kunden, 2 Tickets und 2 neu signierte Lizenzen samt
uebernommenem Signaturschluessel und Anbieterblock. Die laufenden
Installationen blieben unberuehrt, die Probeinstanzen sind abgeraeumt.

Zwei Tests halten die beiden Skripte zusammen: derselbe Namenszusatz, und die
Suche laeuft ueber die echten Zeilen aus upgrade.sh.

Version 2.0.8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:53:38 +02:00
alientimandClaude Opus 5 3295583591 Anmeldeseite: Wortmarke fuer beide, Karte ohne Wiederholung
* Die Wortmarke stand nur bei TESM -- sie hing an der Funktionszeile, und die
  hatte der Lizenzserver nicht. Jetzt zeigen beide die Marke; die Zeile
  darunter ist optional und beim Lizenzserver "LIZENZIEREN".
* Der Kartenkopf ist weg: eine Kachel mit zwei Buchstaben, die Ueberschrift
  "Anmelden" und der Anwendungsname darunter sagten dasselbe wie die Marke
  darueber und der Knopf darunter. Die Ueberschrift bleibt fuer die
  Titelzeile des Browsers.
* Marke und Karte stehen jetzt uebereinander im Fluss statt uebereinander
  gestapelt: auf einem flachen Fenster schnitt die Karte mitten durch den
  Schriftzug.
* Die Marke ist kuerzer und symmetrisch um die Mitte aufgebaut -- Schriftzug
  ueber text-anchor, Zacken spiegelbildlich, Linie links so lang wie der Punkt
  rechts. Volle Deckkraft, weil sie kein Wasserzeichen hinter der Karte mehr
  ist.
* Funktionszeile gekuerzt auf "UEBERWACHEN . BOOTEN . ANBINDEN" -- mit echtem
  Umlaut. "(PXE)" stand fuer etwas, das es noch nicht gibt.
* Der Farbverlauf im Hintergrund hat wieder Geometrie und Staerke der Fassung
  1.x. Der zweite Kreis lag auf --info und 10 %: ein kaltes Blau, das man auf
  dunklem Grund nicht sah und das nicht zur Marke gehoert.
* "Bitte melden Sie sich an." stand auf einer Seite aus Anmeldefeld,
  Passwortfeld und einem Knopf "Anmelden". Statt dessen erfaehrt jetzt der,
  dessen Sitzung gerade ablief, warum er wieder hier ist.

Dabei drei Dinge gefunden, die lautlos nichts taten:

* __version__ stand fest verdrahtet in drei __init__.py. Die Fusszeile zeigte
  vier Releases lang 2.0.2, waehrend die Anwendung 2.0.6 war. Die Nummer kommt
  jetzt aus den Metadaten des installierten Pakets; Quelle ist pyproject.toml.
* "gap: var(--sp-7)" -- die Skala kennt kein --sp-7 (1 bis 6, dann 8, 10, 12,
  16). Eine undefinierte Variable verwirft die ganze Deklaration ohne Meldung:
  der Abstand war "normal", und Marke und Karte stiessen auf den Pixel
  aneinander.
* Ein Test dagegen fand sofort ein zweites: --surface-0 wird dreimal benutzt
  und nirgends definiert. Die Schrift auf der Kachel "kritisch" blieb
  dunkelgrau auf Rot, die Dateivorschau hatte keinen Grund, und das Feld
  "Recht nicht vorhanden" in der Rechtematrix war durchsichtig -- also so hell
  wie die Karte, obwohl es dunkler sein sollte.

Version 2.0.7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:44:34 +02:00
alientimandClaude Opus 5 47aeaba9c8 rename.sh: aus einer Instanz die Hauptinstallation machen
Die laufenden Installationen hiessen "tesm-opus" und "tesm-license-opus" --
ein Instanzname aus der Zeit, in der sie neben der Fassung 1.x getestet wurden.
Jetzt heissen sie "tesm" und "tesm-license".

Umbenennen ist mehr als ein verschobenes Verzeichnis, und drei Stellen sind
leicht zu uebersehen:

* SITE_KEY in der Umgebungsdatei -- daraus entsteht der Name des
  Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
  Instanz: unauffaellig und falsch.
* web_cert_path/web_key_path/web_acme_webroot in der Datenbank enthalten den
  Namen. Ein Zertifikat unter dem alten Pfad findet nginx nicht mehr.
* server_name der nginx-Site. install.sh schreibt die Datei aus seiner Vorlage
  neu, sobald sich die alias-Pfade aendern -- und die aendern sich hier
  zwangslaeufig. Ohne den Namen aus der Datenbank stehen danach zwei Sites mit
  "server_name _" auf Port 80, und die erste gewinnt. web-setup braucht dafuer
  --apply; ohne ihn speichert es nur.

Zwei Fehler hat erst der Lauf am System gezeigt:

* Das venv wandert nicht mit. Seine Konsolenskripte tragen den absoluten Pfad
  im Shebang, und danach zeigt venv/bin/gunicorn auf einen Interpreter, den es
  nicht mehr gibt -- systemd meldet "No such file or directory" fuer eine
  Datei, die sichtbar da ist. Es wird jetzt verworfen und neu gebaut.
* Den Zielnamen belegt nicht nur /srv/<name>, sondern auch /var/log/<name>,
  der ACME-Pfad und die logrotate-Datei. Der Umzug brach mitten drin ab. Jetzt
  wandert alles, was den Namen belegt, in ein Archiv -- geloescht wird nichts,
  die Units und die Site der alten Fassung liegen darin unter systemd-alt/.

Ein Test vergleicht die Pfade, die install.sh unter dem Namen anlegt, mit
denen, die rename.sh kennt. Genau diese Luecke war der zweite Fehler; ohne den
Test faellt der naechste erst wieder auf einem Server auf.

Version 2.0.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:12:43 +02:00
alientimandClaude Opus 5 d31173fa5e Banner: Einzahl, wo eines gemeint ist
"1 Clients online" liest man jeden Tag einmal zu oft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:48:35 +02:00
alientimandClaude Opus 5 05885bfff1 Anmeldebanner wieder in Betrieb -- ohne die Anwendung zu starten
Das Banner der Vorgaengerfassung hing an /srv/tesm/motd_banner.py und
importierte dafuer die ganze Flask-Anwendung samt deren venv. Nach dem
Umstieg zeigte es damit die Zahlen einer Datenbank, die niemand mehr fuellt --
eingefroren, aber unverdaechtig. Aufgefallen ist es erst, als der alte
Ueberwachungsdienst abgeschaltet wurde.

deploy/tesm-motd loest das anders: **nur Standardbibliothek**, kein venv, kein
Import der Anwendung, die Datenbank wird nur lesend geoeffnet (mode=ro). Ein
Lauf dauert Millisekunden statt eines Flask-Starts. install.sh legt das Skript
nach /usr/local/lib/tesm/ und schreibt /etc/update-motd.d/50-<name>, abgesichert
mit "timeout 3" und "|| true" -- ein Banner darf eine Anmeldung nie blockieren.
Ein Banner der Vorgaengerfassung wird entfernt, aber nur, wenn es genau auf
motd_banner.py zeigt.

Damit es nicht zwei Wahrheiten ueber "online" gibt, steht die Zaehlung der
Statusuebersicht jetzt als Konstante DASHBOARD_COUNT_SQL in monitor.py; das
Banner traegt sie wortgleich, und ein Hygienetest vergleicht beide. Dasselbe
fuer die Zeile unter der Wortmarke. Ein weiterer Test verbietet dem Banner
jeden Import ausserhalb der Standardbibliothek -- genau dieser Import war die
Ursache.

Der Lizenzserver bekommt sein eigenes Banner (Kunden und ausgestellte
Lizenzen). Eine Tagline hat er bewusst nicht: die Funktionsliste von TESM waere
dort falsch, und eine erfundene waere schlimmer als keine.

Version 2.0.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:44:24 +02:00
alientimandClaude Opus 5 168d10bf87 Leerlaufabmeldung: die zweite Schreibstelle und ein Tab, der das alte Skript faehrt
Die Abmeldung hat weiterhin nicht ausgeloest. Zwei Gruende, beide fuer sich
ausreichend:

* **save_session** schreibt ``idle_expires_at`` bei jeder Anfrage, die die
  Sitzung anfasst -- ein neu erzeugtes CSRF-Token genuegt. Die Pruefung in
  ``open_session`` lief damit ins Leere. Sitzungsdaten werden weiter
  gespeichert, nur die Fristen bleiben stehen. Dieselbe Stelle benutzte
  ausserdem ``idle_minutes`` statt der einstellbaren Zeit; bei der Vorgabe von
  dreissig Minuten fiel das nicht auf.
* **Der Kopf allein reicht nicht.** Er setzt voraus, dass der Browser das
  aktuelle Skript faehrt -- ein Tab, der seit gestern Abend offen ist, faehrt
  das alte und schickt ihn nicht. Genau dieser Tab ist der Fall, um den es
  geht. Der Server weiss es ohne Zutun des Browsers: die Endpunkte, die eine
  Kachel oder ein Protokoll nachlaedt, sind als Aktualisierungsausschnitt
  gekennzeichnet (``@fragment``) und gelten damit immer als automatisch.
  ``request.endpoint`` steht in ``open_session`` noch nicht -- Flask oeffnet
  die Sitzung vor dem Routing --, deshalb sieht die Erkennung dort selbst nach.

Dass es auf dem Lizenzserver zuverlaessig funktionierte, war der Hinweis: dort
pollt keine Kachel.

Dazu ein Kopf ``X-Session-Expired`` fuer Seiten, die auch oeffentlich
antworten. Die Statusuebersicht liefert nach dem Ablauf eine 200 mit den
oeffentlichen Kacheln -- am Status ist der Verlust der Sitzung also nicht zu
erkennen, und der offene Tab zeigte weiter eine Oberflaeche, als sei alles in
Ordnung. Jetzt laedt er neu und zeigt die Anmeldemaske.

Version 2.0.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:21:38 +02:00
alientimandClaude Opus 5 2dea198bcc Upgrade von 1.x, Leerlaufabmeldung, Mitschrift im Audit
upgrade.sh -- ein Weg vom Stand SONNET5 auf diese Fassung, mit Daten. Der
Ablauf braucht keine Schalter: alte Installation sichern, abschalten, neu
installieren, exportieren, uebersetzen, einspielen. Die Sicherung steht
bewusst vor dem Export -- ohne --instance installiert install.sh in dasselbe
Verzeichnis, und wer erst exportiert und dann sichert, sichert im
schlechtesten Fall nichts mehr. Gelesen wird nur aus der Kopie.

Dafuer zwei neue Befehle: "legacy-export" liest eine alte Installation
unmittelbar (der Vorgaenger kann nur ueber die Weboberflaeche exportieren --
fuer ein Upgrade der falsche Weg, und unmoeglich, wenn die alte Anwendung nicht
mehr startet) und schreibt eine Datei im alten Format, die deshalb auch als
Rueckweg taugt. "legacy-import" liest eine Datei im neuen Format ein, ueber
transfer.apply_import -- dieselbe Funktion wie die Seite "Sicherung", kein
zweiter Schreibpfad.

Am Lizenzserver hat der Lauf gegen echte Altdaten zwei Fehler gezeigt, die
jede Uebernahme unmoeglich machten:

* Der alte Signaturschluessel ist URL-sicher kodiert (- und _). Das neue
  Protokoll prueft Base64 streng -- richtig so, aber beim Lesen einer alten
  Datei brach die Uebernahme mit einem Traceback ueber "kein gueltiges Base64"
  ab. Umkodiert wird jetzt an der Grenze zum Altformat; der kanonische Decoder
  bleibt streng.
* Der Anbieterblock wurde aus den Einstellungen der *neuen* Installation
  gebaut, und ohne Endpunkt brach die Uebernahme ab. Bei einem Upgrade ist die
  neue Installation frisch -- dort steht nichts. Endpunkt und Anbieterdaten
  kommen jetzt aus der alten Installation, aber nur dort, wo neu nichts steht.
  Der alte Signaturschluessel wird uebernommen: sonst muesste jeder Kunde
  einen Ausstellerwechsel bestaetigen.

Leerlaufabmeldung: sie hat nie ausgeloest. Ein offener Tab fragt seine
Kacheln alle 15 Sekunden neu ab, und jede Abfrage verlaengerte das
Leerlauffenster -- nachweisbar an einer Sitzung, deren last_seen_at eine
Minute alt war, obwohl seit zehn Stunden niemand am Rechner sass. Der Client
kennzeichnet automatische Anfragen jetzt mit einem Kopf, der Server
verlaengert daraufhin nichts, und last_seen_at bleibt, was der Name sagt.
Faellt die Sitzung waehrend des Pollens, antwortet der Server mit 401 statt
mit einer Umleitung: der Tab laedt neu und zeigt die Anmeldemaske, statt
stumm mit alten Zahlen stehenzubleiben.

Im Audit ist der Name der Mitschrift jetzt ein Verweis auf die Datei -- es ist
derselbe Weg wie zuvor, nur einen Klick weit. Verlinkt wird nur, wenn die
Datei existiert und der Betrachter Protokolle lesen darf; der Rest des
Eintrags wird maskiert, denn ein Auditeintrag enthaelt Fremdeingaben.

Ausserdem: Version 2.0.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 11:43:04 +02:00
89 changed files with 15584 additions and 255 deletions

No files matched your search

+39 -8
View File
@@ -4,6 +4,13 @@ Ausgabe und Verwaltung der Lizenzen für TESM: Kunde -> Ticket -> Lizenz,
Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand über Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand über
MS Graph und eine Hierarchie aus Unter-Lizenzservern. MS Graph und eine Hierarchie aus Unter-Lizenzservern.
> **Diese Ausgabe ist für Sub-Lizenzserver.** Ein Sub-Lizenzserver bekommt
> seine eigene Lizenz von der Wurzelinstanz und stellt danach selbst
> Kundenlizenzen aus. Was er nicht kann: sich selbst die Rolle `master` geben --
> der Code dafür liegt nicht in diesem Paket, sondern nur in der Master-Ausgabe
> (privates Repository `tesm-license-master`). Das ist keine Einstellung, die
> sich umlegen lässt, sondern ein fehlendes Verzeichnis.
**Dieses Repository enthält genau das, was zum Installieren und Betreiben des **Dieses Repository enthält genau das, was zum Installieren und Betreiben des
Lizenzservers gebraucht wird** -- nichts darüber hinaus: Lizenzservers gebraucht wird** -- nichts darüber hinaus:
@@ -21,6 +28,7 @@ Die verwaltete Anwendung liegt hier **nicht** mit drin.
| Wo sonst noch | | | Wo sonst noch | |
|---|---| |---|---|
| [alientim/tesm](https://gitea.int.eertmoed.net/alientim/tesm) | TESM selbst | | [alientim/tesm](https://gitea.int.eertmoed.net/alientim/tesm) | TESM selbst |
| [alientim/tesm-license-master](https://gitea.int.eertmoed.net/alientim/tesm-license-master) | Master-Ausgabe (privat): die Wurzelinstanz |
| [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku | | [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku |
## Zweige ## Zweige
@@ -34,16 +42,24 @@ Die verwaltete Anwendung liegt hier **nicht** mit drin.
## Installation ## Installation
Eine einzelne Datei genügt -- sie lädt das Release selbst:
```bash ```bash
sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.de curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm-license/raw/branch/main/install.sh
sudo -u tesm-license tesm-license-admin init-key sudo bash install.sh --app tesm-license --https --domain lizenz.firma.de
sudo -u tesm-license tesm-license-admin bootstrap
sudo -u tesm-license tesm-license-admin create-admin sudo -u tesm-license tesm-license-admin create-admin
``` ```
**`init-key` erzeugt den Signaturschlüssel dieses Servers. Sichern Sie ihn Aus einem entpackten Paket ist es derselbe Aufruf ohne `curl`.
sofort.** Ohne ihn lässt sich keine ausgestellte Lizenz mehr erneuern, und
Kunden müssten einen Ausstellerwechsel bestätigen. Den **Signaturschlüssel** legt der Installer selbst an -- mit ihm signiert
dieser Server seine Kundenlizenzen. **Sichern Sie ihn sofort**
(`data/master_signing_key.json`): ohne ihn ist keine ausgestellte Lizenz mehr
prüfbar, und Kunden müssten einen Ausstellerwechsel bestätigen. Eine
Aktualisierung fasst ihn nie an.
Die **eigene Lizenz** dieses Servers kommt von der Wurzelinstanz: dort eine
Lizenz für diesen Server anfordern und unter "Eigene Lizenz" einspielen.
Zielzustand: **Port 443 mit TLS, Port 80 leitet dorthin um** -- ausser dem 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. ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern.
@@ -57,7 +73,7 @@ für nginx-Site, logrotate, Zertifikate und Sitzungscookie.
### Aktualisieren ### Aktualisieren
```bash ```bash
sudo bash deploy/update.sh --app tesm-license sudo bash /srv/tesm-license/deploy/update.sh tesm-license
``` ```
Vor jedem Update entsteht ein vollständiges Backup, danach läuft ein Vor jedem Update entsteht ein vollständiges Backup, danach läuft ein
@@ -70,7 +86,6 @@ Health-Check mit automatischem Rollback.
```bash ```bash
sudo -u tesm-license tesm-license-admin status sudo -u tesm-license tesm-license-admin status
sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel
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 create-admin
sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand übernehmen sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand übernehmen
``` ```
@@ -135,3 +150,19 @@ Entwicklungsrepository, ebenso die Tests.
* **CSRF-Schutz überhaupt.** Der Vorgänger hatte keinen. * **CSRF-Schutz überhaupt.** Der Vorgänger hatte keinen.
* **Rechte werden serverseitig geprüft**, nicht nur im Template ausgeblendet. * **Rechte werden serverseitig geprüft**, nicht nur im Template ausgeblendet.
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock. * **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
---
## Von einer Installation der Fassung 1.x
Eine einzelne Datei genügt -- sie lädt das Release selbst:
```bash
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm-license/raw/branch/main/deploy/upgrade.sh
sudo bash upgrade.sh --app tesm-license
```
Sichert die alte Installation, schaltet sie ab, installiert diese Fassung und
übernimmt die Daten. Einzelheiten und was **nicht** mitkommt:
[docs/BETRIEB.md](docs/BETRIEB.md).
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-license" name = "tesm-license"
version = "2.0.2" version = "2.2.1"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+32 -1
View File
@@ -8,6 +8,7 @@ musste (und ``licensing.py`` byte-identisch gehalten werden *musste*).
from __future__ import annotations from __future__ import annotations
import importlib.util
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -23,17 +24,46 @@ from .permissions import PERMISSIONS, ROLE_PRESETS
from .schema import LICENSE_MIGRATIONS from .schema import LICENSE_MIGRATIONS
from .settings_spec import LICENSE_SERVER_SETTINGS from .settings_spec import LICENSE_SERVER_SETTINGS
__version__ = "2.0.2" #: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
#: Fusszeile zeigte vier Releases lang 2.0.2.
#:
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
#: tatsaechlich installiert ist.
try: # pragma: no cover - haengt an der Installationsart
from importlib.metadata import PackageNotFoundError, version as _paketversion
__version__ = _paketversion("tesm-license")
except (ImportError, PackageNotFoundError): # pragma: no cover
__version__ = "unbekannt"
APP_KEY = "tesm-license" APP_KEY = "tesm-license"
APP_NAME = "TESM-Lizenzserver" APP_NAME = "TESM-Lizenzserver"
APP_SHORT = "TL" APP_SHORT = "TL"
APP_EDITION = "Lizenzverwaltung" APP_EDITION = "Lizenzverwaltung"
#: Zeile unter der Wortmarke auf den Anmeldeseiten, in derselben Schrift wie
#: bei TESM. Ein Wort statt einer Liste: der Lizenzserver tut genau eine Sache.
AUTH_TAGLINE = "LIZENZIEREN"
ENV_PREFIX = "TESM_LICENSE_" ENV_PREFIX = "TESM_LICENSE_"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def is_master_edition() -> bool:
"""Ist das hier die Master-Ausgabe?
Entscheidet die **Anwesenheit** des Teilpakets ``tesm_license.master``, nicht
eine Einstellung: eine Einstellung koennte der lokale Administrator eines
Sub-Lizenzservers umstellen, eine fehlende Datei nicht. Das Release fuer
Sub-Lizenzserver bringt das Teilpaket nicht mit -- dort gibt es also
ueberhaupt keinen Code, der die Rolle ``master`` erzeugen koennte.
"""
return importlib.util.find_spec("tesm_license.master") is not None
def master_key_path(config: Any) -> Path: def master_key_path(config: Any) -> Path:
return Path(config.data_dir) / "master_signing_key.json" return Path(config.data_dir) / "master_signing_key.json"
@@ -56,6 +86,7 @@ def create_app(**overrides: Any) -> Flask:
import_name="tesm_license", import_name="tesm_license",
app_short=APP_SHORT, app_short=APP_SHORT,
app_edition=APP_EDITION, app_edition=APP_EDITION,
auth_tagline=AUTH_TAGLINE,
app_version=__version__, app_version=__version__,
enable_license=True, enable_license=True,
) )
@@ -32,6 +32,7 @@ from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
import tesm_licensing as lic import tesm_licensing as lic
from .. import is_master_edition
from ..services import master_key from ..services import master_key
from .common import can_issue_license_server_licenses, log_event, signing_key from .common import can_issue_license_server_licenses, log_event, signing_key
@@ -62,6 +63,7 @@ def overview():
), ),
signing_key_health=master_key.health(extension.registry["master_key_path"]), signing_key_health=master_key.health(extension.registry["master_key_path"]),
may_issue_license_server=can_issue_license_server_licenses(), may_issue_license_server=can_issue_license_server_licenses(),
master_edition=is_master_edition(),
grace_days=lic.GRACE_PERIOD_DAYS, grace_days=lic.GRACE_PERIOD_DAYS,
) )
+60 -76
View File
@@ -1,13 +1,14 @@
"""Kommandozeilenwerkzeuge des Lizenzservers. """Kommandozeilenwerkzeuge des Lizenzservers.
Enthaelt insbesondere ``bootstrap`` -- die eigene Lizenz der Wurzelinstanz. ``init-key`` erzeugt den Signaturschluessel dieser Installation -- den braucht
**jeder** Lizenzserver, um Kundenlizenzen zu signieren, auch ein
Sub-Lizenzserver.
Warum das ein lokales Skript ist und keine Funktion in der Oberflaeche: die ``bootstrap`` stellt die eigene Lizenz der Wurzelinstanz aus und ist damit der
Master-Lizenz ist die einzige Lizenz, fuer die es **keine hoehere Instanz gibt**, einzige Weg, auf dem eine Lizenzdatei die Rolle ``master`` bekommt (ueber
die sie bestaetigen koennte. Ein Aktivierungs-Handshake gegen sich selbst waere Tickets ist sie auf jedem Server abgewiesen). Die Arbeit dazu liegt im
Theater. Das Skript signiert die Datei mit dem eigenen Schluessel und setzt den Teilpaket ``tesm_license.master``, das nur die Master-Ausgabe mitbringt; fehlt
Aktivierungszustand direkt -- und nur so kann eine Lizenzdatei je die Rolle es, sagt der Befehl das und tut nichts. Siehe dort fuer die Begruendung.
``master`` tragen. Die Ticket-Oberflaeche bietet diese Rolle an keiner Stelle an.
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,7 +21,6 @@ from pathlib import Path
from typing import Any from typing import Any
import tesm_licensing as lic import tesm_licensing as lic
import tesm_licensing_server as licsrv
from tesm_core import audit from tesm_core import audit
from tesm_core.cli_web import add_web_setup, run_web_setup from tesm_core.cli_web import add_web_setup, run_web_setup
from tesm_core.db import Database from tesm_core.db import Database
@@ -28,7 +28,7 @@ from tesm_core.extension import core
from tesm_core.rbac import service as rbac from tesm_core.rbac import service as rbac
from tesm_core.security import passwords from tesm_core.security import passwords
from . import create_app, master_key_path from . import create_app, is_master_edition, master_key_path
from .services import legacy_import, master_key from .services import legacy_import, master_key
@@ -62,73 +62,29 @@ def cmd_init_key(args: argparse.Namespace) -> int:
def cmd_bootstrap(args: argparse.Namespace) -> int: def cmd_bootstrap(args: argparse.Namespace) -> int:
"""Stellt die eigene Lizenz dieser Wurzelinstanz aus.""" """Stellt die eigene Lizenz dieser Wurzelinstanz aus -- nur in der Master-Ausgabe."""
if not is_master_edition():
for zeile in (
"Diese Ausgabe ist fuer Sub-Lizenzserver und kann keine Master-Lizenz erzeugen.",
"",
"Ein Sub-Lizenzserver bekommt seine Lizenz vom Master-Lizenzserver und spielt",
"sie unter 'Eigene Lizenz' ein. Danach stellt er selbst Kundenlizenzen aus --",
"mit seinem eigenen Signaturschluessel, der beim Installieren entstanden ist.",
"",
"Die Master-Ausgabe liegt im Repository tesm-license-master.",
):
print(zeile, file=sys.stderr)
return 2
from .master import bootstrap as eigene_lizenz_ausstellen
app, extension = _app_and_core() app, extension = _app_and_core()
key = master_key.ensure(master_key_path(extension.config)) document, state = eigene_lizenz_ausstellen(
app, extension, days=int(args.days), name=args.name
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, _ = licsrv.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"Master-Lizenz ausgestellt: {document['license_id']}")
print(f"Gueltig bis: {document['expires_at']}") print(f"Gueltig bis: {document['expires_at']}")
print(f"Datei: {config.license_path}") print(f"Datei: {extension.config.license_path}")
if state is not None: if state is not None:
print(f"Zustand: gueltig={state.valid}, aktiviert={state.activated}, Rolle={state.role}") print(f"Zustand: gueltig={state.valid}, aktiviert={state.activated}, Rolle={state.role}")
return 0 return 0
@@ -336,6 +292,28 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
key = master_key.load(key_path) key = master_key.load(key_path)
with app.app_context(), extension.database.session() as conn: with app.app_context(), extension.database.session() as conn:
# Anbieterdaten und Endpunkt zuerst: bei einem Upgrade ist die neue
# Installation frisch, dort steht also nichts, und die Pruefung
# weiter unten haette jedes Upgrade abgebrochen. Beides gehoert zur
# Anbieteridentitaet -- genau die soll ein Upgrade nicht wechseln.
# Uebernommen wird nur, was hier leer ist.
if not args.dry_run:
with extension.database.transaction(conn):
uebernommen = legacy_import.adopt_vendor_settings(
legacy,
lambda key: extension.settings.get(conn, key),
lambda key, wert: extension.settings.set(
conn, key, wert, actor="migration"
),
)
# Nicht "key" als Laufvariable: darueber steht der geladene
# Signaturschluessel mit demselben Namen, und ihn zu
# ueberschreiben lief in ein AttributeError mitten in der
# Uebernahme.
for einstellung, wert in uebernommen.items():
gekuerzt = wert if len(wert) <= 60 else wert[:57] + "..."
print(f"Uebernommen: {einstellung} = {gekuerzt}")
vendor = { vendor = {
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"), "name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
"email": str(extension.settings.get(conn, "vendor_email") or ""), "email": str(extension.settings.get(conn, "vendor_email") or ""),
@@ -346,8 +324,9 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
endpoint = str(extension.settings.get(conn, "master_endpoint") or "") endpoint = str(extension.settings.get(conn, "master_endpoint") or "")
if not endpoint and not args.dry_run: if not endpoint and not args.dry_run:
print( print(
"Es ist kein Endpunkt hinterlegt (Einstellungen -> Anbieter). Ohne ihn " "Es ist kein Endpunkt hinterlegt, und die alte Installation nennt "
"koennen uebernommene Lizenzen sich spaeter nicht aktivieren.", "auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne "
"ihn koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
file=sys.stderr, file=sys.stderr,
) )
return 2 return 2
@@ -411,7 +390,12 @@ def build_parser() -> argparse.ArgumentParser:
init_key.set_defaults(func=cmd_init_key) init_key.set_defaults(func=cmd_init_key)
bootstrap = sub.add_parser( bootstrap = sub.add_parser(
"bootstrap", help="Eigene Master-Lizenz dieser Wurzelinstanz ausstellen" "bootstrap",
help=(
"Eigene Master-Lizenz dieser Wurzelinstanz ausstellen"
if is_master_edition()
else "nicht in dieser Ausgabe (nur Master-Lizenzserver)"
),
) )
bootstrap.add_argument("--days", type=int, default=3650) bootstrap.add_argument("--days", type=int, default=3650)
bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)") bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)")
@@ -25,6 +25,8 @@ angefasst, nicht angehalten und nicht veraendert.
from __future__ import annotations from __future__ import annotations
import base64
import binascii
import json import json
import sqlite3 import sqlite3
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -110,6 +112,39 @@ def open_legacy(root: Path) -> sqlite3.Connection:
return conn return conn
def _standard_b64(wert: str, feld: str) -> str:
"""Nimmt Standard- **und** URL-Alphabet an, gibt Standard-Base64 zurueck.
Der Vorgaenger kodierte seine Schluessel mit ``urlsafe_b64encode``: dort
stehen ``-`` und ``_`` an den Stellen von ``+`` und ``/``. Das neue
Protokoll prueft Base64 streng -- richtig so, aber beim Lesen einer alten
Datei fuehrte es dazu, dass die Uebernahme mit einem Traceback ueber
"kein gueltiges Base64" abbrach, bei jedem echten Altbestand.
"""
roh = wert.strip()
if not roh:
raise LegacyImportError(f"Im alten Signaturschluessel fehlt {feld!r}.")
# Fehlende Polsterung ergaenzen -- der Vorgaenger schrieb sie mit, andere
# Werkzeuge lassen sie weg.
gepolstert = roh + "=" * (-len(roh) % 4)
# Erst das Standard-, dann das URL-Alphabet -- beide mit Pruefung. Ohne
# ``validate`` verschluckt der Decoder unbekannte Zeichen einfach, und aus
# "###" wuerde eine leere Ausgabe statt einer Fehlermeldung.
versuche = (gepolstert, gepolstert.replace("-", "+").replace("_", "/"))
for kandidat in versuche:
try:
rohbytes = base64.b64decode(kandidat, validate=True)
except (ValueError, binascii.Error):
continue
if len(rohbytes) != 32:
raise LegacyImportError(
f"{feld} im alten Signaturschluessel ist {len(rohbytes)} Byte lang, "
"erwartet werden 32 (Ed25519)."
)
return base64.b64encode(rohbytes).decode("ascii")
raise LegacyImportError(f"{feld} im alten Signaturschluessel ist kein Base64.")
def read_legacy_key(root: Path) -> tuple[str, str]: def read_legacy_key(root: Path) -> tuple[str, str]:
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``.""" """Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
path = Path(root) / "master_signing_key.json" path = Path(root) / "master_signing_key.json"
@@ -120,12 +155,23 @@ def read_legacy_key(root: Path) -> tuple[str, str]:
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc
# Die Vorgaengerfassung nannte die Felder "private"/"public". # Die Vorgaengerfassung nannte die Felder "private"/"public" und kodierte
# sie URL-sicher.
private = str(data.get("private") or data.get("private_key") or "") private = str(data.get("private") or data.get("private_key") or "")
public = str(data.get("public") or data.get("public_key") or "") public = str(data.get("public") or data.get("public_key") or "")
if not private or not public: if not private or not public:
raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.") raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.")
if licsrv.public_from_private(private) != public: private = _standard_b64(private, "private")
public = _standard_b64(public, "public")
# Alles, was aus der Protokollschicht kommt, wird hier zu einer Meldung im
# Klartext. Ein Traceback ueber Base64 mitten in einer Uebernahme sagt dem
# Betreiber nichts -- und die Uebernahme bricht ohne --adopt-key gar nicht
# ab, sie merkt sich nur den alten Aussteller nicht.
try:
passt = licsrv.public_from_private(private) == public
except (ValueError, TypeError) as exc:
raise LegacyImportError(f"Der alte Signaturschluessel ist unbrauchbar: {exc}") from exc
if not passt:
raise LegacyImportError( raise LegacyImportError(
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht " "Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
"zueinander. Die Datei ist beschaedigt." "zueinander. Die Datei ist beschaedigt."
@@ -187,6 +233,55 @@ def _modules(raw: Any) -> list[str]:
return sorted(set(str(value) for value in values) & set(lic.ALL_MODULES)) return sorted(set(str(value) for value in values) & set(lic.ALL_MODULES))
#: Einstellungen, die zur Anbieteridentitaet gehoeren und in beiden Fassungen
#: gleich heissen. ``master_endpoint`` ist darunter das entscheidende: ohne ihn
#: findet eine uebernommene Lizenz ihren Server spaeter nicht wieder.
VENDOR_SETTINGS = (
"master_endpoint",
"vendor_name",
"vendor_email",
"vendor_phone",
"vendor_address",
"vendor_logo_base64",
)
def read_vendor_settings(legacy: sqlite3.Connection) -> dict[str, str]:
"""Liest die Anbietereinstellungen der alten Installation."""
try:
rows = legacy.execute(
"SELECT key, value FROM settings WHERE key IN ({})".format(
",".join("?" for _ in VENDOR_SETTINGS)
),
VENDOR_SETTINGS,
).fetchall()
except sqlite3.Error: # pragma: no cover - alte Fassung ohne settings
return {}
return {str(row["key"]): str(row["value"] or "") for row in rows if str(row["value"] or "")}
def adopt_vendor_settings(
legacy: sqlite3.Connection,
read: Any,
write: Any,
) -> dict[str, str]:
"""Fuellt leere Anbietereinstellungen aus der alten Installation.
``read(key)`` und ``write(key, wert)`` kommen von aussen, damit diese
Funktion nichts ueber den Einstellungsspeicher wissen muss. Rueckgabe: was
tatsaechlich uebernommen wurde -- der Aufrufer soll es benennen koennen.
"""
alt = read_vendor_settings(legacy)
uebernommen: dict[str, str] = {}
for key, wert in alt.items():
if str(read(key) or "").strip():
continue # was hier schon steht, gilt
write(key, wert)
uebernommen[key] = wert
return uebernommen
def import_all( def import_all(
legacy: sqlite3.Connection, legacy: sqlite3.Connection,
conn: sqlite3.Connection, conn: sqlite3.Connection,
@@ -116,9 +116,16 @@
{% endif %} {% endif %}
</section> </section>
{% else %} {% else %}
{{ empty_state('Keine gültige Lizenz', {% if master_edition %}
'Spielen Sie eine Lizenzdatei ein oder erzeugen Sie mit "tesm-license-admin bootstrap" {{ empty_state('Keine gültige Lizenz',
die eigene Lizenz der Wurzelinstanz.', 'key') }} 'Spielen Sie eine Lizenzdatei ein oder erzeugen Sie mit "tesm-license-admin bootstrap"
die eigene Lizenz der Wurzelinstanz.', 'key') }}
{% else %}
{{ empty_state('Keine gültige Lizenz',
'Diese Ausgabe ist ein Sub-Lizenzserver: fordern Sie beim Master-Lizenzserver eine
Lizenz für diesen Server an und spielen Sie die Datei hier ein. Eine Master-Lizenz
kann diese Ausgabe nicht erzeugen.', 'key') }}
{% endif %}
{% endif %} {% endif %}
{% if pending %} {% if pending %}
+220 -20
View File
@@ -28,6 +28,9 @@ set -euo pipefail
APP="" APP=""
INSTANCE="" INSTANCE=""
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
#: Portsuche in einem Test wirklich ausfuehren laesst.
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
PORT="" PORT=""
HTTP_PORT="" HTTP_PORT=""
SKIP_PACKAGES=0 SKIP_PACKAGES=0
@@ -46,6 +49,62 @@ warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; } fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; } info() { printf ' %s\n' "$*"; }
#: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist.
SICHERUNG_STEHT=0
INSTALL_FERTIG=0
ZURUECKGEROLLT=0
zurueckrollen_install() {
[[ $SICHERUNG_STEHT -eq 1 && $ZURUECKGEROLLT -eq 0 ]] || return 0
# Kommt nur zum Zug, wenn die Sicherung steht -- dann sind diese Variablen
# gesetzt. Der Vollstaendigkeit halber trotzdem gepruefte Namen.
[[ -n "${BACKUP_DIR:-}" && -d "$BACKUP_DIR" ]] || { warn "Keine Sicherung gefunden -- nichts wiederhergestellt."; return 0; }
ZURUECKGEROLLT=1
warn "Alten Stand wiederherstellen."
systemctl stop "$UNIT" 2>/dev/null || true
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
rm -rf "$FAILED_DIR"
mv "$ROOT" "$FAILED_DIR" 2>/dev/null || true
cp -a "$BACKUP_DIR" "$ROOT"
systemctl start "$UNIT" 2>/dev/null || true
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" 2>/dev/null || true
warn "Wiederhergestellt aus $BACKUP_DIR"
warn "Fehlgeschlagener Stand liegt unter $FAILED_DIR"
}
# Bei **jedem** Fehlschlag, nicht nur bei einem, an den ich gedacht habe:
# Systempakete, venv, sudoers, nginx, ein Ctrl-C. Vorher blieb der Dienst
# gestoppt und das Verzeichnis teils ersetzt zurueck.
bei_fehler() {
local code=$?
[[ $INSTALL_FERTIG -eq 1 || "$code" == "0" ]] && return "$code"
if [[ $SICHERUNG_STEHT -eq 1 ]]; then
zurueckrollen_install
else
# Neuinstallation: es gibt keinen alten Stand. Nichts loeschen -- im
# Zielverzeichnis kann eine Fassung 1.x liegen, deren Sicherung ein
# anderes Skript haelt (upgrade.sh).
# Vorsichtige Ersetzung: bricht es schon beim Auswerten der Argumente ab,
# gibt es $ROOT noch nicht -- und "set -u" wuerde die Falle selbst zum
# Fehler machen.
if [[ ${IS_UPDATE:-0} -eq 1 ]]; then
# Abbruch vor dem Sicherungsschritt: die Installation ist unangetastet.
warn "Abgebrochen (Code $code), bevor etwas geaendert wurde."
warn "Die bestehende Installation ist unberuehrt -- nichts zurueckzurollen."
else
warn "Abgebrochen (Code $code). Es gab keinen vorherigen Stand dieser Fassung,"
warn "also wurde nichts wiederhergestellt und nichts geloescht."
# Nur sagen, was es wirklich gibt: bei einem Abbruch an der Rueckfrage
# existiert das Verzeichnis noch nicht.
if [[ -n "${ROOT:-}" && -d "${ROOT:-}" ]]; then
warn "Angelegt wurde bis hierhin: $ROOT (Daten unter data/ unberuehrt)."
fi
fi
fi
return "$code"
}
trap bei_fehler EXIT
usage() { usage() {
cat <<'USAGE' cat <<'USAGE'
Verwendung: install.sh --app <tesm|tesm-license> [Optionen] Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
@@ -112,13 +171,30 @@ MONITOR_UNIT="$NAME-monitor.service"
NGINX_SITE="/etc/nginx/sites-available/$NAME" NGINX_SITE="/etc/nginx/sites-available/$NAME"
ENV_PREFIX="TESM_" ENV_PREFIX="TESM_"
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_" [[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
# Ausgabe des Lizenzservers: die Master-Ausgabe bringt das Teilpaket
# tesm_license/master mit -- den einzigen Weg, auf dem eine Lizenzdatei die
# Rolle "master" bekommt. Das Release fuer Sub-Lizenzserver hat es nicht.
# Die Ausgabe haengt an der Anwesenheit des Verzeichnisses, nicht an einer
# Einstellung: eine Einstellung liesse sich vor Ort umstellen.
MASTER_TEILPAKET="apps/tesm-license/src/tesm_license/master"
EDITION="standard"
if [[ "$APP" == "tesm-license" ]]; then
if [[ -d "$REPO_DIR/$MASTER_TEILPAKET" ]]; then EDITION="master"; else EDITION="sub"; fi
fi
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst # Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz # verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80 # faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie # lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was # beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
# nicht ausdruecklich neu gesetzt wird. # nicht ausdruecklich neu gesetzt wird.
ENV_FILE="/etc/tesm/$NAME.env" ENV_FILE="$ETC_TESM/$NAME.env"
# Wo die Daten liegen, steht in der Umgebungsdatei; ohne sie beim Vorgabeort.
# "|| true": bei einer Neuinstallation gibt es die Datei noch nicht, und ein
# scheiterndes sed in einer Zuweisung beendet unter "set -e" das ganze Skript.
DATEN_DIR="$(sed -n "s/^${ENV_PREFIX}DATA_DIR=//p" "$ENV_FILE" 2>/dev/null || true)"
DATEN_DIR="$(printf '%s' "$DATEN_DIR" | tail -1)"
DATEN_DIR="${DATEN_DIR:-$ROOT/data}"
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)" PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
fi fi
@@ -127,10 +203,30 @@ if [[ -f "$NGINX_SITE" ]]; then
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)" [[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
fi fi
port_belegt() {
local p="$1"
# Schon von einer anderen Installation vergeben? Die Umgebungsdateien sind
# die Wahrheit -- ein Dienst kann gerade gestoppt sein und den Port trotzdem
# beanspruchen.
grep -qs "BIND=127\.0\.0\.1:$p\$" "$ETC_TESM"/*.env 2>/dev/null &&
return 0
# Oder gerade in Benutzung, von was auch immer.
command -v ss >/dev/null 2>&1 && [[ -n "$(ss -Hltn "sport = :$p" 2>/dev/null)" ]] && return 0
return 1
}
if [[ -z "$PORT" ]]; then if [[ -z "$PORT" ]]; then
PORT=5000 BASISPORT=5000
[[ "$APP" == "tesm-license" ]] && PORT=5001 [[ "$APP" == "tesm-license" ]] && BASISPORT=5001
[[ -n "$INSTANCE" ]] && PORT=$((PORT + 100)) if [[ -n "$INSTANCE" ]]; then
# Frueher bekam **jede** Instanz Basis+100. Die zweite startete nie
# ("Address already in use") -- und die Gesundheitspruefung merkte es
# nicht, weil die erste auf demselben Port antwortete.
PORT=$((BASISPORT + 100))
while port_belegt "$PORT"; do PORT=$((PORT + 1)); done
else
PORT=$BASISPORT
fi
fi fi
if [[ -z "$HTTP_PORT" ]]; then if [[ -z "$HTTP_PORT" ]]; then
HTTP_PORT=80 HTTP_PORT=80
@@ -212,12 +308,41 @@ fi
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte # Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
# gibt es ausschliesslich ueber den Helfer. # gibt es ausschliesslich ueber den Helfer.
# Ein Sub-Paket auf eine Master-Installation waere ein stiller Rueckbau: der
# Abgleich unten laeuft mit --delete und wuerde das Teilpaket entfernen. Danach
# gaebe es auf dem Master keinen Weg mehr, eine Master-Lizenz auszustellen --
# und niemand haette es gesagt.
# Die Rolle steht in der eingespielten Lizenz. Das ist die zuverlaessigere
# Haelfte der Pruefung: eine Master-Installation aus einer aelteren Fassung
# kennt das Teilpaket noch nicht, ist aber trotzdem eine Wurzelinstanz.
ROLLE_ALT=""
if [[ "$EDITION" == "sub" && -s "$DATEN_DIR/license.json" ]]; then
ROLLE_ALT="$(python3 -c '
import json, sys
try:
print(json.load(open(sys.argv[1])).get("role", ""))
except Exception:
pass
' "$DATEN_DIR/license.json" 2>/dev/null || true)"
fi
if [[ "$EDITION" == "sub" && ( -d "$ROOT/$MASTER_TEILPAKET" || "$ROLLE_ALT" == "master" ) ]]; then
fail "Hier laeuft eine Wurzelinstanz${ROLLE_ALT:+ (Lizenz mit Rolle $ROLLE_ALT)}, dieses Paket
ist die Ausgabe fuer Sub-Lizenzserver.
Eine Aktualisierung damit wuerde die Master-Ausgabe entfernen.
Passendes Paket: Repository tesm-license-master (privat).
Bewusster Rueckbau: eine vom Master ausgestellte Lizenz einspielen (Rolle
license_server) und $ROOT/$MASTER_TEILPAKET entfernen -- danach ist dies ein
Sub-Lizenzserver und dieses Paket das richtige."
fi
# -- Backup ------------------------------------------------------------------ # -- Backup ------------------------------------------------------------------
if [[ $IS_UPDATE -eq 1 ]]; then if [[ $IS_UPDATE -eq 1 ]]; then
step "Sicherung vor der Aktualisierung" step "Sicherung vor der Aktualisierung"
systemctl stop "$UNIT" 2>/dev/null || true systemctl stop "$UNIT" 2>/dev/null || true
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true [[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
cp -a "$ROOT" "$BACKUP_DIR" cp -a "$ROOT" "$BACKUP_DIR"
SICHERUNG_STEHT=1
info "Sicherung: $BACKUP_DIR" info "Sicherung: $BACKUP_DIR"
fi fi
@@ -295,6 +420,42 @@ install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP"
rm -f "$TMP_SUDO" rm -f "$TMP_SUDO"
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen" info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
# -- Anmeldebanner (MOTD) ----------------------------------------------------
# Das Banner liest die Datenbank dieser Installation und laeuft dabei mit dem
# System-Python, ohne das venv und ohne die Anwendung zu importieren. Der
# Vorgaenger importierte dafuer die ganze Flask-App -- bei jeder Anmeldung, und
# nach einem Wechsel des Quellverzeichnisses zeigte er stillschweigend die
# Zahlen einer Datenbank, die niemand mehr fuellte.
#
# "timeout" verhindert, dass ein haengender Dateizugriff die Anmeldung
# blockiert; "|| true" sorgt dafuer, dass ein Fehler hier den Login nie stoert.
step "Anmeldebanner"
if [[ -d /etc/update-motd.d ]]; then
install_if_changed "$ROOT/deploy/tesm-motd" /usr/local/lib/tesm/tesm-motd 0755 >/dev/null \
&& info "Bannerskript aktualisiert" || info "Bannerskript unveraendert"
TMP_MOTD="$(mktemp)"
{
echo "#!/bin/sh"
echo "# Von TESM erzeugt ($NAME). Aenderungen werden bei der naechsten"
echo "# Installation ueberschrieben."
echo "timeout 3 /usr/local/lib/tesm/tesm-motd --app $APP --root $ROOT 2>/dev/null || true"
} > "$TMP_MOTD"
install -o root -g root -m 0755 "$TMP_MOTD" "/etc/update-motd.d/50-$NAME"
rm -f "$TMP_MOTD"
info "Banner: /etc/update-motd.d/50-$NAME"
# Ein Banner der Vorgaengerfassung zeigt Zahlen aus einer Datenbank, die
# niemand mehr fuellt. Er wird nur entfernt, wenn er genau darauf zeigt --
# nichts anderes wird angefasst.
ALT_MOTD="/etc/update-motd.d/50-tesm"
if [[ "$ALT_MOTD" != "/etc/update-motd.d/50-$NAME" && -f "$ALT_MOTD" ]] \
&& grep -q "motd_banner.py" "$ALT_MOTD" 2>/dev/null; then
rm -f "$ALT_MOTD"
info "Banner der Vorgaengerfassung entfernt ($ALT_MOTD)"
fi
else
info "Kein /etc/update-motd.d -- Banner uebersprungen"
fi
# -- Rechte ------------------------------------------------------------------ # -- Rechte ------------------------------------------------------------------
step "Dateirechte" step "Dateirechte"
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR" chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
@@ -311,8 +472,8 @@ step "Umgebung"
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das # EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere # wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
# Datenbank im Arbeitsverzeichnis anlegen. # Datenbank im Arbeitsverzeichnis anlegen.
install -d -o root -g root -m 0755 /etc/tesm install -d -o root -g root -m 0755 "$ETC_TESM"
ENV_FILE="/etc/tesm/$NAME.env" ENV_FILE="$ETC_TESM/$NAME.env"
TMP_ENV="$(mktemp)" TMP_ENV="$(mktemp)"
{ {
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten." echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
@@ -455,7 +616,11 @@ fi
step "Gesundheitspruefung" step "Gesundheitspruefung"
HEALTHY=0 HEALTHY=0
for attempt in $(seq 1 20); do 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 # Beides: der Dienst muss laufen **und** antworten. Nur die Antwort zu
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
# eine andere Instanz, und ein toter Dienst galt als gesund.
if systemctl is-active --quiet "$UNIT" &&
curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then
HEALTHY=1 HEALTHY=1
info "Antwort nach ${attempt}x" info "Antwort nach ${attempt}x"
break break
@@ -466,13 +631,9 @@ done
if [[ $HEALTHY -eq 0 ]]; then if [[ $HEALTHY -eq 0 ]]; then
warn "Die Anwendung antwortet nicht." warn "Die Anwendung antwortet nicht."
if [[ $IS_UPDATE -eq 1 ]]; then if [[ $IS_UPDATE -eq 1 ]]; then
warn "Rollback wird ausgefuehrt." # Dieselbe Funktion wie die EXIT-Falle -- zwei Wege, die dasselbe tun
systemctl stop "$UNIT" 2>/dev/null || true # sollen, laufen sonst irgendwann auseinander.
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true zurueckrollen_install
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. fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
Fehlgeschlagene Version : $FAILED_DIR Fehlgeschlagene Version : $FAILED_DIR
Sicherung : $BACKUP_DIR Sicherung : $BACKUP_DIR
@@ -532,12 +693,41 @@ if [[ $HTTPS -eq 1 ]]; then
fi fi
fi fi
# -- Lizenzserver einrichten -------------------------------------------------
# Bei einer Neuinstallation: Signaturschluessel anlegen (braucht jeder
# Lizenzserver, um Kundenlizenzen zu signieren) und in der Master-Ausgabe
# gleich die eigene Master-Lizenz. Bei einer Aktualisierung nichts davon --
# ein bestehender Schluessel darf nie angetastet werden.
if [[ "$APP" == "tesm-license" && $IS_UPDATE -eq 0 ]]; then
step "Signaturschluessel"
if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" init-key; then
info "Bitte die Datei $DATEN_DIR/master_signing_key.json SOFORT sichern."
else
fail "Der Signaturschluessel liess sich nicht erzeugen. Ohne ihn kann dieser Server
keine Lizenz ausstellen. Protokoll: journalctl -u $UNIT -n 50"
fi
if [[ "$EDITION" == "master" ]]; then
step "Eigene Master-Lizenz"
if [[ -s "$DATEN_DIR/license.json" ]]; then
info "Es liegt schon eine Lizenzdatei -- unangetastet."
elif sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" bootstrap; then
systemctl restart "$UNIT"
info "Dienst neu gestartet, damit die Lizenz gelesen wird."
else
fail "Die Master-Lizenz liess sich nicht ausstellen.
Nachtraeglich: sudo -u $SERVICE_USER $NAME-admin bootstrap"
fi
fi
fi
if [[ $IS_UPDATE -eq 1 ]]; then if [[ $IS_UPDATE -eq 1 ]]; then
rm -rf "$BACKUP_DIR" rm -rf "$BACKUP_DIR"
info "Sicherung entfernt (Aktualisierung erfolgreich)" info "Sicherung entfernt (Aktualisierung erfolgreich)"
fi fi
# -- Abschluss --------------------------------------------------------------- # -- Abschluss ---------------------------------------------------------------
INSTALL_FERTIG=1
step "Fertig" step "Fertig"
ADMIN_CMD="tesm-admin" ADMIN_CMD="tesm-admin"
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin" [[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
@@ -553,14 +743,24 @@ cat <<SUMMARY
Naechste Schritte: Naechste Schritte:
SUMMARY SUMMARY
if [[ "$APP" == "tesm-license" ]]; then if [[ "$APP" == "tesm-license" && "$EDITION" == "master" ]]; then
cat <<SUMMARY cat <<SUMMARY
1. Signaturschluessel erzeugen und sofort sichern: 1. Administrator anlegen:
sudo -u $SERVICE_USER $NAME-admin init-key
2. Eigene Master-Lizenz ausstellen:
sudo -u $SERVICE_USER $NAME-admin bootstrap
3. Administrator anlegen:
sudo -u $SERVICE_USER $NAME-admin create-admin sudo -u $SERVICE_USER $NAME-admin create-admin
2. Signaturschluessel sichern -- ohne ihn ist keine ausgestellte Lizenz
mehr pruefbar:
$ROOT/data/master_signing_key.json
SUMMARY
elif [[ "$APP" == "tesm-license" ]]; then
cat <<SUMMARY
1. Administrator anlegen:
sudo -u $SERVICE_USER $NAME-admin create-admin
2. Beim Master-Lizenzserver eine Lizenz fuer diesen Server anfordern und
unter "Eigene Lizenz" einspielen. Diese Ausgabe ist ein Sub-Lizenzserver
und stellt keine eigene Master-Lizenz aus.
3. Signaturschluessel sichern -- mit ihm signiert dieser Server seine
Kundenlizenzen:
$ROOT/data/master_signing_key.json
SUMMARY SUMMARY
else else
cat <<SUMMARY cat <<SUMMARY
+12 -3
View File
@@ -14,12 +14,21 @@ TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# Vorgabe: der Name der einzigen Anwendung im Baum, sonst der Verzeichnisname. # Vorgabe: der Name des Repositoriums. Genau den erwartet der Bootstrap von
# update.sh als Anhangsnamen ("$repo-$TAG.tar.gz"). Die Anwendung heisst nicht
# immer so: im Repositorium tesm-license-master liegt die Anwendung
# "tesm-license", und ein Anhang unter deren Namen waere nie zu finden.
if [[ -n "${2:-}" ]]; then if [[ -n "${2:-}" ]]; then
NAME="$2" NAME="$2"
else else
mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true) HERKUNFT="$(git -C "$REPO_DIR" config --get remote.origin.url 2>/dev/null || true)"
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi if [[ -n "$HERKUNFT" ]]; then
NAME="$(basename "$HERKUNFT" .git)"
else
# Ohne Gegenseite: die einzige Anwendung im Baum, sonst das Verzeichnis.
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
fi fi
OUT="$NAME-$TAG.tar.gz" OUT="$NAME-$TAG.tar.gz"
+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env bash
# =============================================================================
# Benennt eine Installation um -- aus einer Instanz wird die Hauptinstallation
# (oder umgekehrt).
#
# sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test \
# --to tesm-license
#
# Der Name steckt an mehr Stellen, als man beim Verschieben eines Verzeichnisses
# erwartet, und drei davon sind leicht zu vergessen:
#
# * ``SITE_KEY`` in der Umgebungsdatei -- daraus entsteht der Name des
# Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
# Instanz.
# * ``web_cert_path``/``web_key_path``/``web_acme_webroot`` in der Datenbank.
# Sie enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx
# nach dem Umzug nicht mehr.
# * ``server_name`` der nginx-Site. install.sh schreibt die Datei neu, sobald
# sich die alias-Pfade aendern -- und die aendern sich hier zwangslaeufig.
# Ohne den Namen aus der Datenbank stuenden danach zwei Sites mit ``_`` auf
# Port 80 und die erste gewinnt.
#
# Belegt der Zielname noch eine Installation der Fassung 1.x, wird sie beiseite
# geschoben: Verzeichnis nach ``<ziel>-<suffix>``, ihre systemd-Units und ihre
# nginx-Site als Kopie hinein. Danach ist der Name frei und nichts auf dem
# System zeigt mehr ins Leere.
#
# Alle angemeldeten Benutzer werden abgemeldet: das Sitzungscookie heisst
# danach anders.
# =============================================================================
set -euo pipefail
APP=""
FROM=""
TO=""
SUFFIX="sonnet5"
ASSUME_YES=0
DRY_RUN=0
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && 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: rename.sh --app <tesm|tesm-license> --from <alt> --to <neu> [Optionen]
--app <name> Welche Anwendung (Pflicht)
--from <name> Bisheriger Installationsname, z.B. tesm-test (Pflicht)
--to <name> Neuer Installationsname, z.B. tesm (Pflicht)
--suffix <text> Namenszusatz fuer eine beiseite geschobene Installation
der Fassung 1.x (Vorgabe: sonnet5)
--dry-run Nur zeigen, was geschehen wuerde
--yes Keine Rueckfrage
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--app) APP="${2:?}"; shift 2 ;;
--from) FROM="${2:?}"; shift 2 ;;
--to) TO="${2:?}"; shift 2 ;;
--suffix) SUFFIX="${2:?}"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) usage; fail "unbekannte Option: $1" ;;
esac
done
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
[[ -n "$FROM" && -n "$TO" ]] || { usage; fail "--from und --to sind Pflicht"; }
[[ "$FROM" != "$TO" ]] || fail "--from und --to sind gleich"
[[ "$TO" =~ ^[a-z0-9-]{1,32}$ ]] || fail "ungueltiger Zielname: $TO"
[[ "$TO" == "$APP" || "$TO" == "$APP-"* ]] || fail "der Zielname muss mit '$APP' beginnen"
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
[[ -d "/srv/$FROM" ]] || fail "/srv/$FROM gibt es nicht"
[[ -f "/srv/$FROM/data/app.db" ]] || fail "/srv/$FROM sieht nicht nach einer Installation dieser Fassung aus"
ENV_PREFIX="TESM_"
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
# Alles, was den Namen traegt. Reihenfolge: erst Daten, dann Konfiguration.
VERSCHIEBEN=(
"/srv/$FROM:/srv/$TO"
"/var/log/$FROM:/var/log/$TO"
"/etc/tesm/$FROM.env:/etc/tesm/$TO.env"
"/var/www/$FROM-acme:/var/www/$TO-acme"
"/etc/tesm/certs/$FROM:/etc/tesm/certs/$TO"
)
ENTFERNEN=(
"/etc/systemd/system/$FROM.service"
"/etc/systemd/system/$FROM-monitor.service"
"/etc/nginx/sites-enabled/$FROM"
"/etc/nginx/sites-available/$FROM"
"/usr/local/bin/$FROM-admin"
"/etc/logrotate.d/$FROM"
"/etc/update-motd.d/50-$FROM"
)
# -- Webeinstellungen auslesen, bevor irgendetwas umzieht -------------------- #
# Mit dem System-Python und der Standardbibliothek: das venv zieht gleich mit
# um, und ein zusaetzliches Werkzeug (sqlite3) ist auf einem schlanken Server
# nicht garantiert.
lese_einstellung() {
python3 - "$1" "/srv/$FROM/data/app.db" <<'PY'
import sqlite3, sys
schluessel, datenbank = sys.argv[1], sys.argv[2]
try:
conn = sqlite3.connect(f"file:{datenbank}?mode=ro", uri=True)
row = conn.execute("SELECT value FROM settings WHERE key=?", (schluessel,)).fetchone()
conn.close()
except sqlite3.Error:
row = None
print((row[0] if row and row[0] is not None else "").strip())
PY
}
DOMAIN="$(lese_einstellung web_domain || true)"
HTTPS_AN="$(lese_einstellung web_https_enabled || true)"
HSTS_AN="$(lese_einstellung web_hsts_enabled || true)"
CERT_ALT="$(lese_einstellung web_cert_path || true)"
step "Vorhaben"
info "Anwendung : $APP"
info "Alt : $FROM"
info "Neu : $TO"
info "Domain : ${DOMAIN:-(keine)}"
info "HTTPS : $([[ "$HTTPS_AN" == "1" ]] && echo "ja${CERT_ALT:+, Zertifikat $CERT_ALT}" || echo nein)"
for paar in "${VERSCHIEBEN[@]}"; do
quelle="${paar%%:*}"; ziel="${paar##*:}"
[[ -e "$quelle" ]] && info "verschieben: $quelle -> $ziel"
done
for pfad in "${ENTFERNEN[@]}"; do
[[ -e "$pfad" ]] && info "entfernen : $pfad (install.sh legt es unter dem neuen Namen neu an)"
done
# -- Was belegt den Zielnamen? ----------------------------------------------- #
# Nicht nur das Verzeichnis unter /srv: eine Installation der Fassung 1.x
# hinterlaesst auch /var/log/<name>, /etc/logrotate.d/<name> und den
# ACME-Pfad. Der Umzug bricht sonst mitten drin ab -- live genau so passiert.
BELEGT=()
for paar in "${VERSCHIEBEN[@]}"; do
ziel="${paar##*:}"
[[ -e "$ziel" ]] && BELEGT+=("$ziel")
done
for weiteres in "/etc/logrotate.d/$TO" "/etc/update-motd.d/50-$TO"; do
[[ -e "$weiteres" ]] && BELEGT+=("$weiteres")
done
BEISEITE=""
if [[ ${#BELEGT[@]} -gt 0 ]]; then
if [[ -f "/srv/$TO/data/app.db" ]]; then
fail "/srv/$TO ist bereits eine Installation dieser Fassung. Welche gewinnt, entscheidet niemand automatisch."
fi
BEISEITE="/srv/$TO-$SUFFIX"
if [[ -e "$BEISEITE" ]]; then
# Ein vorhandenes Archiv derselben Verdraengung wird weiterbenutzt -- die
# Reste einer Installation gehoeren zusammen und nicht in ein zweites
# Verzeichnis. Alles andere unter diesem Namen bleibt unangetastet.
if [[ -f "$BEISEITE/sqlite.db" || -d "$BEISEITE/systemd-alt" ]]; then
info "Archiv $BEISEITE ist vorhanden und wird weiterbenutzt."
else
fail "$BEISEITE existiert und sieht nicht nach einem solchen Archiv aus -- bitte --suffix waehlen"
fi
fi
info "Der Zielname ist belegt. Alles davon wandert nach $BEISEITE:"
for pfad in "${BELEGT[@]}"; do info " $pfad"; done
fi
if [[ $DRY_RUN -eq 1 ]]; then
step "--dry-run: hier ist Schluss."
exit 0
fi
if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Fortfahren? Alle angemeldeten Benutzer werden abgemeldet. [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
fi
# -- 1. Dienste anhalten ----------------------------------------------------- #
step "Dienste anhalten"
for unit in "$FROM.service" "$FROM-monitor.service"; do
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
systemctl disable --now "$unit" >/dev/null 2>&1 || true
info "$unit gestoppt und deaktiviert"
fi
done
# -- 2. Zielnamen freimachen ------------------------------------------------- #
if [[ -n "$BEISEITE" ]]; then
step "Zielnamen freimachen"
# Alles wandert ins Archiv, nichts wird geloescht: unter /var/log/<name>
# liegen Protokolle, unter /srv/<name> im Zweifel eine ganze Installation.
# Die Units und die Site kommen mit -- sie zeigen auf das alte Verzeichnis
# und waeren auf dem System nur noch Fehlerquellen, im Archiv aber ein Beleg.
mkdir -p "$BEISEITE"
for pfad in "${BELEGT[@]}"; do
ziel="$BEISEITE/$(basename "$pfad")"
if [[ "$pfad" == "/srv/$TO" ]]; then
# Das Verzeichnis selbst wird das Archiv -- sein Inhalt bleibt, wie er ist.
for eintrag in "$pfad"/* "$pfad"/.[!.]*; do
[[ -e "$eintrag" ]] || continue
mv "$eintrag" "$BEISEITE/"
done
rmdir "$pfad"
info "/srv/$TO -> $BEISEITE/"
continue
fi
if [[ -e "$ziel" ]]; then
ziel="$ziel-$(date +%Y%m%d-%H%M%S)"
warn "Im Archiv lag schon etwas mit diesem Namen -- ablegen als $(basename "$ziel")"
fi
mv "$pfad" "$ziel"
info "$pfad -> $ziel"
done
mkdir -p "$BEISEITE/systemd-alt"
for unit in "/etc/systemd/system/$TO.service" "/etc/systemd/system/$TO-check.service" \
"/etc/systemd/system/$TO-check.timer" "/etc/systemd/system/$TO-check-restart.service" \
"/etc/systemd/system/$TO-check-restart.timer"; do
[[ -f "$unit" ]] || continue
systemctl disable --now "$(basename "$unit")" >/dev/null 2>&1 || true
mv "$unit" "$BEISEITE/systemd-alt/"
info "$(basename "$unit") archiviert"
done
for site in "/etc/nginx/sites-enabled/$TO" "/etc/nginx/sites-available/$TO"; do
[[ -e "$site" ]] || continue
[[ -L "$site" ]] && { rm -f "$site"; continue; }
mv "$site" "$BEISEITE/systemd-alt/nginx-site"
info "nginx-Site archiviert"
done
for motd in "/etc/update-motd.d/50-$TO"; do
[[ -f "$motd" ]] && grep -q "motd_banner.py" "$motd" 2>/dev/null && rm -f "$motd" \
&& info "Banner der Fassung 1.x entfernt"
done
info "Alles Verdraengte liegt unter $BEISEITE"
fi
# -- 3. Umziehen ------------------------------------------------------------- #
step "Umziehen"
for paar in "${VERSCHIEBEN[@]}"; do
quelle="${paar%%:*}"; ziel="${paar##*:}"
[[ -e "$quelle" ]] || continue
[[ -e "$ziel" ]] && fail "$ziel existiert bereits -- Abbruch, damit nichts vermischt wird"
mkdir -p "$(dirname "$ziel")"
mv "$quelle" "$ziel"
info "$quelle -> $ziel"
done
# -- 4. Namen in den Dateien nachziehen -------------------------------------- #
step "Namen nachziehen"
ENV_FILE="/etc/tesm/$TO.env"
if [[ -f "$ENV_FILE" ]]; then
# Der SITE_KEY bestimmt den Namen des Sitzungscookies. Bleibt er alt, heisst
# das Cookie weiter wie die alte Instanz -- unauffaellig und falsch.
sed -i "s#^${ENV_PREFIX}SITE_KEY=.*#${ENV_PREFIX}SITE_KEY=$TO#" "$ENV_FILE"
info "SITE_KEY=$TO"
fi
# Pfade in der Datenbank, die den Namen enthalten.
python3 - "$TO" "$FROM" "/srv/$TO/data/app.db" <<'PY'
import sqlite3, sys
neu, alt, datenbank = sys.argv[1], sys.argv[2], sys.argv[3]
schluessel = ("web_cert_path", "web_key_path", "web_acme_webroot")
try:
conn = sqlite3.connect(datenbank, timeout=5.0)
except sqlite3.Error as fehler:
print(f" Datenbank nicht schreibbar: {fehler}")
raise SystemExit(0)
try:
with conn:
for key in schluessel:
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
if not row or not row[0]:
continue
wert = str(row[0])
ersetzt = wert.replace(alt, neu)
if ersetzt != wert:
conn.execute("UPDATE settings SET value=? WHERE key=?", (ersetzt, key))
print(f" {key}: {wert} -> {ersetzt}")
finally:
conn.close()
PY
# -- 5. Alte Konfiguration entfernen ----------------------------------------- #
step "Alte Konfiguration entfernen"
for pfad in "${ENTFERNEN[@]}"; do
[[ -e "$pfad" ]] || continue
rm -f "$pfad"
info "$pfad"
done
systemctl daemon-reload
# -- 6. Unter dem neuen Namen einrichten ------------------------------------- #
# Das venv wird verworfen und neu gebaut. Es ist abgeleiteter Zustand, und
# seine Konsolenskripte tragen den absoluten Pfad im Shebang: nach dem Umzug
# zeigt "venv/bin/gunicorn" auf einen Interpreter, den es nicht mehr gibt, und
# systemd meldet nur "No such file or directory" -- fuer eine Datei, die
# sichtbar da ist. Live auf POETEST genau so passiert.
if [[ -d "/srv/$TO/venv" ]]; then
step "venv verwerfen (Shebangs tragen den alten Pfad)"
rm -rf "/srv/$TO/venv"
info "wird von install.sh neu gebaut"
fi
step "Unter dem neuen Namen einrichten"
INSTALL_ARGS=(--app "$APP" --yes)
[[ "$TO" != "$APP" ]] && INSTALL_ARGS+=(--instance "${TO#"$APP"-}")
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}"
# -- 7. nginx-Site aus den Einstellungen neu schreiben ----------------------- #
# install.sh schreibt die Site aus seiner Vorlage, sobald sich die alias-Pfade
# aendern -- und die aendern sich hier zwangslaeufig. Der Name der Site steht
# aber in der Datenbank, nicht in der Vorlage. Ohne diesen Schritt stuenden
# zwei Sites mit "_" auf Port 80 und die erste gewinnt.
if [[ -n "$DOMAIN" ]]; then
step "Webserver-Konfiguration erneuern"
# --apply nicht vergessen: ohne ihn speichert web-setup nur die Einstellungen
# und laesst die Site-Datei stehen, die install.sh aus seiner Vorlage
# geschrieben hat -- mit "server_name _" und ohne TLS.
WEB_ARGS=(web-setup --apply --domain "$DOMAIN")
if [[ "$HTTPS_AN" == "1" ]]; then
WEB_ARGS+=(--https)
[[ "$HSTS_AN" == "1" ]] && WEB_ARGS+=(--hsts)
# Das Zertifikat ist mitgezogen und sein Pfad in der Datenbank nachgefuehrt
# -- "web-setup --https" laesst es dann unangetastet. Ein neues erzeugen
# wuerde nur eine weitere Browserwarnung produzieren. Nur wenn nach dem
# Umzug wirklich keines da ist, wird eines erzeugt.
CERT_NEU="${CERT_ALT//$FROM/$TO}"
if [[ -n "$CERT_NEU" && -f "$CERT_NEU" ]]; then
info "HTTPS mit dem vorhandenen Zertifikat: $CERT_NEU"
else
WEB_ARGS+=(--self-signed)
warn "Zertifikat nicht gefunden (${CERT_NEU:-keins hinterlegt}) -- es wird ein neues"
warn "selbstsigniertes erzeugt. Der Browser warnt dann einmal erneut."
fi
fi
if sudo -u "$APP" "/usr/local/bin/$TO-admin" "${WEB_ARGS[@]}"; then
info "Site neu geschrieben: server_name $DOMAIN"
else
warn "web-setup ist fehlgeschlagen -- bitte in der Anwendung unter"
warn "'Verwaltung -> Webserver und TLS' die Domain eintragen und uebernehmen."
fi
fi
# -- 8. Ergebnis ------------------------------------------------------------- #
step "Fertig"
info "Verzeichnis : /srv/$TO"
info "Dienst : systemctl status $TO.service"
info "Werkzeug : $TO-admin"
info "Aktualisieren: sudo bash /srv/$TO/deploy/update.sh $APP$([[ "$TO" != "$APP" ]] && echo " latest --instance ${TO#"$APP"-}")"
[[ -n "$BEISEITE" ]] && info "Fassung 1.x : $BEISEITE (Units und Site darin unter systemd-alt/)"
echo
warn "Alle Benutzer sind abgemeldet: das Sitzungscookie heisst jetzt anders."
+7 -1
View File
@@ -5,5 +5,11 @@
# #
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe # Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
# Problem, indem die gesamte Anwendung als root lief. # Problem, indem die gesamte Anwendung als root lief.
Defaults:tesm !requiretty #
# Hier stand "Defaults:tesm !requiretty". Auf Debian und Ubuntu ist
# requiretty nie gesetzt, die Zeile war also von Anfang an wirkungslos -- und
# ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
# Datei ab ("unknown setting: 'requiretty'"). Auf einem frisch aufgesetzten
# Server brach die Installation deshalb mitten drin ab. Nicht wieder
# hinzufuegen; ein Hygienetest haelt das fest.
tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
+7 -1
View File
@@ -5,5 +5,11 @@
# #
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe # Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
# Problem, indem die gesamte Anwendung als root lief. # Problem, indem die gesamte Anwendung als root lief.
Defaults:tesm-license !requiretty #
# Hier stand "Defaults:tesm-license !requiretty". Auf Debian und Ubuntu ist
# requiretty nie gesetzt, die Zeile war also von Anfang an wirkungslos -- und
# ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
# Datei ab ("unknown setting: 'requiretty'"). Auf einem frisch aufgesetzten
# Server brach die Installation deshalb mitten drin ab. Nicht wieder
# hinzufuegen; ein Hygienetest haelt das fest.
tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
+1 -1
View File
@@ -47,7 +47,7 @@ check_in_list() {
} }
# Name einer Installation auf diesem Host: entweder die Hauptinstallation # Name einer Installation auf diesem Host: entweder die Hauptinstallation
# ("tesm") oder eine benannte Instanz daneben ("tesm-opus"). Mehr als diese # ("tesm") oder eine benannte Instanz daneben ("tesm-test"). Mehr als diese
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen # beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen. # Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
check_app_key() { check_app_key() {
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Live-Banner fuer die SSH-Anmeldung (MOTD).
Eingebunden ueber ``/etc/update-motd.d/50-<name>``, das ``install.sh``
erzeugt. Aufruf mit ``--app`` und ``--root``; alles andere steht in der
Datenbank der Installation.
**Nur Standardbibliothek, kein Flask, kein venv.** Der Vorgaenger importierte
dafuer die ganze Anwendung -- bei jeder SSH-Anmeldung. Das ist teuer, und es
faellt aus, sobald das venv nicht mehr passt: nach dem Umstieg auf diese
Fassung zeigte das alte Banner monatelang eingefrorene Zahlen aus einer
Datenbank, die niemand mehr fuellte. Hier wird gelesen, was auf der Platte
liegt, und zwar **nur lesend** (``mode=ro``).
Ein Banner darf eine Anmeldung niemals stoeren: jede Auskunft, die nicht zu
holen ist, wird zu einem kurzen Hinweis, und am Ende faengt ``main`` alles ab.
Der Aufrufer setzt zusaetzlich ein Zeitlimit.
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
import time
from pathlib import Path
AMBER = "\033[38;2;226;166;60m"
DIM = "\033[2m"
BOLD = "\033[1m"
RESET = "\033[0m"
#: Kea schreibt seine Leases als CSV; die letzte Zeile je Adresse gilt.
#: Dieselbe Datei liest die Anwendung (``services/kea.py``: ``LEASE_FILE``).
LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
#: Zeile unter der Wortmarke. Wortlaut wie in der jeweiligen Anwendung
#: (``AUTH_TAGLINE`` in deren ``__init__.py``) -- ein Hygienetest haelt beides
#: zusammen.
TAGLINE = {
"tesm": "ÜBERWACHEN · BOOTEN · ANBINDEN",
"tesm-license": "LIZENZIEREN",
}
TITEL = {"tesm": "TimEShepManager", "tesm-license": "Lizenzserver"}
#: Wortgleich mit ``services/monitor.py``: ``DASHBOARD_COUNT_SQL``. Die
#: Statusuebersicht und dieses Banner sollen nicht zwei Wahrheiten haben; ein
#: Hygienetest vergleicht die beiden Zeichenketten.
DASHBOARD_COUNT_SQL = """
SELECT
COUNT(*) AS total,
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='online' THEN 1 ELSE 0 END) AS online,
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='offline' THEN 1 ELSE 0 END) AS offline,
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='unknown' THEN 1 ELSE 0 END) AS unknown,
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
WHERE d.deleted_at IS NULL
"""
def _zahl(anzahl: int, singular: str, plural: str) -> str:
"""Zahl mit passendem Wort. "1 Clients" liest man jeden Tag einmal zu oft."""
return f"{BOLD}{anzahl}{RESET} {singular if anzahl == 1 else plural}"
def _oeffnen(root: Path) -> sqlite3.Connection:
pfad = root / "data" / "app.db"
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True, timeout=1.0)
conn.row_factory = sqlite3.Row
return conn
def _flotte(conn: sqlite3.Connection) -> str:
row = conn.execute(DASHBOARD_COUNT_SQL).fetchone()
online = int(row["online"] or 0)
offline = int(row["offline"] or 0)
teile = [
_zahl(online, "Client online", "Clients online"),
f"{BOLD}{offline}{RESET} nicht erreichbar",
]
# Nur nennen, wenn es sie gibt: eine Null bei "startet neu" ist keine
# Auskunft, sondern Fuellmaterial.
restarting = int(row["restarting"] or 0)
if restarting:
teile.append(_zahl(restarting, "startet neu", "starten neu"))
unbekannt = int(row["unknown"] or 0)
if unbekannt:
teile.append(f"{DIM}{unbekannt} ohne Pruefung{RESET}")
return " · ".join(teile)
def _dhcp() -> str:
"""Aktive Leases aus der Kea-CSV. Laeuft als root, braucht keinen Helfer."""
datei = Path(LEASE_FILE)
if not datei.is_file():
return "keine Lease-Datei"
try:
zeilen = datei.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return "Lease-Datei nicht lesbar"
if len(zeilen) < 2:
return "0 Leases aktiv"
kopf = [name.strip().strip('"') for name in zeilen[0].split(",")]
try:
i_adresse = kopf.index("address")
i_ablauf = kopf.index("expire")
i_zustand = kopf.index("state")
except ValueError:
return "Lease-Datei mit unerwarteten Spalten"
jetzt = int(time.time())
letzte: dict[str, tuple[int, str]] = {}
for zeile in zeilen[1:]:
felder = [feld.strip().strip('"') for feld in zeile.split(",")]
if len(felder) < len(kopf):
continue
try:
letzte[felder[i_adresse]] = (int(felder[i_ablauf] or 0), felder[i_zustand] or "0")
except ValueError:
continue
aktiv = sum(1 for ablauf, zustand in letzte.values() if zustand == "0" and ablauf > jetzt)
return f"{aktiv} {'Lease' if aktiv == 1 else 'Leases'} aktiv"
def _lizenzen(conn: sqlite3.Connection) -> str:
kunden = int(conn.execute("SELECT COUNT(*) FROM license_customers").fetchone()[0] or 0)
lizenzen = int(
conn.execute(
"SELECT COUNT(*) FROM licenses WHERE status IN ('issued','active')"
).fetchone()[0]
or 0
)
return (
_zahl(kunden, "Kunde", "Kunden")
+ " · "
+ _zahl(lizenzen, "Lizenz ausgestellt", "Lizenzen ausgestellt")
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="tesm-motd", description="Banner fuer die SSH-Anmeldung")
parser.add_argument("--app", required=True, choices=sorted(TITEL))
parser.add_argument("--root", required=True, help="Verzeichnis der Installation")
parser.add_argument("--no-color", action="store_true")
args = parser.parse_args(argv)
if args.no_color:
globals().update(AMBER="", DIM="", BOLD="", RESET="")
root = Path(args.root)
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
# trotzdem ein echtes Terminal -- deshalb immer Farbe.
print(f"{BOLD}{AMBER}TESM{RESET} · {TITEL[args.app]}")
if TAGLINE[args.app]:
print(f"{DIM}{TAGLINE[args.app]}{RESET}")
print()
try:
conn = _oeffnen(root)
except sqlite3.Error:
print(f"{DIM}Datenbank nicht lesbar: {root}{RESET}")
print()
return 0
try:
if args.app == "tesm":
print(f"{_flotte(conn)} · DHCP: {_dhcp()}")
else:
print(_lizenzen(conn))
except sqlite3.Error as fehler:
print(f"{DIM}Zustand nicht verfuegbar ({fehler}){RESET}")
finally:
conn.close()
print()
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except SystemExit:
raise
except Exception: # noqa: BLE001 - ein Banner darf keinen Login stoeren
sys.exit(0)
+171 -61
View File
@@ -1,89 +1,199 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================= # =============================================================================
# Bootstrapper: laedt ein Release herunter und ruft dessen install.sh auf. # Aktualisierung: laedt das aktuelle Release und ruft dessen install.sh auf.
# Enthaelt selbst keinerlei Installationslogik. # Enthaelt selbst keinerlei Installationslogik.
# #
# Zwei Fallen des Vorgaengers sind hier bewusst vermieden: # Eine einzelne Datei genuegt -- sie holt sich alles Weitere:
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die #
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein # curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets. # sudo bash update.sh tesm
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt #
# denselben Fehler ein zweites Mal ab. # Auf einer bestehenden Installation liegt sie schon bereit:
#
# sudo bash /srv/tesm/deploy/update.sh tesm
#
# Laeuft hier die Master-Ausgabe des Lizenzservers, kommt das Release aus dem
# privaten Repositorium tesm-license-master -- dafuer braucht es einen
# Zugriffstoken in TESM_RELEASE_TOKEN oder in /etc/tesm-release.token.
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel
# unbrauchbar ("bad interpreter"). Die Pruefung darf dabei nicht selbst
# von einem Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken
# Server fehlt -- endete sie in "command not found", grep bekam nichts zu
# sehen, und das Skript machte weiter, als waere alles in Ordnung.
hat_crlf() {
local cr
cr="$(printf '\r')"
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer Windows-Buildmaschine
# aber entscheidend: dort entfernt grep den Wagenruecklauf beim Lesen und
# findet deshalb nie einen.
LC_ALL=C grep -qU "$cr" "$1"
}
APP="${1:-}" APP="${1:-}"
TAG="${2:-latest}" TAG="${2:-latest}"
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse # Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen # sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-opus" eine # aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-test" eine
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener # zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
# nginx-Site und demselben Port. # nginx-Site und demselben Port.
shift $(( $# > 2 ? 2 : $# )) shift $(( $# > 2 ? 2 : $# ))
EXTRA=("$@") EXTRA=("$@")
# Bezugsquelle. Die Vorgaben zeigen dorthin, wo die Releases tatsaechlich
# liegen -- ein Platzhalter wie "gitea.example.invalid" macht das Skript auf
# jedem Host unbrauchbar, und genau das war hier der Fall.
#
# Das Repository heisst wie die Anwendung; getrennte Repositorien, getrennte
# Releases. Der Anhang heisst "<repo>-<tag>.tar.gz", siehe unten.
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
OWNER="${TESM_RELEASE_OWNER:-alientim}"
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { [[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2 echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
echo "Beispiel : update.sh tesm latest --instance opus" >&2 echo "Beispiel : update.sh tesm latest --instance test" >&2
echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&2
exit 2 exit 2
} }
REPO="${TESM_RELEASE_REPO:-$APP}" # Repositorium: die Master-Ausgabe des Lizenzservers liegt in einem eigenen,
# privaten Repositorium, heisst als Installation aber weiter "tesm-license".
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)" # Erkannt wird sie an der Installation und nicht an einem Schalter -- wer
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt # aktualisiert, soll nicht erst wissen muessen, welche Ausgabe hier laeuft.
# Sonst holte der Aufruf die Ausgabe fuer Sub-Lizenzserver, und der Installer
if [[ "$TAG" == "latest" ]]; then # muesste sie abweisen.
echo "==> Ermittle aktuellen Release-Tag ..." REPO="$APP"
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \ if [[ "$APP" == "tesm-license" ]]; then
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" NAME="$APP"
echo " $TAG" for ((i = 0; i < ${#EXTRA[@]}; i++)); do
[[ "${EXTRA[i]}" == "--instance" ]] && NAME="$APP-${EXTRA[i + 1]:-}"
done
# Die Wurzel ist ueberschreibbar, damit ein Test die Erkennung wirklich
# ausfuehren kann und nicht nur den Quelltext liest.
if [[ -d "${TESM_SRV_ROOT:-/srv}/$NAME/apps/tesm-license/src/tesm_license/master" ]]; then
REPO="tesm-license-master"
echo "==> Master-Ausgabe erkannt -- Release aus $REPO."
fi
fi fi
ASSET="$REPO-$TAG.tar.gz" # --8<-- Bootstrap: Release holen --------------------------------------------
echo "==> Lade $ASSET ..." # Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET" # Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
#
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
# beim Beenden auf.
#
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
# koennen: die Master-Ausgabe des Lizenzservers liegt in
# "tesm-license-master", die Installation heisst weiter "tesm-license".
#
# Zwei Fallen des Vorgaengers sind hier 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-Anhangs.
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
OWNER="${TESM_RELEASE_OWNER:-alientim}"
echo "==> Entpacke ..." # Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
tar -xzf "$WORK/$ASSET" -C "$WORK" # Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also # der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die # dem Server liegt, ist keine Entscheidung eines Skripts.
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download. TOKEN="${TESM_RELEASE_TOKEN:-}"
SRC="$(find "$WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)" if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; } TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
SRC="$(cd "$SRC" && pwd)"
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
if hat_crlf "$SRC/deploy/install.sh"; then
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
echo "'git -c core.autocrlf=false archive'." >&2
exit 1
fi fi
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer # Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0 # ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
# machte weiter, als waere alles in Ordnung.
hat_crlf() {
local cr
cr="$(printf '\r')"
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
LC_ALL=C grep -qU "$cr" "$1"
}
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"} BOOTSTRAP_WORK=""
bootstrap_aufraeumen() {
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
local code=$?
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
# bliebe unter /tmp liegen.
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
return "$code"
}
trap bootstrap_aufraeumen EXIT
release_holen() {
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
local -a kopf=()
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
for werkzeug in curl tar python3 find; do
command -v "$werkzeug" >/dev/null 2>&1 || {
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
return 1
}
done
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
if [[ "$TAG" == "latest" ]]; then
echo "==> Ermittle aktuellen Release-Tag ..."
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
[[ -z "$TOKEN" ]] &&
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
return 1
}
echo " $TAG"
fi
asset="$repo-$TAG.tar.gz"
echo "==> Lade $asset ..."
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
return 1
}
echo "==> Entpacke ..."
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
-printf '%h/..\n' | head -1)"
[[ -n "$SRC" ]] || {
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
return 1
}
SRC="$(cd "$SRC" && pwd)"
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
if hat_crlf "$SRC/deploy/install.sh"; then
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
echo "'git -c core.autocrlf=false archive'." >&2
return 1
fi
}
# --8<-- Ende Bootstrap ------------------------------------------------------
release_holen
# --yes darf auch hier stehen, nicht nur bei install.sh und upgrade.sh. Es
# bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht.
OHNE_RUECKFRAGE=0
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
[[ "${EXTRA[i]}" == "--yes" || "${EXTRA[i]}" == "-y" ]] && OHNE_RUECKFRAGE=1
done
if [[ $OHNE_RUECKFRAGE -eq 1 ]]; then
echo "==> Version $TAG wird ohne Rueckfrage installiert."
elif [[ -t 0 ]]; then
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
else
# Ohne Terminal las "read" hier EOF, die Antwort blieb leer, und das Skript
# endete mit 0 -- wer es aus einem anderen Skript aufrief, hielt das Update
# fuer erledigt. Lieber laut abbrechen.
echo "Kein Terminal fuer die Rueckfrage, und --yes fehlt -- es wurde nichts" >&2
echo "installiert. Aufruf mit --yes wiederholen." >&2
exit 3
fi
# Kein exec: sonst laeuft die Aufraeumfalle nie, und jedes Update liesse ein
# entpacktes Release unter /tmp liegen.
bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
+687
View File
@@ -0,0 +1,687 @@
#!/usr/bin/env bash
# =============================================================================
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
#
# Eine einzelne Datei genuegt -- sie holt sich das Release selbst:
#
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
# sudo bash upgrade.sh --app tesm
#
# Auf einer 1.2.8 liegt nichts von diesem Projekt; alles andere waere die
# falsche Voraussetzung. Liegt das Skript in einem entpackten Release, wird
# dessen install.sh benutzt statt eines Downloads:
#
# sudo bash deploy/upgrade.sh --app tesm
# sudo bash deploy/upgrade.sh --app tesm --instance test --source /srv/tesm
#
# Der Ablauf ist die Vorgabe und braucht keine Schalter:
#
# 1. **Sichern.** Vollstaendige Kopie der alten Installation nach
# /srv/<name>-upgrade-<zeitstempel>/alt. Erst danach wird irgendetwas
# angefasst -- und aus dieser Kopie stammen auch alle weiteren Schritte.
# 2. **Altes System abschalten.** Dienst und Timer werden gestoppt und
# deaktiviert, damit sie nach einem Neustart nicht zurueckkommen.
# 3. **Neu installieren.** install.sh dieses Verzeichnisses.
# 4. **Exportieren.** Aus der Kopie entsteht eine Exportdatei im **alten**
# Format -- genau die, die der Vorgaenger selbst geschrieben haette.
# 5. **Uebersetzen.** Daraus eine Datei im neuen Format.
# 6. **Einspielen.** Diese Datei geht durch denselben Import wie eine
# Sicherung.
#
# Warum die Sicherung vor dem Export steht und nicht danach: laeuft das
# Upgrade **in dasselbe Verzeichnis** (also ohne --instance), ueberschreibt
# install.sh die alte Installation. Wer erst exportiert und dann sichert,
# sichert im schlechtesten Fall nichts mehr. Gelesen wird ausschliesslich aus
# der Kopie -- das Ergebnis ist dasselbe, die Reihenfolge ist die sichere.
#
# Der Lizenzserver kennt Schritt 4 und 5 nicht: seine Uebernahme liest die alte
# Datenbank unmittelbar und **signiert jede Lizenz neu**. Dabei wird der alte
# Signaturschluessel uebernommen -- sonst muesste jeder Kunde einen
# Ausstellerwechsel bestaetigen. Wer das nicht will: --new-key. Dort ist die
# Kopie aus Schritt 1 der Export.
# =============================================================================
set -euo pipefail
APP=""
INSTANCE=""
SOURCE=""
PASSPHRASE=""
ASSUME_YES=0
DRY_RUN=0
WITH_DATA=1
STOP_OLD=1
ADOPT_KEY=1
TAG="latest"
ROLLBACK=""
#: Steht die Sicherung schon, und ist der Lauf durch? Daran entscheidet
#: die EXIT-Falle, ob sie zurueckrollt.
SICHERUNG_STEHT=0
UPGRADE_FERTIG=0
#: Namenszusatz, unter dem ``rename.sh`` eine verdraengte Installation der
#: Fassung 1.x ablegt. Beide Skripte muessen denselben Wert benutzen; ein
#: Hygienetest vergleicht sie.
#:
#: Auf einer echten 1.2.8 gibt es das **nicht** -- dort liegt die Installation
#: unter ihrem Hauptnamen. Der Zusatz entsteht erst, wenn ``rename.sh`` den
#: Hauptnamen fuer die neue Fassung braucht. Deshalb ist er der zweite Ort, an
#: dem gesucht wird, nicht der erste.
ARCHIV_SUFFIX="sonnet5"
#: Nur fuer die Ausgabe: wurde die Quelle selbst gefunden oder angegeben?
info_quelle=""
EXTRA=()
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: upgrade.sh --app <tesm|tesm-license> [Optionen]
--app <name> Welche Anwendung (Pflicht)
--source <pfad> Verzeichnis der alten Installation
(Vorgabe: /srv/tesm bzw. /srv/tesm-license)
--instance <name> Neue Installation als benannte Instanz anlegen, statt
das alte Verzeichnis zu ueberschreiben. Damit bleibt die
alte Installation liegen und ist zurueckholbar.
--passphrase <text> Passphrase der Zwischendateien. Ohne Angabe wird eine
erzeugt und in der Sicherung abgelegt.
--no-data Nur installieren, keine Datenuebernahme
--keep-old-running Alten Dienst nicht stoppen (nur mit --instance sinnvoll)
--new-key Nur Lizenzserver: einen neuen Signaturschluessel
erzeugen, statt den alten zu uebernehmen. Dann muessen
alle Kunden den Ausstellerwechsel bestaetigen -- die
Vorgabe ist deshalb die Uebernahme.
--tag <vX.Y.Z> Bestimmte Version installieren (Vorgabe: latest).
Wird nur beachtet, wenn das Release geladen wird.
--rollback [pfad] Einen fehlgeschlagenen Lauf zurueckdrehen: die alte
Installation aus der Sicherung wiederherstellen und
ihren Dienst wieder einschalten. Ohne Pfad wird die
neueste Sicherung dieses Namens genommen.
--dry-run Nur zeigen, was geschehen wuerde
--yes Keine Rueckfrage
-h, --help Diese Hilfe
Alles weitere wird unveraendert an install.sh weitergegeben, etwa --https
oder --domain.
Beispiele:
# In dasselbe Verzeichnis, alte Installation wird ersetzt:
sudo bash deploy/upgrade.sh --app tesm
# Daneben, zum Vergleich, alte Installation bleibt unangetastet:
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--app) APP="${2:?}"; shift 2 ;;
--source) SOURCE="${2:?}"; shift 2 ;;
--instance) INSTANCE="${2:?}"; shift 2 ;;
--passphrase) PASSPHRASE="${2:?}"; shift 2 ;;
--no-data) WITH_DATA=0; shift ;;
--keep-old-running) STOP_OLD=0; shift ;;
--new-key) ADOPT_KEY=0; shift ;;
--adopt-key) ADOPT_KEY=1; shift ;; # Vorgabe, hier nur zur Deutlichkeit
--tag) TAG="${2:?}"; shift 2 ;;
--rollback)
# Der Pfad ist optional: allein genommen heisst es "die neueste".
if [[ -n "${2:-}" && "${2:-}" != --* ]]; then ROLLBACK="$2"; shift 2
else ROLLBACK="auto"; shift; fi
;;
--dry-run) DRY_RUN=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) EXTRA+=("$1"); shift ;;
esac
done
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
NAME="$APP"
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
ADMIN="$NAME-admin"
NEW_ROOT="/srv/$NAME"
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP="/srv/$NAME-upgrade-$STAMP"
ALT_KOPIE="$BACKUP/alt"
SYSTEM_KOPIE="$BACKUP/system"
EXPORT_ALT="$BACKUP/export-alt.json"
IMPORT_NEU="$BACKUP/import-neu.json"
# Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst
# zu stoppen liesse ihn beim naechsten Ablauf wieder anlaufen.
# -- Rueckweg ---------------------------------------------------------------- #
# Alles, was install.sh unter demselben Namen ueberschreiben wuerde. Bei einem
# Upgrade in dasselbe Verzeichnis sind das die Dateien der alten Fassung.
SYSTEMDATEIEN=(
"/etc/systemd/system/$NAME.service"
"/etc/systemd/system/$NAME-monitor.service"
"/etc/nginx/sites-available/$NAME"
"/etc/logrotate.d/$NAME"
"/etc/update-motd.d/50-$NAME"
"/etc/tesm/$NAME.env"
)
zurueckrollen() {
local sicherung="$1" grund="${2:-}"
local alt="$sicherung/alt" system="$sicherung/system"
[[ -d "$alt" ]] || fail "In $sicherung fehlt das Verzeichnis 'alt' -- das ist keine Sicherung dieses Skripts."
# Quelle und Art des Umzugs stehen im Manifest. Aus dem laufenden Aufruf
# abzuleiten waere falsch: ein Rueckweg kann Tage spaeter und mit anderen
# Schaltern aufgerufen werden -- und beim automatischen Rueckweg steht
# IN_PLACE zwar fest, beim spaeteren --rollback aber nicht.
local ziel="${SOURCE:-}" gleiches_verzeichnis="${IN_PLACE:-0}" vermerkt
if [[ -f "$system/manifest" ]]; then
vermerkt="$(awk '$1=="quelle" {print $2}' "$system/manifest" | head -1)"
[[ -n "$vermerkt" ]] && ziel="$vermerkt"
vermerkt="$(awk '$1=="in-place" {print $2}' "$system/manifest" | head -1)"
[[ -n "$vermerkt" ]] && gleiches_verzeichnis="$vermerkt"
fi
[[ -n "$ziel" ]] || fail "Im Manifest fehlt die Quelle -- bitte --source angeben."
[[ -n "$grund" ]] && warn "$grund"
step "Rueckweg: alten Stand wiederherstellen"
# Der neue Dienst zuerst -- er haelt sonst Dateien offen, die gleich ersetzt
# werden.
for unit in "$NAME.service" "$NAME-monitor.service"; do
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
systemctl disable --now "$unit" >/dev/null 2>&1 || true
info "$unit gestoppt"
fi
done
# Verzeichnis: nur bei gleichem Ziel -- bei einer eigenen Instanz war die
# alte Installation nie angefasst.
if [[ "$gleiches_verzeichnis" == "1" ]]; then
rm -rf "$ziel"
mkdir -p "$ziel"
tar -C "$alt" -cf - . | tar -C "$ziel" -xf -
info "$ziel aus der Sicherung wiederhergestellt"
[[ -d "$ziel/venv" ]] || warn "Im Backup war kein venv -- der alte Dienst braucht dann noch 'python3 -m venv venv' und seine requirements.txt."
else
info "$ziel war nicht betroffen (eigene Instanz) -- bleibt, wie es ist"
if [[ -d "$NEW_ROOT" && "$NEW_ROOT" != "$ziel" ]]; then
rm -rf "$NEW_ROOT"
info "$NEW_ROOT entfernt"
fi
fi
# Systemdateien zurueck, alles andere entfernen, was der Lauf angelegt hat.
local site_wieder=0
if [[ -f "$system/manifest" ]]; then
while read -r zeile; do
case "$zeile" in
unit-enabled\ *|site-enabled\ *) continue ;;
esac
local name; name="$(basename "$zeile")"
if [[ -e "$system/$name" ]]; then
cp -a "$system/$name" "$zeile"
info "wiederhergestellt: $zeile"
[[ "$zeile" == /etc/nginx/* ]] && site_wieder=1
fi
done < <(grep -v '^\(unit\|site\)-enabled ' "$system/manifest" || true)
fi
systemctl daemon-reload
if [[ $site_wieder -eq 1 ]] || [[ -e "/etc/nginx/sites-enabled/$NAME" ]]; then
nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 \
&& info "nginx neu geladen" || warn "nginx meldet einen Fehler -- bitte 'nginx -t' ansehen"
fi
# Und die Dienste wieder einschalten, die vorher liefen.
if [[ -f "$system/manifest" ]]; then
while read -r _ unit; do
systemctl enable --now "$unit" >/dev/null 2>&1 \
&& info "$unit wieder eingeschaltet" || warn "$unit liess sich nicht starten"
done < <(grep '^unit-enabled ' "$system/manifest" || true)
fi
step "Zurueckgerollt"
info "Sicherung bleibt liegen: $sicherung"
# Nicht ueber ALTE_UNITS: die Liste entsteht erst nach der Quellensuche, und
# bei "--rollback" laeuft diese Funktion vorher. Das Manifest weiss es.
local hinweis
hinweis="$(awk '$1=="unit-enabled" {print $2; exit}' "$system/manifest" 2>/dev/null || true)"
info "Zustand pruefen: systemctl status ${hinweis:-$APP.service}"
}
# Wird von der EXIT-Falle des Bootstraps aufgerufen -- mit dem Ausstiegscode.
# Damit gilt der Rueckweg fuer **jeden** Fehlschlag und nicht nur fuer den, an
# den ich gedacht habe: Export, Uebersetzen, Einspielen, nginx, ein Ctrl-C.
beim_beenden() {
local code="${1:-0}"
[[ "$code" == "0" ]] && return 0
[[ $UPGRADE_FERTIG -eq 1 ]] && return 0
if [[ $SICHERUNG_STEHT -eq 0 ]]; then
warn "Abgebrochen (Code $code), bevor eine Sicherung stand -- nichts zurueckzurollen."
return 0
fi
zurueckrollen "$BACKUP" "Abgebrochen (Code $code)."
}
neueste_sicherung() {
local kandidat
kandidat="$(ls -1dt /srv/"$NAME"-upgrade-*/ 2>/dev/null | head -1)"
[[ -n "$kandidat" ]] || return 1
printf '%s' "${kandidat%/}"
}
if [[ -n "$ROLLBACK" ]]; then
if [[ "$ROLLBACK" == "auto" ]]; then
ROLLBACK="$(neueste_sicherung)" || fail "Keine Sicherung unter /srv/$NAME-upgrade-* gefunden. Pfad angeben."
info "Neueste Sicherung: $ROLLBACK"
fi
[[ -d "$ROLLBACK" ]] || fail "$ROLLBACK gibt es nicht"
if [[ $DRY_RUN -eq 1 ]]; then
step "--dry-run: es wuerde $ROLLBACK zurueckgerollt."
exit 0
fi
if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Stand aus $ROLLBACK wiederherstellen? [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
fi
zurueckrollen "$ROLLBACK"
exit 0
fi
# Ab hier wird wirklich installiert -- ein Rueckweg hat oben schon
# beendet. Erst jetzt braucht es ein Release und eine Quelle.
# --8<-- Bootstrap: Release holen --------------------------------------------
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
#
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
# beim Beenden auf.
#
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
# koennen: die Master-Ausgabe des Lizenzservers liegt in
# "tesm-license-master", die Installation heisst weiter "tesm-license".
#
# Zwei Fallen des Vorgaengers sind hier 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-Anhangs.
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
OWNER="${TESM_RELEASE_OWNER:-alientim}"
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
# dem Server liegt, ist keine Entscheidung eines Skripts.
TOKEN="${TESM_RELEASE_TOKEN:-}"
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
fi
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
# machte weiter, als waere alles in Ordnung.
hat_crlf() {
local cr
cr="$(printf '\r')"
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
LC_ALL=C grep -qU "$cr" "$1"
}
BOOTSTRAP_WORK=""
bootstrap_aufraeumen() {
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
local code=$?
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
# bliebe unter /tmp liegen.
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
return "$code"
}
trap bootstrap_aufraeumen EXIT
release_holen() {
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
local -a kopf=()
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
for werkzeug in curl tar python3 find; do
command -v "$werkzeug" >/dev/null 2>&1 || {
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
return 1
}
done
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
if [[ "$TAG" == "latest" ]]; then
echo "==> Ermittle aktuellen Release-Tag ..."
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
[[ -z "$TOKEN" ]] &&
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
return 1
}
echo " $TAG"
fi
asset="$repo-$TAG.tar.gz"
echo "==> Lade $asset ..."
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
return 1
}
echo "==> Entpacke ..."
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
-printf '%h/..\n' | head -1)"
[[ -n "$SRC" ]] || {
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
return 1
}
SRC="$(cd "$SRC" && pwd)"
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
if hat_crlf "$SRC/deploy/install.sh"; then
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
echo "'git -c core.autocrlf=false archive'." >&2
return 1
fi
}
# --8<-- Ende Bootstrap ------------------------------------------------------
# Woher kommt install.sh? Liegt dieses Skript in einem entpackten Release,
# nimm dessen Verzeichnis -- wer ein Paket ausgepackt hat, will nicht, dass
# ungefragt ein anderes geladen wird. Sonst hol das Release.
if [[ -f "$SCRIPT_DIR/install.sh" ]]; then
SRC="$REPO_DIR"
RELEASE_HERKUNFT="$SRC (entpacktes Release neben diesem Skript)"
else
release_holen || fail "Das Release liess sich nicht holen."
RELEASE_HERKUNFT="$TAG von $BASE/$OWNER"
fi
# -- Wo liegt die alte Fassung? ---------------------------------------------- #
# Zwei Orte kommen in Frage, und der zweite ist der Grund fuer diesen Block:
# ``rename.sh`` schiebt eine Installation der Fassung 1.x nach
# ``/srv/<app>-sonnet5``, sobald der Hauptname fuer die neue gebraucht wird.
# Danach liegt unter ``/srv/<app>`` die **laufende** Installation, und die
# Vorgabe zeigte ins Leere. Erkannt wird an ``sqlite.db`` im Wurzelverzeichnis
# -- die neue Fassung legt ihre Datenbank unter ``data/app.db`` ab.
ist_alte_fassung() { [[ -f "$1/sqlite.db" ]]; }
if [[ -z "$SOURCE" ]]; then
for kandidat in "/srv/$APP" "/srv/$APP-$ARCHIV_SUFFIX"; do
if ist_alte_fassung "$kandidat"; then SOURCE="$kandidat"; break; fi
done
if [[ -z "$SOURCE" ]]; then
warn "Keine Installation der Fassung 1.x gefunden. Gesucht wurde in:"
warn " /srv/$APP (dort liegt sie normalerweise)"
warn " /srv/$APP-$ARCHIV_SUFFIX (dorthin verschiebt rename.sh sie, falls"
warn " der Hauptname schon vergeben war)"
[[ -f "/srv/$APP/data/app.db" ]] && warn "Unter /srv/$APP liegt die laufende Installation dieser Fassung."
fail "Bitte --source setzen."
fi
info_quelle="gefunden: $SOURCE"
fi
[[ -d "$SOURCE" ]] || fail "Die alte Installation $SOURCE gibt es nicht (--source setzen)"
ist_alte_fassung "$SOURCE" || fail "In $SOURCE liegt keine sqlite.db -- das ist keine Installation der Vorgaengerfassung"
IN_PLACE=0
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
# Die Units der alten Fassung erkennt man daran, dass sie in genau dieses
# Verzeichnis zeigen -- nicht am Namen der Anwendung. Nach einem rename.sh
# heisst die *laufende* Installation "tesm", und eine Namensliste haette sie
# gestoppt und deaktiviert, sobald jemand die archivierte 1.x in eine eigene
# Instanz hochzieht.
#
# Timer und Hilfsdienste tragen keinen Pfad (der Restart-Timer ruft nur
# "systemctl restart ..."). Sie kommen ueber den Namen der gefundenen Unit mit.
alte_units_finden() {
local quelle="$1" gefunden=() datei name runde
shopt -s nullglob
for datei in /etc/systemd/system/*.service; do
grep -qs -- "$quelle/" "$datei" && gefunden+=("$(basename "$datei")")
done
# Zwei Runden: erst Timer und Helfer, die eine gefundene Unit nennen, dann
# noch einmal fuer deren eigene Timer.
for runde in 1 2; do
for datei in /etc/systemd/system/*.service /etc/systemd/system/*.timer; do
name="$(basename "$datei")"
[[ " ${gefunden[*]-} " == *" $name "* ]] && continue
for treffer in ${gefunden[*]-}; do
if grep -qs -- "$treffer" "$datei"; then gefunden+=("$name"); break; fi
done
done
done
shopt -u nullglob
printf '%s\n' ${gefunden[*]-}
}
mapfile -t ALTE_UNITS < <(alte_units_finden "$SOURCE")
if [[ ${#ALTE_UNITS[@]} -eq 0 ]]; then
if [[ "$SOURCE" == "/srv/$APP" ]]; then
# Kein Fund, aber die Quelle ist der Hauptpfad: die historischen Namen der
# Fassung 1.x als Rueckfall.
if [[ "$APP" == "tesm" ]]; then
ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service
tesm-check-restart.timer tesm-check-restart.service)
else
ALTE_UNITS=(tesm-license.service)
fi
else
ALTE_UNITS=()
fi
fi
step "Vorhaben"
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
info "Neue Fassung : $RELEASE_HERKUNFT"
info "Alt : $SOURCE${info_quelle:+ (automatisch gefunden)}"
info "Neu : $NEW_ROOT$([[ $IN_PLACE -eq 1 ]] && echo ' << dasselbe Verzeichnis')"
info "Sicherung : $ALT_KOPIE"
info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)"
info "Altes System : $([[ $STOP_OLD -eq 1 ]] && echo 'stoppen und deaktivieren' || echo 'laeuft weiter')"
if [[ $IN_PLACE -eq 1 && $STOP_OLD -eq 0 ]]; then
warn "--keep-old-running bei gleichem Verzeichnis: der alte Dienst laeuft auf Dateien,"
warn "die gleich ersetzt werden. Das geht schief. Bitte --instance verwenden."
fi
if [[ $DRY_RUN -eq 1 ]]; then
step "--dry-run: hier ist Schluss."
exit 0
fi
if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Fortfahren? [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
fi
# -- 1. Sichern -------------------------------------------------------------- #
step "Sichern"
mkdir -p "$ALT_KOPIE" "$SYSTEM_KOPIE"
chmod 700 "$BACKUP"
# Das venv kommt nur mit, wenn in dasselbe Verzeichnis installiert wird -- dort
# ersetzt install.sh es, und ohne das alte startet der alte Dienst nach einem
# Rueckweg nicht mehr. Bei einer eigenen Instanz bleibt die alte Installation
# unangetastet, dann waere es nur Ballast (und seine Shebangs zeigen auf einen
# anderen Pfad).
if [[ $IN_PLACE -eq 1 ]]; then
tar -C "$SOURCE" --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (mit venv, weil dasselbe Verzeichnis)"
else
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
fi
[[ -f "$ALT_KOPIE/sqlite.db" ]] || fail "In der Kopie fehlt die Datenbank -- Abbruch"
# Und die Systemdateien: bei gleichem Verzeichnis schreibt install.sh Unit,
# nginx-Site, logrotate und Banner neu. Ohne Kopie liesse sich der alte Stand
# nicht wiederherstellen -- das Verzeichnis allein genuegt nicht.
: > "$SYSTEM_KOPIE/manifest"
# Woher kam der Stand, und ging es in dasselbe Verzeichnis? Ein spaeterer
# Rueckweg darf das nicht raten muessen -- er laeuft womoeglich Tage danach
# und mit anderen Schaltern.
printf 'quelle %s\n' "$SOURCE" >> "$SYSTEM_KOPIE/manifest"
printf 'in-place %s\n' "$IN_PLACE" >> "$SYSTEM_KOPIE/manifest"
for pfad in "${SYSTEMDATEIEN[@]}"; do
[[ -e "$pfad" ]] || continue
cp -a "$pfad" "$SYSTEM_KOPIE/$(basename "$pfad")"
printf '%s\n' "$pfad" >> "$SYSTEM_KOPIE/manifest"
done
# Welche Units liefen, und war die Site eingeschaltet?
for unit in "${ALTE_UNITS[@]}"; do
systemctl is-enabled "$unit" >/dev/null 2>&1 && printf 'unit-enabled %s\n' "$unit" >> "$SYSTEM_KOPIE/manifest"
done
[[ -L "/etc/nginx/sites-enabled/$NAME" ]] && printf 'site-enabled %s\n' "$NAME" >> "$SYSTEM_KOPIE/manifest"
info "$(grep -c . "$SYSTEM_KOPIE/manifest") Systemdatei(en) und Zustaende vermerkt"
SICHERUNG_STEHT=1
# -- 2. Altes System abschalten ---------------------------------------------- #
if [[ $STOP_OLD -eq 1 ]]; then
step "Altes System abschalten"
for unit in "${ALTE_UNITS[@]}"; do
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
systemctl disable --now "$unit" >/dev/null 2>&1 || true
info "$unit gestoppt und deaktiviert"
fi
done
else
step "Altes System bleibt in Betrieb"
fi
# -- 3. Neu installieren ----------------------------------------------------- #
step "Neu installieren"
INSTALL_ARGS=(--app "$APP" --yes)
[[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE")
# Kein eigener Fehlerzweig mehr: die EXIT-Falle rollt bei jedem Fehlschlag
# zurueck -- hier, beim Export, beim Einspielen oder bei einem Ctrl-C.
bash "$SRC/deploy/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}
if [[ $WITH_DATA -eq 0 ]]; then
UPGRADE_FERTIG=1
step "Fertig -- ohne Datenuebernahme"
info "Sicherung der alten Installation: $ALT_KOPIE"
exit 0
fi
command -v "$ADMIN" >/dev/null 2>&1 || fail "$ADMIN nicht gefunden -- die Installation ist unvollstaendig"
if [[ -z "$PASSPHRASE" ]]; then
PASSPHRASE="$(head -c 24 /dev/urandom | base64 | tr -d '=+/' | cut -c1-24)"
# Die Passphrase liegt neben den Dateien, die sie schuetzt. Das ist hier kein
# Widerspruch: das alte Format verlangt eine, das Verzeichnis gehoert root
# allein (0700), und ohne sie waeren die beiden Dateien spaeter unbrauchbar --
# auch als Rueckweg.
printf '%s\n' "$PASSPHRASE" > "$BACKUP/passphrase.txt"
chmod 600 "$BACKUP/passphrase.txt"
fi
if [[ "$APP" == "tesm" ]]; then
# -- 4. Exportieren -------------------------------------------------------- #
step "Daten der alten Installation lesen"
"$ADMIN" legacy-export --source "$ALT_KOPIE" --out "$EXPORT_ALT" \
--passphrase "$PASSPHRASE" --force
chmod 600 "$EXPORT_ALT"
# -- 5. Uebersetzen -------------------------------------------------------- #
step "Uebersetzen"
"$ADMIN" convert-legacy "$EXPORT_ALT" --ausgabe "$IMPORT_NEU" \
--passphrase "$PASSPHRASE" --neue-passphrase "$PASSPHRASE" --force
chmod 600 "$IMPORT_NEU"
# -- 6. Einspielen --------------------------------------------------------- #
step "Einspielen"
"$ADMIN" legacy-import "$IMPORT_NEU" --passphrase "$PASSPHRASE" --actor upgrade
else
# -- 4.-6. in einem: die Uebernahme liest die alte Datenbank unmittelbar --- #
step "Lizenzbestand uebernehmen"
# Der alte Signaturschluessel wird uebernommen. Fuer ein Upgrade ist das
# die einzige sinnvolle Vorgabe: es ist derselbe Server mit neuerer Software,
# und ohne den alten Schluessel muesste jeder Kunde den Ausstellerwechsel
# bestaetigen. Ueberschrieben wird dabei nichts -- die Uebernahme lehnt ab,
# wenn die neue Installation schon einen eigenen Schluessel hat.
MIGRATE_ARGS=(migrate-legacy --source "$ALT_KOPIE")
[[ $ADOPT_KEY -eq 1 ]] && MIGRATE_ARGS+=(--adopt-key)
"$ADMIN" "${MIGRATE_ARGS[@]}"
if [[ $ADOPT_KEY -eq 0 ]]; then
warn "--new-key: es gilt ein neuer Signaturschluessel. Die Kunden muessen den"
warn "Ausstellerwechsel einmal bestaetigen."
fi
fi
# -- Abschluss --------------------------------------------------------------- #
UPGRADE_FERTIG=1
step "Fertig"
info "Sicherung der alten Installation : $ALT_KOPIE"
if [[ "$APP" == "tesm" ]]; then
info "Export im alten Format : $EXPORT_ALT"
info "Uebersetzte Datei : $IMPORT_NEU"
[[ -f "$BACKUP/passphrase.txt" ]] && info "Passphrase beider Dateien : $BACKUP/passphrase.txt"
fi
cat <<TEXT
Von Hand nachzuziehen -- das laesst sich nicht uebersetzen:
* Lokale Konten: der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto anlegen mit
sudo -u $APP $ADMIN create-admin
* Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.
* Verzeichnisdienst (LDAP), Webserver und Zertifikat.
TEXT
# Der Rueckweg sieht in den beiden Faellen verschieden aus. Ein Hinweis, der
# das alte Verzeichnis wiederherstellen laesst, obwohl es nie angefasst wurde,
# waere schlimmer als keiner: er wuerde die Daten der neuen Installation mit
# einem alten Stand ueberschreiben.
if [[ $IN_PLACE -eq 1 ]]; then
cat <<TEXT
Zurueck auf den alten Stand -- das Verzeichnis wurde ersetzt, die Kopie
bringt es zurueck:
sudo systemctl disable --now $NAME
sudo rm -rf $SOURCE && sudo mkdir -p $SOURCE
sudo tar -C $ALT_KOPIE -cf - . | sudo tar -C $SOURCE -xf -
# venv der alten Installation neu bauen:
sudo python3 -m venv $SOURCE/venv
sudo $SOURCE/venv/bin/python -m pip install -r $SOURCE/requirements.txt
sudo systemctl enable --now ${ALTE_UNITS[0]}
TEXT
else
cat <<TEXT
Zurueck auf den alten Stand -- $SOURCE wurde nicht angefasst, es genuegt,
die neue Instanz abzuschalten:
sudo systemctl disable --now $NAME $NAME-monitor
sudo rm -f /etc/nginx/sites-enabled/$NAME && sudo systemctl reload nginx
sudo systemctl enable --now ${ALTE_UNITS[0]}
TEXT
fi
+187 -5
View File
@@ -105,19 +105,26 @@ Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
### Ohne Quellbaum: `update.sh` ### Ohne Quellbaum: `update.sh`
Liegt kein entpacktes Release vor, holt `update.sh` es selbst -- es lädt das `update.sh` genügt als **einzelne Datei**. Auf einem System, auf dem noch nichts
Release-Asset, entpackt es und ruft dessen `install.sh` auf: von diesem Projekt liegt:
```bash ```bash
sudo bash /opt/tesm/deploy/update.sh tesm curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
sudo bash /opt/tesm-license/deploy/update.sh tesm-license sudo bash update.sh tesm
```
Auf einer bestehenden Installation liegt sie schon bereit:
```bash
sudo bash /srv/tesm/deploy/update.sh tesm
sudo bash /srv/tesm-license/deploy/update.sh tesm-license
``` ```
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg, Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
eine **benannte Instanz** zu aktualisieren: eine **benannte Instanz** zu aktualisieren:
```bash ```bash
sudo bash /opt/tesm-opus/deploy/update.sh tesm latest --instance opus sudo bash /srv/tesm/deploy/update.sh tesm
``` ```
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
@@ -141,6 +148,104 @@ etwa auf eine Spiegelung:
Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`. Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`.
### Upgrade vom Stand 1.x (Branch `SONNET5`)
Ein eigenes Skript, weil dabei mehr passiert als ein Dateiabgleich: die alte
Fassung hat ein anderes Schema, andere Rechteschlüssel und ein anderes
Exportformat.
Auch `upgrade.sh` genügt als **einzelne Datei** -- und das ist hier der
Regelfall: auf einer 1.2.8 liegt nichts von dieser Fassung.
```bash
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
sudo bash upgrade.sh --app tesm
```
Sie lädt das aktuelle Release, entpackt es und benutzt dessen `install.sh`.
Liegt das Skript in einem entpackten Release, wird dieses benutzt statt eines
Downloads -- wer ein Paket ausgepackt hat, will nicht, dass ungefragt ein
anderes geladen wird:
```bash
sudo bash deploy/upgrade.sh --app tesm
sudo bash deploy/upgrade.sh --app tesm-license
```
Eine bestimmte Version statt `latest`: `--tag v2.0.8`.
**Wenn etwas schiefgeht.** Schlägt `install.sh` fehl, dreht `upgrade.sh` den
Lauf selbst zurück: der neue Dienst wird gestoppt, das Verzeichnis aus der
Sicherung wiederhergestellt und der alte Dienst wieder eingeschaltet. Das
Verzeichnis allein genügt dafür nicht -- bei einem Upgrade in dasselbe
Verzeichnis schreibt `install.sh` auch systemd-Unit, nginx-Site, logrotate und
Banner neu und ersetzt das venv. Die Sicherung enthält deshalb alles davon,
und ein Manifest hält fest, woher der Stand kam.
Später von Hand zurückdrehen, etwa nach einem Lauf, der durchlief aber nicht
gefallen hat:
```bash
sudo bash upgrade.sh --app tesm --rollback # neueste Sicherung
sudo bash upgrade.sh --app tesm --rollback /srv/tesm-upgrade-20260908-225428
```
Die Sicherung bleibt danach liegen; gelöscht wird nichts.
Ohne `--source` wird die alte Fassung an zwei Stellen gesucht: unter
`/srv/<app>` und unter `/srv/<app>-sonnet5`. Der zweite Ort ist der, an den
`rename.sh` sie verschiebt, sobald der Hauptname für die neue Installation
gebraucht wird -- danach liegt unter `/srv/<app>` die **laufende** Installation.
Erkannt wird eine 1.x an der `sqlite.db` im Wurzelverzeichnis; die neue Fassung
legt ihre Datenbank unter `data/app.db` ab.
Der Ablauf ist die Vorgabe und braucht keine Schalter:
| | |
|---|---|
| 1. Sichern | Vollständige Kopie der alten Installation nach `/srv/<name>-upgrade-<zeitstempel>/alt` (ohne `venv`, Verzeichnis 0700) |
| 2. Abschalten | Alter Dienst **und seine Timer** werden gestoppt und deaktiviert |
| 3. Installieren | `install.sh` dieses Verzeichnisses |
| 4. Exportieren | Aus der Kopie entsteht `export-alt.json` im **alten** Format |
| 5. Übersetzen | Daraus `import-neu.json` im neuen Format |
| 6. Einspielen | Diese Datei geht durch denselben Import wie eine Sicherung |
Warum die Sicherung **vor** dem Export steht: ohne `--instance` installiert
`install.sh` in dasselbe Verzeichnis und ersetzt die alte Installation. Wer
erst exportiert und dann sichert, sichert im schlechtesten Fall nichts mehr.
Gelesen wird ausschliesslich aus der Kopie -- die alte Installation wird nie
verändert.
Die beiden Zwischendateien bleiben liegen; ihre Passphrase steht daneben in
`passphrase.txt` (0600, Verzeichnis 0700). `export-alt.json` ist ein echter
Export des Vorgängers und taugt deshalb auch als Rückweg.
**Daneben statt darüber.** Mit `--instance` entsteht eine zweite Installation,
und die alte bleibt vollständig liegen:
```bash
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running --port 5100 --http-port 8080
```
**Was nicht mitkommt** -- und warum:
* **Lokale Konten.** Der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto mit
`tesm-admin create-admin`.
* **Gruppen und Rechte.** Die Rechteschlüssel wurden neu geschnitten.
* **LDAP, Webserver, Zertifikat.** Von Hand nachziehen.
Beim **Lizenzserver** gibt es Schritt 4 und 5 nicht: die Übernahme liest die
alte Datenbank unmittelbar, holt Endpunkt und Anbieterdaten aus ihren
Einstellungen und **signiert jede Lizenz neu**. Der alte Signaturschlüssel wird
dabei übernommen -- sonst müsste jeder Kunde einen Ausstellerwechsel
bestätigen; `--new-key` erzwingt das Gegenteil. Der Bericht nennt namentlich,
wer eine neue Lizenzdatei braucht, denn die Bindung an eine Maschine gilt nur
für die alte Datei.
Nur die Anwendung, ohne Daten: `--no-data`. Nur zeigen, was geschähe:
`--dry-run`.
--- ---
## 4. HTTPS ## 4. HTTPS
@@ -193,6 +298,83 @@ würde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
--- ---
## 4a. Anmeldebanner (MOTD)
`install.sh` legt `/etc/update-motd.d/50-<name>` an. Bei jeder SSH-Anmeldung
steht dann der Zustand der Installation im Login-Banner:
```
TESM · TimEShepManager
UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)
3 Clients online · 0 nicht erreichbar · DHCP: 2 Leases aktiv
```
Das Skript liegt unter `/usr/local/lib/tesm/tesm-motd` und benutzt **nur die
Standardbibliothek**: kein venv, kein Import der Anwendung, die Datenbank wird
nur lesend geöffnet. Ein Lauf dauert Millisekunden. Der Vorgänger importierte
dafür die ganze Flask-Anwendung -- bei jeder Anmeldung, und nach einem Wechsel
des Quellverzeichnisses zeigte er stillschweigend die Zahlen einer Datenbank,
die niemand mehr füllte.
Von Hand prüfen:
```bash
sudo /usr/local/lib/tesm/tesm-motd --app tesm --root /srv/tesm-test
```
Der Aufruf im Banner ist mit `timeout 3` und `|| true` abgesichert: ein
hängender oder fehlschlagender Bannerlauf darf eine Anmeldung nie blockieren.
Ein Banner der Vorgängerfassung (`50-tesm`, das `motd_banner.py` aufruft) wird
entfernt -- nur dieses, nichts anderes in dem Verzeichnis.
---
## 4b. Installation umbenennen
Aus einer Instanz die Hauptinstallation machen (oder umgekehrt):
```bash
sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test --to tesm-license
```
Der Name steckt an mehr Stellen als im Verzeichnisnamen, und drei davon sind
leicht zu übersehen:
* **`SITE_KEY`** in der Umgebungsdatei -- daraus entsteht der Name des
Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
Instanz.
* **`web_cert_path`, `web_key_path`, `web_acme_webroot`** in der Datenbank. Sie
enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx nach
dem Umzug nicht mehr.
* **`server_name`** der nginx-Site. `install.sh` schreibt die Datei aus seiner
Vorlage neu, sobald sich die `alias`-Pfade ändern -- und die ändern sich hier
zwangsläufig. Ohne den Namen aus der Datenbank stünden danach zwei Sites mit
`server_name _` auf Port 80, und die erste gewinnt.
Ausserdem wird das **venv verworfen und neu gebaut**: seine Konsolenskripte
tragen den absoluten Pfad im Shebang, und nach dem Umzug zeigt
`venv/bin/gunicorn` auf einen Interpreter, den es nicht mehr gibt. systemd
meldet dann `No such file or directory` -- für eine Datei, die sichtbar da ist.
Belegt der Zielname noch eine Installation der Fassung 1.x, wandert **alles**,
was ihn belegt, in ein Archiv `/srv/<ziel>-sonnet5`: das Verzeichnis, die
Protokolle unter `/var/log/<ziel>`, der ACME-Pfad, die logrotate-Datei, und die
systemd-Units samt nginx-Site unter `systemd-alt/`. Gelöscht wird nichts.
Ports und HTTPS bleiben, wie sie waren; ein vorhandenes Zertifikat zieht mit
und wird weiterverwendet. **Alle angemeldeten Benutzer werden abgemeldet** --
das Sitzungscookie heisst danach anders.
Danach ist auch das Aktualisieren einfacher, weil der Instanzname wegfällt:
```bash
sudo bash /srv/tesm/deploy/update.sh tesm
```
---
## 5. Dienste ## 5. Dienste
```bash ```bash
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
# =============================================================================
# Installation -- der kurze Weg.
#
# Diese Datei liegt oben im Paket und ruft den Installer unter deploy/ auf:
#
# tar xzf tesm-vX.Y.Z.tar.gz && cd tesm
# sudo bash install.sh --app tesm --https
#
# Sie genuegt aber auch **allein**: liegt kein Paket um sie herum, holt sie
# sich das aktuelle Release selbst.
#
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/install.sh
# sudo bash install.sh --app tesm --https
#
# Die Master-Ausgabe des Lizenzservers liegt im privaten Repositorium
# tesm-license-master. Allein-Betrieb dafuer:
#
# export TESM_RELEASE_REPO=tesm-license-master
# export TESM_RELEASE_TOKEN=<Gitea-Token>
# sudo -E bash install.sh --app tesm-license --https
#
# Aus einem entpackten Paket braucht es das nicht: dort entscheidet, welches
# Paket es ist. Die Master-Ausgabe legt beim Installieren Signaturschluessel
# und eigene Master-Lizenz selbst an.
#
# Alle Argumente gehen unveraendert an deploy/install.sh weiter; dessen Hilfe:
#
# bash install.sh --help
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --app wird nur gelesen, nicht verbraucht: der Bootstrap braucht es fuer den
# Namen des Repositoriums, der Installer bekommt die Argumente unveraendert.
APP=""
TAG="latest"
ARGUMENTE=("$@")
for ((i = 0; i < $#; i++)); do
case "${ARGUMENTE[i]}" in
--app) APP="${ARGUMENTE[i + 1]:-}" ;;
--tag) TAG="${ARGUMENTE[i + 1]:-latest}" ;;
esac
done
# Ohne Paket drumherum brauchen wir den Namen, um das richtige Release zu
# finden -- die beiden Anwendungen liegen in getrennten Repositorien.
if [[ ! -f "$SCRIPT_DIR/deploy/install.sh" ]]; then
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
echo "Diese Datei liegt allein hier und muss sich das Release holen --" >&2
echo "dafuer braucht sie --app <tesm|tesm-license>." >&2
echo "Verwendung: install.sh --app <tesm|tesm-license> [weitere Optionen]" >&2
exit 2
}
fi
# --8<-- Bootstrap: Release holen --------------------------------------------
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
#
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
# beim Beenden auf.
#
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
# koennen: die Master-Ausgabe des Lizenzservers liegt in
# "tesm-license-master", die Installation heisst weiter "tesm-license".
#
# Zwei Fallen des Vorgaengers sind hier 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-Anhangs.
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
OWNER="${TESM_RELEASE_OWNER:-alientim}"
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
# dem Server liegt, ist keine Entscheidung eines Skripts.
TOKEN="${TESM_RELEASE_TOKEN:-}"
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
fi
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
# machte weiter, als waere alles in Ordnung.
hat_crlf() {
local cr
cr="$(printf '\r')"
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
LC_ALL=C grep -qU "$cr" "$1"
}
BOOTSTRAP_WORK=""
bootstrap_aufraeumen() {
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
local code=$?
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
# bliebe unter /tmp liegen.
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
return "$code"
}
trap bootstrap_aufraeumen EXIT
release_holen() {
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
local -a kopf=()
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
for werkzeug in curl tar python3 find; do
command -v "$werkzeug" >/dev/null 2>&1 || {
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
return 1
}
done
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
if [[ "$TAG" == "latest" ]]; then
echo "==> Ermittle aktuellen Release-Tag ..."
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
[[ -z "$TOKEN" ]] &&
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
return 1
}
echo " $TAG"
fi
asset="$repo-$TAG.tar.gz"
echo "==> Lade $asset ..."
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
return 1
}
echo "==> Entpacke ..."
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
-printf '%h/..\n' | head -1)"
[[ -n "$SRC" ]] || {
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
return 1
}
SRC="$(cd "$SRC" && pwd)"
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
if hat_crlf "$SRC/deploy/install.sh"; then
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
echo "'git -c core.autocrlf=false archive'." >&2
return 1
fi
}
# --8<-- Ende Bootstrap ------------------------------------------------------
if [[ -f "$SCRIPT_DIR/deploy/install.sh" ]]; then
# Im Paket: der Installer liegt daneben.
exec bash "$SCRIPT_DIR/deploy/install.sh" ${ARGUMENTE[0]+"${ARGUMENTE[@]}"}
fi
# Allein: Release holen und dessen Installer benutzen. Kein exec -- sonst
# laeuft die Aufraeumfalle nie und das entpackte Release bliebe unter /tmp.
release_holen || { echo "Das Release liess sich nicht holen." >&2; exit 1; }
bash "$SRC/deploy/install.sh" ${ARGUMENTE[0]+"${ARGUMENTE[@]}"}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.0.2" version = "2.2.1"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+14 -1
View File
@@ -8,7 +8,20 @@ Design-System.
from __future__ import annotations from __future__ import annotations
__version__ = "2.0.2" #: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
#: Fusszeile zeigte vier Releases lang 2.0.2.
#:
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
#: tatsaechlich installiert ist.
try: # pragma: no cover - haengt an der Installationsart
from importlib.metadata import PackageNotFoundError, version as _paketversion
__version__ = _paketversion("tesm-core")
except (ImportError, PackageNotFoundError): # pragma: no cover
__version__ = "unbekannt"
from .app_factory import create_base_app from .app_factory import create_base_app
from .config import CoreConfig, SecurityConfig, load_core_config from .config import CoreConfig, SecurityConfig, load_core_config
@@ -31,6 +31,7 @@ from .security import csrf
from .security import sessions as session_store from .security import sessions as session_store
from .security.sessions import SqliteSessionInterface from .security.sessions import SqliteSessionInterface
from .settings_store import SettingSpec, SettingsStore from .settings_store import SettingSpec, SettingsStore
from . import uploads
from .sysops import SysOps from .sysops import SysOps
from .auth import directory from .auth import directory
from .auth import routes as auth_routes from .auth import routes as auth_routes
@@ -130,11 +131,11 @@ def create_base_app(
import_name: str = "tesm_core", import_name: str = "tesm_core",
app_short: str = "TESM", app_short: str = "TESM",
app_edition: str = "", app_edition: str = "",
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = kein #: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = nur die
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Funktionsliste von TESM waere dort schlicht falsch. #: Anwendungen.
auth_tagline: str = "", auth_tagline: str = "",
app_version: str = "2.0.2", app_version: str = "2.2.1",
built_in_trusted_keys: tuple[str, ...] = (), built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True, enable_license: bool = True,
template_folder: str | None = None, template_folder: str | None = None,
@@ -163,6 +164,10 @@ def create_base_app(
config.data_dir.mkdir(parents=True, exist_ok=True) config.data_dir.mkdir(parents=True, exist_ok=True)
config.log_dir.mkdir(parents=True, exist_ok=True) config.log_dir.mkdir(parents=True, exist_ok=True)
# Die Groessengrenze einer Anfrage kommt aus der Einstellung, nicht aus
# der Konfiguration beim Start.
app.request_class = uploads.UploadLimitRequest
app.config.update( app.config.update(
SECRET_KEY=load_or_create_secret_key(config.secret_key_file), SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei # site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
@@ -172,6 +177,9 @@ def create_base_app(
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=config.security.cookie_secure, SESSION_COOKIE_SECURE=config.security.cookie_secure,
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite, SESSION_COOKIE_SAMESITE=config.security.cookie_samesite,
# Nur die aeussere Schranke aus der Umgebung. Die eigentliche Grenze
# steht in der Einstellung und wird pro Anfrage gelesen (uploads.py) --
# sonst braeuchte jede Aenderung einen Neustart aller Arbeiter.
MAX_CONTENT_LENGTH=config.security.max_content_length, MAX_CONTENT_LENGTH=config.security.max_content_length,
JSON_SORT_KEYS=False, JSON_SORT_KEYS=False,
TRAP_HTTP_EXCEPTIONS=False, TRAP_HTTP_EXCEPTIONS=False,
@@ -259,6 +267,17 @@ def create_base_app(
@app.after_request @app.after_request
def _finish_request(response): # type: ignore[no-untyped-def] def _finish_request(response): # type: ignore[no-untyped-def]
# Ein Aktualisierungsdurchlauf, der mit Sitzungscookie kommt und
# unangemeldet endet, hat gerade seine Sitzung verloren. Bei einer
# geschuetzten Seite sagt das schon der Status (401); bei einer, die
# auch oeffentlich antwortet -- die Statusuebersicht etwa -- kommt eine
# 200 mit den oeffentlichen Kacheln zurueck, und der offene Tab merkt
# nichts: er zeigt weiter eine Oberflaeche, als sei alles in Ordnung.
# Deshalb ein Kopf, den der Browser unabhaengig vom Status auswerten
# kann.
if session_store.is_automatic(request) and not current_user.is_authenticated:
if request.cookies.get(app.config["SESSION_COOKIE_NAME"], ""):
response.headers["X-Session-Expired"] = "1"
hsts = "" hsts = ""
if config.security.hsts_enabled: if config.security.hsts_enabled:
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains" hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
@@ -317,6 +336,16 @@ def create_base_app(
# -- Fehlerseiten ------------------------------------------------------ # # -- Fehlerseiten ------------------------------------------------------ #
@app.errorhandler(HTTPException) @app.errorhandler(HTTPException)
def _http_error(error: HTTPException): # type: ignore[no-untyped-def] def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
if error.code == 413:
# "Request Entity Too Large" allein hilft niemandem weiter: die
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
# sehen. Vorher stand hier nur der nackte Status.
grenze = uploads.limit_mb()
error.description = (
f"Diese Datei ist groesser als erlaubt. Zurzeit sind {grenze} MB je Upload "
"eingestellt; aendern kann das ein Administrator unter "
'"Einstellungen -> Webserver und TLS".'
)
if _wants_json(): if _wants_json():
return { return {
"error": error.name, "error": error.name,
@@ -10,6 +10,7 @@ from flask import abort, flash, g, redirect, request, session, url_for
from werkzeug.local import LocalProxy from werkzeug.local import LocalProxy
from ..rbac.model import Action, PermissionTree from ..rbac.model import Action, PermissionTree
from ..security.sessions import is_automatic
AUTH_SOURCES = ("local", "ldap") AUTH_SOURCES = ("local", "ldap")
@@ -189,9 +190,22 @@ def _deny(message: str) -> Any:
if not current_user.is_authenticated: if not current_user.is_authenticated:
if request.method == "GET" and not request.headers.get("X-Requested-With"): 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 session["_after_login"] = request.full_path if request.query_string else request.path
# Eine automatische Aktualisierung fragt einen Ausschnitt ab und
# erwartet HTML. Eine Umleitung auf die Anmeldeseite wuerde sie
# dankbar entgegennehmen und eine ganze Seite in eine Kachel setzen --
# der Tab bliebe stumm stehen, obwohl die Sitzung abgelaufen ist. 401
# sagt es deutlich; der Browser laedt daraufhin die Seite neu und der
# Mensch sieht die Anmeldemaske.
if is_automatic(request):
abort(401, description="Sitzung abgelaufen.")
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html: if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
abort(401, description="Anmeldung erforderlich.") abort(401, description="Anmeldung erforderlich.")
flash("Bitte melden Sie sich an.", "info") # "Bitte melden Sie sich an." stand auf einer Seite, die aus einem
# Anmeldefeld, einem Passwortfeld und einem Knopf "Anmelden" besteht --
# eine Meldung, die nichts hinzufuegt. Wer dagegen gerade noch
# angemeldet war, soll erfahren, warum er es nicht mehr ist.
if getattr(g, "session_expired", False):
flash("Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", "info")
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
abort(403, description=message) abort(403, description=message)
+1 -1
View File
@@ -76,7 +76,7 @@ class CoreConfig:
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units.""" """Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
site_key: str site_key: str
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-opus``. """Name *dieser Installation* auf dem Host, z. B. ``tesm-test``.
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
@@ -26,7 +26,8 @@ import sqlite3
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from flask import Flask, Request, Response from flask import Flask, Request, Response, g
from flask import request as aktuelle_anfrage
from flask.sessions import SessionInterface, SessionMixin from flask.sessions import SessionInterface, SessionMixin
from werkzeug.datastructures import CallbackDict from werkzeug.datastructures import CallbackDict
@@ -34,6 +35,79 @@ from ..db import Database
SESSION_ID_BYTES = 32 SESSION_ID_BYTES = 32
#: Kopfzeile, mit der der Browser eine Anfrage als **automatisch** kennzeichnet:
#: ein Aktualisierungsdurchlauf einer Kachel, kein Klick eines Menschen.
#:
#: Ohne diese Unterscheidung ist die automatische Abmeldung wirkungslos, und das
#: war sie auch: ein offener Tab fragt seine Bereiche alle 15 Sekunden neu ab,
#: jede Abfrage verlaengerte das Leerlauffenster, und niemand wurde je
#: abgemeldet -- nachweisbar an einer Sitzung, deren ``last_seen_at`` eine
#: Minute alt war, obwohl seit zehn Stunden niemand am Rechner sass.
#:
#: Die Entscheidung gehoert zum Client: nur er weiss, ob ein Zeitgeber oder ein
#: Mensch die Anfrage ausgeloest hat. Ein Angreifer koennte den Kopf setzen --
#: er wuerde damit seine *eigene* Sitzung schneller ablaufen lassen.
AUTO_REFRESH_HEADER = "X-Auto-Refresh"
#: Marke auf der Ansichtsfunktion. Steht an der Route, damit man sie dort sieht,
#: wo sie gilt.
FRAGMENT_ATTRIBUT = "_tesm_fragment"
def fragment(view: Any) -> Any:
"""Kennzeichnet eine Route als Aktualisierungsausschnitt.
Solche Routen ruft ein Zeitgeber auf, kein Mensch. Sie duerfen die Sitzung
benutzen, halten sie aber nicht am Leben -- sonst bleibt jeder offene Tab
unbegrenzt angemeldet.
Der Kopf ``X-Auto-Refresh`` allein genuegt dafuer nicht: er setzt voraus,
dass der Browser das aktuelle Skript faehrt. Ein Tab, der seit Stunden
offen ist, faehrt das alte -- und das ist genau der Tab, um den es geht.
"""
setattr(view, FRAGMENT_ATTRIBUT, True)
return view
def _ist_fragment_route() -> bool:
"""Gehoert die laufende Anfrage zu einer als Ausschnitt markierten Route?
``request.endpoint`` steht hier nicht immer schon: Flask oeffnet die Sitzung
**vor** dem Routing (``RequestContext.push`` ruft ``open_session`` und erst
danach ``match_request``). In ``save_session`` ist der Endpunkt gesetzt, in
``open_session`` nicht -- und ohne diesen Nachtrag griff die Erkennung
genau dort nicht, wo sie gebraucht wird.
"""
from flask import current_app
try:
endpunkt = getattr(aktuelle_anfrage, "endpoint", None)
if not endpunkt:
# Selbst nachsehen. Ein zweiter Routenabgleich je Anfrage ist
# billiger als eine Abmeldung, die nicht stattfindet.
adapter = current_app.url_map.bind_to_environ(aktuelle_anfrage.environ)
endpunkt, _ = adapter.match(
aktuelle_anfrage.path, method=aktuelle_anfrage.method
)
ansicht = current_app.view_functions.get(str(endpunkt))
except Exception: # noqa: BLE001 - keine Route, Umleitung, kein App-Kontext
return False
return bool(getattr(ansicht, FRAGMENT_ATTRIBUT, False))
def is_automatic(request: Request) -> bool:
"""Kam die Anfrage von einem Zeitgeber statt von einem Menschen?
Zwei Kennzeichen, und eines genuegt: der Kopf, den das Skript setzt,
und die Route selbst. Das zweite traegt auch dann, wenn ein lange
offener Tab noch ein aelteres Skript faehrt -- und das ist der Tab,
um den es geht.
"""
if request.headers.get(AUTO_REFRESH_HEADER, "") == "1":
return True
return _ist_fragment_route()
def _now() -> datetime: def _now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
@@ -135,6 +209,11 @@ class SqliteSessionInterface(SessionInterface):
"WHERE id=?", "WHERE id=?",
(_iso(now), sid), (_iso(now), sid),
) )
# Fuer die Anmeldeseite vermerken, **warum** sie erscheint. Ohne
# das steht dort entweder nichts -- dann wundert sich, wer
# gerade noch angemeldet war -- oder eine Meldung, die jeder
# Besucher zu sehen bekommt und die deshalb nichts sagt.
g.session_expired = True
return self.session_class(new=True) return self.session_class(new=True)
try: try:
@@ -154,8 +233,17 @@ class SqliteSessionInterface(SessionInterface):
} }
# Leerlauffenster verlaengern, aber hoechstens einmal pro Minute # Leerlauffenster verlaengern, aber hoechstens einmal pro Minute
# schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang. # schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang.
#
# Eine automatische Abfrage verlaengert gar nichts. Sie darf die
# Sitzung benutzen, solange diese lebt, aber sie haelt sie nicht am
# Leben: sonst bleibt jeder offene Tab bis in alle Ewigkeit
# angemeldet, und "Abmeldung nach 30 Minuten Leerlauf" ist eine
# Zusage, die die Anwendung nicht einhaelt. Damit bleibt
# ``last_seen_at`` ausserdem, was der Name sagt: der letzte
# Zeitpunkt, an dem ein Mensch da war.
last_seen = _parse(row.get("last_seen_at")) last_seen = _parse(row.get("last_seen_at"))
if last_seen is None or (now - last_seen) > timedelta(seconds=60): veraltet = last_seen is None or (now - last_seen) > timedelta(seconds=60)
if veraltet and not is_automatic(request):
with self.database.transaction(conn): with self.database.transaction(conn):
conn.execute( conn.execute(
"UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?", "UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
@@ -211,6 +299,31 @@ class SqliteSessionInterface(SessionInterface):
), ),
) )
session.new = False session.new = False
# ``save_session`` bekommt die Anfrage nicht als Argument -- anders als
# ``open_session``, wo ein Parameter denselben Namen traegt. Hier ist es
# der Anfragekontext von Flask; die Antwort wird darin erzeugt.
elif is_automatic(aktuelle_anfrage):
# Die zweite Schreibstelle, und die eigentliche Ursache dafuer,
# dass die Abmeldung nie ausgeloest hat: hier wurde das
# Leerlauffenster bei **jeder** Anfrage verlaengert, die die
# Sitzung anfasst. Die Pruefung in open_session lief damit ins
# Leere -- ein Aktualisierungsdurchlauf einer Kachel fasst die
# Sitzung an (Flash-Speicher, CSRF), und schon stand das Fenster
# wieder 30 Minuten offen. Auf dem Lizenzserver fiel es nicht
# auf: dort pollt keine Kachel.
#
# Geaenderte Sitzungsdaten muessen trotzdem erhalten bleiben --
# sie zu verwerfen waere eine zweite Fehlerquelle. Nur die
# Fristen bleiben stehen.
conn.execute(
"UPDATE user_sessions SET user_id=?, data=?, reauth_at=? WHERE id=?",
(
session.get("user_id"),
payload,
session.get("_reauth_at"),
session.sid,
),
)
else: else:
conn.execute( conn.execute(
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, " "UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
@@ -219,7 +332,11 @@ class SqliteSessionInterface(SessionInterface):
session.get("user_id"), session.get("user_id"),
payload, payload,
_iso(now), _iso(now),
_iso(now + timedelta(minutes=self.idle_minutes)), # ``_idle(conn)`` und nicht ``idle_minutes``: die Zeit ist
# zur Laufzeit einstellbar, und diese Stelle hat die
# Einstellung ignoriert. Bei der Vorgabe von 30 Minuten
# fiel das nicht auf -- bei einer geaenderten schon.
_iso(now + timedelta(minutes=self._idle(conn))),
session.get("_reauth_at"), session.get("_reauth_at"),
session.sid, session.sid,
), ),
@@ -412,7 +412,10 @@
und beide sind rot. */ und beide sind rot. */
.pill--critical { .pill--critical {
background: var(--danger); background: var(--danger);
color: var(--surface-0); /* Weiss wie bei .btn--danger. Vorher stand hier ein Token, das es nicht
gibt -- die Deklaration war damit unwirksam, und die Schrift blieb in
der Textfarbe: dunkelgrau auf Rot. */
color: #fff;
font-weight: 700; font-weight: 700;
} }
.pill--critical::before { background: currentColor; } .pill--critical::before { background: currentColor; }
@@ -682,7 +685,10 @@
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; } .modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; }
/* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der /* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der
uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */ uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */
.modal__body--flush { padding: 0; max-height: none; } /* Zwei Klassen am selben Element: diese hier muss gewinnen, obwohl
.modal__body weiter unten steht. Mit der doppelten Klasse haengt das
nicht mehr an der Reihenfolge im Stylesheet. */
.modal__body.modal__body--flush { padding: 0; max-height: none; overflow: visible; }
.modal__caption { .modal__caption {
padding: 0 var(--sp-5) var(--sp-2); padding: 0 var(--sp-5) var(--sp-2);
color: var(--text-muted); color: var(--text-muted);
@@ -695,7 +701,77 @@
width: 100%; width: 100%;
height: min(70dvh, 760px); height: min(70dvh, 760px);
border: 0; border: 0;
background: var(--surface-0); /* Die Vorschau zeigt fremde Dokumente; ein neutraler Grund gehoert
darunter. --surface-0 gibt es nicht, der Rahmen blieb durchsichtig. */
background: var(--surface-1);
}
/* -- Betrachter: Bild, PDF, Rahmen ------------------------------------- */
/* Eine feste Hoehe fuer alle drei Anzeigen: sonst springt der Dialog beim
Wechsel zwischen Foto und PDF. */
.viewer {
position: relative;
display: flex;
flex-direction: column;
/* Was der Dialog uebrig laesst: Kopf, Dateiname und Fusszeile brauchen
zusammen rund 190 Pixel. Ein fester Anteil der Fensterhoehe schob die
Zoomleiste auf kleinen Fenstern unter den Rand. */
height: min(calc(100dvh - 240px), 760px);
min-height: 220px;
background: var(--surface-1);
}
.viewer > .file-preview {
flex: 1;
height: auto;
}
.viewer__stage {
position: relative;
flex: 1;
/* Ohne min-height waechst die Buehne mit dem Bild, und dessen
max-height:100% haengt dann an sich selbst: ein 4032x3024-Foto stand
36 Pixel ueber den Rand hinaus. */
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
/* Die Seite darf nicht mitwandern, wenn im Bild geschoben wird. */
touch-action: none;
cursor: zoom-in;
}
.viewer__stage--gezogen {
cursor: grab;
}
.viewer__img {
/* Der Ausgangszustand ist "passt in die Flaeche" -- genau das fehlte:
ein Foto vom iPad ist 4032 Pixel breit und wurde im Rahmen 1:1
gezeigt, also als Ausschnitt aus der Mitte. */
max-width: 100%;
max-height: 100%;
/* Zoomen soll nicht unscharf wirken, solange nicht vergroessert wird. */
transform-origin: center center;
will-change: transform;
user-select: none;
-webkit-user-drag: none;
}
/* pdf.js verlangt einen absolut positionierten, scrollenden Behaelter. */
.viewer__pdf {
position: absolute;
inset: 0;
bottom: var(--sp-10);
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.viewer__bar {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-4);
border-top: 1px solid var(--border);
background: var(--surface-2, var(--surface-1));
}
.viewer__hint {
margin-inline-start: auto;
} }
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein /* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
@@ -849,7 +925,10 @@
width: 17px; width: 17px;
height: 17px; height: 17px;
border-radius: 5px; border-radius: 5px;
background: var(--surface-0); /* Dunkler als die Karte -- genau das war die Absicht: "dunkler heisst,
es gibt das Recht nicht". Mit dem nicht existierenden --surface-0 war
das Feld durchsichtig und damit so hell wie die Karte. */
background: var(--canvas);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
box-shadow: inset 0 1px 2px rgb(0 0 0 / 28%); box-shadow: inset 0 1px 2px rgb(0 0 0 / 28%);
vertical-align: -3px; vertical-align: -3px;
@@ -943,9 +1022,15 @@
display: grid; display: grid;
place-items: center; place-items: center;
padding: var(--sp-6); padding: var(--sp-6);
/* Zwei warme Kreise, Geometrie und Staerke aus der Fassung 1.x
uebernommen: oben links deutlich, unten rechts angedeutet. Die vorige
Fassung setzte den zweiten Kreis auf --info und 10 % -- ein kaltes
Blau, das man auf dunklem Grund nicht sah und das nicht zur Marke
gehoert. Ueber color-mix statt festem rgba, damit es im hellen Thema
nicht schmutzig wird. */
background: background:
radial-gradient(1100px 520px at 12% -10%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 70%), radial-gradient(circle at 15% 15%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 45%),
radial-gradient(900px 480px at 100% 110%, color-mix(in srgb, var(--info) 10%, transparent), transparent 70%), radial-gradient(circle at 85% 85%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 40%),
var(--canvas); var(--canvas);
} }
/* Die Wortmarke liegt hinter allem (z-index 0), die Anmeldebox davor. /* Die Wortmarke liegt hinter allem (z-index 0), die Anmeldebox davor.
@@ -1026,24 +1111,35 @@
color: var(--text-faint); color: var(--text-faint);
} }
.auth-page { position: relative; overflow: hidden; align-content: center; } /* Marke und Karte stehen uebereinander im normalen Fluss. Vorher lag die
Marke absolut positioniert hinter der Karte -- auf einem flachen Fenster
schnitt die Karte mitten durch den Schriftzug und die Funktionszeile. Ein
Stapel kann nicht kollidieren, und die Seite darf notfalls scrollen. */
.auth-stack {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-8);
width: 100%;
}
.auth-backdrop { .auth-backdrop {
position: absolute;
top: 4vh;
left: 50%;
transform: translateX(-50%);
width: min(72vw, 880px); width: min(72vw, 880px);
pointer-events: none; pointer-events: none;
z-index: 0; /* Volle Deckung: solange die Marke hinter der Karte lag, war sie ein
opacity: 0.5; Wasserzeichen und musste zurueckhaltend sein. Jetzt steht sie ueber der
Karte und ist das Erste, was man sieht -- halbtransparent sah sie dort
nur blass aus. Die Farben kommen ohnehin aus zurueckhaltenden Token. */
opacity: 1;
} }
.auth-backdrop svg { width: 100%; height: auto; display: block; } .auth-backdrop svg { width: 100%; height: auto; display: block; }
@media (max-width: 640px) { @media (max-width: 640px) {
.auth-backdrop { width: min(90vw, 460px); opacity: 0.32; } .auth-backdrop { width: min(90vw, 460px); }
} }
/* Wer wenig Bewegung/Reize moechte, bekommt das Motiv dezenter. */ /* Wer wenig Bewegung/Reize moechte, bekommt das Motiv dezenter. */
/* Wer weniger Reize moechte, bekommt die Marke ruhiger -- aber nicht
unsichtbar: sie ist die Ueberschrift der Seite. */
@media (prefers-contrast: more) { @media (prefers-contrast: more) {
.auth-backdrop { opacity: 0.18; } .auth-backdrop { opacity: 0.75; }
} }
.auth-card { .auth-card {
@@ -45,22 +45,45 @@ function csrfToken() {
return document.body?.dataset.csrf || ""; return document.body?.dataset.csrf || "";
} }
/** fetch mit CSRF-Header und JSON-Erwartung. */ /**
* fetch mit CSRF-Header und JSON-Erwartung.
*
* `auto: true` kennzeichnet eine Anfrage, die ein Zeitgeber ausgeloest hat und
* kein Mensch. Der Server verlaengert das Leerlauffenster der Sitzung dann
* nicht -- ohne diese Unterscheidung haelt jeder offene Tab die Anmeldung
* unbegrenzt am Leben, und die automatische Abmeldung findet nie statt.
*
* Der HTTP-Status haengt am geworfenen Fehler, damit der Aufrufer 401
* ("Sitzung abgelaufen") von einem Netzfehler unterscheiden kann.
*/
export async function api(url, options = {}) { export async function api(url, options = {}) {
const opts = { credentials: "same-origin", ...options }; const { auto = false, ...rest } = options;
const opts = { credentials: "same-origin", ...rest };
opts.headers = { opts.headers = {
"X-Requested-With": "fetch", "X-Requested-With": "fetch",
"X-CSRF-Token": csrfToken(), "X-CSRF-Token": csrfToken(),
...(auto ? { "X-Auto-Refresh": "1" } : {}),
...(options.headers || {}), ...(options.headers || {}),
}; };
const response = await fetch(url, opts); const response = await fetch(url, opts);
// Eine Seite, die auch oeffentlich antwortet -- die Statusuebersicht etwa --
// liefert nach dem Ablauf der Sitzung eine 200 mit den oeffentlichen
// Kacheln. Am Status ist der Verlust also nicht zu erkennen; an diesem Kopf
// schon. Behandelt wird er wie eine 401.
if (response.headers.get("X-Session-Expired") === "1") {
const abgelaufen = new Error("Die Sitzung ist abgelaufen.");
abgelaufen.status = 401;
throw abgelaufen;
}
const type = response.headers.get("content-type") || ""; const type = response.headers.get("content-type") || "";
const payload = type.includes("application/json") ? await response.json() : await response.text(); const payload = type.includes("application/json") ? await response.json() : await response.text();
if (!response.ok) { if (!response.ok) {
const message = const message =
(payload && typeof payload === "object" && (payload.error || payload.message)) || (payload && typeof payload === "object" && (payload.error || payload.message)) ||
`Anfrage fehlgeschlagen (HTTP ${response.status})`; `Anfrage fehlgeschlagen (HTTP ${response.status})`;
throw new Error(message); const error = new Error(message);
error.status = response.status;
throw error;
} }
return payload; return payload;
} }
@@ -556,7 +579,10 @@ function initAutoRefresh() {
const tick = async () => { const tick = async () => {
if (stopped || document.hidden) return schedule(); if (stopped || document.hidden) return schedule();
try { try {
const html = await api(region.dataset.refreshUrl, { headers: { Accept: "text/html" } }); const html = await api(region.dataset.refreshUrl, {
auto: true,
headers: { Accept: "text/html" },
});
// Eine vollstaendige Seite gehoert nie in einen Ausschnitt. Zeigt die // Eine vollstaendige Seite gehoert nie in einen Ausschnitt. Zeigt die
// URL versehentlich auf eine ganze Seite, waere das Ergebnis eine // URL versehentlich auf eine ganze Seite, waere das Ergebnis eine
// Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer. // Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer.
@@ -588,7 +614,16 @@ function initAutoRefresh() {
initRelativeTimes(region); initRelativeTimes(region);
initCountdowns(region); initCountdowns(region);
} }
} catch { } catch (error) {
// 401 heisst: die Sitzung ist abgelaufen. Weiterpollen waere sinnlos,
// und stehenbleiben waere irrefuehrend -- die Kacheln zeigten dann
// beliebig alte Zahlen, als sei alles in Ordnung. Neu laden bringt die
// Anmeldemaske mit ihrem Hinweis.
if (error && error.status === 401) {
stopped = true;
window.location.reload();
return;
}
/* Netzfehler: beim naechsten Intervall erneut versuchen. */ /* Netzfehler: beim naechsten Intervall erneut versuchen. */
} }
schedule(); schedule();
@@ -860,6 +895,16 @@ function boot() {
initTileFilters(); initTileFilters();
initLogTails(); initLogTails();
unsavedGuard.init(); unsavedGuard.init();
initViewer();
}
/* Der Dateibetrachter kommt nur auf Seiten mit Vorschau -- und er zieht bei
einem PDF noch pdf.js nach. Beides erst dann, wenn es gebraucht wird. */
function initViewer() {
if (!document.querySelector("[data-viewer]")) return;
import("./viewer.js")
.then((modul) => modul.initFilePreview())
.catch((fehler) => console.error("Dateibetrachter", fehler));
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {
@@ -0,0 +1,397 @@
/* =============================================================================
* Dateivorschau: Bilder zoomen, PDF-Formulare ausfuellen.
*
* Vorher lief jede Vorschau durch einen <iframe> -- der Browser entschied
* selbst. Zwei Dinge gingen dabei schief:
*
* * Ein Foto vom iPad ist 4032 Pixel breit. Der Rahmen zeigte es 1:1, also
* einen Ausschnitt aus der Mitte, und zoomen liess sich nichts.
* * Ein PDF im Rahmen ist auf iPadOS nur ein Standbild der ersten Seite.
* Formularfelder sind dort nicht anfassbar.
*
* Jetzt entscheidet die Art der Datei (bild | pdf | text), welche Anzeige
* aufgeht. Bilder bekommen einen eigenen Betrachter mit Zoom und Verschieben
* (Finger, Rad, Knoepfe); PDFs werden von pdf.js gerendert -- damit sind
* Formularfelder echte Eingabefelder, auch in Safari.
*
* pdf.js liegt im Paket (static/vendor/pdfjs) und wird erst geladen, wenn
* wirklich ein PDF geoeffnet wird: 1,7 MB gehoeren nicht in jeden Seitenaufruf.
*
* Die Dateien heissen ".js" und nicht ".mjs": die mime.types von nginx kennen
* ".mjs" nicht und liefern "application/octet-stream" -- damit lehnt jeder
* Browser ein Modul ab, und zwar ohne dass der Benutzer erfaehrt, warum. Am
* Inhalt aendert die Endung nichts.
* ========================================================================== */
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 8;
/* -- Bilder ---------------------------------------------------------------- */
/** Zoom und Verschieben fuer ein einzelnes Bild. */
function bildBetrachter(buehne, bild) {
let zoom = 1;
let x = 0;
let y = 0;
let angepasst = true; // noch nie von Hand gezoomt -> passt sich der Flaeche an
const anwenden = () => {
bild.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`;
buehne.classList.toggle("viewer__stage--gezogen", zoom > 1);
buehne.dispatchEvent(new CustomEvent("zoom", { detail: { zoom, angepasst } }));
};
const grenzen = () => {
// Nicht aus dem Bild herausschieben: was ueber die Buehne hinausragt,
// darf verschoben werden, mehr nicht.
const b = buehne.getBoundingClientRect();
const breite = (bild.clientWidth * zoom - b.width) / 2;
const hoehe = (bild.clientHeight * zoom - b.height) / 2;
x = Math.min(Math.max(x, -Math.max(0, breite)), Math.max(0, breite));
y = Math.min(Math.max(y, -Math.max(0, hoehe)), Math.max(0, hoehe));
};
const setzen = (wert, { fest = false } = {}) => {
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, wert));
angepasst = !fest && Math.abs(zoom - 1) < 0.001;
if (zoom <= 1) {
x = 0;
y = 0;
} else {
grenzen();
}
anwenden();
};
const zuruecksetzen = () => {
zoom = 1;
x = 0;
y = 0;
angepasst = true;
anwenden();
};
/* Ziehen mit Maus oder einem Finger, sobald hineingezoomt ist. */
let zieht = null;
buehne.addEventListener("pointerdown", (event) => {
if (zoom <= 1 || event.pointerType === "touch" && event.isPrimary === false) return;
zieht = { id: event.pointerId, x: event.clientX - x, y: event.clientY - y };
buehne.setPointerCapture(event.pointerId);
});
buehne.addEventListener("pointermove", (event) => {
if (!zieht || event.pointerId !== zieht.id) return;
event.preventDefault();
x = event.clientX - zieht.x;
y = event.clientY - zieht.y;
grenzen();
anwenden();
});
const loslassen = (event) => {
if (zieht && event.pointerId === zieht.id) zieht = null;
};
buehne.addEventListener("pointerup", loslassen);
buehne.addEventListener("pointercancel", loslassen);
/* Zwei Finger: aufziehen und zusammenziehen. Der Dialog liegt ueber der
Seite, deren eigenes Zoomen hier nicht greift -- also selbst rechnen. */
let start = null;
const abstand = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
buehne.addEventListener(
"touchstart",
(event) => {
if (event.touches.length !== 2) return;
start = { d: abstand(event.touches), zoom };
},
{ passive: true }
);
buehne.addEventListener(
"touchmove",
(event) => {
if (!start || event.touches.length !== 2) return;
event.preventDefault();
setzen((start.zoom * abstand(event.touches)) / start.d, { fest: true });
},
{ passive: false }
);
buehne.addEventListener("touchend", () => {
start = null;
});
/* Rad zoomt, Umschalt oder Strg nicht noetig -- im Dialog gibt es nichts
anderes zu scrollen. */
buehne.addEventListener(
"wheel",
(event) => {
event.preventDefault();
setzen(zoom * (event.deltaY < 0 ? 1.12 : 1 / 1.12), { fest: true });
},
{ passive: false }
);
/* Doppeltippen: zwischen angepasst und doppelt umschalten. */
buehne.addEventListener("dblclick", () => setzen(zoom > 1 ? 1 : 2, { fest: zoom <= 1 }));
return {
hinein: () => setzen(zoom * 1.25, { fest: true }),
hinaus: () => setzen(zoom / 1.25, { fest: true }),
anpassen: zuruecksetzen,
zuruecksetzen,
get zoom() {
return zoom;
},
};
}
/* -- PDF ------------------------------------------------------------------- */
let pdfjsGeladen = null;
/** Laedt pdf.js einmalig nach; das Paket liegt neben dieser Datei. */
async function pdfjsLaden(basis) {
if (!pdfjsGeladen) {
pdfjsGeladen = (async () => {
const lib = await import(`${basis}pdf.min.js`);
// Der Komponentenbau von pdf.js sucht die Bibliothek unter diesem Namen.
globalThis.pdfjsLib = lib;
const komponenten = await import(`${basis}pdf_viewer.js`);
// Das Stylesheet von pdf.js zeichnet die Formularfelder. 100 KB gehoeren
// nicht in jeden Seitenaufruf -- also erst hier.
const stil = document.createElement("link");
stil.rel = "stylesheet";
stil.href = `${basis}pdf_viewer.css`;
document.head.append(stil);
lib.GlobalWorkerOptions.workerSrc = `${basis}pdf.worker.min.js`;
return { lib, komponenten };
})().catch((fehler) => {
pdfjsGeladen = null;
throw fehler;
});
}
return pdfjsGeladen;
}
/** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */
async function pdfBetrachter(behaelter, seiten, url, basis) {
const { lib, komponenten } = await pdfjsLaden(basis);
const eventBus = new komponenten.EventBus();
const linkService = new komponenten.PDFLinkService({ eventBus });
const viewer = new komponenten.PDFViewer({
container: behaelter,
viewer: seiten,
eventBus,
linkService,
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern.
annotationMode: lib.AnnotationMode.ENABLE_FORMS,
textLayerMode: 1,
});
linkService.setViewer(viewer);
const dokument = await lib.getDocument({
url,
// Ohne die Standardschriften bleibt jede Seite mit Text leer: Helvetica
// und Verwandte liegen als Datei im Paket, nicht im Browser.
standardFontDataUrl: `${basis}standard_fonts/`,
isEvalSupported: false,
}).promise;
viewer.setDocument(dokument);
linkService.setDocument(dokument, null);
await new Promise((fertig) => eventBus.on("pagesinit", fertig, { once: true }));
// Auf einem iPad ist die Seitenbreite das, was man sehen will.
viewer.currentScaleValue = "page-width";
/* Zwei Finger zoomen auch hier. */
let start = null;
const abstand = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
behaelter.addEventListener(
"touchstart",
(event) => {
if (event.touches.length === 2) start = { d: abstand(event.touches), s: viewer.currentScale };
},
{ passive: true }
);
behaelter.addEventListener(
"touchmove",
(event) => {
if (!start || event.touches.length !== 2) return;
event.preventDefault();
const wert = (start.s * abstand(event.touches)) / start.d;
viewer.currentScale = Math.min(MAX_ZOOM, Math.max(0.25, wert));
},
{ passive: false }
);
behaelter.addEventListener("touchend", () => {
start = null;
});
return {
hinein: () => {
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
},
hinaus: () => {
viewer.currentScale = Math.max(0.25, viewer.currentScale / 1.2);
},
anpassen: () => {
viewer.currentScaleValue = "page-width";
},
/** Das ausgefuellte Formular als Bytes -- pdf.js schreibt die Werte hinein. */
speichern: () => dokument.saveDocument(),
/** Wurde ueberhaupt etwas eingetragen? */
veraendert: () => {
const s = dokument.annotationStorage;
return Boolean(s && s.size);
},
schliessen: () => {
viewer.setDocument(null);
linkService.setDocument(null);
dokument.destroy();
},
};
}
/* -- Zusammenbau ----------------------------------------------------------- */
export function initFilePreview(wurzel = document) {
const rumpf = wurzel.querySelector("[data-viewer]");
if (!rumpf) return;
// Fusszeile und Titel liegen ausserhalb des Rumpfes -- gesucht wird deshalb
// im ganzen Dialog. (Der Speichern-Knopf blieb sonst immer verborgen.)
const dialog = rumpf.closest("dialog") || rumpf;
const stueck = (name) => dialog.querySelector(`[data-viewer-${name}]`);
const bildBuehne = stueck("bildbuehne");
const bild = stueck("bild");
const pdfBehaelter = stueck("pdf");
const pdfSeiten = stueck("pdfseiten");
const rahmen = stueck("rahmen");
const leiste = stueck("leiste");
const hinweis = stueck("hinweis");
const speichern = stueck("speichern");
const basis = rumpf.dataset.viewerPdfjs || "";
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
// soll nicht als leere Flaeche enden.
bild.addEventListener("error", () => {
if (bildBuehne.hidden || !bild.getAttribute("src")) return;
if (hinweis) {
hinweis.textContent =
"Dieses Bildformat kann Ihr Browser nicht anzeigen -- bitte herunterladen.";
}
});
const bilder = bildBetrachter(bildBuehne, bild);
bildBuehne.addEventListener("zoom", (event) => {
if (!hinweis || bildBuehne.hidden) return;
const { zoom, angepasst } = event.detail;
hinweis.textContent = angepasst ? "eingepasst" : `${Math.round(zoom * 100)} %`;
});
let pdf = null;
let aktuell = null;
const zeigen = (art) => {
bildBuehne.hidden = art !== "bild";
pdfBehaelter.hidden = art !== "pdf";
rahmen.hidden = art !== "text";
leiste.hidden = art === "text";
if (speichern) speichern.hidden = art !== "pdf";
};
const aufraeumen = () => {
bild.removeAttribute("src");
bilder.zuruecksetzen();
rahmen.src = "about:blank";
if (pdf) {
try {
pdf.schliessen();
} catch {
/* ein halb geladenes Dokument darf beim Schliessen nicht stoeren */
}
pdf = null;
}
pdfSeiten.replaceChildren();
if (hinweis) hinweis.textContent = "";
aktuell = null;
};
const oeffnen = async (ausloeser) => {
aufraeumen();
const art = ausloeser.dataset.fillKind || "text";
const quelle = ausloeser.dataset.fillSrc || "";
aktuell = { art, quelle, pfad: ausloeser.dataset.fillPfad || "" };
zeigen(art);
if (art === "bild") {
bild.src = quelle;
return;
}
if (art !== "pdf") {
rahmen.src = quelle;
return;
}
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
try {
pdf = await pdfBetrachter(pdfBehaelter, pdfSeiten, quelle, basis);
if (hinweis) hinweis.textContent = "";
} catch (fehler) {
// Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige
// zurueckfallen. Die kann wenigstens lesen, wenn auch nicht ausfuellen.
console.error("pdf.js", fehler);
zeigen("text");
rahmen.src = quelle;
if (hinweis) {
hinweis.textContent =
"Die Formularansicht liess sich nicht laden -- es bleibt bei der einfachen Anzeige.";
// Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade
// verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und
// es sah aus, als koennte die Vorschau einfach keine Formulare.
leiste.hidden = false;
}
}
};
document.addEventListener("click", (event) => {
const ausloeser = event.target.closest('[data-dialog-open="file-preview"]');
if (ausloeser) {
// Der Dialog selbst wird von app.js geoeffnet; hier nur der Inhalt.
oeffnen(ausloeser);
return;
}
const knopf = event.target.closest("[data-viewer-zoom]");
if (!knopf) return;
event.preventDefault();
const betrachter = pdf || bilder;
({ ein: betrachter.hinein, aus: betrachter.hinaus, fit: betrachter.anpassen })[
knopf.dataset.viewerZoom
]?.();
});
dialog.addEventListener("close", aufraeumen);
/* Ausgefuelltes Formular zurueck in die Freigabe. */
speichern?.addEventListener("click", async () => {
if (!pdf || !aktuell) return;
const ziel = speichern.dataset.url;
speichern.disabled = true;
const vorher = speichern.textContent;
speichern.textContent = "Speichern ...";
try {
const bytes = await pdf.speichern();
const daten = new FormData();
daten.append("pfad", aktuell.pfad);
daten.append(
"datei",
new Blob([bytes], { type: "application/pdf" }),
(aktuell.pfad.split("/").pop() || "formular.pdf")
);
const antwort = await window.tesm.api(ziel, { method: "POST", body: daten });
window.tesm.showToast(antwort.message || "Gespeichert.", "success");
dialog.close();
window.location.reload();
} catch (fehler) {
// Die Meldung fuer den Benutzer, die Einzelheiten fuer die Konsole.
console.error("Formular speichern", fehler);
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
} finally {
speichern.disabled = false;
speichern.textContent = vorher;
}
});
}
@@ -0,0 +1,11 @@
pdf.js 4.10.38 -- https://github.com/mozilla/pdf.js
Aus dem npm-Paket pdfjs-dist; nur build/pdf.min.mjs, build/pdf.worker.min.mjs,
web/pdf_viewer.mjs, web/pdf_viewer.css, web/images und standard_fonts.
Apache-2.0, siehe LICENSE.
Die drei Skriptdateien sind von .mjs auf .js umbenannt: die mime.types von
nginx kennen .mjs nicht, und mit application/octet-stream lehnt jeder Browser
ein Modul ab. Der Inhalt ist unveraendert.
Warum eingebettet und nicht von einem CDN: TESM laeuft in abgeschotteten
Netzen. Was die Oberflaeche braucht, muss im Paket liegen.
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
@@ -0,0 +1 @@
4.10.38
@@ -0,0 +1,3 @@
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.375 7.625V11.875C5.375 12.0408 5.44085 12.1997 5.55806 12.3169C5.67527 12.4342 5.83424 12.5 6 12.5C6.16576 12.5 6.32473 12.4342 6.44194 12.3169C6.55915 12.1997 6.625 12.0408 6.625 11.875V7.625L7.125 7.125H11.375C11.5408 7.125 11.6997 7.05915 11.8169 6.94194C11.9342 6.82473 12 6.66576 12 6.5C12 6.33424 11.9342 6.17527 11.8169 6.05806C11.6997 5.94085 11.5408 5.875 11.375 5.875H7.125L6.625 5.375V1.125C6.625 0.95924 6.55915 0.800269 6.44194 0.683058C6.32473 0.565848 6.16576 0.5 6 0.5C5.83424 0.5 5.67527 0.565848 5.55806 0.683058C5.44085 0.800269 5.375 0.95924 5.375 1.125V5.375L4.875 5.875H0.625C0.45924 5.875 0.300269 5.94085 0.183058 6.05806C0.065848 6.17527 0 6.33424 0 6.5C0 6.66576 0.065848 6.82473 0.183058 6.94194C0.300269 7.05915 0.45924 7.125 0.625 7.125H4.762L5.375 7.625Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 920 B

@@ -0,0 +1,3 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.49073 1.3015L3.30873 2.1505C3.29349 2.22246 3.25769 2.28844 3.20568 2.34045C3.15368 2.39246 3.08769 2.42826 3.01573 2.4435L2.16673 2.6255C1.76473 2.7125 1.76473 3.2865 2.16673 3.3725L3.01573 3.5555C3.08769 3.57074 3.15368 3.60654 3.20568 3.65855C3.25769 3.71056 3.29349 3.77654 3.30873 3.8485L3.49073 4.6975C3.57773 5.0995 4.15173 5.0995 4.23773 4.6975L4.42073 3.8485C4.43598 3.77654 4.47177 3.71056 4.52378 3.65855C4.57579 3.60654 4.64178 3.57074 4.71373 3.5555L5.56173 3.3725C5.96373 3.2855 5.96373 2.7115 5.56173 2.6255L4.71273 2.4435C4.64083 2.42814 4.57491 2.3923 4.52292 2.34031C4.47093 2.28832 4.43509 2.2224 4.41973 2.1505L4.23773 1.3015C4.15073 0.8995 3.57673 0.8995 3.49073 1.3015ZM10.8647 13.9995C10.4853 14.0056 10.1158 13.8782 9.82067 13.6397C9.52553 13.4013 9.32347 13.0667 9.24973 12.6945L8.89273 11.0275C8.83676 10.7687 8.70738 10.5316 8.52009 10.3445C8.3328 10.1574 8.09554 10.0282 7.83673 9.9725L6.16973 9.6155C5.38873 9.4465 4.86473 8.7975 4.86473 7.9995C4.86473 7.2015 5.38873 6.5525 6.16973 6.3845L7.83673 6.0275C8.09551 5.97135 8.33267 5.84193 8.51992 5.65468C8.70716 5.46744 8.83658 5.23028 8.89273 4.9715L9.25073 3.3045C9.41773 2.5235 10.0667 1.9995 10.8647 1.9995C11.6627 1.9995 12.3117 2.5235 12.4797 3.3045L12.8367 4.9715C12.9507 5.4995 13.3647 5.9135 13.8927 6.0265L15.5597 6.3835C16.3407 6.5525 16.8647 7.2015 16.8647 7.9995C16.8647 8.7975 16.3407 9.4465 15.5597 9.6145L13.8927 9.9715C13.6337 10.0275 13.3963 10.157 13.209 10.3445C13.0217 10.5319 12.8925 10.7694 12.8367 11.0285L12.4787 12.6945C12.4054 13.0667 12.2036 13.4014 11.9086 13.6399C11.6135 13.8784 11.2441 14.0057 10.8647 13.9995ZM10.8647 3.2495C10.7667 3.2495 10.5337 3.2795 10.4727 3.5655L10.1147 5.2335C10.0081 5.72777 9.76116 6.18082 9.40361 6.53837C9.04606 6.89593 8.59301 7.14283 8.09873 7.2495L6.43173 7.6065C6.14573 7.6685 6.11473 7.9015 6.11473 7.9995C6.11473 8.0975 6.14573 8.3305 6.43173 8.3925L8.09873 8.7495C8.59301 8.85617 9.04606 9.10307 9.40361 9.46062C9.76116 9.81817 10.0081 10.2712 10.1147 10.7655L10.4727 12.4335C10.5337 12.7195 10.7667 12.7495 10.8647 12.7495C10.9627 12.7495 11.1957 12.7195 11.2567 12.4335L11.6147 10.7665C11.7212 10.272 11.9681 9.81878 12.3256 9.46103C12.6832 9.10329 13.1363 8.85624 13.6307 8.7495L15.2977 8.3925C15.5837 8.3305 15.6147 8.0975 15.6147 7.9995C15.6147 7.9015 15.5837 7.6685 15.2977 7.6065L13.6307 7.2495C13.1365 7.14283 12.6834 6.89593 12.3259 6.53837C11.9683 6.18082 11.7214 5.72777 11.6147 5.2335L11.2567 3.5655C11.1957 3.2795 10.9627 3.2495 10.8647 3.2495ZM3.30873 12.1505L3.49073 11.3015C3.57673 10.8995 4.15073 10.8995 4.23773 11.3015L4.41973 12.1505C4.43509 12.2224 4.47093 12.2883 4.52292 12.3403C4.57491 12.3923 4.64083 12.4281 4.71273 12.4435L5.56173 12.6255C5.96373 12.7115 5.96373 13.2855 5.56173 13.3725L4.71273 13.5545C4.64083 13.5699 4.57491 13.6057 4.52292 13.6577C4.47093 13.7097 4.43509 13.7756 4.41973 13.8475L4.23773 14.6965C4.15173 15.0985 3.57773 15.0985 3.49073 14.6965L3.30873 13.8475C3.29337 13.7756 3.25754 13.7097 3.20555 13.6577C3.15356 13.6057 3.08764 13.5699 3.01573 13.5545L2.16673 13.3725C1.76473 13.2865 1.76473 12.7125 2.16673 12.6255L3.01573 12.4435C3.08769 12.4283 3.15368 12.3925 3.20568 12.3405C3.25769 12.2884 3.29349 12.2225 3.30873 12.1505Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,3 @@
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 0.5C5.21207 0.5 4.43185 0.655195 3.7039 0.956723C2.97595 1.25825 2.31451 1.70021 1.75736 2.25736C1.20021 2.81451 0.758251 3.47595 0.456723 4.2039C0.155195 4.93185 0 5.71207 0 6.5C0 7.28793 0.155195 8.06815 0.456723 8.7961C0.758251 9.52405 1.20021 10.1855 1.75736 10.7426C2.31451 11.2998 2.97595 11.7417 3.7039 12.0433C4.43185 12.3448 5.21207 12.5 6 12.5C7.5913 12.5 9.11742 11.8679 10.2426 10.7426C11.3679 9.61742 12 8.0913 12 6.5C12 4.9087 11.3679 3.38258 10.2426 2.25736C9.11742 1.13214 7.5913 0.5 6 0.5ZM5.06 8.9L2.9464 6.7856C2.85273 6.69171 2.80018 6.56446 2.80033 6.43183C2.80048 6.29921 2.85331 6.17207 2.9472 6.0784C3.04109 5.98473 3.16834 5.93218 3.30097 5.93233C3.43359 5.93248 3.56073 5.98531 3.6544 6.0792L5.3112 7.7368L8.3464 4.7008C8.44109 4.6109 8.56715 4.56153 8.69771 4.56322C8.82827 4.56492 8.95301 4.61754 9.04534 4.70986C9.13766 4.80219 9.19028 4.92693 9.19198 5.05749C9.19367 5.18805 9.1443 5.31411 9.0544 5.4088L5.5624 8.9H5.06Z" fill="#FBFBFE"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,30 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<style>
@media not (prefers-reduced-motion) {
@keyframes loadingRotate {
from { rotate: 0; } to { rotate: 360deg }
}
#circle-arrows {
animation: loadingRotate 1.8s linear infinite;
transform-origin: 50% 50%;
}
#hourglass {
display: none;
}
}
@media (prefers-reduced-motion) {
#circle-arrows {
display: none;
}
}
</style>
<path id="circle-arrows" d="M9 5.528c0 .42.508.63.804.333l2.528-2.528a.47.47 0 0 0 0-.666L9.805.14A.471.471 0 0 0 9 .472v1.866A5.756 5.756 0 0 0 2.25 8c0 .942.232 1.83.635 2.615l1.143-1.143A4.208 4.208 0 0 1 3.75 8 4.254 4.254 0 0 1 8 3.75c.345 0 .68.042 1 .122v1.656zM7 10.472v1.656c.32.08.655.122 1 .122A4.254 4.254 0 0 0 12.25 8c0-.52-.107-1.013-.279-1.474l1.143-1.143c.404.786.636 1.674.636 2.617A5.756 5.756 0 0 1 7 13.662v1.866a.47.47 0 0 1-.804.333l-2.528-2.528a.47.47 0 0 1 0-.666l2.528-2.528a.47.47 0 0 1 .804.333z"/>
<g id="hourglass">
<path d="M13,1 C13.5522847,1 14,1.44771525 14,2 C14,2.55228475 13.5522847,3 13,3 L12.9854217,2.99990801 C12.9950817,3.16495885 13,3.33173274 13,3.5 C13,5.24679885 10.9877318,6.01090495 10.9877318,8.0017538 C10.9877318,9.99260264 13,10.7536922 13,12.5 C13,12.6686079 12.9950617,12.8357163 12.985363,13.0010943 L13,13 C13.5522847,13 14,13.4477153 14,14 C14,14.5522847 13.5522847,15 13,15 L3,15 C2.44771525,15 2,14.5522847 2,14 C2,13.4477153 2.44771525,13 3,13 L3.01463704,13.0010943 C3.00493827,12.8357163 3,12.6686079 3,12.5 C3,10.7536922 4.9877318,9.99260264 5,8.0017538 C5.0122682,6.01090495 3,5.24679885 3,3.5 C3,3.33173274 3.00491834,3.16495885 3.01457832,2.99990801 L3,3 C2.44771525,3 2,2.55228475 2,2 C2,1.44771525 2.44771525,1 3,1 L13,1 Z M10.987,3 L5.012,3 L5.00308914,3.24815712 C5.00103707,3.33163368 5,3.4155948 5,3.5 C5,5.36125069 6.99153646,6.01774089 6.99153646,8.0017538 C6.99153646,9.98576671 5,10.6393737 5,12.5 L5.00307746,12.7513676 L5.01222201,12.9998392 L5.60191711,12.9988344 L6.0425138,12.2959826 C7.02362731,10.7653275 7.67612271,10 8,10 C8.37014547,10 9.16950644,10.9996115 10.3980829,12.9988344 L10.987778,12.9998392 C10.9958674,12.8352104 11,12.66849 11,12.5 C11,10.6393737 8.98689779,10.0147381 8.98689779,8.0017538 C8.98689779,5.98876953 11,5.36125069 11,3.5 L10.9969109,3.24815712 L10.987,3 Z"/>
<path d="M6,4 L10,4 C8.95166016,6 8.28499349,7 8,7 C7.71500651,7 7.04833984,6 6,4 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,3 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.78182 2.63903C8.58882 2.28803 8.25782 2.25003 8.12482 2.25003C7.99019 2.24847 7.85771 2.28393 7.74185 2.35253C7.62599 2.42113 7.5312 2.52023 7.46782 2.63903L1.97082 12.639C1.90673 12.7528 1.87406 12.8816 1.87617 13.0122C1.87828 13.1427 1.91509 13.2704 1.98282 13.382C2.04798 13.4951 2.14207 13.5888 2.25543 13.6535C2.36879 13.7182 2.49732 13.7515 2.62782 13.75H13.6218C13.7523 13.7515 13.8809 13.7182 13.9942 13.6535C14.1076 13.5888 14.2017 13.4951 14.2668 13.382C14.3346 13.2704 14.3714 13.1427 14.3735 13.0122C14.3756 12.8816 14.3429 12.7528 14.2788 12.639L8.78182 2.63903ZM6.37282 2.03703C6.75182 1.34603 7.43882 1.00003 8.12482 1.00003C8.48341 0.997985 8.83583 1.09326 9.14454 1.2757C9.45325 1.45814 9.70668 1.72092 9.87782 2.03603L15.3748 12.036C16.1078 13.369 15.1438 15 13.6228 15H2.62782C1.10682 15 0.141823 13.37 0.875823 12.037L6.37282 2.03703ZM8.74982 9.06203C8.74982 9.22779 8.68397 9.38676 8.56676 9.50397C8.44955 9.62118 8.29058 9.68703 8.12482 9.68703C7.95906 9.68703 7.80009 9.62118 7.68288 9.50397C7.56566 9.38676 7.49982 9.22779 7.49982 9.06203V5.62503C7.49982 5.45927 7.56566 5.3003 7.68288 5.18309C7.80009 5.06588 7.95906 5.00003 8.12482 5.00003C8.29058 5.00003 8.44955 5.06588 8.56676 5.18309C8.68397 5.3003 8.74982 5.45927 8.74982 5.62503V9.06203ZM7.74982 12L7.49982 11.75V11L7.74982 10.75H8.49982L8.74982 11V11.75L8.49982 12H7.74982Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 40 40">
<path
d="M 1.5006714,23.536225 6.8925879,18.994244 14.585721,26.037937 34.019683,4.5410479 38.499329,9.2235032 14.585721,35.458952 z"
id="path4"
style="fill:#ffff00;fill-opacity:1;stroke:#000000;stroke-width:1.25402856;stroke-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 415 B

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
height="40"
width="40"
viewBox="0 0 40 40">
<rect
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
width="33.76017"
height="33.76017"
x="3.119915"
y="3.119915" />
<path
d="m 20.677967,8.54499 c -7.342801,0 -13.295293,4.954293 -13.295293,11.065751 0,2.088793 0.3647173,3.484376 1.575539,5.150563 L 6.0267418,31.45501 13.560595,29.011117 c 2.221262,1.387962 4.125932,1.665377 7.117372,1.665377 7.3428,0 13.295291,-4.954295 13.295291,-11.065753 0,-6.111458 -5.952491,-11.065751 -13.295291,-11.065751 z"
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:0.93031836;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"/>
</svg>

After

Width:  |  Height:  |  Size: 883 B

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 40 40">
<g
transform="translate(0,-60)"
id="layer1">
<rect
width="36.460953"
height="34.805603"
x="1.7695236"
y="62.597198"
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.30826771;stroke-opacity:1" />
<g
transform="matrix(0.88763677,0,0,0.88763677,2.2472646,8.9890584)">
<path
d="M 20,64.526342 C 11.454135,64.526342 4.5263421,71.454135 4.5263421,80 4.5263421,88.545865 11.454135,95.473658 20,95.473658 28.545865,95.473658 35.473658,88.545865 35.473658,80 35.473658,71.454135 28.545865,64.526342 20,64.526342 z m -0.408738,9.488564 c 3.527079,0 6.393832,2.84061 6.393832,6.335441 0,3.494831 -2.866753,6.335441 -6.393832,6.335441 -3.527079,0 -6.393832,-2.84061 -6.393832,-6.335441 0,-3.494831 2.866753,-6.335441 6.393832,-6.335441 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:1.02768445;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
<path
d="m 7.2335209,71.819938 4.9702591,4.161823 c -1.679956,2.581606 -1.443939,6.069592 0.159325,8.677725 l -5.1263071,3.424463 c 0.67516,1.231452 3.0166401,3.547686 4.2331971,4.194757 l 3.907728,-4.567277 c 2.541952,1.45975 5.730694,1.392161 8.438683,-0.12614 l 3.469517,6.108336 c 1.129779,-0.44367 4.742234,-3.449633 5.416358,-5.003859 l -5.46204,-4.415541 c 1.44319,-2.424098 1.651175,-5.267515 0.557303,-7.748623 l 5.903195,-3.833951 C 33.14257,71.704996 30.616217,69.018606 29.02952,67.99296 l -4.118813,4.981678 C 22.411934,71.205099 18.900853,70.937534 16.041319,72.32916 l -3.595408,-5.322091 c -1.345962,0.579488 -4.1293881,2.921233 -5.2123901,4.812869 z m 8.1010311,3.426672 c 2.75284,-2.446266 6.769149,-2.144694 9.048998,0.420874 2.279848,2.56557 2.113919,6.596919 -0.638924,9.043185 -2.752841,2.446267 -6.775754,2.13726 -9.055604,-0.428308 -2.279851,-2.565568 -2.107313,-6.589485 0.64553,-9.035751 z"
style="fill:#000000;fill-opacity:1;stroke:none" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 64 64">
<path
d="M 32.003143,1.4044602 57.432701,62.632577 6.5672991,62.627924 z"
style="fill:#ffff00;fill-opacity:0.94117647;fill-rule:nonzero;stroke:#000000;stroke-width:1.00493038;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 64 64">
<path
d="M 25.470843,9.4933766 C 25.30219,12.141818 30.139101,14.445969 34.704831,13.529144 40.62635,12.541995 41.398833,7.3856498 35.97505,5.777863 31.400921,4.1549155 25.157674,6.5445892 25.470843,9.4933766 z M 4.5246282,17.652051 C 4.068249,11.832873 9.2742983,5.9270407 18.437379,3.0977088 29.751911,-0.87185184 45.495663,1.4008022 53.603953,7.1104009 c 9.275765,6.1889221 7.158128,16.2079421 -3.171076,21.5939521 -1.784316,1.635815 -6.380222,1.21421 -7.068351,3.186186 -1.04003,0.972427 -1.288046,2.050158 -1.232864,3.168203 1.015111,2.000108 -3.831548,1.633216 -3.270553,3.759574 0.589477,5.264544 -0.179276,10.53738 -0.362842,15.806257 -0.492006,2.184998 1.163456,4.574232 -0.734888,6.610642 -2.482919,2.325184 -7.30604,2.189143 -9.193497,-0.274767 -2.733688,-1.740626 -8.254447,-3.615254 -6.104247,-6.339626 3.468112,-1.708686 -2.116197,-3.449897 0.431242,-5.080274 5.058402,-1.39256 -2.393215,-2.304318 -0.146889,-4.334645 3.069198,-0.977415 2.056986,-2.518352 -0.219121,-3.540397 1.876567,-1.807151 1.484149,-4.868919 -2.565455,-5.942205 0.150866,-1.805474 2.905737,-4.136876 -1.679967,-5.20493 C 10.260902,27.882167 4.6872697,22.95045 4.5245945,17.652051 z"
id="path604"
style="fill:#ffff00;fill-opacity:1;stroke:#000000;stroke-width:1.72665179;stroke-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 64 64">
<path
d="M 32.003143,10.913072 57.432701,53.086929 6.567299,53.083723 z"
id="path2985"
style="fill:#ffff00;fill-opacity:0.94117647;fill-rule:nonzero;stroke:#000000;stroke-width:0.83403099;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
</svg>

After

Width:  |  Height:  |  Size: 426 B

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 40 40">
</svg>

After

Width:  |  Height:  |  Size: 158 B

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 40 40">
<rect
width="36.075428"
height="31.096582"
x="1.962286"
y="4.4517088"
id="rect4"
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.23004246;stroke-opacity:1" />
<rect
width="27.96859"
height="1.5012145"
x="6.0157046"
y="10.285"
id="rect6"
style="fill:#000000;fill-opacity:1;stroke:none" />
<rect
width="27.96859"
height="0.85783684"
x="6.0157056"
y="23.21689"
id="rect8"
style="fill:#000000;fill-opacity:1;stroke:none" />
<rect
width="27.96859"
height="0.85783684"
x="5.8130345"
y="28.964394"
id="rect10"
style="fill:#000000;fill-opacity:1;stroke:none" />
<rect
width="27.96859"
height="0.85783684"
x="6.0157046"
y="17.426493"
id="rect12"
style="fill:#000000;fill-opacity:1;stroke:none" />
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,6 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" height="40" width="40">
<path d="M9 3.5a1.5 1.5 0 0 0-3-.001v7.95C6 12.83 7.12 14 8.5 14s2.5-1.17 2.5-2.55V5.5a.5.5 0 0 1 1 0v6.03C11.955 13.427 10.405 15 8.5 15S5.044 13.426 5 11.53V3.5a2.5 2.5 0 0 1 5 0v7.003a1.5 1.5 0 0 1-3-.003v-5a.5.5 0 0 1 1 0v5a.5.5 0 0 0 1 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 552 B

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 40 40">
<rect
width="33.76017"
height="33.76017"
x="3.119915"
y="3.119915"
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
<path
d="m 17.692678,34.50206 0,-16.182224 c -1.930515,-0.103225 -3.455824,-0.730383 -4.57593,-1.881473 -1.12011,-1.151067 -1.680164,-2.619596 -1.680164,-4.405591 0,-1.992435 0.621995,-3.5796849 1.865988,-4.7617553 1.243989,-1.1820288 3.06352,-1.7730536 5.458598,-1.7730764 l 9.802246,0 0,2.6789711 -2.229895,0 0,26.3251486 -2.632515,0 0,-26.3251486 -3.45324,0 0,26.3251486 z"
style="font-size:29.42051125px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.07795751;stroke-opacity:1;font-family:Arial;-inkscape-font-specification:Arial" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,7 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" height="40" width="40">
<path d="M8.156 12.5a.99.99 0 0 0 .707-.294l.523-2.574L10.5 8.499l1.058-1.04 2.65-.601a.996.996 0 0 0 0-1.414l-3.657-3.658a.996.996 0 0 0-1.414 0l-.523 2.576L7.5 5.499 6.442 6.535l-2.65.6a.996.996 0 0 0 0 1.413l3.657 3.658a.999.999 0 0 0 .707.295z"/>
<path d="M9.842.996c-.386 0-.77.146-1.06.44a.5.5 0 0 0-.136.251l-.492 2.43-1.008 1.03-.953.933-2.511.566a.5.5 0 0 0-.243.133 1.505 1.505 0 0 0-.002 2.123l1.477 1.477-2.768 2.767a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l2.767-2.767 1.475 1.474a1.494 1.494 0 0 0 2.123-.002.5.5 0 0 0 .135-.254l.492-2.427 1.008-1.024.953-.937 2.511-.57a.5.5 0 0 0 .243-.132c.586-.58.583-1.543.002-2.125l-3.659-3.656A1.501 1.501 0 0 0 9.842.996Zm.05 1.025a.394.394 0 0 1 .305.12l3.658 3.657c.18.18.141.432.002.627l-2.41.545a.5.5 0 0 0-.24.131L10.15 8.142a.5.5 0 0 0-.007.006L9.029 9.283a.5.5 0 0 0-.133.25l-.48 2.36c-.082.053-.165.109-.26.109a.492.492 0 0 1-.353-.149L4.145 8.195c-.18-.18-.141-.432-.002-.627l2.41-.545a.5.5 0 0 0 .238-.13L7.85 5.857a.5.5 0 0 0 .007-.008l1.114-1.138a.5.5 0 0 0 .133-.25l.472-2.323a.619.619 0 0 1 .317-.117Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,6 @@
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2 3.09C12.28 3.01 12.43 3 12.43 3C12.48 3 12.58 3.02 12.66 3.1L14.45 4.89C14.58 5.02 14.58 5.22 14.45 5.35L11.7713 8.02872L9.51628 5.77372L12.2 3.09ZM13.2658 5.12L11.7713 6.6145L10.9305 5.77372L12.425 4.27921L13.2658 5.12Z" fill="#FBFBFE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.98 9.32L8.23 11.57L10.7106 9.08938L8.45562 6.83438L5.98 9.31V9.32ZM8.23 10.1558L9.29641 9.08938L8.45562 8.24859L7.38921 9.315L8.23 10.1558Z" fill="#FBFBFE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1526 13.1816L16.2125 7.1217C16.7576 6.58919 17.05 5.8707 17.05 5.12C17.05 4.36931 16.7576 3.65084 16.2126 3.11834L14.4317 1.33747C13.8992 0.79242 13.1807 0.5 12.43 0.5C11.6643 0.5 10.9529 0.812929 10.4329 1.33289L3.68289 8.08289C3.04127 8.72452 3.00459 9.75075 3.57288 10.4363L1.29187 12.7239C1.09186 12.9245 0.990263 13.1957 1.0007 13.4685L1 14.5C0.447715 14.5 0 14.9477 0 15.5V17.5C0 18.0523 0.447715 18.5 1 18.5H16C16.5523 18.5 17 18.0523 17 17.5V15.5C17 14.9477 16.5523 14.5 16 14.5H10.2325C9.83594 14.5 9.39953 13.9347 10.1526 13.1816ZM4.39 9.85L4.9807 10.4407L2.39762 13.0312H6.63877L7.10501 12.565L7.57125 13.0312H8.88875L15.51 6.41C15.86 6.07 16.05 5.61 16.05 5.12C16.05 4.63 15.86 4.17 15.51 3.83L13.72 2.04C13.38 1.69 12.92 1.5 12.43 1.5C11.94 1.5 11.48 1.7 11.14 2.04L4.39 8.79C4.1 9.08 4.1 9.56 4.39 9.85ZM16 17.5V15.5H1V17.5H16Z" fill="#FBFBFE"/>
<path d="M15.1616 6.05136L15.1616 6.05132L15.1564 6.05645L8.40645 12.8064C8.35915 12.8537 8.29589 12.88 8.23 12.88C8.16411 12.88 8.10085 12.8537 8.05355 12.8064L7.45857 12.2115L7.10501 11.8579L6.75146 12.2115L6.03289 12.93H3.20465L5.33477 10.7937L5.6873 10.4402L5.33426 10.0871L4.74355 9.49645C4.64882 9.40171 4.64882 9.23829 4.74355 9.14355L11.4936 2.39355C11.7436 2.14354 12.0779 2 12.43 2C12.7883 2 13.1179 2.13776 13.3614 2.38839L13.3613 2.38843L13.3664 2.39355L15.1564 4.18355L15.1564 4.18359L15.1616 4.18864C15.4122 4.43211 15.55 4.76166 15.55 5.12C15.55 5.47834 15.4122 5.80789 15.1616 6.05136ZM7.87645 11.9236L8.23 12.2771L8.58355 11.9236L11.0642 9.44293L11.4177 9.08938L11.0642 8.73582L8.80918 6.48082L8.45562 6.12727L8.10207 6.48082L5.62645 8.95645L5.48 9.10289V9.31V9.32V9.52711L5.62645 9.67355L7.87645 11.9236ZM11.4177 8.38227L11.7713 8.73582L12.1248 8.38227L14.8036 5.70355C15.1288 5.37829 15.1288 4.86171 14.8036 4.53645L13.0136 2.74645C12.8186 2.55146 12.5792 2.5 12.43 2.5H12.4134L12.3967 2.50111L12.43 3C12.3967 2.50111 12.3966 2.50112 12.3965 2.50112L12.3963 2.50114L12.3957 2.50117L12.3947 2.50125L12.3924 2.50142L12.387 2.50184L12.3732 2.50311C12.3628 2.50416 12.3498 2.50567 12.3346 2.50784C12.3049 2.51208 12.2642 2.51925 12.2178 2.53146C12.1396 2.55202 11.9797 2.60317 11.8464 2.73645L9.16273 5.42016L8.80918 5.77372L9.16273 6.12727L11.4177 8.38227ZM1.5 16H15.5V17H1.5V16Z" stroke="#15141A"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.75H12.5V2.25V1V0.5H12H10.358C9.91165 0.5 9.47731 0.625661 9.09989 0.860442L9.09886 0.861087L8 1.54837L6.89997 0.860979L6.89911 0.860443C6.5218 0.625734 6.08748 0.5 5.642 0.5H4H3.5V1V2.25V2.75H4H5.642C5.66478 2.75 5.6885 2.75641 5.71008 2.76968C5.71023 2.76977 5.71038 2.76986 5.71053 2.76995L6.817 3.461C6.81704 3.46103 6.81709 3.46105 6.81713 3.46108C6.81713 3.46108 6.81713 3.46108 6.81714 3.46109C6.8552 3.48494 6.876 3.52285 6.876 3.567V8V12.433C6.876 12.4771 6.85523 12.515 6.81722 12.5389C6.81715 12.5389 6.81707 12.539 6.817 12.539L5.70953 13.23C5.70941 13.2301 5.70929 13.2302 5.70917 13.2303C5.68723 13.2438 5.6644 13.25 5.641 13.25H4H3.5V13.75V15V15.5H4H5.642C6.08835 15.5 6.52269 15.3743 6.90011 15.1396L6.90086 15.1391L8 14.4526L9.10003 15.14L9.10089 15.1406C9.47831 15.3753 9.91265 15.501 10.359 15.501H12H12.5V15.001V13.751V13.251H12H10.358C10.3352 13.251 10.3115 13.2446 10.2899 13.2313C10.2897 13.2312 10.2896 13.2311 10.2895 13.231L9.183 12.54C9.18298 12.54 9.18295 12.54 9.18293 12.54C9.18291 12.5399 9.18288 12.5399 9.18286 12.5399C9.14615 12.5169 9.125 12.4797 9.125 12.434V8V3.567C9.125 3.52266 9.14603 3.48441 9.18364 3.4606C9.18377 3.46052 9.1839 3.46043 9.18404 3.46035L10.2895 2.76995C10.2896 2.76985 10.2898 2.76975 10.2899 2.76966C10.3119 2.75619 10.3346 2.75 10.358 2.75H12Z" fill="black" stroke="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.0189877 13.6645L0.612989 10.4635C0.687989 10.0545 0.884989 9.6805 1.18099 9.3825L9.98199 0.5805C10.756 -0.1925 12.015 -0.1945 12.792 0.5805L14.42 2.2085C15.194 2.9835 15.194 4.2435 14.42 5.0185L5.61599 13.8215C5.31999 14.1165 4.94599 14.3125 4.53799 14.3875L1.33599 14.9815C1.26599 14.9935 1.19799 15.0005 1.12999 15.0005C0.832989 15.0005 0.544988 14.8835 0.330988 14.6695C0.0679874 14.4055 -0.0490122 14.0305 0.0189877 13.6645Z" fill="white"/>
<path d="M0.0189877 13.6645L0.612989 10.4635C0.687989 10.0545 0.884989 9.6805 1.18099 9.3825L9.98199 0.5805C10.756 -0.1925 12.015 -0.1945 12.792 0.5805L14.42 2.2085C15.194 2.9835 15.194 4.2435 14.42 5.0185L5.61599 13.8215C5.31999 14.1165 4.94599 14.3125 4.53799 14.3875L1.33599 14.9815C1.26599 14.9935 1.19799 15.0005 1.12999 15.0005C0.832989 15.0005 0.544988 14.8835 0.330988 14.6695C0.0679874 14.4055 -0.0490122 14.0305 0.0189877 13.6645ZM12.472 5.1965L13.632 4.0365L13.631 3.1885L11.811 1.3675L10.963 1.3685L9.80299 2.5285L12.472 5.1965ZM4.31099 13.1585C4.47099 13.1285 4.61799 13.0515 4.73399 12.9345L11.587 6.0815L8.91899 3.4135L2.06599 10.2655C1.94899 10.3835 1.87199 10.5305 1.84099 10.6915L1.36699 13.2485L1.75199 13.6335L4.31099 13.1585Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,8 @@
<svg width="29" height="32" viewBox="0 0 29 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M28 16.75C28.2761 16.75 28.5 16.5261 28.5 16.25V15C28.5 14.7239 28.2761 14.5 28 14.5H26.358C25.9117 14.5 25.4773 14.6257 25.0999 14.8604L25.0989 14.8611L24 15.5484L22.9 14.861L22.8991 14.8604C22.5218 14.6257 22.0875 14.5 21.642 14.5H20C19.7239 14.5 19.5 14.7239 19.5 15V16.25C19.5 16.5261 19.7239 16.75 20 16.75H21.642C21.6648 16.75 21.6885 16.7564 21.7101 16.7697C21.7102 16.7698 21.7104 16.7699 21.7105 16.77L22.817 17.461C22.817 17.461 22.8171 17.4611 22.8171 17.4611C22.8171 17.4611 22.8171 17.4611 22.8171 17.4611C22.8552 17.4849 22.876 17.5229 22.876 17.567V22.625V27.683C22.876 27.7271 22.8552 27.765 22.8172 27.7889C22.8171 27.7889 22.8171 27.789 22.817 27.789L21.7095 28.48C21.7094 28.4801 21.7093 28.4802 21.7092 28.4803C21.6872 28.4938 21.6644 28.5 21.641 28.5H20C19.7239 28.5 19.5 28.7239 19.5 29V30.25C19.5 30.5261 19.7239 30.75 20 30.75H21.642C22.0883 30.75 22.5227 30.6243 22.9001 30.3896L22.9009 30.3891L24 29.7026L25.1 30.39L25.1009 30.3906C25.4783 30.6253 25.9127 30.751 26.359 30.751H28C28.2761 30.751 28.5 30.5271 28.5 30.251V29.001C28.5 28.7249 28.2761 28.501 28 28.501H26.358C26.3352 28.501 26.3115 28.4946 26.2899 28.4813C26.2897 28.4812 26.2896 28.4811 26.2895 28.481L25.183 27.79C25.183 27.79 25.183 27.79 25.1829 27.79C25.1829 27.7899 25.1829 27.7899 25.1829 27.7899C25.1462 27.7669 25.125 27.7297 25.125 27.684V22.625V17.567C25.125 17.5227 25.146 17.4844 25.1836 17.4606C25.1838 17.4605 25.1839 17.4604 25.184 17.4603L26.2895 16.77C26.2896 16.7699 26.2898 16.7698 26.2899 16.7697C26.3119 16.7562 26.3346 16.75 26.358 16.75H28Z" fill="black" stroke="#FBFBFE" stroke-linejoin="round"/>
<path d="M24.625 17.567C24.625 17.35 24.735 17.152 24.918 17.037L26.026 16.345C26.126 16.283 26.24 16.25 26.358 16.25H28V15H26.358C26.006 15 25.663 15.099 25.364 15.285L24.256 15.978C24.161 16.037 24.081 16.113 24 16.187C23.918 16.113 23.839 16.037 23.744 15.978L22.635 15.285C22.336 15.099 21.993 15 21.642 15H20V16.25H21.642C21.759 16.25 21.874 16.283 21.974 16.345L23.082 17.037C23.266 17.152 23.376 17.35 23.376 17.567V22.625V27.683C23.376 27.9 23.266 28.098 23.082 28.213L21.973 28.905C21.873 28.967 21.759 29 21.641 29H20V30.25H21.642C21.994 30.25 22.337 30.151 22.636 29.965L23.744 29.273C23.84 29.213 23.919 29.137 24 29.064C24.081 29.137 24.161 29.213 24.256 29.273L25.365 29.966C25.664 30.152 26.007 30.251 26.359 30.251H28V29.001H26.358C26.241 29.001 26.126 28.968 26.026 28.906L24.918 28.214C24.734 28.099 24.625 27.901 24.625 27.684V22.625V17.567Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2 2.59C12.28 2.51 12.43 2.5 12.43 2.5C12.48 2.5 12.58 2.52 12.66 2.6L14.45 4.39C14.58 4.52 14.58 4.72 14.45 4.85L11.7713 7.52872L9.51628 5.27372L12.2 2.59ZM13.2658 4.62L11.7713 6.1145L10.9305 5.27372L12.425 3.77921L13.2658 4.62Z" fill="#FBFBFE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.98 8.82L8.23 11.07L10.7106 8.58938L8.45562 6.33438L5.98 8.81V8.82ZM8.23 9.65579L9.29641 8.58938L8.45562 7.74859L7.38921 8.815L8.23 9.65579Z" fill="#FBFBFE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1526 12.6816L16.2125 6.6217C16.7576 6.08919 17.05 5.3707 17.05 4.62C17.05 3.86931 16.7576 3.15084 16.2126 2.61834L14.4317 0.837474C13.8992 0.29242 13.1807 0 12.43 0C11.6643 0 10.9529 0.312929 10.4329 0.832893L3.68289 7.58289C3.04127 8.22452 3.00459 9.25075 3.57288 9.93634L1.29187 12.2239C1.09186 12.4245 0.990263 12.6957 1.0007 12.9685L1 14C0.447715 14 0 14.4477 0 15V17C0 17.5523 0.447715 18 1 18H16C16.5523 18 17 17.5523 17 17V15C17 14.4477 16.5523 14 16 14H10.2325C9.83594 14 9.39953 13.4347 10.1526 12.6816ZM4.39 9.35L4.9807 9.9407L2.39762 12.5312H6.63877L7.10501 12.065L7.57125 12.5312H8.88875L15.51 5.91C15.86 5.57 16.05 5.11 16.05 4.62C16.05 4.13 15.86 3.67 15.51 3.33L13.72 1.54C13.38 1.19 12.92 1 12.43 1C11.94 1 11.48 1.2 11.14 1.54L4.39 8.29C4.1 8.58 4.1 9.06 4.39 9.35ZM16 17V15H1V17H16Z" fill="#FBFBFE"/>
<path d="M15.1616 5.55136L15.1616 5.55132L15.1564 5.55645L8.40645 12.3064C8.35915 12.3537 8.29589 12.38 8.23 12.38C8.16411 12.38 8.10085 12.3537 8.05355 12.3064L7.45857 11.7115L7.10501 11.3579L6.75146 11.7115L6.03289 12.43H3.20465L5.33477 10.2937L5.6873 9.94019L5.33426 9.58715L4.74355 8.99645C4.64882 8.90171 4.64882 8.73829 4.74355 8.64355L11.4936 1.89355C11.7436 1.64354 12.0779 1.5 12.43 1.5C12.7883 1.5 13.1179 1.63776 13.3614 1.88839L13.3613 1.88843L13.3664 1.89355L15.1564 3.68355L15.1564 3.68359L15.1616 3.68864C15.4122 3.93211 15.55 4.26166 15.55 4.62C15.55 4.97834 15.4122 5.30789 15.1616 5.55136ZM5.48 8.82V9.02711L5.62645 9.17355L7.87645 11.4236L8.23 11.7771L8.58355 11.4236L11.0642 8.94293L11.4177 8.58938L11.0642 8.23582L8.80918 5.98082L8.45562 5.62727L8.10207 5.98082L5.62645 8.45645L5.48 8.60289V8.81V8.82ZM11.4177 7.88227L11.7713 8.23582L12.1248 7.88227L14.8036 5.20355C15.1288 4.87829 15.1288 4.36171 14.8036 4.03645L13.0136 2.24645C12.8186 2.05146 12.5792 2 12.43 2H12.4134L12.3967 2.00111L12.43 2.5C12.3967 2.00111 12.3966 2.00112 12.3965 2.00112L12.3963 2.00114L12.3957 2.00117L12.3947 2.00125L12.3924 2.00142L12.387 2.00184L12.3732 2.00311C12.3628 2.00416 12.3498 2.00567 12.3346 2.00784C12.3049 2.01208 12.2642 2.01925 12.2178 2.03146C12.1396 2.05202 11.9797 2.10317 11.8464 2.23645L9.16273 4.92016L8.80918 5.27372L9.16273 5.62727L11.4177 7.88227ZM1.5 16.5V15.5H15.5V16.5H1.5Z" stroke="#15141A"/>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11 3H13.6C14 3 14.3 3.3 14.3 3.6C14.3 3.9 14 4.2 13.7 4.2H13.3V14C13.3 15.1 12.4 16 11.3 16H4.80005C3.70005 16 2.80005 15.1 2.80005 14V4.2H2.40005C2.00005 4.2 1.80005 4 1.80005 3.6C1.80005 3.2 2.00005 3 2.40005 3H5.00005V2C5.00005 0.9 5.90005 0 7.00005 0H9.00005C10.1 0 11 0.9 11 2V3ZM6.90005 1.2L6.30005 1.8V3H9.80005V1.8L9.20005 1.2H6.90005ZM11.4 14.7L12 14.1V4.2H4.00005V14.1L4.60005 14.7H11.4ZM7.00005 12.4C7.00005 12.7 6.70005 13 6.40005 13C6.10005 13 5.80005 12.7 5.80005 12.4V7.6C5.70005 7.3 6.00005 7 6.40005 7C6.80005 7 7.00005 7.3 7.00005 7.6V12.4ZM10.2001 12.4C10.2001 12.7 9.90006 13 9.60006 13C9.30006 13 9.00006 12.7 9.00006 12.4V7.6C9.00006 7.3 9.30006 7 9.60006 7C9.90006 7 10.2001 7.3 10.2001 7.6V12.4Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 909 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.85822 8.84922L4.85322 11.8542C4.75891 11.9453 4.63261 11.9957 4.50151 11.9946C4.37042 11.9934 4.24501 11.9408 4.15231 11.8481C4.0596 11.7554 4.00702 11.63 4.00588 11.4989C4.00474 11.3678 4.05514 11.2415 4.14622 11.1472L7.15122 8.14222V7.85922L4.14622 4.85322C4.05514 4.75891 4.00474 4.63261 4.00588 4.50151C4.00702 4.37042 4.0596 4.24501 4.15231 4.15231C4.24501 4.0596 4.37042 4.00702 4.50151 4.00588C4.63261 4.00474 4.75891 4.05514 4.85322 4.14622L7.85822 7.15122H8.14122L11.1462 4.14622C11.2405 4.05514 11.3668 4.00474 11.4979 4.00588C11.629 4.00702 11.7544 4.0596 11.8471 4.15231C11.9398 4.24501 11.9924 4.37042 11.9936 4.50151C11.9947 4.63261 11.9443 4.75891 11.8532 4.85322L8.84822 7.85922V8.14222L11.8532 11.1472C11.9443 11.2415 11.9947 11.3678 11.9936 11.4989C11.9924 11.63 11.9398 11.7554 11.8471 11.8481C11.7544 11.9408 11.629 11.9934 11.4979 11.9946C11.3668 11.9957 11.2405 11.9453 11.1462 11.8542L8.14122 8.84922L8.14222 8.85022L7.85822 8.84922Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.8748 12.037L9.37782 2.037C8.99682 1.346 8.31082 1 7.62482 1C6.93882 1 6.25282 1.346 5.87282 2.037L0.375823 12.037C-0.358177 13.37 0.606823 15 2.12782 15H13.1228C14.6428 15 15.6078 13.37 14.8748 12.037ZM8.24982 11.75L7.99982 12H7.24982L6.99982 11.75V11L7.24982 10.75H7.99982L8.24982 11V11.75ZM8.24982 9.062C8.24982 9.22776 8.18398 9.38673 8.06677 9.50394C7.94955 9.62115 7.79058 9.687 7.62482 9.687C7.45906 9.687 7.30009 9.62115 7.18288 9.50394C7.06567 9.38673 6.99982 9.22776 6.99982 9.062V5.625C6.99982 5.45924 7.06567 5.30027 7.18288 5.18306C7.30009 5.06585 7.45906 5 7.62482 5C7.79058 5 7.94955 5.06585 8.06677 5.18306C8.18398 5.30027 8.24982 5.45924 8.24982 5.625V9.062Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 811 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5ZM0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8ZM8.75 4V5.5H7.25V4H8.75ZM8.75 12V7H7.25V12H8.75Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 417 B

@@ -0,0 +1,6 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.10918 11.66C7.24918 11.8 7.43918 11.88 7.63918 11.88C7.83918 11.88 8.02918 11.8 8.16918 11.66L14.9192 4.91C15.2692 4.57 15.4592 4.11 15.4592 3.62C15.4592 3.13 15.2692 2.67 14.9192 2.33L13.1292 0.54C12.7892 0.19 12.3292 0 11.8392 0C11.3492 0 10.8892 0.2 10.5492 0.54L3.79918 7.29C3.50918 7.58 3.50918 8.06 3.79918 8.35L4.38988 8.9407L1.40918 11.93H5.64918L6.51419 11.065L7.10918 11.66ZM7.63918 10.07L5.38918 7.82V7.81L7.8648 5.33438L10.1198 7.58938L7.63918 10.07ZM11.1805 6.52872L13.8592 3.85C13.9892 3.72 13.9892 3.52 13.8592 3.39L12.0692 1.6C11.9892 1.52 11.8892 1.5 11.8392 1.5C11.8392 1.5 11.6892 1.51 11.6092 1.59L8.92546 4.27372L11.1805 6.52872Z" fill="#000"/>
<path d="M0.40918 14H15.4092V16H0.40918V14Z" fill="#000"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 910 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.23336 10.4664L11.8474 6.85339C11.894 6.8071 11.931 6.75203 11.9563 6.69136C11.9816 6.63069 11.9946 6.56562 11.9946 6.49989C11.9946 6.43417 11.9816 6.3691 11.9563 6.30843C11.931 6.24776 11.894 6.19269 11.8474 6.14639C11.7536 6.05266 11.6264 6 11.4939 6C11.3613 6 11.2341 6.05266 11.1404 6.14639L7.99236 9.29339L4.84736 6.14739C4.75305 6.05631 4.62675 6.00592 4.49566 6.00706C4.36456 6.0082 4.23915 6.06078 4.14645 6.15348C4.05374 6.24619 4.00116 6.37159 4.00002 6.50269C3.99888 6.63379 4.04928 6.76009 4.14036 6.85439L7.75236 10.4674L8.23336 10.4664Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 681 B

@@ -0,0 +1,21 @@
/**
* @licstart The following is the entire license notice for the
* JavaScript code in this page
*
* Copyright 2024 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @licend The above is the entire license notice for the
* JavaScript code in this page
*/var t={d:(e,i)=>{for(var s in i)t.o(i,s)&&!t.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:i[s]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e)},__webpack_exports__ = globalThis.pdfjsLib = {};t.d(__webpack_exports__,{AbortException:()=>AbortException,AnnotationEditorLayer:()=>AnnotationEditorLayer,AnnotationEditorParamsType:()=>m,AnnotationEditorType:()=>g,AnnotationEditorUIManager:()=>AnnotationEditorUIManager,AnnotationLayer:()=>AnnotationLayer,AnnotationMode:()=>p,ColorPicker:()=>ColorPicker,DOMSVGFactory:()=>DOMSVGFactory,DrawLayer:()=>DrawLayer,FeatureTest:()=>util_FeatureTest,GlobalWorkerOptions:()=>GlobalWorkerOptions,ImageKind:()=>_,InvalidPDFException:()=>InvalidPDFException,MissingPDFException:()=>MissingPDFException,OPS:()=>X,OutputScale:()=>OutputScale,PDFDataRangeTransport:()=>PDFDataRangeTransport,PDFDateString:()=>PDFDateString,PDFWorker:()=>PDFWorker,PasswordResponses:()=>K,PermissionFlag:()=>f,PixelsPerInch:()=>PixelsPerInch,RenderingCancelledException:()=>RenderingCancelledException,TextLayer:()=>TextLayer,TouchManager:()=>TouchManager,UnexpectedResponseException:()=>UnexpectedResponseException,Util:()=>Util,VerbosityLevel:()=>q,XfaLayer:()=>XfaLayer,build:()=>Nt,createValidAbsoluteUrl:()=>createValidAbsoluteUrl,fetchData:()=>fetchData,getDocument:()=>getDocument,getFilenameFromUrl:()=>getFilenameFromUrl,getPdfFilenameFromUrl:()=>getPdfFilenameFromUrl,getXfaPageViewport:()=>getXfaPageViewport,isDataScheme:()=>isDataScheme,isPdfFile:()=>isPdfFile,noContextMenu:()=>noContextMenu,normalizeUnicode:()=>normalizeUnicode,setLayerDimensions:()=>setLayerDimensions,shadow:()=>shadow,stopEvent:()=>stopEvent,version:()=>Ot});const e=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type),i=[1,0,0,1,0,0],s=[.001,0,0,.001,0,0],n=1.35,a=1,r=2,o=4,l=16,h=32,d=64,c=128,u=256,p={DISABLE:0,ENABLE:1,ENABLE_FORMS:2,ENABLE_STORAGE:3},g={DISABLE:-1,NONE:0,FREETEXT:3,HIGHLIGHT:9,STAMP:13,INK:15},m={RESIZE:1,CREATE:2,FREETEXT_SIZE:11,FREETEXT_COLOR:12,FREETEXT_OPACITY:13,INK_COLOR:21,INK_THICKNESS:22,INK_OPACITY:23,HIGHLIGHT_COLOR:31,HIGHLIGHT_DEFAULT_COLOR:32,HIGHLIGHT_THICKNESS:33,HIGHLIGHT_FREE:34,HIGHLIGHT_SHOW_ALL:35,DRAW_STEP:41},f={PRINT:4,MODIFY_CONTENTS:8,COPY:16,MODIFY_ANNOTATIONS:32,FILL_INTERACTIVE_FORMS:256,COPY_FOR_ACCESSIBILITY:512,ASSEMBLE:1024,PRINT_HIGH_QUALITY:2048},b=0,A=1,w=2,v=3,y=3,x=4,_={GRAYSCALE_1BPP:1,RGB_24BPP:2,RGBA_32BPP:3},E=1,S=2,C=3,T=4,M=5,P=6,D=7,k=8,R=9,I=10,F=11,L=12,O=13,N=14,B=15,H=16,z=17,U=20,G=1,$=2,V=3,j=4,W=5,q={ERRORS:0,WARNINGS:1,INFOS:5},X={dependency:1,setLineWidth:2,setLineCap:3,setLineJoin:4,setMiterLimit:5,setDash:6,setRenderingIntent:7,setFlatness:8,setGState:9,save:10,restore:11,transform:12,moveTo:13,lineTo:14,curveTo:15,curveTo2:16,curveTo3:17,closePath:18,rectangle:19,stroke:20,closeStroke:21,fill:22,eoFill:23,fillStroke:24,eoFillStroke:25,closeFillStroke:26,closeEOFillStroke:27,endPath:28,clip:29,eoClip:30,beginText:31,endText:32,setCharSpacing:33,setWordSpacing:34,setHScale:35,setLeading:36,setFont:37,setTextRenderingMode:38,setTextRise:39,moveText:40,setLeadingMoveText:41,setTextMatrix:42,nextLine:43,showText:44,showSpacedText:45,nextLineShowText:46,nextLineSetSpacingShowText:47,setCharWidth:48,setCharWidthAndBounds:49,setStrokeColorSpace:50,setFillColorSpace:51,setStrokeColor:52,setStrokeColorN:53,setFillColor:54,setFillColorN:55,setStrokeGray:56,setFillGray:57,setStrokeRGBColor:58,setFillRGBColor:59,setStrokeCMYKColor:60,setFillCMYKColor:61,shadingFill:62,beginInlineImage:63,beginImageData:64,endInlineImage:65,paintXObject:66,markPoint:67,markPointProps:68,beginMarkedContent:69,beginMarkedContentProps:70,endMarkedContent:71,beginCompat:72,endCompat:73,paintFormXObjectBegin:74,paintFormXObjectEnd:75,beginGroup:76,endGroup:77,beginAnnotation:80,endAnnotation:81,paintImageMaskXObject:83,paintImageMaskXObjectGroup:84,paintImageXObject:85,paintInlineImageXObject:86,paintInlineImageXObjectGroup:87,paintImageXObjectRepeat:88,paintImageMaskXObjectRepeat:89,paintSolidColorImageMask:90,constructPath:91,setStrokeTransparent:92,setFillTransparent:93},K={NEED_PASSWORD:1,INCORRECT_PASSWORD:2};let Y=q.WARNINGS;function setVerbosityLevel(t){Number.isInteger(t)&&(Y=t)}function getVerbosityLevel(){return Y}function info(t){Y>=q.INFOS&&console.log(`Info: ${t}`)}function warn(t){Y>=q.WARNINGS&&console.log(`Warning: ${t}`)}function unreachable(t){throw new Error(t)}function assert(t,e){t||unreachable(e)}function createValidAbsoluteUrl(t,e=null,i=null){if(!t)return null;try{if(i&&"string"==typeof t){if(i.addDefaultProtocol&&t.startsWith("www.")){const e=t.match(/\./g);e?.length>=2&&(t=`http://${t}`)}if(i.tryConvertEncoding)try{t=function stringToUTF8String(t){return decodeURIComponent(escape(t))}(t)}catch{}}const s=e?new URL(t,e):new URL(t);if(function _isValidProtocol(t){switch(t?.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:Line truncated
@@ -0,0 +1,21 @@
/**
* @licstart The following is the entire license notice for the
* JavaScript code in this page
*
* Copyright 2024 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @licend The above is the entire license notice for the
* JavaScript code in this page
*/var e={d:(t,i)=>{for(var a in i)e.o(i,a)&&!e.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:i[a]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)},__webpack_exports__ = globalThis.pdfjsWorker = {};e.d(__webpack_exports__,{WorkerMessageHandler:()=>WorkerMessageHandler});const t=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type),i=[1,0,0,1,0,0],a=[.001,0,0,.001,0,0],s=1.35,r=.35,n=.25925925925925924,g=1,o=2,c=4,C=8,h=16,l=64,Q=128,E=256,u="pdfjs_internal_editor_",d=3,f=9,p=13,m=15,y={PRINT:4,MODIFY_CONTENTS:8,COPY:16,MODIFY_ANNOTATIONS:32,FILL_INTERACTIVE_FORMS:256,COPY_FOR_ACCESSIBILITY:512,ASSEMBLE:1024,PRINT_HIGH_QUALITY:2048},w=0,D=4,b=1,F=2,S=3,k=1,R=2,N=3,G=4,M=5,U=6,x=7,L=8,H=9,J=10,Y=11,v=12,K=13,T=14,q=15,O=16,W=17,j=20,X="Group",Z="R",V=1,z=2,_=4,$=16,AA=32,eA=128,tA=512,iA=1,aA=2,sA=4096,rA=8192,nA=32768,gA=65536,oA=131072,IA=1048576,cA=2097152,CA=8388608,hA=16777216,lA=1,BA=2,QA=3,EA=4,uA=5,dA={E:"Mouse Enter",X:"Mouse Exit",D:"Mouse Down",U:"Mouse Up",Fo:"Focus",Bl:"Blur",PO:"PageOpen",PC:"PageClose",PV:"PageVisible",PI:"PageInvisible",K:"Keystroke",F:"Format",V:"Validate",C:"Calculate"},fA={WC:"WillClose",WS:"WillSave",DS:"DidSave",WP:"WillPrint",DP:"DidPrint"},pA={O:"PageOpen",C:"PageClose"},mA=1,yA=5,wA=1,DA=2,bA=3,FA=4,SA=5,kA=6,RA=7,NA=8,GA=9,MA=10,UA=11,xA=12,LA=13,HA=14,JA=15,YA=16,vA=17,KA=18,TA=19,qA=20,OA=21,PA=22,WA=23,jA=24,XA=25,ZA=26,VA=27,zA=28,_A=29,$A=30,Ae=31,ee=32,te=33,ie=34,ae=35,se=36,re=37,ne=38,ge=39,oe=40,Ie=41,ce=42,Ce=43,he=44,le=45,Be=46,Qe=47,Ee=48,ue=49,de=50,fe=51,pe=52,me=53,ye=54,we=55,De=56,be=57,Fe=58,Se=59,ke=60,Re=61,Ne=62,Ge=63,Me=64,Ue=65,xe=66,Le=67,He=68,Je=69,Ye=70,ve=71,Ke=72,Te=73,qe=74,Oe=75,Pe=76,We=77,je=80,Xe=81,Ze=83,Ve=84,ze=85,_e=86,$e=87,At=88,et=89,tt=90,it=91,at=92,st=93,rt=1,nt=2;let gt=mA;function getVerbosityLevel(){return gt}function info(e){gt>=yA&&console.log(`Info: ${e}`)}function warn(e){gt>=mA&&console.log(`Warning: ${e}`)}function unreachable(e){throw new Error(e)}function assert(e,t){e||unreachable(t)}function createValidAbsoluteUrl(e,t=null,i=null){if(!e)return null;try{if(i&&"string"==typeof e){if(i.addDefaultProtocol&&e.startsWith("www.")){const t=e.match(/\./g);t?.length>=2&&(e=`http://${e}`)}if(i.tryConvertEncoding)try{e=stringToUTF8String(e)}catch{}}const a=t?new URL(e,t):new URL(e);if(function _isValidProtocol(e){switch(e?.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}(a))return a}catch{}return null}function shadow(e,t,i,a=!1){Object.defineProperty(e,t,{value:i,enumerable:!a,configurable:!0,writable:!1});return i}const ot=function BaseExceptionClosure(){function BaseException(e,t){this.message=e;this.name=t}BaseException.prototype=new Error;BaseException.constructor=BaseException;return BaseException}();class PasswordException extends ot{constructor(e,t){super(e,"PasswordException");this.code=t}}class UnknownErrorException extends ot{constructor(e,t){super(e,"UnknownErrorException");this.details=t}}class InvalidPDFException extends ot{constructor(e){super(e,"InvalidPDFException")}}class MissingPDFException extends ot{constructor(e){super(e,"MissingPDFException")}}class UnexpectedResponseException extends ot{constructor(e,t){super(e,"UnexpectedResponseException");this.status=t}}class FormatError extends ot{constructor(e){super(e,"FormatError")}}class AbortException extends ot{constructor(e){super(e,"AbortException")}}function bytesToString(e){"object"==typeof e&&void 0!==e?.length||unreachable("Invalid argument for bytesToString");const t=e.length,i=8192;if(t<i)return String.fromCharCode.apply(null,e);const a=[];for(let s=0;s<t;s+=i){const r=Math.min(s+i,t),n=e.subarray(s,r);a.push(String.fromCharCode.apply(null,n))}return a.join("")}function stringToBytes(e){"string"!=typeof e&&unreachable("Invalid argument for stringToBytes");const t=e.length,i=new Uint8Array(t);for(let a=0;a<t;++a)i[a]=255&e.charCodeAt(a);return i}function string32(e){return String.fromCharCode(e>>24&255,e>>16&255,e>>8&255,255&e)}function objectSize(e){return Object.keys(e).length}class FeatureTest{static get isLittleEndian(){return shadow(this,"isLittleEndian",function isLittleEndian(){const e=new Uint8Array(4);e[0]=1;return 1===new Uint32Array(e.buffer,0,1)[0]}())}static get isEvalSupported(){return shadow(this,"isEvalSupported",function isEvalSupported(){try{new Function("");return!0}catch{return!1}}())}static get isOffscreenCanvasSupported(){return shadow(this,"isOffscreenCanvasSupported","undefined"!=typeof OffscreenCanvas)}static get isImageDecoderSupported(){return shadow(this,"isImageDecoderSupported","undefined"!=typeof ImageDecoder)}static get platform(){return"undefined"!=typeof navigator&&"string"==typeof navigator?.platform?shadow(this,"platform",{isMac:navigator.platform.includes("Mac"),isWindows:navigator.platform.includes("Win"),isFirefox:"string"==typeof navigator?.userAgent&&navLine truncated
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,27 @@
// Copyright 2014 PDFium Authors. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,102 @@
Digitized data copyright (c) 2010 Google Corporation
with Reserved Font Arimo, Tinos and Cousine.
Copyright (c) 2012 Red Hat, Inc.
with Reserved Font Name Liberation.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
PREAMBLE The goals of the Open Font License (OFL) are to stimulate
worldwide development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to provide
a free and open framework in which fonts may be shared and improved in
partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves.
The fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such.
This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components
as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting ? in part or in whole ?
any of the components of the Original Version, by changing formats or
by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer
or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this license, and must not be distributed
under any other license. The requirement for fonts to remain under
this license does not apply to any document created using the Font
Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
@@ -26,6 +26,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from .. import uploads
from ..sysops import SysOps from ..sysops import SysOps
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -99,7 +100,10 @@ def validate_domain(value: str) -> str:
def render(config: WebConfig) -> str: def render(config: WebConfig) -> str:
"""Erzeugt die vollstaendige Site-Konfiguration.""" """Erzeugt die vollstaendige Site-Konfiguration."""
body = f"{max(1, config.max_body_mb)}m" # Bewusst mehr als die Anwendung erlaubt: laeuft die Grenze hier zuerst
# auf, sieht der Benutzer die nackte 413 von nginx. Kommt die Anfrage
# dagegen bis zur Anwendung, sagt die, wie viel erlaubt ist.
body = f"{uploads.nginx_mb(config.max_body_mb)}m"
# Kopfzeilen und proxy_pass -- ohne Zeitlimits. Die kommen je Block dazu, # Kopfzeilen und proxy_pass -- ohne Zeitlimits. Die kommen je Block dazu,
# denn nginx lehnt eine doppelte Direktive im selben Block ab (auf POETEST # denn nginx lehnt eine doppelte Direktive im selben Block ab (auf POETEST
# real passiert: der Websocket-Block erbte 120s und setzte noch einmal # real passiert: der Websocket-Block erbte 120s und setzte noch einmal
@@ -194,8 +198,9 @@ def render(config: WebConfig) -> str:
def _site_body(config: WebConfig, common_proxy: list[str], body: str, *, tls: bool) -> list[str]: def _site_body(config: WebConfig, common_proxy: list[str], body: str, *, tls: bool) -> list[str]:
lines = [ lines = [
f" client_max_body_size {body};", f" client_max_body_size {body};",
" # Groesser als das Limit der Anwendung, damit deren eigene, erklaerende", f" # {uploads.NGINX_ZUSCHLAG_MB} MB mehr als die Anwendung erlaubt, damit deren"
" # Fehlerseite gewinnt statt der generischen 413 von nginx.", " eigene,",
" # erklaerende Fehlerseite gewinnt statt der generischen 413 von nginx.",
"", "",
" location /assets/ {", " location /assets/ {",
f" alias {config.static_root.rstrip('/')}/;", f" alias {config.static_root.rstrip('/')}/;",
@@ -1,6 +1,9 @@
{% extends "tesm_core/standalone.html" %} {% extends "tesm_core/standalone.html" %}
{# Die Ueberschrift bleibt fuer die Titelzeile des Browsers -- sichtbar ist sie
hier nicht: der leere ``brand``-Block laesst Kachel, Ueberschrift und
Unterzeile weg. Die Wortmarke darueber sagt schon, wo man ist. #}
{% block heading %}Anmelden{% endblock %} {% block heading %}Anmelden{% endblock %}
{% block subheading %}{{ app_name }} &ndash; {{ app_edition }}{% endblock %} {% block brand %}{% endblock %}
{% block content %} {% block content %}
<form method="post" class="stack" data-no-guard autocomplete="on"> <form method="post" class="stack" data-no-guard autocomplete="on">
@@ -19,7 +19,7 @@
<td class="fs-sm">{{ row.actor }}</td> <td class="fs-sm">{{ row.actor }}</td>
<td class="mono fs-xs">{{ row.action }}</td> <td class="mono fs-xs">{{ row.action }}</td>
<td class="fs-sm">{{ row.target }}</td> <td class="fs-sm">{{ row.target }}</td>
<td class="fs-sm muted">{{ row.detail }}</td> <td class="fs-sm muted">{{ row.detail | audit_detail }}</td>
<td class="mono fs-xs faint">{{ row.hash[:12] }}</td> <td class="mono fs-xs faint">{{ row.hash[:12] }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -64,7 +64,7 @@
<td class="fs-sm fw-600">{{ row.actor }}</td> <td class="fs-sm fw-600">{{ row.actor }}</td>
<td class="mono fs-xs">{{ row.action }}</td> <td class="mono fs-xs">{{ row.action }}</td>
<td class="fs-sm">{{ row.target }}</td> <td class="fs-sm">{{ row.target }}</td>
<td class="fs-sm muted break-all">{{ row.detail }}</td> <td class="fs-sm muted break-all">{{ row.detail | audit_detail }}</td>
<td> <td>
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info {# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
@@ -41,6 +41,15 @@ blieb dadurch über Jahre bestehen und lieferte alle statischen Dateien als 404.
disabled=bool(license_hint), extra_class='field--full') }} disabled=bool(license_hint), extra_class='field--full') }}
{{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot, {{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot,
disabled=bool(license_hint), extra_class='field--full') }} disabled=bool(license_hint), extra_class='field--full') }}
{# Eine Zahl für beides. Vorher galt in der Anwendung eine andere
Grenze als in nginx: die Freigabeseite versprach 512 MB, bei
64 MB brach die Anfrage mit einer nackten 413 ab. #}
{{ field('web_max_body_mb', 'Maximale Uploadgröße (MB)',
value=config.max_body_mb, type='number',
attrs='min="1" max="8192" step="1"',
hint_text='Gilt für die Anwendung sofort. nginx bekommt beim Übernehmen
' ~ nginx_zuschlag ~ ' MB mehr, damit die Anwendung erklären kann, was zu
groß war -- statt einer nackten Fehlerseite des Webservers.') }}
</div> </div>
<div class="stack stack--tight"> <div class="stack stack--tight">
{{ checkbox('web_https_enabled', 'HTTPS auf Port 443 anbieten', {{ checkbox('web_https_enabled', 'HTTPS auf Port 443 anbieten',
@@ -22,37 +22,51 @@
</head> </head>
<body data-csrf="{{ csrf_token() }}"> <body data-csrf="{{ csrf_token() }}">
<div class="auth-page"> <div class="auth-page">
{% if auth_tagline %} <div class="auth-stack">
{# Wortmarke mit Funktionszeile als tatsächlicher Seitenhintergrund -- {# Wortmarke als Seitenhintergrund -- fuer beide Anwendungen. Bewusst inline
übernommen vom Vorgänger. Bewusst inline und mit Themen-Tokens statt und mit Themen-Tokens statt zweier Dateien fuer hell und dunkel: bei der
zweier Dateien für hell und dunkel: bei der Einstellung "System" und Einstellung "System" und beim Umschalten von Hand waere die versteckte
beim Umschalten von Hand wäre die versteckte Variante die falsche. Variante die falsche. ``aria-hidden``, weil der Name in der Titelzeile
``aria-hidden``, weil der Name schon in der Überschrift steht. #} steht.
<div class="auth-backdrop" aria-hidden="true">
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg"> Die Geometrie ist symmetrisch um die Mitte (x=260) aufgebaut, damit die
<g transform="translate(150,15)"> Marke ohne Nachrechnen mittig sitzt: der Schriftzug haengt an
<circle cx="298" cy="70" r="15" fill="var(--logo-mark)" opacity="0.22"/> ``text-anchor="middle"``, die beiden Zacken liegen spiegelbildlich, und
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 die Linie beginnt links so weit vor der Mitte, wie der Punkt rechts nach
218,35 226,105 234,70 244,70 300,70" ihr endet. Wer die Linie verlaengert, muss beide Enden anfassen.
fill="none" stroke="var(--logo-mark)" stroke-width="3.2"
stroke-linecap="round" stroke-linejoin="round" opacity="0.85"/> Ohne Funktionszeile (Lizenzserver) ist der Kasten kuerzer -- sonst
<text x="14" y="98" font-family="var(--font-mono)" font-weight="800" stuende unter der Marke ein leerer Streifen. #}
font-size="82" letter-spacing="2" fill="var(--logo-text)">TESM</text> <div class="auth-backdrop" aria-hidden="true">
<circle cx="298" cy="70" r="8" fill="var(--logo-mark)"/> <svg viewBox="0 0 520 {{ 150 if auth_tagline else 116 }}" xmlns="http://www.w3.org/2000/svg">
</g> <circle cx="452" cy="62" r="15" fill="var(--logo-mark)" opacity="0.22"/>
<text x="310" y="178" text-anchor="middle" font-family="var(--font-sans)" <polyline points="60,62 163,62 171,12 179,112 187,62 333,62 341,12 349,112 357,62 452,62"
fill="none" stroke="var(--logo-mark)" stroke-width="3.2"
stroke-linecap="round" stroke-linejoin="round" opacity="0.85"/>
<text x="260" y="90" text-anchor="middle" font-family="var(--font-mono)"
font-weight="800" font-size="82" letter-spacing="2"
fill="var(--logo-text)">TESM</text>
<circle cx="452" cy="62" r="8" fill="var(--logo-mark)"/>
{% if auth_tagline %}
<text x="260" y="140" text-anchor="middle" font-family="var(--font-sans)"
font-weight="600" font-size="13" letter-spacing="1.5" font-weight="600" font-size="13" letter-spacing="1.5"
fill="var(--logo-mark)">{{ auth_tagline }}</text> fill="var(--logo-mark)">{{ auth_tagline }}</text>
</svg> {% endif %}
</div> </svg>
{% endif %} </div>
<div class="auth-card {% block card_class %}{% endblock %}"> <div class="auth-card {% block card_class %}{% endblock %}">
<div class="auth-brand"> {# Die Anmeldeseite laesst diesen Kopf weg: die Wortmarke darueber sagt
<span class="auth-brand__mark" aria-hidden="true">{{ app_short }}</span> schon, wo man ist, und "Anmelden" steht auf dem Knopf. Die uebrigen
<h1>{% block heading %}{{ app_name }}{% endblock %}</h1> Standalone-Seiten behalten ihn -- "Zweiter Faktor" oder der Kundenname
{% set sub %}{% block subheading %}{% endblock %}{% endset %} im Portal sind dort die Auskunft, um die es geht. #}
{% if sub | trim %}<p>{{ sub }}</p>{% endif %} {% block brand %}
</div> <div class="auth-brand">
<span class="auth-brand__mark" aria-hidden="true">{{ app_short }}</span>
<h1>{% block heading %}{{ app_name }}{% endblock %}</h1>
{% set sub %}{% block subheading %}{% endblock %}{% endset %}
{% if sub | trim %}<p>{{ sub }}</p>{% endif %}
</div>
{% endblock %}
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
@@ -72,6 +86,7 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
<p class="auth-footnote">{{ app_name }} {{ app_version }} &middot; &copy; {{ current_year }} {{ copyright_notice }}</p> <p class="auth-footnote">{{ app_name }} {{ app_version }} &middot; &copy; {{ current_year }} {{ copyright_notice }}</p>
</div>
</div> </div>
</div> </div>
<script type="module" src="{{ asset_url('js/app.js') }}" nonce="{{ csp_nonce }}"></script> <script type="module" src="{{ asset_url('js/app.js') }}" nonce="{{ csp_nonce }}"></script>
+89 -1
View File
@@ -8,12 +8,13 @@ nichts, was die CSP aufweichen muesste.
from __future__ import annotations from __future__ import annotations
import math import math
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from flask import Flask, url_for from flask import Flask, url_for
from markupsafe import Markup from markupsafe import Markup, escape
ICONS: dict[str, str] = { ICONS: dict[str, str] = {
"grid": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>', "grid": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
@@ -43,6 +44,9 @@ ICONS: dict[str, str] = {
"log-out": '<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3M10 8l-4 4 4 4M6 12h10"/>', "log-out": '<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3M10 8l-4 4 4 4M6 12h10"/>',
"settings": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>', "settings": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
"plus": '<path d="M12 5v14M5 12h14"/>', "plus": '<path d="M12 5v14M5 12h14"/>',
"minus": '<path d="M5 12h14"/>',
# Diskette -- die Vorschau schreibt ein ausgefuelltes Formular zurueck.
"save": '<path d="M5 4h11l3 3v13H5z"/><path d="M8 4v5h7V4M8 20v-6h8v6"/>',
"pencil": '<path d="M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3z"/>', "pencil": '<path d="M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3z"/>',
"x": '<path d="M6 6l12 12M18 6 6 18"/>', "x": '<path d="M6 6l12 12M18 6 6 18"/>',
"check": '<path d="m5 13 4 4L19 7"/>', "check": '<path d="m5 13 4 4L19 7"/>',
@@ -263,6 +267,89 @@ def asset_url(app: Flask) -> Any:
return build return build
# --------------------------------------------------------------------------- #
# Auditeintraege: Dateinamen anklickbar machen
# --------------------------------------------------------------------------- #
#: Name einer Mitschrift, wie ``terminal.py`` sie bildet: Zeitstempel, Benutzer,
#: Ziel. Der Ausdruck laesst bewusst keinen Schraegstrich zu -- der Name kommt
#: aus einem Auditeintrag, und ein Eintrag ist Text, kein Pfad.
MITSCHRIFT = re.compile(r"\b(\d{8}-\d{6}-[A-Za-z0-9._-]{1,140}\.log)\b")
#: Unterverzeichnisse unter dem Protokollverzeichnis, in denen nachgesehen wird.
#: Der leere Eintrag steht fuer das Verzeichnis selbst.
PROTOKOLL_ORTE = ("terminal", "")
def _mitschrift_ziel(name: str) -> str | None:
"""Der Name relativ zum Protokollverzeichnis -- oder ``None``.
Ein Verweis auf eine Datei, die es nicht mehr gibt, ist schlechter als
keiner: er sieht nach einem Angebot aus und endet in 404. Protokolle werden
aufgeraeumt, Auditeintraege bleiben -- der Fall ist die Regel, nicht die
Ausnahme.
"""
from .extension import core
try:
wurzel = core().config.log_dir
except Exception: # noqa: BLE001 - ausserhalb eines App-Kontexts
return None
for ort in PROTOKOLL_ORTE:
relativ = f"{ort}/{name}" if ort else name
try:
if (wurzel / relativ).is_file():
return relativ
except OSError: # pragma: no cover - Verzeichnis nicht lesbar
continue
return None
def audit_detail(text: Any) -> Markup:
"""Auditeintrag als Text -- Dateinamen darin als Verweis.
Der Eintrag zur Konsole nennt seine Mitschrift beim Namen. Bisher stand
dort nur die Zeichenkette, und wer sehen wollte, was passiert ist, musste
sie von Hand in die Protokollansicht uebertragen. Es ist derselbe Weg wie
zuvor, nur einen Klick weit.
Verlinkt wird ausschliesslich, wenn die Datei existiert **und** der
Betrachter Protokolldateien lesen darf -- sonst bliebe ein Verweis, der in
403 endet. Der Rest des Eintrags wird maskiert; ein Auditeintrag enthaelt
Fremdeingaben (Geraetenamen etwa) und darf niemals als HTML gelten.
"""
from .auth.models import current_user
roh = "" if text is None else str(text)
if not roh or not MITSCHRIFT.search(roh):
return Markup(escape(roh))
try:
erlaubt = bool(current_user.is_authenticated and current_user.has("logs_history.view"))
except Exception: # noqa: BLE001 - ausserhalb einer Anfrage
erlaubt = False
if not erlaubt:
return Markup(escape(roh))
def verweis(name: str) -> str:
ziel = _mitschrift_ziel(name)
if ziel is None:
return str(escape(name))
try:
url = url_for("logs.history_file", name=ziel)
except Exception: # noqa: BLE001 - Route fehlt in dieser Anwendung
return str(escape(name))
return f'<a href="{escape(url)}" title="Mitschrift ansehen">{escape(name)}</a>'
teile: list[str] = []
ende = 0
for treffer in MITSCHRIFT.finditer(roh):
teile.append(str(escape(roh[ende : treffer.start()])))
teile.append(verweis(treffer.group(1)))
ende = treffer.end()
teile.append(str(escape(roh[ende:])))
return Markup("".join(teile))
def init_app(app: Flask) -> None: def init_app(app: Flask) -> None:
app.jinja_env.globals.update( app.jinja_env.globals.update(
icon=icon, icon=icon,
@@ -293,6 +380,7 @@ def init_app(app: Flask) -> None:
from_json=from_json, from_json=from_json,
issuer_source=issuer_source, issuer_source=issuer_source,
issuer_source_hint=issuer_source_hint, issuer_source_hint=issuer_source_hint,
audit_detail=audit_detail,
) )
app.jinja_env.trim_blocks = True app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True app.jinja_env.lstrip_blocks = True
@@ -0,0 +1,80 @@
"""Die zulaessige Groesse eines Uploads -- **eine** Zahl fuer alles.
Vorher gab es drei Grenzen, die nichts voneinander wussten:
* ``MAX_CONTENT_LENGTH`` der Anwendung (64 MB, aus einer Umgebungsvariablen),
* ``client_max_body_size`` von nginx (aus der Einstellung ``web_max_body_mb``),
* eine eigene Grenze der Dateifreigabe (512 MB, nirgends einstellbar).
Wirksam war die kleinste, sichtbar die groesste: die Freigabeseite versprach
512 MB je Datei, und bei 64 MB brach die Anfrage mit einer nackten 413 ab.
Jetzt gilt ``web_max_body_mb`` fuer beides. Und weil eine Einstellung erst
nuetzlich ist, wenn sie sofort greift, liest die Anwendung sie **pro Anfrage**
statt beim Start -- sonst braeuchte jede Aenderung einen Neustart des Dienstes.
nginx bekommt bewusst etwas mehr als die Anwendung: laeuft die Grenze im
Webserver zuerst auf, sieht der Benutzer die generische Fehlerseite von nginx.
Kommt die Anfrage dagegen bis zur Anwendung, kann diese erklaeren, was zu gross
war und wie viel erlaubt ist.
"""
from __future__ import annotations
from typing import Any
from flask import Request, current_app
#: Wieviel nginx mehr erlaubt als die Anwendung. Nicht null: die Anwendung soll
#: die Grenze melden duerfen, nicht der Webserver.
NGINX_ZUSCHLAG_MB = 16
#: Wird die Einstellung nicht gefunden (fruehe Anfrage, kaputte Datenbank),
#: gilt diese Grenze. Gross genug fuer den Alltag, klein genug, um nicht als
#: unbegrenzt durchzugehen.
NOTFALL_MB = 128
SETTING = "web_max_body_mb"
def limit_mb(conn: Any = None) -> int:
"""Die eingestellte Grenze in MB.
Ohne Verbindung wird die der laufenden Anfrage benutzt. Faellt irgendetwas
aus -- keine Anwendung, keine Datenbank --, gilt ``NOTFALL_MB``: eine
Groessenpruefung darf nie daran scheitern, dass sie ihre Grenze nicht
kennt.
"""
try:
from .extension import core, db
extension = core()
return max(1, int(extension.settings.get(conn if conn is not None else db(), SETTING)))
except Exception: # noqa: BLE001 - Grenze muss immer eine Zahl sein
return NOTFALL_MB
def limit_bytes(conn: Any = None) -> int:
return limit_mb(conn) * 1024 * 1024
def nginx_mb(app_limit_mb: int) -> int:
"""Was in ``client_max_body_size`` gehoert."""
return max(1, int(app_limit_mb)) + NGINX_ZUSCHLAG_MB
class UploadLimitRequest(Request):
"""Anfrage, deren Groessengrenze aus der Einstellung kommt.
Flask liest ``MAX_CONTENT_LENGTH`` aus der Konfiguration -- also aus dem
Zustand beim Start des Prozesses. Bei mehreren gunicorn-Arbeitern haette
eine Aenderung damit erst nach einem Neustart in allen gewirkt. Diese
Eigenschaft fragt stattdessen die Einstellung; das ist ein Lesezugriff auf
SQLite und passiert nur, wenn eine Anfrage ueberhaupt einen Koerper hat.
"""
@property
def max_content_length(self) -> int | None: # type: ignore[override]
if current_app:
return limit_bytes()
return super().max_content_length
@@ -19,6 +19,7 @@ from typing import Any
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from .. import audit from .. import audit
from .. import uploads
from ..auth import directory from ..auth import directory
from ..auth.models import current_user, permission_required from ..auth.models import current_user, permission_required
from ..auth.service import client_ip from ..auth.service import client_ip
@@ -235,19 +236,41 @@ def web():
certbot_available=bool(shutil.which("certbot")), certbot_available=bool(shutil.which("certbot")),
sysops_available=bool(extension.sysops and extension.sysops.available), sysops_available=bool(extension.sysops and extension.sysops.available),
local_names=webserver.local_names(), local_names=webserver.local_names(),
nginx_zuschlag=uploads.NGINX_ZUSCHLAG_MB,
) )
@bp.post("/webserver") @bp.post("/webserver")
@permission_required("settings_web.edit") @permission_required("settings_web.edit")
def web_save(): def web_save():
extension = core()
conn = db()
# Die Uploadgrenze gilt fuer die Anwendung selbst; sie zu setzen braucht
# keine nginx-Lizenz. Deshalb steht sie **vor** der Lizenzpruefung: sonst
# waere das Feld auf einer Installation ohne nginx-Modul sichtbar, aber
# ohne Wirkung.
if request.form.get("web_max_body_mb"):
try:
with transaction():
extension.settings.set(
conn,
"web_max_body_mb",
request.form["web_max_body_mb"],
actor=current_user.username,
)
_audit("settings.upload_limit", request.form["web_max_body_mb"], severity="notice")
except ValueError as exc:
flash(str(exc), "danger")
return redirect(url_for("hostsettings.web"))
problem = _license_hint("nginx") problem = _license_hint("nginx")
if problem: if problem:
# Ohne nginx-Modul bleibt es bei der Grenze -- der Rest des Formulars
# gehoert zum Webserver.
flash(problem, "danger") flash(problem, "danger")
return redirect(url_for("hostsettings.web")) return redirect(url_for("hostsettings.web"))
extension = core()
conn = db()
try: try:
domain = webserver.validate_domain(request.form.get("web_domain", "")) domain = webserver.validate_domain(request.form.get("web_domain", ""))
except ValueError as exc: except ValueError as exc:
@@ -24,6 +24,7 @@ from .. import audit
from ..auth.models import current_user, permission_required from ..auth.models import current_user, permission_required
from ..auth.service import client_ip from ..auth.service import client_ip
from ..extension import core, db, transaction from ..extension import core, db, transaction
from ..security.sessions import fragment
bp = Blueprint("logs", __name__, url_prefix="/protokolle") bp = Blueprint("logs", __name__, url_prefix="/protokolle")
@@ -53,6 +54,7 @@ def live():
@bp.get("/laufend/inhalt") @bp.get("/laufend/inhalt")
@permission_required("logs_live.view") @permission_required("logs_live.view")
@fragment
def live_fragment(): def live_fragment():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES) lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
needle = (request.args.get("q") or "").strip() needle = (request.args.get("q") or "").strip()
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing-server" name = "tesm-licensing-server"
version = "2.0.2" version = "2.2.1"
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten" description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["tesm-licensing==2.0.2", "cryptography>=41"] dependencies = ["tesm-licensing==2.2.1", "cryptography>=41"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing" name = "tesm-licensing"
version = "2.0.2" version = "2.2.1"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["cryptography>=42"] dependencies = ["cryptography>=42"]