Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed17bce40a | ||
|
|
5282cd8f2d | ||
|
|
754cbea6bb | ||
|
|
07ae69e3b7 | ||
|
|
d3c700283a | ||
|
|
61a47debdc | ||
|
|
bec74e6936 | ||
|
|
d2679361bc | ||
|
|
20c7fd8813 | ||
|
|
eaca6f7990 | ||
|
|
4eb1c0c2d8 | ||
|
|
c0a6dd057c | ||
|
|
8c2cd10ee4 | ||
|
|
2c3ca4acc1 | ||
|
|
f16ba7d875 | ||
|
|
7bfe328e4b | ||
|
|
5b9373bb24 | ||
|
|
f77c8b221c | ||
|
|
985deb7a24 | ||
|
|
258e90bf4f | ||
|
|
337a52cc38 | ||
|
|
160675e368 | ||
|
|
3bbddd7d29 | ||
|
|
7407f4bc00 | ||
|
|
635722089e | ||
|
|
6dff1ed137 | ||
|
|
b4529a0e31 | ||
|
|
313eb4bbf9 |
No files matched your search
@@ -23,7 +23,8 @@ mitbringen.
|
||||
|
||||
| Wo sonst noch | |
|
||||
|---|---|
|
||||
| [alientim/tesm-license](https://gitea.int.eertmoed.net/alientim/tesm-license) | der Lizenzserver |
|
||||
| [alientim/tesm-license](https://gitea.int.eertmoed.net/alientim/tesm-license) | der Lizenzserver (Sub-Instanzen) |
|
||||
| [alientim/tesm-license-master](https://gitea.int.eertmoed.net/alientim/tesm-license-master) | Lizenzserver, Master-Ausgabe (privat) |
|
||||
| [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku |
|
||||
|
||||
## Zweige
|
||||
@@ -38,10 +39,18 @@ mitbringen.
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm --https
|
||||
sudo bash install.sh --app tesm --https
|
||||
sudo -u tesm tesm-admin create-admin
|
||||
```
|
||||
|
||||
`install.sh` liegt oben im Paket und genügt auch **allein**: ohne Paket
|
||||
drumherum lädt es sich das aktuelle Release selbst.
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/install.sh
|
||||
sudo bash install.sh --app tesm --https
|
||||
```
|
||||
|
||||
Danach `https://<host>/`. Ohne `--domain` entsteht ein selbstsigniertes
|
||||
Zertifikat; der Browser warnt einmal, die Verbindung ist verschlüsselt.
|
||||
|
||||
@@ -152,3 +161,19 @@ anders gelöst ist. Die wichtigsten:
|
||||
* **Zwei Installationen stören sich nicht** -- eigener `SITE_KEY` für
|
||||
nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie.
|
||||
* **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/raw/branch/main/deploy/upgrade.sh
|
||||
sudo bash upgrade.sh --app tesm
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm"
|
||||
version = "2.0.3"
|
||||
version = "2.7.0"
|
||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -23,7 +23,20 @@ from .permissions import PERMISSIONS, ROLE_PRESETS
|
||||
from .schema import TESM_MIGRATIONS
|
||||
from .settings_spec import TESM_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")
|
||||
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||
__version__ = "unbekannt"
|
||||
|
||||
APP_KEY = "tesm"
|
||||
APP_NAME = "TESM"
|
||||
@@ -32,10 +45,12 @@ APP_EDITION = "Geraeteverwaltung"
|
||||
#: Funktionszeile unter der Wortmarke auf der Anmeldeseite. Uebernommen vom
|
||||
#: Vorgaenger, wo sie Teil des Hintergrundmotivs war -- sie sagt in einer Zeile,
|
||||
#: was die Anwendung tut, und das gehoert auf die erste Seite.
|
||||
AUTH_TAGLINE = (
|
||||
"UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · "
|
||||
"BETRIEBSSYSTEM GEBEN (PXE)"
|
||||
)
|
||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Kurz gehalten: sie ist
|
||||
#: eine Marke, keine Funktionsliste. "(PXE)" stand ausserdem fuer etwas, das
|
||||
#: es noch nicht gibt -- das Anmeldebanner hat es aus demselben Grund nie
|
||||
#: gezeigt. Wortgleich in ``deploy/tesm-motd``; ein Hygienetest haelt beides
|
||||
#: zusammen.
|
||||
AUTH_TAGLINE = "ÜBERWACHEN · BOOTEN · ANBINDEN"
|
||||
ENV_PREFIX = "TESM_"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -338,19 +353,32 @@ def _start_fileshare_sweep(app: Flask) -> None:
|
||||
weiter eingebunden.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
from .services import fileshare
|
||||
|
||||
def schleife() -> None:
|
||||
while True:
|
||||
time.sleep(FILESHARE_SWEEP_SECONDS)
|
||||
try:
|
||||
def einmal() -> None:
|
||||
with app.app_context():
|
||||
geloest = fileshare.sweep_expired(core(app).database)
|
||||
if geloest:
|
||||
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest)
|
||||
|
||||
def schleife() -> None:
|
||||
# Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner
|
||||
# Durchlauf scheitert; das aeussere sorgt dafuer, dass sein Ende
|
||||
# ueberhaupt auffaellt. Vorher fehlte der Import von "time" -- der
|
||||
# NameError kam beim ersten sleep, also vor jedem try, und der Faden
|
||||
# war weg, ohne eine Zeile zu hinterlassen.
|
||||
try:
|
||||
while True:
|
||||
time.sleep(FILESHARE_SWEEP_SECONDS)
|
||||
try:
|
||||
einmal()
|
||||
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
|
||||
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
|
||||
except BaseException: # noqa: BLE001 - auch ein Abbruch gehoert protokolliert
|
||||
logger.exception("Der Aufraeumfaden der Dateifreigaben ist beendet")
|
||||
raise
|
||||
|
||||
threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from tesm_core.licensing_client import LicenseError
|
||||
|
||||
from ..services import transfer
|
||||
|
||||
bp = Blueprint("backup", __name__, url_prefix="/sicherung")
|
||||
bp = Blueprint("backup", __name__, url_prefix="/administration/backup")
|
||||
|
||||
MAX_IMPORT_BYTES = 64 * 1024 * 1024
|
||||
PREVIEW_SESSION_KEY = "_import_token"
|
||||
@@ -78,7 +78,7 @@ def overview():
|
||||
|
||||
@bp.post("/export")
|
||||
@permission_required("backup.export")
|
||||
@reauth_required
|
||||
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||
def export():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -129,7 +129,7 @@ def export():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/import/vorschau")
|
||||
@bp.post("/import/preview")
|
||||
@permission_required("backup.execute")
|
||||
@reauth_required
|
||||
def import_preview():
|
||||
@@ -161,7 +161,7 @@ def import_preview():
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
|
||||
@bp.post("/import/uebernehmen")
|
||||
@bp.post("/import/apply")
|
||||
@permission_required("backup.execute")
|
||||
@reauth_required
|
||||
def import_apply():
|
||||
@@ -225,7 +225,7 @@ def import_apply():
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
|
||||
@bp.post("/import/verwerfen")
|
||||
@bp.post("/import/discard")
|
||||
@permission_required("backup.execute")
|
||||
def import_discard():
|
||||
token = session.pop(PREVIEW_SESSION_KEY, "")
|
||||
|
||||
@@ -6,13 +6,13 @@ from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||
from tesm_core.auth.models import current_user, permission_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import inventory
|
||||
|
||||
bp = Blueprint("credentials", __name__, url_prefix="/zugangsdaten")
|
||||
bp = Blueprint("credentials", __name__, url_prefix="/monitoring/credentials")
|
||||
|
||||
|
||||
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||
@@ -39,7 +39,7 @@ def overview():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/neu")
|
||||
@bp.post("/new")
|
||||
@permission_required("credentials.create")
|
||||
def create():
|
||||
try:
|
||||
@@ -91,7 +91,7 @@ def update(credential_id: int):
|
||||
return redirect(url_for("credentials.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:credential_id>/loeschen")
|
||||
@bp.post("/<int:credential_id>/delete")
|
||||
@permission_required("credentials.delete")
|
||||
def delete(credential_id: int):
|
||||
try:
|
||||
@@ -109,9 +109,9 @@ def delete(credential_id: int):
|
||||
return redirect(url_for("credentials.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:credential_id>/anzeigen")
|
||||
@bp.post("/<int:credential_id>/reveal")
|
||||
@permission_required("credentials.secrets")
|
||||
@reauth_required
|
||||
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||
def reveal(credential_id: int):
|
||||
"""Zeigt ein gespeichertes Passwort im Klartext.
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, render_template, request
|
||||
from tesm_core.security.sessions import fragment
|
||||
from tesm_core.extension import core, db
|
||||
|
||||
from ..services import monitor
|
||||
@@ -120,7 +121,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@bp.get("/uebersicht")
|
||||
@bp.get("/overview")
|
||||
def index():
|
||||
from tesm_core.auth.models import current_user
|
||||
|
||||
@@ -137,7 +138,8 @@ def index_view():
|
||||
return index()
|
||||
|
||||
|
||||
@bp.get("/uebersicht/kacheln")
|
||||
@bp.get("/overview/tiles")
|
||||
@fragment
|
||||
def tiles():
|
||||
"""Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment."""
|
||||
from tesm_core.auth.models import current_user
|
||||
|
||||
@@ -20,7 +20,7 @@ from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import inventory, monitor, restart, ssh
|
||||
|
||||
bp = Blueprint("devices", __name__, url_prefix="/clients")
|
||||
bp = Blueprint("devices", __name__, url_prefix="/monitoring/clients")
|
||||
|
||||
|
||||
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||
@@ -113,7 +113,7 @@ def _credential_from_form(conn: Any) -> int | None:
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/neu")
|
||||
@bp.post("/new")
|
||||
@permission_required("devices.create")
|
||||
def create():
|
||||
try:
|
||||
@@ -161,7 +161,7 @@ def update(device_id: int):
|
||||
return redirect(url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/aktiv")
|
||||
@bp.post("/<int:device_id>/active")
|
||||
@permission_required("devices.edit")
|
||||
def toggle_active(device_id: int):
|
||||
active = request.form.get("active") == "1"
|
||||
@@ -180,7 +180,7 @@ def toggle_active(device_id: int):
|
||||
return redirect(request.referrer or url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/loeschen")
|
||||
@bp.post("/<int:device_id>/delete")
|
||||
@permission_required("devices.delete")
|
||||
def delete(device_id: int):
|
||||
with transaction() as conn:
|
||||
@@ -193,7 +193,7 @@ def delete(device_id: int):
|
||||
return redirect(url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/neustart")
|
||||
@bp.post("/<int:device_id>/restart")
|
||||
@permission_required("devices.execute")
|
||||
def restart_device(device_id: int):
|
||||
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
|
||||
@@ -351,7 +351,7 @@ def detail(device_id: int):
|
||||
categories=inventory.DEVICE_CATEGORIES,
|
||||
host_keys=host_keys,
|
||||
method_labels=monitor.METHOD_LABELS,
|
||||
terminal_enabled=bool(extension.settings.get(conn, "terminal_enabled")),
|
||||
terminal_enabled=bool(extension.settings.get(conn, "terminal_clients_enabled")),
|
||||
)
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@ def detail(device_id: int):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/hostkey/pruefen")
|
||||
@bp.post("/<int:device_id>/host-key/probe")
|
||||
@permission_required("devices.edit")
|
||||
def probe_host_key(device_id: int):
|
||||
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden.
|
||||
@@ -390,7 +390,7 @@ def probe_host_key(device_id: int):
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/hostkey/freigeben")
|
||||
@bp.post("/<int:device_id>/host-key/trust")
|
||||
@permission_required("devices.edit")
|
||||
@reauth_required
|
||||
def trust_host_key(device_id: int):
|
||||
@@ -424,7 +424,7 @@ def trust_host_key(device_id: int):
|
||||
return redirect(url_for("devices.detail", device_id=device_id))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/hostkey/verwerfen")
|
||||
@bp.post("/<int:device_id>/host-key/forget")
|
||||
@permission_required("devices.edit")
|
||||
def forget_host_key(device_id: int):
|
||||
conn = db()
|
||||
|
||||
@@ -105,7 +105,7 @@ def overview():
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/einstellungen")
|
||||
@bp.post("/settings")
|
||||
@permission_required("dhcp.edit")
|
||||
def save_settings():
|
||||
extension = core()
|
||||
@@ -122,7 +122,7 @@ def save_settings():
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/subnetz")
|
||||
@bp.post("/subnet")
|
||||
@permission_required("dhcp.edit")
|
||||
def add_subnet():
|
||||
extension = core()
|
||||
@@ -158,7 +158,7 @@ def add_subnet():
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/subnetz/<int:subnet_id>")
|
||||
@bp.post("/subnet/<int:subnet_id>")
|
||||
@permission_required("dhcp.edit")
|
||||
def update_subnet(subnet_id: int):
|
||||
extension = core()
|
||||
@@ -194,7 +194,7 @@ def update_subnet(subnet_id: int):
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/subnetz/<int:subnet_id>/loeschen")
|
||||
@bp.post("/subnet/<int:subnet_id>/delete")
|
||||
@permission_required("dhcp.edit")
|
||||
def delete_subnet(subnet_id: int):
|
||||
with transaction() as conn:
|
||||
@@ -209,7 +209,7 @@ def delete_subnet(subnet_id: int):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/option/wert")
|
||||
@bp.post("/option/value")
|
||||
@permission_required("dhcp.edit")
|
||||
def set_option_value():
|
||||
conn = db()
|
||||
@@ -248,7 +248,7 @@ def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ign
|
||||
return gruppen
|
||||
|
||||
|
||||
@bp.post("/option/geraet")
|
||||
@bp.post("/option/device")
|
||||
@permission_required("dhcp.edit")
|
||||
def set_device_option_value():
|
||||
"""Setzt eine Option fuer ein einzelnes Geraet.
|
||||
@@ -296,7 +296,7 @@ def set_device_option_value():
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/option/neu")
|
||||
@bp.post("/option/new")
|
||||
@permission_required("dhcp.edit")
|
||||
def add_custom_option():
|
||||
conn = db()
|
||||
@@ -327,7 +327,7 @@ def add_custom_option():
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
|
||||
@bp.post("/option/<int:option_id>/loeschen")
|
||||
@bp.post("/option/<int:option_id>/delete")
|
||||
@permission_required("dhcp.edit")
|
||||
def delete_custom_option(option_id: int):
|
||||
conn = db()
|
||||
@@ -352,7 +352,7 @@ def delete_custom_option(option_id: int):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/reservierung")
|
||||
@bp.post("/reservation")
|
||||
@permission_required("dhcp.edit")
|
||||
def add_reservation():
|
||||
conn = db()
|
||||
@@ -412,7 +412,7 @@ def add_reservation():
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/reservierung/<int:reservation_id>/loeschen")
|
||||
@bp.post("/reservation/<int:reservation_id>/delete")
|
||||
@permission_required("dhcp.edit")
|
||||
def delete_reservation(reservation_id: int):
|
||||
with transaction() as conn:
|
||||
@@ -480,13 +480,13 @@ def _write_and_redirect():
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
|
||||
@bp.post("/schreiben")
|
||||
@bp.post("/write")
|
||||
@permission_required("dhcp.edit")
|
||||
def write_config():
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/dienst/<action>")
|
||||
@bp.post("/service/<action>")
|
||||
@permission_required("dhcp.execute")
|
||||
def service_action(action: str):
|
||||
allowed = {"install", "start", "stop"}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
@@ -18,11 +21,12 @@ from flask import (
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core import uploads
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import fileshare as service
|
||||
|
||||
bp = Blueprint("fileshare", __name__, url_prefix="/dateifreigaben")
|
||||
bp = Blueprint("fileshare", __name__, url_prefix="/fileshares")
|
||||
|
||||
|
||||
@bp.before_request
|
||||
@@ -46,6 +50,28 @@ def _audit(action: str, target: str, detail: str = "", severity: str = "info") -
|
||||
)
|
||||
|
||||
|
||||
def _unterschriften(roh: str) -> list[str]:
|
||||
"""Die Namen aus dem Unterschriftsfeld -- Text, keine Struktur.
|
||||
|
||||
Was hier ankommt, hat der Browser geschickt und damit der Mensch davor.
|
||||
Es wird protokolliert, nicht geglaubt: laenge begrenzt, Zeilenumbrueche
|
||||
raus, mehr passiert damit nicht.
|
||||
"""
|
||||
namen: list[str] = []
|
||||
try:
|
||||
eintraege = json.loads(roh) if roh else []
|
||||
except ValueError:
|
||||
return []
|
||||
if not isinstance(eintraege, list):
|
||||
return []
|
||||
for eintrag in eintraege[:20]:
|
||||
name = str(eintrag if isinstance(eintrag, str) else eintrag.get("name", ""))
|
||||
name = " ".join(name.split())[:120]
|
||||
if name:
|
||||
namen.append(name)
|
||||
return namen
|
||||
|
||||
|
||||
def _mount_or_404(token: str) -> service.Mount:
|
||||
mounts = service.active_mounts(db(), current_user.id)
|
||||
for mount in mounts:
|
||||
@@ -59,8 +85,8 @@ def _mount_or_404(token: str) -> service.Mount:
|
||||
def browse():
|
||||
conn = db()
|
||||
mounts = service.active_mounts(conn, current_user.id)
|
||||
token = request.args.get("freigabe") or (mounts[0].token if mounts else "")
|
||||
relative = request.args.get("pfad") or ""
|
||||
token = request.args.get("share") or (mounts[0].token if mounts else "")
|
||||
relative = request.args.get("path") or ""
|
||||
listing = None
|
||||
error = ""
|
||||
if token:
|
||||
@@ -75,15 +101,27 @@ def browse():
|
||||
token=token,
|
||||
listing=listing,
|
||||
error=error,
|
||||
max_upload_mb=int(core().settings.get(conn, "fileshare_upload_max_mb") or 512),
|
||||
# Dieselbe Zahl, die auch die Anfrage begrenzt und in nginx steht --
|
||||
# vorher versprach diese Seite 512 MB, waehrend bei 64 MB abgebrochen
|
||||
# wurde.
|
||||
max_upload_mb=uploads.limit_mb(conn),
|
||||
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
|
||||
verzeichnisse=(service.all_directories(mount) if listing else []),
|
||||
signature_witness=bool(core().settings.get(conn, "signature_witness_enabled")),
|
||||
# Die Uhr des Servers. Das Datum unter einer Unterschrift soll nicht
|
||||
# davon abhaengen, wie das Tablet gestellt ist.
|
||||
serverzeit=datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<token>/herunterladen")
|
||||
@permission_required("fileshare.view")
|
||||
@bp.get("/<token>/download")
|
||||
# Herunterladen traegt Daten aus dem System heraus -- in der Rechtematrix ist
|
||||
# das "Exportieren", nicht "Ansehen". Eine Vorschau bleibt im Haus, eine
|
||||
# heruntergeladene Datei nicht.
|
||||
@permission_required("fileshare.export")
|
||||
def download(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.args.get("pfad") or ""
|
||||
relative = request.args.get("path") or ""
|
||||
try:
|
||||
path = service.resolve_path(mount, relative)
|
||||
except service.FileshareError as exc:
|
||||
@@ -95,15 +133,20 @@ def download(token: str):
|
||||
path,
|
||||
as_attachment=True,
|
||||
download_name=path.name,
|
||||
mimetype=service.download_mime(path.name),
|
||||
# Bewusst der neutrale Typ und nicht der erkannte: iOS Safari
|
||||
# entscheidet nach dem MIME-Typ und zeigt eine "application/pdf" im
|
||||
# Browser an, auch wenn sie als Anhang ausgeliefert wird. Wer hier
|
||||
# klickt, will die Datei aber ablegen -- angesehen wird sie in der
|
||||
# Vorschau. Fuer alle anderen Browser aendert sich damit nichts.
|
||||
mimetype="application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<token>/vorschau")
|
||||
@bp.get("/<token>/preview")
|
||||
@permission_required("fileshare.view")
|
||||
def preview(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.args.get("pfad") or ""
|
||||
relative = request.args.get("path") or ""
|
||||
try:
|
||||
path = service.resolve_path(mount, relative)
|
||||
mimetype = service.preview_mime(path.name)
|
||||
@@ -138,17 +181,17 @@ def preview(token: str):
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<token>/hochladen")
|
||||
@bp.post("/<token>/upload")
|
||||
@permission_required("fileshare.create")
|
||||
def upload(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("pfad") or ""
|
||||
relative = request.form.get("path") or ""
|
||||
files = request.files.getlist("files")
|
||||
if not files:
|
||||
flash("Keine Datei ausgewaehlt.", "danger")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
||||
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||
|
||||
limit = int(core().settings.get(db(), "fileshare_upload_max_mb") or 512) * 1024 * 1024
|
||||
limit = uploads.limit_bytes(db())
|
||||
stored = 0
|
||||
try:
|
||||
target_dir = service.resolve_path(mount, relative)
|
||||
@@ -164,52 +207,188 @@ def upload(token: str):
|
||||
stored += 1
|
||||
except service.FileshareError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
||||
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||
|
||||
if stored:
|
||||
_audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)")
|
||||
flash(f"{stored} Datei(en) hochgeladen.", "success")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
||||
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||
|
||||
|
||||
@bp.post("/<token>/ordner")
|
||||
@bp.get("/<token>/signature")
|
||||
@permission_required("fileshare.view")
|
||||
def signature_state(token: str):
|
||||
"""Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll?
|
||||
|
||||
Die Vorschau fragt das beim Oeffnen. Eine unterschriebene Fassung, die
|
||||
seither veraendert wurde, sieht man einer PDF sonst nicht an -- und genau
|
||||
das ist die Frage, die man sich vor einer Unterschrift stellt.
|
||||
"""
|
||||
mount = _mount_or_404(token)
|
||||
try:
|
||||
return service.signaturstand(db(), mount, request.args.get("path") or "")
|
||||
except service.FileshareError as exc:
|
||||
return {"error": str(exc)}, 400
|
||||
|
||||
|
||||
@bp.get("/<token>/folders")
|
||||
@permission_required("fileshare.view")
|
||||
def folders(token: str):
|
||||
"""Die Ordner **dieser** Freigabe als Vorschlagsliste.
|
||||
|
||||
Gebraucht, seit das Speichern in eine andere Freigabe gehen darf: die
|
||||
Vorschlaege muessen der gewaehlten Freigabe folgen. Alle Listen beim
|
||||
Seitenaufbau mitzuschicken waere die Alternative gewesen -- bei vier
|
||||
eingebundenen Freigaben mit je ein paar hundert Ordnern ist das eine
|
||||
viertel Megabyte, die fast nie gebraucht wird.
|
||||
"""
|
||||
mount = _mount_or_404(token)
|
||||
return {"folders": service.all_directories(mount)}
|
||||
|
||||
|
||||
@bp.post("/<token>/form")
|
||||
@permission_required("fileshare_pdf.create")
|
||||
def save_form(token: str):
|
||||
"""Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab.
|
||||
|
||||
Als **neue** Datei: auf einer Freigabe liegt in der Regel die leere
|
||||
Vorlage, und die soll leer bleiben. Wer das Original ersetzen will, kann
|
||||
die alte Datei danach loeschen -- das ist eine bewusste Handlung.
|
||||
"""
|
||||
mount = _mount_or_404(token)
|
||||
# Das Ziel darf eine andere Freigabe sein: die Vorlage liegt oft in einer
|
||||
# Vorlagenfreigabe, die ausgefuellte Fassung gehoert in die Ablage des
|
||||
# Bereichs. Geprueft wird es wie jede andere Einbindung -- nur eine, die
|
||||
# diesem Benutzer gerade gehoert, kommt in Frage.
|
||||
ziel_token = (request.form.get("ziel") or "").strip()
|
||||
ziel_mount = _mount_or_404(ziel_token) if ziel_token else mount
|
||||
relative = request.form.get("path") or ""
|
||||
upload_file = request.files.get("datei")
|
||||
if upload_file is None or not upload_file.filename:
|
||||
return {"error": "Es kam keine Datei an."}, 400
|
||||
|
||||
grenze = uploads.limit_bytes(db())
|
||||
daten = upload_file.stream.read(grenze + 1)
|
||||
if len(daten) > grenze:
|
||||
return {"error": f"Zu gross (Grenze {grenze // 1024 // 1024} MB)."}, 413
|
||||
if not daten.startswith(b"%PDF-"):
|
||||
# Diese Route nimmt nur, was pdf.js hier ablegt. Ein anderer Inhalt
|
||||
# waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen.
|
||||
return {"error": "Das ist keine PDF-Datei."}, 415
|
||||
|
||||
# Geprueft wird die **Datei**, nicht ein mitgeschicktes Auswahlfeld: wer nur
|
||||
# anlegen darf, legt endgueltige Fassungen ab. Traegt die Datei noch
|
||||
# Formularfelder, ist sie weiter veraenderbar -- dafuer braucht es
|
||||
# "PDF-Formulare: Bearbeiten". Ein umgebogenes Feld im Browser hilft damit
|
||||
# niemandem weiter.
|
||||
if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.edit"):
|
||||
return {
|
||||
"error": "Sie duerfen ausgefuellte Formulare nur abgeschlossen speichern "
|
||||
"(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' waehlen."
|
||||
}, 403
|
||||
|
||||
# Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen.
|
||||
ordner = (request.form.get("ordner") or "").strip().strip("/")
|
||||
wunschname = (request.form.get("name") or "").strip()
|
||||
ersetzen = request.form.get("ersetzen") == "1"
|
||||
|
||||
try:
|
||||
original = service.resolve_path(mount, relative)
|
||||
# Ohne Namen landet die Datei neben dem Original -- das geht nur in
|
||||
# derselben Freigabe.
|
||||
if ziel_mount.token != mount.token and not wunschname:
|
||||
return {"error": "Fuer eine andere Freigabe braucht es einen Dateinamen."}, 400
|
||||
# Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis,
|
||||
# so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt
|
||||
# es beim Ordner des Originals.
|
||||
if wunschname:
|
||||
zielordner = service.resolve_path(ziel_mount, ordner)
|
||||
else:
|
||||
zielordner = service.resolve_path(mount, ordner) if ordner else original.parent
|
||||
if not zielordner.is_dir():
|
||||
return {
|
||||
"error": f"Den Ordner '{ordner}' gibt es in der Freigabe "
|
||||
f"'{ziel_mount.label}' nicht."
|
||||
}, 400
|
||||
if wunschname:
|
||||
# Eingegeben wird der Name ohne Endung; die kommt von hier. Wer
|
||||
# sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche
|
||||
# tippt, bekommt trotzdem eine Datei, die sich oeffnen laesst.
|
||||
name = service.safe_filename(wunschname)
|
||||
if not name.lower().endswith(".pdf"):
|
||||
name += ".pdf"
|
||||
ziel = zielordner / name
|
||||
if ziel.exists() and not ersetzen:
|
||||
return {
|
||||
"error": f"In '{ziel_mount.label}' gibt es '{name}' schon. Anderen "
|
||||
"Namen waehlen oder 'Vorhandene ersetzen' ankreuzen."
|
||||
}, 409
|
||||
else:
|
||||
# Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben.
|
||||
ziel = service.free_name(original.parent, original.stem, "-ausgefuellt", ".pdf")
|
||||
ziel.write_bytes(daten)
|
||||
except service.FileshareError as exc:
|
||||
return {"error": str(exc)}, 400
|
||||
|
||||
wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix()
|
||||
pruefsumme = hashlib.sha256(daten).hexdigest()
|
||||
unterschriften = _unterschriften(request.form.get("unterschriften") or "")
|
||||
|
||||
# Zwei Angaben, die man nicht verwechseln darf, und deshalb stehen beide
|
||||
# da: der Name **in** der Unterschrift ist eine Angabe des
|
||||
# Unterschreibenden -- wie auf Papier. Wer die Datei abgelegt hat, steht
|
||||
# ohnehin als Handelnder in der Zeile und ist belegt.
|
||||
beschreibung = f"{ziel_mount.label}:{wohin} sha256={pruefsumme}"
|
||||
if unterschriften:
|
||||
namen = ", ".join(unterschriften)
|
||||
beschreibung = f"{beschreibung} -- angegeben unterschrieben von: {namen}"
|
||||
_audit(
|
||||
"fileshare.form_signed" if unterschriften else "fileshare.form_saved",
|
||||
f"{mount.label}:{relative}",
|
||||
beschreibung,
|
||||
severity="notice",
|
||||
)
|
||||
return {"message": f"Gespeichert als {wohin}.", "path": wohin, "sha256": pruefsumme}
|
||||
|
||||
|
||||
@bp.post("/<token>/folder")
|
||||
@permission_required("fileshare.create")
|
||||
def make_directory(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("pfad") or ""
|
||||
relative = request.form.get("path") or ""
|
||||
try:
|
||||
service.make_directory(mount, relative, request.form.get("name", ""))
|
||||
except (service.FileshareError, OSError) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
||||
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||
_audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}")
|
||||
flash("Ordner angelegt.", "success")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
||||
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||
|
||||
|
||||
@bp.post("/<token>/umbenennen")
|
||||
@bp.post("/<token>/rename")
|
||||
@permission_required("fileshare.edit")
|
||||
def rename(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("pfad") or ""
|
||||
relative = request.form.get("path") or ""
|
||||
parent = request.form.get("parent") or ""
|
||||
try:
|
||||
service.rename_entry(mount, relative, request.form.get("name", ""))
|
||||
except (service.FileshareError, OSError) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
|
||||
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||
_audit("fileshare.renamed", f"{mount.label}:{relative}")
|
||||
flash("Umbenannt.", "success")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
|
||||
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||
|
||||
|
||||
@bp.post("/<token>/loeschen")
|
||||
@bp.post("/<token>/delete")
|
||||
@permission_required("fileshare.delete")
|
||||
def delete(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
parent = request.form.get("parent") or ""
|
||||
removed = 0
|
||||
for relative in request.form.getlist("pfade"):
|
||||
for relative in request.form.getlist("paths"):
|
||||
try:
|
||||
kind = service.delete_entry(mount, relative)
|
||||
_audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning")
|
||||
@@ -218,10 +397,10 @@ def delete(token: str):
|
||||
flash(str(exc), "danger")
|
||||
if removed:
|
||||
flash(f"{removed} Eintrag/Eintraege geloescht.", "success")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
|
||||
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||
|
||||
|
||||
@bp.post("/<token>/freigeben")
|
||||
@bp.post("/<token>/release")
|
||||
@permission_required("fileshare.view")
|
||||
def release(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
@@ -237,7 +416,7 @@ def release(token: str):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/zuordnungen")
|
||||
@bp.get("/mappings")
|
||||
@permission_required("fileshare.edit")
|
||||
def mappings():
|
||||
from tesm_core.auth import directory
|
||||
@@ -251,7 +430,7 @@ def mappings():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/zuordnungen")
|
||||
@bp.post("/mappings")
|
||||
@permission_required("fileshare.edit")
|
||||
def mapping_add():
|
||||
try:
|
||||
@@ -263,18 +442,33 @@ def mapping_add():
|
||||
label=request.form.get("label", ""),
|
||||
unc=request.form.get("unc", ""),
|
||||
)
|
||||
_audit("fileshare.mapping_added", request.form.get("label", ""), severity="notice")
|
||||
_audit(
|
||||
"fileshare.mapping_added",
|
||||
request.form.get("label", ""),
|
||||
service.describe_mapping(
|
||||
{
|
||||
"unc": request.form.get("unc", ""),
|
||||
"ad_group_name": request.form.get("ad_group_name", ""),
|
||||
"ad_group_dn": request.form.get("ad_group_dn", ""),
|
||||
}
|
||||
),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Zuordnung gespeichert.", "success")
|
||||
except service.FileshareError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
|
||||
|
||||
@bp.post("/zuordnungen/<int:mapping_id>")
|
||||
@bp.post("/mappings/<int:mapping_id>")
|
||||
@permission_required("fileshare.edit")
|
||||
def mapping_update(mapping_id: int):
|
||||
try:
|
||||
with transaction() as conn:
|
||||
# Den Stand vor der Aenderung merken -- danach ist er weg, und
|
||||
# "geaendert" ohne "was" beantwortet keine einzige Frage, die man
|
||||
# spaeter an ein Protokoll stellt.
|
||||
vorher = service.get_mapping(conn, mapping_id)
|
||||
service.update_mapping(
|
||||
conn,
|
||||
mapping_id,
|
||||
@@ -283,18 +477,32 @@ def mapping_update(mapping_id: int):
|
||||
label=request.form.get("label", ""),
|
||||
unc=request.form.get("unc", ""),
|
||||
)
|
||||
_audit("fileshare.mapping_updated", request.form.get("label", ""), severity="notice")
|
||||
nachher = service.get_mapping(conn, mapping_id)
|
||||
_audit(
|
||||
"fileshare.mapping_updated",
|
||||
str((nachher or {}).get("label") or request.form.get("label", "")),
|
||||
service.mapping_changes(vorher, nachher) or "keine inhaltliche Aenderung",
|
||||
severity="notice",
|
||||
)
|
||||
flash("Zuordnung geaendert.", "success")
|
||||
except service.FileshareError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
|
||||
|
||||
@bp.post("/zuordnungen/<int:mapping_id>/loeschen")
|
||||
@bp.post("/mappings/<int:mapping_id>/delete")
|
||||
@permission_required("fileshare.edit")
|
||||
def mapping_delete(mapping_id: int):
|
||||
with transaction() as conn:
|
||||
entfernt = service.get_mapping(conn, mapping_id)
|
||||
service.delete_mapping(conn, mapping_id)
|
||||
_audit("fileshare.mapping_deleted", str(mapping_id), severity="notice")
|
||||
# Eine entfernte Zuordnung nimmt allen Mitgliedern der Gruppe den Zugang
|
||||
# zu dieser Freigabe -- das ist keine Notiz am Rande, sondern eine Warnung.
|
||||
_audit(
|
||||
"fileshare.mapping_deleted",
|
||||
str((entfernt or {}).get("label") or mapping_id),
|
||||
service.describe_mapping(entfernt),
|
||||
severity="warning",
|
||||
)
|
||||
flash("Zuordnung entfernt.", "success")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
@@ -31,7 +31,7 @@ from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import inventory, ssh
|
||||
|
||||
bp = Blueprint("hostkeys", __name__, url_prefix="/hostschluessel")
|
||||
bp = Blueprint("hostkeys", __name__, url_prefix="/monitoring/host-keys")
|
||||
|
||||
KIND_DEVICE = "client"
|
||||
KIND_SWITCH = "switch"
|
||||
@@ -142,7 +142,7 @@ def _require(kind: str) -> None:
|
||||
abort(403)
|
||||
|
||||
|
||||
@bp.post("/<kind>/<int:target_id>/pruefen")
|
||||
@bp.post("/<kind>/<int:target_id>/probe")
|
||||
@permission_required("devices.view", "switches.view", mode="any")
|
||||
def probe(kind: str, target_id: int):
|
||||
conn = db()
|
||||
@@ -169,7 +169,7 @@ def probe(kind: str, target_id: int):
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<kind>/<int:target_id>/freigeben")
|
||||
@bp.post("/<kind>/<int:target_id>/trust")
|
||||
@permission_required("devices.view", "switches.view", mode="any")
|
||||
@reauth_required
|
||||
def trust(kind: str, target_id: int):
|
||||
@@ -203,7 +203,7 @@ def trust(kind: str, target_id: int):
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
|
||||
@bp.post("/<kind>/<int:target_id>/verwerfen")
|
||||
@bp.post("/<kind>/<int:target_id>/forget")
|
||||
@permission_required("devices.view", "switches.view", mode="any")
|
||||
def forget(kind: str, target_id: int):
|
||||
conn = db()
|
||||
@@ -222,7 +222,7 @@ def forget(kind: str, target_id: int):
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
|
||||
@bp.post("/verwaist/verwerfen")
|
||||
@bp.post("/orphaned/forget")
|
||||
@permission_required("devices.edit", "switches.edit", mode="any")
|
||||
def forget_orphan():
|
||||
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
|
||||
|
||||
@@ -23,7 +23,7 @@ from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
bp = Blueprint("license", __name__, url_prefix="/lizenz")
|
||||
bp = Blueprint("license", __name__, url_prefix="/administration/license")
|
||||
|
||||
MAX_UPLOAD_BYTES = 256 * 1024
|
||||
|
||||
@@ -69,7 +69,7 @@ def overview():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/einspielen")
|
||||
@bp.post("/install")
|
||||
@permission_required("settings_license.edit")
|
||||
def upload():
|
||||
extension = core()
|
||||
@@ -110,13 +110,13 @@ def upload():
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/aktivieren")
|
||||
@bp.post("/activate")
|
||||
@permission_required("settings_license.edit")
|
||||
def activate():
|
||||
return _handshake("activate")
|
||||
|
||||
|
||||
@bp.post("/deaktivieren")
|
||||
@bp.post("/deactivate")
|
||||
@permission_required("settings_license.edit")
|
||||
@reauth_required
|
||||
def deactivate():
|
||||
@@ -158,7 +158,7 @@ def _handshake(action: str):
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/offline-antwort")
|
||||
@bp.post("/offline-response")
|
||||
@permission_required("settings_license.edit")
|
||||
def offline_response():
|
||||
extension = core()
|
||||
@@ -177,7 +177,7 @@ def offline_response():
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/serverzertifikat")
|
||||
@bp.post("/server-certificate")
|
||||
@permission_required("settings_license.edit")
|
||||
def server_certificate():
|
||||
"""Hinterlegt das Zertifikat des Lizenzservers.
|
||||
@@ -222,7 +222,7 @@ def server_certificate():
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/aussteller/<key_id>/entfernen")
|
||||
@bp.post("/issuer/<key_id>/remove")
|
||||
@admin_required
|
||||
@reauth_required
|
||||
def remove_trusted_key(key_id: str):
|
||||
|
||||
@@ -13,12 +13,12 @@ from tesm_core.extension import core, db
|
||||
|
||||
from ..services import kea, monitor
|
||||
|
||||
bp = Blueprint("tesmlogs", __name__, url_prefix="/protokolle")
|
||||
bp = Blueprint("tesmlogs", __name__, url_prefix="/logs")
|
||||
|
||||
MAX_TAIL_LINES = 2000
|
||||
|
||||
|
||||
@bp.get("/kea")
|
||||
@bp.get("/dhcp")
|
||||
@permission_required("logs_dhcp.view")
|
||||
def dhcp():
|
||||
extension = core()
|
||||
@@ -38,7 +38,7 @@ def dhcp():
|
||||
return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error)
|
||||
|
||||
|
||||
@bp.get("/neustarts")
|
||||
@bp.get("/restarts")
|
||||
@permission_required("devices.view")
|
||||
def restarts():
|
||||
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
|
||||
@@ -48,7 +48,7 @@ def restarts():
|
||||
der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht,
|
||||
der wissen will, warum ein Geraet weg war.
|
||||
"""
|
||||
method = (request.args.get("methode") or "").strip()
|
||||
method = (request.args.get("method") or "").strip()
|
||||
if method and method not in monitor.METHOD_LABELS:
|
||||
method = ""
|
||||
sql = (
|
||||
|
||||
@@ -16,6 +16,7 @@ geratene Kategorie schickt ein ``reboot`` an das falsche Geraet).
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
|
||||
from tesm_core.security.sessions import fragment
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
@@ -24,7 +25,7 @@ from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import inventory, maintenance as service, ssh, windowsops, monitor
|
||||
|
||||
bp = Blueprint("maintenance", __name__, url_prefix="/wartung")
|
||||
bp = Blueprint("maintenance", __name__, url_prefix="/maintenance")
|
||||
|
||||
|
||||
@bp.before_request
|
||||
@@ -84,7 +85,7 @@ def overview():
|
||||
category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
|
||||
category_effects=inventory.CATEGORY_EFFECTS,
|
||||
update_category=inventory.MAINTENANCE_CATEGORY,
|
||||
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
|
||||
terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
|
||||
windows_tool_missing=_windows_tool_missing(conn),
|
||||
windows_package=windowsops.REQUIRED_PACKAGE,
|
||||
)
|
||||
@@ -104,7 +105,7 @@ def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def]
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/paket-nachinstallieren")
|
||||
@bp.post("/install-package")
|
||||
@permission_required("maintenance.execute")
|
||||
def install_windows_tool():
|
||||
"""Installiert das Paket, das der Fehlermeldung nach fehlt.
|
||||
@@ -164,7 +165,7 @@ def status():
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/auftrag/<job_id>")
|
||||
@bp.get("/job/<job_id>")
|
||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
||||
def job_detail(job_id: str):
|
||||
conn = db()
|
||||
@@ -174,8 +175,9 @@ def job_detail(job_id: str):
|
||||
return render_template("tesm/maintenance_job.html", job=job)
|
||||
|
||||
|
||||
@bp.get("/auftrag/<job_id>/ausgabe")
|
||||
@bp.get("/job/<job_id>/output")
|
||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
||||
@fragment
|
||||
def job_output(job_id: str):
|
||||
conn = db()
|
||||
job = service.get_job(conn, job_id)
|
||||
@@ -274,14 +276,14 @@ def _start(kind: str) -> None:
|
||||
flash(f"{started} Auftrag/Auftraege gestartet.", "success")
|
||||
|
||||
|
||||
@bp.post("/aktualisieren")
|
||||
@bp.post("/update")
|
||||
@permission_required("maintenance.execute")
|
||||
def start_update():
|
||||
_start(service.JOB_KIND_UPDATE)
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
|
||||
|
||||
@bp.post("/neustarten")
|
||||
@bp.post("/restart")
|
||||
@permission_required("maintenance.execute")
|
||||
def start_reboot():
|
||||
_start(service.JOB_KIND_REBOOT)
|
||||
|
||||
@@ -18,7 +18,7 @@ from tesm_core.auth.service import client_ip
|
||||
from tesm_core.extension import core, db, transaction
|
||||
from tesm_core.licensing_client import LicenseError
|
||||
|
||||
bp = Blueprint("settings", __name__, url_prefix="/einstellungen")
|
||||
bp = Blueprint("settings", __name__, url_prefix="/administration")
|
||||
|
||||
|
||||
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
||||
@@ -79,9 +79,13 @@ def system():
|
||||
"monitor_failures_before_restart",
|
||||
"monitor_auto_restart",
|
||||
"dashboard_public",
|
||||
"terminal_enabled",
|
||||
"terminal_switches_enabled",
|
||||
"terminal_clients_enabled",
|
||||
"terminal_idle_timeout",
|
||||
"signature_witness_enabled",
|
||||
"trash_retention_days",
|
||||
"session_idle_minutes",
|
||||
"session_absolute_hours",
|
||||
"log_rotate_interval",
|
||||
"log_rotate_keep",
|
||||
],
|
||||
@@ -92,6 +96,7 @@ def system():
|
||||
timezones=_timezone_choices(),
|
||||
nav_items=extension.nav.items,
|
||||
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
||||
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
|
||||
license_hint=_license_hint("monitor_interval"),
|
||||
sysops_available=bool(sysops and sysops.available),
|
||||
)
|
||||
@@ -109,9 +114,13 @@ def system_save():
|
||||
"monitor_failures_before_restart",
|
||||
"monitor_auto_restart",
|
||||
"dashboard_public",
|
||||
"terminal_enabled",
|
||||
"terminal_switches_enabled",
|
||||
"terminal_clients_enabled",
|
||||
"terminal_idle_timeout",
|
||||
"signature_witness_enabled",
|
||||
"trash_retention_days",
|
||||
"session_idle_minutes",
|
||||
"session_absolute_hours",
|
||||
"log_rotate_interval",
|
||||
"log_rotate_keep",
|
||||
):
|
||||
@@ -181,7 +190,7 @@ def set_hostname():
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
|
||||
@bp.post("/system/zeitzone")
|
||||
@bp.post("/system/timezone")
|
||||
@permission_required("settings_system.execute")
|
||||
def set_timezone():
|
||||
extension = core()
|
||||
@@ -210,7 +219,7 @@ def set_timezone():
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
|
||||
@bp.post("/system/pruefung-jetzt")
|
||||
@bp.post("/system/check-now")
|
||||
@permission_required("settings_system.execute")
|
||||
def run_check_now():
|
||||
"""Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund."""
|
||||
|
||||
@@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import inventory, ssh, switchcli
|
||||
|
||||
bp = Blueprint("switches", __name__, url_prefix="/switche")
|
||||
bp = Blueprint("switches", __name__, url_prefix="/monitoring/switches")
|
||||
|
||||
|
||||
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||
@@ -54,11 +54,11 @@ def overview():
|
||||
"tesm/switches.html",
|
||||
switches=inventory.list_switches(conn),
|
||||
credentials=inventory.list_credentials(conn),
|
||||
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
|
||||
terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/neu")
|
||||
@bp.post("/new")
|
||||
@permission_required("switches.create")
|
||||
def create():
|
||||
try:
|
||||
@@ -98,7 +98,7 @@ def update(switch_id: int):
|
||||
return redirect(request.referrer or url_for("switches.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/loeschen")
|
||||
@bp.post("/<int:switch_id>/delete")
|
||||
@permission_required("switches.delete")
|
||||
def delete(switch_id: int):
|
||||
try:
|
||||
@@ -142,7 +142,7 @@ def detail(switch_id: int):
|
||||
devices=devices,
|
||||
credentials=inventory.list_credentials(conn),
|
||||
safe_commands=switchcli.SAFE_COMMANDS,
|
||||
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
|
||||
terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")),
|
||||
)
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ def detail(switch_id: int):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/hostkey/pruefen")
|
||||
@bp.post("/<int:switch_id>/host-key/probe")
|
||||
@permission_required("switches.edit")
|
||||
def probe_host_key(switch_id: int):
|
||||
conn = db()
|
||||
@@ -176,7 +176,7 @@ def probe_host_key(switch_id: int):
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/hostkey/freigeben")
|
||||
@bp.post("/<int:switch_id>/host-key/trust")
|
||||
@permission_required("switches.edit")
|
||||
@reauth_required
|
||||
def trust_host_key(switch_id: int):
|
||||
@@ -215,7 +215,7 @@ def trust_host_key(switch_id: int):
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/hostkey/verwerfen")
|
||||
@bp.post("/<int:switch_id>/host-key/forget")
|
||||
@permission_required("switches.edit")
|
||||
def forget_host_key(switch_id: int):
|
||||
conn = db()
|
||||
@@ -234,7 +234,7 @@ def forget_host_key(switch_id: int):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/kommando")
|
||||
@bp.post("/<int:switch_id>/command")
|
||||
@permission_required("switches.execute")
|
||||
@reauth_required
|
||||
def run_command(switch_id: int):
|
||||
|
||||
@@ -24,7 +24,10 @@ Rechteausweitung, die dieses Projekt an anderer Stelle ausschliesst.
|
||||
Gemeinsame Sicherheitsmerkmale:
|
||||
|
||||
* Frische Passwortbestaetigung, zusaetzlich zum Recht.
|
||||
* Abschaltbar ueber die Einstellung ``terminal_enabled``.
|
||||
* Abschaltbar -- getrennt fuer Switche (``terminal_switches_enabled``) und
|
||||
Clients (``terminal_clients_enabled``). Das Freigeben von Host-Schluesseln
|
||||
haengt an eigenen Routen und bleibt davon unberuehrt: es wird fuer den
|
||||
PoE-Neustart gebraucht.
|
||||
* Der Host-Schluessel muss vorher freigegeben sein -- die Konsole akzeptiert
|
||||
keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier
|
||||
interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite.
|
||||
@@ -128,6 +131,18 @@ def _load_target(conn: Any, kind: str, target_id: int) -> Target | None:
|
||||
)
|
||||
|
||||
|
||||
def _konsole_erlaubt(conn: Any, kind: str) -> bool:
|
||||
"""Ist die Konsole fuer diese Art Ziel eingeschaltet?
|
||||
|
||||
Zwei Schalter, weil beides unabhaengig voneinander gebraucht wird: eine
|
||||
Werkstatt will vielleicht auf Switche, aber nicht auf Clients.
|
||||
"""
|
||||
schluessel = (
|
||||
"terminal_switches_enabled" if kind == KIND_SWITCH else "terminal_clients_enabled"
|
||||
)
|
||||
return bool(core().settings.get(conn, schluessel))
|
||||
|
||||
|
||||
def _client_module_active() -> bool:
|
||||
manager = core().license
|
||||
return manager is None or manager.module_active("maintenance")
|
||||
@@ -136,7 +151,7 @@ def _client_module_active() -> bool:
|
||||
def _render_console(target: Target): # type: ignore[no-untyped-def]
|
||||
extension = core()
|
||||
conn = db()
|
||||
if not extension.settings.get(conn, "terminal_enabled"):
|
||||
if not _konsole_erlaubt(conn, target.kind):
|
||||
abort(404)
|
||||
if not target.credential_id:
|
||||
abort(409, description="Fuer dieses Ziel sind keine Zugangsdaten hinterlegt.")
|
||||
@@ -151,7 +166,7 @@ def _render_console(target: Target): # type: ignore[no-untyped-def]
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/switche/<int:switch_id>/konsole")
|
||||
@bp.get("/monitoring/switches/<int:switch_id>/console")
|
||||
@permission_required("switches.execute")
|
||||
@reauth_required
|
||||
def switch_console(switch_id: int):
|
||||
@@ -161,7 +176,7 @@ def switch_console(switch_id: int):
|
||||
return _render_console(target)
|
||||
|
||||
|
||||
@bp.get("/clients/<int:device_id>/konsole")
|
||||
@bp.get("/monitoring/clients/<int:device_id>/console")
|
||||
@permission_required("maintenance.execute")
|
||||
@reauth_required
|
||||
def device_console(device_id: int):
|
||||
@@ -217,7 +232,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
|
||||
if not reauth_is_fresh():
|
||||
_send(ws, "error", "Die Passwortbestaetigung ist abgelaufen. Bitte Seite neu laden.")
|
||||
return
|
||||
if not extension.settings.get(conn, "terminal_enabled"):
|
||||
if not _konsole_erlaubt(conn, kind):
|
||||
_send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.")
|
||||
return
|
||||
if kind == KIND_DEVICE and not _client_module_active():
|
||||
|
||||
@@ -45,10 +45,10 @@ def _has_fileshare_mounts(user: Any) -> bool:
|
||||
|
||||
NAVIGATION = NavTree(
|
||||
[
|
||||
NavItem(key="index", label="Statusuebersicht", icon="grid", endpoint="dashboard.index"),
|
||||
NavItem(key="index", label="Statusübersicht", icon="grid", endpoint="dashboard.index"),
|
||||
NavItem(
|
||||
key="monitoring",
|
||||
label="Ueberwachung",
|
||||
label="Überwachung",
|
||||
icon="cpu",
|
||||
children=(
|
||||
NavItem(
|
||||
@@ -74,11 +74,13 @@ NAVIGATION = NavTree(
|
||||
),
|
||||
NavItem(
|
||||
key="hostkeys",
|
||||
label="Host-Schluessel",
|
||||
label="Host-Schlüssel",
|
||||
icon="shield",
|
||||
endpoint="hostkeys.overview",
|
||||
permissions=("devices.view", "switches.view"),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavItem(
|
||||
key="maintenance",
|
||||
label="Wartung",
|
||||
@@ -87,8 +89,6 @@ NAVIGATION = NavTree(
|
||||
permissions=("maintenance.view", "maintenance.execute"),
|
||||
condition=_module("maintenance"),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavItem(
|
||||
key="dhcp",
|
||||
label="DHCP-Server",
|
||||
@@ -147,7 +147,7 @@ NAVIGATION = NavTree(
|
||||
),
|
||||
NavItem(
|
||||
key="logs_dhcp",
|
||||
label="Kea-DHCP",
|
||||
label="DHCP",
|
||||
icon="network",
|
||||
endpoint="tesmlogs.dhcp",
|
||||
permissions=("logs_dhcp.view",),
|
||||
|
||||
@@ -31,7 +31,7 @@ DEVICES = Resource(
|
||||
key="devices",
|
||||
label="Clients",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
|
||||
hint="Ueberwachte PoE-Endgeraete",
|
||||
hint="Überwachte PoE-Endgeräte",
|
||||
action_hints={
|
||||
A.EXECUTE: "PoE-Neustart eines Geraets manuell ausloesen. Der automatische "
|
||||
"Neustart bei Ausfall ist davon unabhaengig und laeuft immer.",
|
||||
@@ -65,7 +65,8 @@ CREDENTIALS = Resource(
|
||||
|
||||
MAINTENANCE = Resource(
|
||||
key="maintenance",
|
||||
label="Wartung",
|
||||
# Nicht "Wartung": so heisst der Bereich. Die Zeile sagt, was sie erlaubt.
|
||||
label="Aktualisierung und Neustart",
|
||||
actions=(A.VIEW, A.EXECUTE),
|
||||
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
|
||||
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."},
|
||||
@@ -75,7 +76,7 @@ DHCP = Resource(
|
||||
key="dhcp",
|
||||
label="DHCP-Server",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
||||
hint="Kea-DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)",
|
||||
hint="DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)",
|
||||
action_hints={
|
||||
A.EDIT: "Konfiguration aendern und schreiben.",
|
||||
A.EXECUTE: "Dienst installieren, starten oder stoppen.",
|
||||
@@ -85,27 +86,65 @@ DHCP = Resource(
|
||||
FILESHARE = Resource(
|
||||
key="fileshare",
|
||||
label="Dateifreigaben",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXPORT),
|
||||
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
|
||||
action_hints={
|
||||
A.CREATE: "Ordner anlegen und Dateien hochladen.",
|
||||
A.EDIT: "Umbenennen und verschieben.",
|
||||
# Genau die Bedeutung aus der Legende: Daten aus dem System
|
||||
# heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau
|
||||
# bleibt im Haus, eine heruntergeladene Datei nicht.
|
||||
A.EXPORT: "Dateien herunterladen.",
|
||||
},
|
||||
# Exporte verlangen sonst eine frische Passwortbestaetigung. Hier nicht:
|
||||
# Herunterladen ist in einer Dateifreigabe der Alltag, nicht der
|
||||
# Sonderfall. Alle 15 Minuten ein Passwort waere Schikane -- und wuerde
|
||||
# nur dazu fuehren, dass jemand das Recht pauschal vergibt oder ganz
|
||||
# weglaesst. Die Grenze zieht hier das Recht selbst, nicht die Abfrage.
|
||||
ohne_wiederanmeldung=(A.EXPORT,),
|
||||
)
|
||||
|
||||
#: Eigene Zeile statt Anhaengsel von "Dateifreigaben": ein Formular auszufuellen
|
||||
#: ist ein anderer Vorgang als eine Datei hochzuladen, und wer umbenennen darf,
|
||||
#: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen.
|
||||
FILESHARE_PDF = Resource(
|
||||
key="fileshare_pdf",
|
||||
label="PDF-Formulare",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT),
|
||||
hint="Formulare in der Vorschau ausfuellen und in der Freigabe ablegen",
|
||||
action_hints={
|
||||
A.VIEW: "In der Vorschau ausfuellen (ohne Speichern).",
|
||||
A.CREATE: "Ausgefuellt speichern -- abgeschlossen, Felder gesperrt.",
|
||||
A.EDIT: "Ausgefuellt speichern -- weiter ausfuellbar, inklusive Bild.",
|
||||
},
|
||||
)
|
||||
|
||||
LOGS_DHCP = Resource(
|
||||
key="logs_dhcp",
|
||||
label="Kea-DHCP-Protokoll",
|
||||
label="DHCP-Protokoll",
|
||||
actions=(A.VIEW,),
|
||||
)
|
||||
|
||||
MONITORING_AREA = Area(
|
||||
key="monitoring",
|
||||
label="Ueberwachung",
|
||||
label="Überwachung",
|
||||
icon="cpu",
|
||||
hint="Geraete, Switche, Zugangsdaten und Wartung. Ohne diesen Zugang bleibt der "
|
||||
hint="Geraete, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
|
||||
"gesamte Bereich verborgen.",
|
||||
resources=(DEVICES, SWITCHES, CREDENTIALS, MAINTENANCE),
|
||||
resources=(DEVICES, SWITCHES, CREDENTIALS),
|
||||
)
|
||||
|
||||
#: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als
|
||||
#: eigener Punkt im Menue. Der Bereichsschluessel heisst ``servicing``, weil
|
||||
#: ``maintenance`` schon die Ressource ist und Bereiche und Ressourcen sich
|
||||
#: einen Namensraum teilen. Nach aussen heisst beides "Wartung".
|
||||
MAINTENANCE_AREA = Area(
|
||||
key="servicing",
|
||||
label="Wartung",
|
||||
icon="wrench",
|
||||
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
|
||||
"verborgen -- auch wenn einzelne Rechte darunter gesetzt sind.",
|
||||
resources=(MAINTENANCE,),
|
||||
)
|
||||
|
||||
NETWORK_AREA = Area(
|
||||
@@ -119,12 +158,13 @@ FILES_AREA = Area(
|
||||
key="files",
|
||||
label="Dateifreigaben",
|
||||
icon="folder",
|
||||
resources=(FILESHARE,),
|
||||
resources=(FILESHARE, FILESHARE_PDF),
|
||||
)
|
||||
|
||||
PERMISSIONS = PermissionTree(
|
||||
[
|
||||
MONITORING_AREA,
|
||||
MAINTENANCE_AREA,
|
||||
NETWORK_AREA,
|
||||
FILES_AREA,
|
||||
observability_area(LOGS_DHCP),
|
||||
|
||||
@@ -250,8 +250,69 @@ CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, t
|
||||
"""
|
||||
|
||||
|
||||
# Neue Rechte sind nach einem Update bei niemandem gesetzt -- ohne diesen
|
||||
# Schritt koennte niemand mehr herunterladen oder ein Formular speichern,
|
||||
# obwohl sich an den Gruppen nichts geaendert hat. Die Zuordnung bildet ab, was
|
||||
# vorher galt.
|
||||
V4 = """
|
||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||
SELECT group_id, 'fileshare.export' FROM group_permissions
|
||||
WHERE permission = 'fileshare.view';
|
||||
|
||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||
SELECT group_id, 'fileshare_pdf.view' FROM group_permissions
|
||||
WHERE permission = 'fileshare.view';
|
||||
|
||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||
SELECT group_id, 'fileshare_pdf.create' FROM group_permissions
|
||||
WHERE permission = 'fileshare.create';
|
||||
|
||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||
SELECT group_id, 'fileshare_pdf.edit' FROM group_permissions
|
||||
WHERE permission = 'fileshare.edit';
|
||||
"""
|
||||
|
||||
|
||||
# Aus einem Schalter werden zwei. Wer die Konsole abgeschaltet hatte, soll sie
|
||||
# nicht durch ein Update zurueckbekommen -- der alte Wert gilt deshalb fuer
|
||||
# beide weiter. Fehlt er, greift die Vorgabe der Einstellung.
|
||||
V5 = """
|
||||
INSERT OR IGNORE INTO settings (key, value, updated_at, updated_by)
|
||||
SELECT 'terminal_switches_enabled', value, datetime('now'), 'migration'
|
||||
FROM settings WHERE key = 'terminal_enabled';
|
||||
|
||||
INSERT OR IGNORE INTO settings (key, value, updated_at, updated_by)
|
||||
SELECT 'terminal_clients_enabled', value, datetime('now'), 'migration'
|
||||
FROM settings WHERE key = 'terminal_enabled';
|
||||
|
||||
DELETE FROM settings WHERE key = 'terminal_enabled';
|
||||
"""
|
||||
|
||||
|
||||
# Wartung ist ein eigener Bereich geworden und hat damit einen eigenen
|
||||
# Kill-Switch. Ohne diesen Schritt waere sie nach dem Update bei allen weg:
|
||||
# das neue Bereichsrecht ist bei niemandem gesetzt, und ohne Bereichsrecht
|
||||
# greift kein Recht darunter.
|
||||
#
|
||||
# Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich
|
||||
# Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update
|
||||
# darf Zugang erhalten, aber keinen neuen schaffen.
|
||||
V6 = """
|
||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||
SELECT DISTINCT group_id, 'servicing.view'
|
||||
FROM group_permissions
|
||||
WHERE permission IN ('maintenance.view', 'maintenance.execute')
|
||||
AND group_id IN (
|
||||
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.view'
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
TESM_MIGRATIONS: tuple[Migration, ...] = (
|
||||
Migration(version=1, name="baseline", sql=V1),
|
||||
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
||||
Migration(version=3, name="restart-events", sql=V3),
|
||||
Migration(version=4, name="fileshare-pdf-rechte", sql=V4),
|
||||
Migration(version=5, name="terminal-getrennt", sql=V5),
|
||||
Migration(version=6, name="wartung-eigener-bereich", sql=V6),
|
||||
)
|
||||
@@ -21,6 +21,8 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import zlib
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
@@ -32,7 +34,7 @@ import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core
|
||||
@@ -57,6 +59,10 @@ PREVIEW_MIME: dict[str, str] = {
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
# Das Format, in dem ein iPad fotografiert. Safari zeigt es an, andere
|
||||
# Browser nicht -- die Vorschau sagt es dann und bietet den Download an.
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".csv": "text/plain; charset=utf-8",
|
||||
".log": "text/plain; charset=utf-8",
|
||||
@@ -68,6 +74,21 @@ PREVIEW_MIME: dict[str, str] = {
|
||||
#: im Ursprung der Anwendung ausgefuehrt. Download bleibt moeglich.
|
||||
|
||||
|
||||
def preview_kind(name: str) -> str:
|
||||
"""Welche Anzeige passt: ``bild``, ``pdf`` oder ``text``?
|
||||
|
||||
Die Unterscheidung gehoert hierher und nicht in die Vorlage: die
|
||||
Zuordnung Endung -> MIME-Typ steht schon hier, und eine zweite Liste in
|
||||
Jinja liefe frueher oder spaeter auseinander.
|
||||
"""
|
||||
mime = PREVIEW_MIME.get(Path(name).suffix.lower(), "")
|
||||
if mime.startswith("image/"):
|
||||
return "bild"
|
||||
if mime == "application/pdf":
|
||||
return "pdf"
|
||||
return "text"
|
||||
|
||||
|
||||
class FileshareError(Exception):
|
||||
"""Fehler mit Klartextmeldung fuer die Oberflaeche."""
|
||||
|
||||
@@ -188,6 +209,50 @@ def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
)
|
||||
|
||||
|
||||
#: Felder einer Zuordnung mit der Beschriftung, unter der sie in der
|
||||
#: Oberflaeche stehen. Das Protokoll soll dieselben Woerter benutzen wie das
|
||||
#: Formular -- sonst muss man beim Lesen uebersetzen.
|
||||
MAPPING_FELDER: tuple[tuple[str, str], ...] = (
|
||||
("label", "Anzeigename"),
|
||||
("unc", "Pfad"),
|
||||
("ad_group_name", "Gruppe"),
|
||||
("ad_group_dn", "Gruppen-DN"),
|
||||
)
|
||||
|
||||
|
||||
def get_mapping(conn: sqlite3.Connection, mapping_id: int) -> dict[str, Any] | None:
|
||||
return Database.one(conn, "SELECT * FROM fileshare_mappings WHERE id=?", (mapping_id,))
|
||||
|
||||
|
||||
def describe_mapping(row: Mapping[str, Any] | None) -> str:
|
||||
"""Eine Zuordnung in einer Zeile -- fuer das Protokoll.
|
||||
|
||||
Ohne sie stand beim Loeschen nur die Nummer der Zuordnung im Protokoll.
|
||||
Die ist nach dem Loeschen nirgends mehr nachschlagbar; damit war der
|
||||
Eintrag da, aber wertlos.
|
||||
"""
|
||||
if row is None:
|
||||
return ""
|
||||
teile = [str(row["unc"] or "")]
|
||||
gruppe = str(row["ad_group_name"] or row["ad_group_dn"] or "")
|
||||
if gruppe:
|
||||
teile.append(f"Gruppe: {gruppe}")
|
||||
return " -- ".join(teil for teil in teile if teil)
|
||||
|
||||
|
||||
def mapping_changes(vorher: Mapping[str, Any] | None, nachher: Mapping[str, Any] | None) -> str:
|
||||
"""Was sich geaendert hat, Feld fuer Feld: ``Anzeigename: alt -> neu``."""
|
||||
if vorher is None or nachher is None:
|
||||
return ""
|
||||
geaendert = []
|
||||
for feld, beschriftung in MAPPING_FELDER:
|
||||
alt = str(vorher[feld] or "")
|
||||
neu = str(nachher[feld] or "")
|
||||
if alt != neu:
|
||||
geaendert.append(f"{beschriftung}: {alt or '(leer)'} -> {neu or '(leer)'}")
|
||||
return "; ".join(geaendert)
|
||||
|
||||
|
||||
def add_mapping(
|
||||
conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str
|
||||
) -> int:
|
||||
@@ -369,7 +434,39 @@ def mount_for_login(
|
||||
if not password:
|
||||
return ["Kein Passwort fuer den Fileserver verfuegbar -- keine Einbindungen."]
|
||||
|
||||
# Was zuerst kommt: das Ende der Sitzung oder die eigene Obergrenze. Die
|
||||
# Sitzung ist die eigentliche Klammer -- mit ihr endet die Einbindung; die
|
||||
# Obergrenze sorgt dafuer, dass eine sehr lange Sitzung nicht beliebig
|
||||
# lange auf fremden Zugangsdaten sitzt.
|
||||
hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12)
|
||||
frist = _now() + timedelta(hours=hours)
|
||||
# Die Sitzungszeile gibt es hier noch nicht: eingebunden wird waehrend der
|
||||
# Anmeldung, geschrieben wird sie erst beim Speichern der Antwort. Die
|
||||
# Dauer steht aber in derselben Einstellung, aus der die Sitzung sie
|
||||
# gleich bekommt -- und falls die Zeile doch schon steht (erneutes
|
||||
# Einbinden), gilt deren Ende.
|
||||
zeile = (
|
||||
Database.one(
|
||||
conn, "SELECT absolute_expires_at FROM user_sessions WHERE id=?", (session_id,)
|
||||
)
|
||||
if session_id
|
||||
else None
|
||||
)
|
||||
if zeile is not None and zeile["absolute_expires_at"]:
|
||||
try:
|
||||
sitzungsende = datetime.fromisoformat(str(zeile["absolute_expires_at"]))
|
||||
if sitzungsende.tzinfo is None:
|
||||
sitzungsende = sitzungsende.replace(tzinfo=timezone.utc)
|
||||
frist = min(frist, sitzungsende)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
dauer = int(extension.settings.get(conn, "session_absolute_hours") or 0)
|
||||
except (TypeError, ValueError):
|
||||
dauer = 0
|
||||
if dauer > 0:
|
||||
frist = min(frist, _now() + timedelta(hours=dauer))
|
||||
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
|
||||
messages: list[str] = []
|
||||
|
||||
@@ -399,7 +496,7 @@ def mount_for_login(
|
||||
str(share["unc"]),
|
||||
str(target),
|
||||
_iso(_now()),
|
||||
_iso(_now() + timedelta(hours=hours)),
|
||||
_iso(frist),
|
||||
),
|
||||
)
|
||||
messages.append(f"{share['label']}: eingebunden")
|
||||
@@ -454,7 +551,10 @@ def sweep_expired(database: Any) -> int:
|
||||
released = 0
|
||||
with database.session() as conn:
|
||||
# Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie
|
||||
# entstanden ist. Vorher hing sie an einer eigenen Frist von zwoelf
|
||||
# entstanden ist. Ohne Sitzungsangabe gilt allein die eigene Frist:
|
||||
# "keine Sitzung eingetragen" ist etwas anderes als "Sitzung
|
||||
# abgelaufen". Diese Verwechslung hat jede frische Einbindung binnen
|
||||
# Minuten wieder geloest, weil die ID beim Anmelden noch nicht stand. Vorher hing sie an einer eigenen Frist von zwoelf
|
||||
# Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung
|
||||
# bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers
|
||||
# weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen;
|
||||
@@ -467,10 +567,11 @@ def sweep_expired(database: Any) -> int:
|
||||
LEFT JOIN user_sessions s ON s.id = m.session_id
|
||||
WHERE m.released_at IS NULL
|
||||
AND (m.expires_at <= ?
|
||||
OR s.id IS NULL
|
||||
OR (COALESCE(m.session_id, '') <> ''
|
||||
AND (s.id IS NULL
|
||||
OR s.revoked_at IS NOT NULL
|
||||
OR s.idle_expires_at <= ?
|
||||
OR s.absolute_expires_at <= ?)
|
||||
OR s.absolute_expires_at <= ?)))
|
||||
""",
|
||||
(_iso(_now()), _iso(_now()), _iso(_now())),
|
||||
)
|
||||
@@ -504,6 +605,83 @@ def resolve_path(mount: Mount, relative: str) -> Path:
|
||||
return target
|
||||
|
||||
|
||||
#: Wie eine unterschriebene Fassung im Aenderungsprotokoll steht. Die
|
||||
#: Pruefsumme ist der Anker -- sie steht im selben Eintrag wie der Name, den
|
||||
#: der Unterschreibende angegeben hat.
|
||||
SIGNATUR_AKTION = "fileshare.form_signed"
|
||||
|
||||
|
||||
def pruefsumme(pfad: Path) -> str:
|
||||
"""SHA-256 der Datei, blockweise gelesen."""
|
||||
kessel = hashlib.sha256()
|
||||
with pfad.open("rb") as strom:
|
||||
for block in iter(lambda: strom.read(1024 * 1024), b""):
|
||||
kessel.update(block)
|
||||
return kessel.hexdigest()
|
||||
|
||||
|
||||
def signaturstand(conn: sqlite3.Connection, mount: "Mount", relative: str) -> dict[str, Any]:
|
||||
"""Ist diese Datei die, die unterschrieben wurde?
|
||||
|
||||
Drei moegliche Antworten, und die dritte ist die wichtigste:
|
||||
|
||||
* ``unveraendert`` -- es gibt einen Protokolleintrag mit genau dieser
|
||||
Pruefsumme. Die Datei ist Byte fuer Byte die unterschriebene.
|
||||
* ``veraendert`` -- es gibt einen Eintrag zu genau diesem Ablageort, aber
|
||||
mit anderer Pruefsumme. Jemand hat nach der Unterschrift etwas an der
|
||||
Datei geaendert. Das ist der Fall, wegen dem es diese Pruefung gibt.
|
||||
* ``unbekannt`` -- kein Eintrag. Kein Verdacht: die meisten Dateien in
|
||||
einer Freigabe sind nie durch TESM gegangen.
|
||||
|
||||
Gesucht wird nur in der Datenbank. Ausgelagerte Protokolltage (Archiv)
|
||||
bleiben aussen vor -- ein Dateidialog darf nicht minutenlang suchen.
|
||||
"""
|
||||
ziel = resolve_path(mount, relative)
|
||||
if not ziel.is_file():
|
||||
return {"status": "unbekannt"}
|
||||
hash_ = pruefsumme(ziel)
|
||||
|
||||
zeile = Database.one(
|
||||
conn,
|
||||
"SELECT ts, actor, detail FROM audit_log WHERE action=? AND detail LIKE ? "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(SIGNATUR_AKTION, f"%sha256={hash_}%"),
|
||||
)
|
||||
if zeile is not None:
|
||||
return {
|
||||
"status": "unveraendert",
|
||||
"sha256": hash_,
|
||||
"zeit": str(zeile["ts"]),
|
||||
"konto": str(zeile["actor"]),
|
||||
"namen": _namen_aus_detail(str(zeile["detail"])),
|
||||
}
|
||||
|
||||
# Nichts mit dieser Pruefsumme -- aber vielleicht etwas zu diesem Ort.
|
||||
zeile = Database.one(
|
||||
conn,
|
||||
"SELECT ts, actor, detail FROM audit_log WHERE action=? AND detail LIKE ? "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(SIGNATUR_AKTION, f"{mount.label}:{relative} sha256=%"),
|
||||
)
|
||||
if zeile is not None:
|
||||
return {
|
||||
"status": "veraendert",
|
||||
"sha256": hash_,
|
||||
"zeit": str(zeile["ts"]),
|
||||
"konto": str(zeile["actor"]),
|
||||
"namen": _namen_aus_detail(str(zeile["detail"])),
|
||||
}
|
||||
return {"status": "unbekannt", "sha256": hash_}
|
||||
|
||||
|
||||
def _namen_aus_detail(detail: str) -> list[str]:
|
||||
"""Die angegebenen Namen aus dem Protokolltext -- ohne sie zu deuten."""
|
||||
marke = "angegeben unterschrieben von: "
|
||||
if marke not in detail:
|
||||
return []
|
||||
return [teil.strip() for teil in detail.split(marke, 1)[1].split(",") if teil.strip()]
|
||||
|
||||
|
||||
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
||||
path = resolve_path(mount, relative)
|
||||
if not path.is_dir():
|
||||
@@ -528,6 +706,11 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
||||
directories.append(record)
|
||||
else:
|
||||
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
||||
record["preview_kind"] = preview_kind(entry.name)
|
||||
# Getrennt, damit die Oberflaeche nur den Namen zum Bearbeiten
|
||||
# gibt und die Endung danebenstellt.
|
||||
record["stem"] = entry.stem
|
||||
record["suffix"] = entry.suffix
|
||||
files.append(record)
|
||||
except OSError as exc:
|
||||
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
|
||||
@@ -568,6 +751,76 @@ def safe_filename(name: str) -> str:
|
||||
return cleaned
|
||||
|
||||
|
||||
def all_directories(mount: Mount, *, tiefe: int = 4, grenze: int = 400) -> list[str]:
|
||||
"""Die Ordner der Freigabe, relativ und alphabetisch -- fuer eine Auswahlliste.
|
||||
|
||||
Begrenzt in Tiefe und Anzahl: eine Netzwerkfreigabe kann zehntausende
|
||||
Verzeichnisse haben, und eine Liste, die den Browser lahmlegt, hilft
|
||||
niemandem. Wer tiefer speichern will, tippt den Pfad -- das Feld nimmt
|
||||
beides.
|
||||
"""
|
||||
wurzel = mount.path.resolve()
|
||||
gefunden: list[str] = []
|
||||
|
||||
def hinein(verzeichnis: Path, ebene: int) -> None:
|
||||
if ebene > tiefe or len(gefunden) >= grenze:
|
||||
return
|
||||
try:
|
||||
eintraege = sorted(verzeichnis.iterdir(), key=lambda e: e.name.lower())
|
||||
except OSError:
|
||||
return
|
||||
for eintrag in eintraege:
|
||||
if len(gefunden) >= grenze:
|
||||
return
|
||||
if not eintrag.is_dir() or eintrag.name.startswith("."):
|
||||
continue
|
||||
gefunden.append(eintrag.relative_to(wurzel).as_posix())
|
||||
hinein(eintrag, ebene + 1)
|
||||
|
||||
hinein(wurzel, 1)
|
||||
return gefunden
|
||||
|
||||
|
||||
def hat_formularfelder(daten: bytes) -> bool:
|
||||
"""Steckt in diesen PDF-Bytes noch ein ausfuellbares Formular?
|
||||
|
||||
Gesucht wird nach Formularfeldern (``/Subtype /Widget``) -- auch in
|
||||
gepackten Objektstroemen, denn moderne Schreiber legen fast alles dort ab.
|
||||
Ein flach gerechnetes Formular hat keine mehr.
|
||||
|
||||
Bewusst ohne Zusatzpaket: die Frage ist einfach genug, und eine
|
||||
PDF-Bibliothek auf jedem Zielsystem waere ein hoher Preis dafuer.
|
||||
"""
|
||||
text = bytearray(daten)
|
||||
for treffer in re.finditer(rb"stream\r?\n", daten):
|
||||
start = treffer.end()
|
||||
ende = daten.find(b"endstream", start)
|
||||
if ende == -1:
|
||||
continue
|
||||
try:
|
||||
text += b"\n" + zlib.decompress(daten[start:ende])
|
||||
except zlib.error:
|
||||
continue # kein zlib-Strom -- Bilder, Schriften, alles moegliche
|
||||
return re.search(rb"/Subtype\s*/Widget", bytes(text)) is not None
|
||||
|
||||
|
||||
def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path:
|
||||
"""Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``.
|
||||
|
||||
Nichts ueberschreiben: auf einer Freigabe liegt meist die leere Vorlage,
|
||||
und zwei Leute fuellen sie nacheinander aus. Ab dem zweiten Mal wird
|
||||
durchnummeriert.
|
||||
"""
|
||||
kandidat = parent / f"{stem}{zusatz}{suffix}"
|
||||
zaehler = 2
|
||||
while kandidat.exists():
|
||||
kandidat = parent / f"{stem}{zusatz}-{zaehler}{suffix}"
|
||||
zaehler += 1
|
||||
if zaehler > 200:
|
||||
raise FileshareError("Zu viele Dateien dieses Namens -- bitte aufraeumen.")
|
||||
return kandidat
|
||||
|
||||
|
||||
def delete_entry(mount: Mount, relative: str) -> str:
|
||||
path = resolve_path(mount, relative)
|
||||
if path == mount.path.resolve():
|
||||
@@ -589,8 +842,26 @@ def make_directory(mount: Mount, relative: str, name: str) -> Path:
|
||||
|
||||
|
||||
def rename_entry(mount: Mount, relative: str, new_name: str) -> Path:
|
||||
"""Benennt um und behaelt die Endung der Datei.
|
||||
|
||||
Eingegeben wird nur der Name ohne Endung. Eine Datei, die danach auf
|
||||
``.pdf.txt`` oder auf gar nichts endet, waere auf einer Windows-Freigabe
|
||||
praktisch kaputt -- sie oeffnet sich nicht mehr mit dem richtigen
|
||||
Programm. Verzeichnisse haben keine Endung; dort bleibt der Name, wie er
|
||||
eingegeben wurde.
|
||||
|
||||
Wer die Endung trotzdem mittippt, bekommt sie nicht doppelt.
|
||||
"""
|
||||
path = resolve_path(mount, relative)
|
||||
target = path.parent / safe_filename(new_name)
|
||||
endung = path.suffix if path.is_file() else ""
|
||||
gewuenscht = (new_name or "").strip()
|
||||
if endung and gewuenscht.lower().endswith(endung.lower()):
|
||||
gewuenscht = gewuenscht[: -len(endung)]
|
||||
if not gewuenscht:
|
||||
raise FileshareError("Der Name darf nicht leer sein.")
|
||||
target = path.parent / safe_filename(gewuenscht + endung)
|
||||
if target == path:
|
||||
return path
|
||||
if target.exists():
|
||||
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
|
||||
path.rename(target)
|
||||
|
||||
@@ -636,10 +636,12 @@ def next_check_at(conn: sqlite3.Connection, interval_seconds: int) -> datetime |
|
||||
return stamp + timedelta(seconds=max(1, int(interval_seconds)))
|
||||
|
||||
|
||||
def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
||||
row = Database.one(
|
||||
conn,
|
||||
"""
|
||||
#: Zaehlung fuer die Statusuebersicht. Steht als Konstante, weil das
|
||||
#: Anmeldebanner (``deploy/tesm-motd``) dieselben Zahlen zeigt und ohne
|
||||
#: Anwendung laeuft -- es kann diese Datei nicht importieren, also vergleicht
|
||||
#: ein Hygienetest die beiden Zeichenketten. Zwei Wahrheiten ueber "online"
|
||||
#: waeren schlimmer als eine doppelte Zeichenkette.
|
||||
DASHBOARD_COUNT_SQL = """
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
|
||||
@@ -649,8 +651,11 @@ def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
||||
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 dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
||||
row = Database.one(conn, DASHBOARD_COUNT_SQL)
|
||||
row = row or {}
|
||||
return {
|
||||
key: int(row.get(key) or 0)
|
||||
|
||||
@@ -79,15 +79,6 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
minimum=1,
|
||||
maximum=168,
|
||||
),
|
||||
SettingSpec(
|
||||
"fileshare_upload_max_mb",
|
||||
512,
|
||||
"int",
|
||||
"Maximale Dateigroesse beim Hochladen (MB)",
|
||||
section="fileshare",
|
||||
minimum=1,
|
||||
maximum=8192,
|
||||
),
|
||||
# -- Wartung ------------------------------------------------------------ #
|
||||
SettingSpec(
|
||||
"maintenance_command_timeout",
|
||||
@@ -107,14 +98,37 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
minimum=30,
|
||||
maximum=1800,
|
||||
),
|
||||
# -- Terminal ----------------------------------------------------------- #
|
||||
# -- Unterschriften ------------------------------------------------------ #
|
||||
SettingSpec(
|
||||
"terminal_enabled",
|
||||
"signature_witness_enabled",
|
||||
True,
|
||||
"bool",
|
||||
"Browser-Terminal fuer Switche aktiv",
|
||||
"Unterschriften mit dem ablegenden Konto bezeugen",
|
||||
hint="Unter jede Unterschrift kommt eine Zeile, wer das Dokument in TESM abgelegt "
|
||||
"hat. Der Name in der Unterschrift ist eine Angabe des Unterschreibenden -- wie "
|
||||
"auf Papier; das Konto daneben ist belegt. Eine Passwortabfrage gibt es dafuer "
|
||||
"nicht.",
|
||||
section="fileshare",
|
||||
),
|
||||
# -- Terminal ----------------------------------------------------------- #
|
||||
SettingSpec(
|
||||
"terminal_switches_enabled",
|
||||
True,
|
||||
"bool",
|
||||
"Browser-Konsole fuer Switche zulassen",
|
||||
hint="Auch wenn aktiv: erfordert das Recht 'Ausfuehren' auf Switche, eine frische "
|
||||
"Passwortbestaetigung und wird vollstaendig protokolliert.",
|
||||
"Passwortbestaetigung und wird vollstaendig protokolliert. Das Freigeben von "
|
||||
"Host-Schluesseln bleibt davon unberuehrt -- es wird fuer den PoE-Neustart "
|
||||
"gebraucht.",
|
||||
section="monitoring",
|
||||
),
|
||||
SettingSpec(
|
||||
"terminal_clients_enabled",
|
||||
True,
|
||||
"bool",
|
||||
"Browser-Konsole fuer Clients zulassen",
|
||||
hint="Braucht zusaetzlich das Modul Wartung und das Recht 'Ausfuehren' auf Wartung. "
|
||||
"Das Freigeben von Host-Schluesseln bleibt davon unberuehrt.",
|
||||
section="monitoring",
|
||||
),
|
||||
SettingSpec(
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="tabs">
|
||||
{% for mount in mounts %}
|
||||
<a class="tab {{ 'is-active' if mount.token == token else '' }}"
|
||||
href="{{ url_for('fileshare.browse', freigabe=mount.token) }}">{{ mount.label }}</a>
|
||||
href="{{ url_for('fileshare.browse', share=mount.token) }}">{{ mount.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -33,9 +33,9 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('fileshare.browse', freigabe=token) }}">{{ current.label }}</a></span>
|
||||
<span><a href="{{ url_for('fileshare.browse', share=token) }}">{{ current.label }}</a></span>
|
||||
{% for crumb in listing.breadcrumb %}
|
||||
<span><a href="{{ url_for('fileshare.browse', freigabe=token, pfad=crumb.relative) }}">{{ crumb.name }}</a></span>
|
||||
<span><a href="{{ url_for('fileshare.browse', share=token, path=crumb.relative) }}">{{ crumb.name }}</a></span>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<div class="card__sub mono fs-xs">{{ current.unc }}</div>
|
||||
@@ -69,8 +69,8 @@
|
||||
<tr>
|
||||
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
|
||||
<td colspan="4">
|
||||
<a class="link-icon" href="{{ url_for('fileshare.browse', freigabe=token,
|
||||
pfad=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
|
||||
<a class="link-icon" href="{{ url_for('fileshare.browse', share=token,
|
||||
path=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
|
||||
{{ icon('chevron-right', size=14) }} eine Ebene höher
|
||||
</a>
|
||||
</td>
|
||||
@@ -79,11 +79,11 @@
|
||||
{% for entry in listing.directories %}
|
||||
<tr>
|
||||
{% if current_user.has('fileshare.delete') %}
|
||||
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||
{% endif %}
|
||||
<td>
|
||||
{{ icon('folder', size=15) }}
|
||||
<a href="{{ url_for('fileshare.browse', freigabe=token, pfad=entry.relative) }}">{{ entry.name }}</a>
|
||||
<a href="{{ url_for('fileshare.browse', share=token, path=entry.relative) }}">{{ entry.name }}</a>
|
||||
</td>
|
||||
<td class="numeric faint">–</td>
|
||||
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
||||
@@ -93,7 +93,7 @@
|
||||
{% for entry in listing.files %}
|
||||
<tr>
|
||||
{% if current_user.has('fileshare.delete') %}
|
||||
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||
{% endif %}
|
||||
{# Der Name ist die grösste Fläche der Zeile -- er soll auch
|
||||
das Naheliegende tun. Was sich anzeigen lässt, öffnet die
|
||||
@@ -102,16 +102,27 @@
|
||||
{% if entry.previewable %}
|
||||
<button class="link-icon file-name" type="button"
|
||||
data-dialog-open="file-preview"
|
||||
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
|
||||
data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
|
||||
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
|
||||
data-fill-Kind="{{ entry.preview_kind }}"
|
||||
data-fill-Pfad="{{ entry.relative }}"
|
||||
data-fill-Name="{{ entry.name }}"
|
||||
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
|
||||
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %}>
|
||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||
</button>
|
||||
{% else %}
|
||||
{% if current_user.has('fileshare.export') %}
|
||||
<a class="link-icon"
|
||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
|
||||
href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}">
|
||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
{# Ohne Exportrecht bleibt der Name Text: es gibt hier
|
||||
nichts zu holen, und ein toter Link waere Spott. #}
|
||||
<span class="link-icon faint">
|
||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
|
||||
@@ -120,21 +131,27 @@
|
||||
{% if entry.previewable %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="file-preview"
|
||||
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
|
||||
data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
|
||||
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
|
||||
data-fill-Kind="{{ entry.preview_kind }}"
|
||||
data-fill-Pfad="{{ entry.relative }}"
|
||||
data-fill-Name="{{ entry.name }}"
|
||||
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
||||
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %}
|
||||
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('fileshare.edit') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="file-rename"
|
||||
data-fill-Pfad="{{ entry.relative }}"
|
||||
data-fill-Name="{{ entry.name }}"
|
||||
data-fill-Name="{{ entry.stem }}"
|
||||
data-fill-Endung="{{ entry.suffix or '' }}"
|
||||
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('fileshare.export') %}
|
||||
<a class="btn btn--sm btn--ghost"
|
||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
||||
href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"
|
||||
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -167,7 +184,7 @@
|
||||
<form method="post" enctype="multipart/form-data" data-no-guard
|
||||
action="{{ url_for('fileshare.upload', token=token) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="pfad" value="{{ listing.relative }}">
|
||||
<input type="hidden" name="path" value="{{ listing.relative }}">
|
||||
<div class="card__body row">
|
||||
<input class="input" type="file" name="files" multiple required style="flex:1">
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Lädt ...">Hochladen</button>
|
||||
@@ -184,7 +201,7 @@
|
||||
{% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %}
|
||||
<form method="post" action="{{ url_for('fileshare.make_directory', token=token) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="pfad" value="{{ listing.relative }}">
|
||||
<input type="hidden" name="path" value="{{ listing.relative }}">
|
||||
<div class="modal__body">{{ field('name', 'Name', required=True) }}</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
@@ -201,17 +218,157 @@
|
||||
Dialog für alle Dateien statt einer je Zeile. #}
|
||||
{% call modal('file-preview', 'Vorschau', '', 'wide') %}
|
||||
<div class="modal__caption" data-fill-target="Name"></div>
|
||||
<div class="modal__body modal__body--flush">
|
||||
<iframe class="file-preview" title="Vorschau" data-fill-target="Src" data-fill-as="src"
|
||||
src="about:blank" referrerpolicy="same-origin"></iframe>
|
||||
{# Drei Anzeigen, eine je Art. Ein Rahmen für alles zeigte ein Foto vom
|
||||
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
|
||||
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
|
||||
<div class="modal__body modal__body--flush" data-viewer
|
||||
{# Ohne das Recht zum Ausfuellen zeigt die Vorschau das Dokument so,
|
||||
wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #}
|
||||
data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.view') else '' }}"
|
||||
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
|
||||
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"
|
||||
data-viewer-konto="{{ current_user.username }}"
|
||||
data-viewer-zeuge="{{ '1' if signature_witness else '0' }}"
|
||||
data-viewer-zeit="{{ serverzeit }}">
|
||||
<div class="viewer">
|
||||
<div class="viewer__stage" data-viewer-bildbuehne hidden>
|
||||
<img class="viewer__img" alt="" data-viewer-bild>
|
||||
</div>
|
||||
<div class="viewer__pdf" data-viewer-pdf hidden>
|
||||
{# Zwei Ebenen: aussen ein normaler Kasten, der sich die Hoehe mit
|
||||
Leiste und Speicherzeile teilt, innen der absolute Scrollbereich,
|
||||
den pdf.js verlangt. #}
|
||||
<div class="viewer__pdfscroll" data-viewer-pdfscroll>
|
||||
<div class="pdfViewer" data-viewer-pdfseiten></div>
|
||||
</div>
|
||||
</div>
|
||||
<iframe class="file-preview" title="Vorschau" data-viewer-rahmen
|
||||
src="about:blank" referrerpolicy="same-origin" hidden></iframe>
|
||||
{# Gefragt wird erst, wenn gespeichert werden soll -- vorher waere es
|
||||
eine Zeile Formular über jeder Bildvorschau. Und nur, wer Formulare
|
||||
ablegen darf, sieht sie überhaupt. #}
|
||||
{% if current_user.has('fileshare_pdf.create') %}
|
||||
{# Unterschreiben: Name tippen, mit dem Finger zeichnen, ins Dokument
|
||||
setzen. Der Name daneben ist eine Angabe des Unterschreibenden – wie
|
||||
auf Papier; wer abgelegt hat, steht im Änderungsprotokoll. #}
|
||||
<div class="viewer__sign" data-viewer-unterschriftpanel hidden>
|
||||
<div class="viewer__signfelder">
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Vorname</span>
|
||||
<input class="input" data-viewer-vorname autocomplete="off">
|
||||
</label>
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Nachname</span>
|
||||
<input class="input" data-viewer-nachname autocomplete="off">
|
||||
</label>
|
||||
</div>
|
||||
<canvas class="viewer__signpad" data-viewer-signpad width="700" height="200"
|
||||
aria-label="Unterschriftsfeld"></canvas>
|
||||
<div class="viewer__signaktionen">
|
||||
<span class="fs-xs faint" data-viewer-signhinweis></span>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-signleeren>Leeren</button>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-signabbrechen>Abbrechen</button>
|
||||
<button class="btn btn--sm btn--primary" type="button" data-viewer-signsetzen>
|
||||
Unterschrift setzen
|
||||
</button>
|
||||
</div>
|
||||
<p class="fs-xs faint">
|
||||
Nach „Unterschrift setzen“ auf die Stelle im Dokument tippen. Mit Unterschrift
|
||||
wird immer abgeschlossen gespeichert – die Felder sind danach gesperrt.
|
||||
</p>
|
||||
</div>
|
||||
<div class="viewer__save" data-viewer-speichernzeile hidden>
|
||||
{# Zwei eingebundene Freigaben, ein Formular: die Vorlage liegt in der
|
||||
einen, die ausgefüllte Fassung gehört in die andere. #}
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Freigabe</span>
|
||||
<select class="select" data-viewer-ziel
|
||||
data-url-vorlage="{{ url_for('fileshare.folders', token='__token__') }}">
|
||||
{% for eintrag in mounts %}
|
||||
<option value="{{ eintrag.token }}" {% if eintrag.token == token %}selected{% endif %}>
|
||||
{{ eintrag.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Ordner</span>
|
||||
<input class="input" list="freigabe-ordner" data-viewer-ordner
|
||||
placeholder="Wurzelverzeichnis der Freigabe" autocomplete="off">
|
||||
</label>
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Dateiname (ohne Endung)</span>
|
||||
<input class="input" data-viewer-name autocomplete="off">
|
||||
</label>
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Speichern als</span>
|
||||
{# Zwei Wege, beide gewollt: weiter ausfüllbar (das Bild wird das
|
||||
Aussehen des Knopfes und lässt sich später austauschen) oder
|
||||
abgeschlossen (alle Felder werden Teil der Seite). #}
|
||||
{# Nur, was das Recht hergibt: "weiter ausfüllbar" hängt an
|
||||
PDF-Formulare/Bearbeiten, "abgeschlossen" an Anlegen. Wer nur
|
||||
anlegen darf, sieht eine Zeile statt einer Auswahl. #}
|
||||
<select class="select" data-viewer-modus>
|
||||
{% if current_user.has('fileshare_pdf.edit') %}
|
||||
<option value="formular">Formular – weiter ausfüllbar</option>
|
||||
{% endif %}
|
||||
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
|
||||
</select>
|
||||
</label>
|
||||
{# Haken über den Knöpfen und beides rechts: gelesen wird die Zeile von
|
||||
links nach rechts (wohin, wie es heißt, wie gespeichert), und die
|
||||
Entscheidung steht am Ende. #}
|
||||
<div class="viewer__saveaktionen">
|
||||
<label class="check" data-viewer-ersetzenfeld>
|
||||
<input type="checkbox" data-viewer-ersetzen>
|
||||
<span>Vorhandene ersetzen</span>
|
||||
</label>
|
||||
<div class="viewer__saveknoepfe">
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Das Ergebnis der Pruefung steht über der Leiste – wer eine
|
||||
unterschriebene Fassung öffnet, soll es sehen, bevor er liest. #}
|
||||
<p class="viewer__pruefung" data-viewer-pruefung hidden></p>
|
||||
<div class="viewer__bar" data-viewer-leiste hidden>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
|
||||
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="fit">Anpassen</button>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="ein"
|
||||
aria-label="Vergrössern">{{ icon('plus', size=15) }}</button>
|
||||
<span class="viewer__hint fs-xs faint" data-viewer-hinweis></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
{% if current_user.has('fileshare.export') %}
|
||||
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
|
||||
{{ icon('download', size=15) }} Herunterladen
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.has('fileshare_pdf.create') %}
|
||||
<button class="btn btn--sm" type="button" data-viewer-unterschreiben hidden>
|
||||
{{ icon('pencil', size=15) }} Unterschreiben
|
||||
</button>
|
||||
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
|
||||
data-url="{{ url_for('fileshare.save_form', token=token) }}">
|
||||
{{ icon('save', size=15) }} Ausgefüllt speichern
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{# Die Ordner der Freigabe als Vorschlag; getippt werden darf trotzdem. #}
|
||||
<datalist id="freigabe-ordner">
|
||||
{% for eintrag in verzeichnisse %}<option value="{{ eintrag }}"></option>{% endfor %}
|
||||
</datalist>
|
||||
{% endif %}
|
||||
|
||||
{% if listing and current_user.has('fileshare.edit') %}
|
||||
@@ -219,11 +376,14 @@
|
||||
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="parent" value="{{ listing.relative }}">
|
||||
<input type="hidden" name="pfad" data-fill-target="Pfad" value="">
|
||||
<input type="hidden" name="path" data-fill-target="Pfad" value="">
|
||||
<div class="modal__body">
|
||||
{{ field('name', 'Neuer Name', required=True, attrs='data-fill-target="Name"') }}
|
||||
{{ field('name', 'Neuer Name (ohne Endung)', required=True,
|
||||
attrs='data-fill-target="Name"') }}
|
||||
<p class="fs-xs faint">
|
||||
Umbenannt wird auf der Freigabe selbst -- es gibt dort keinen Papierkorb.
|
||||
Die Endung <code data-fill-target="Endung"></code> bleibt, wie sie ist --
|
||||
eine Datei ohne passende Endung öffnet auf der Freigabe nichts mehr.
|
||||
Umbenannt wird dort direkt; einen Papierkorb gibt es nicht.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import alert %}
|
||||
{% block page_title %}Kea-DHCP-Protokoll{% endblock %}
|
||||
{% block page_title %}DHCP-Protokoll{% endblock %}
|
||||
{% block page_sub %}{{ path }}{% endblock %}
|
||||
{% block content %}
|
||||
{% if error %}{{ alert(error, 'warning') }}{% endif %}
|
||||
|
||||
@@ -15,7 +15,7 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||
<div class="card__sub">
|
||||
{% for value, label in methods %}
|
||||
<a class="btn btn--sm {{ 'btn--ghost' if method != value else '' }}"
|
||||
href="{{ url_for('tesmlogs.restarts', methode=value) }}">{{ label }}</a>
|
||||
href="{{ url_for('tesmlogs.restarts', method=value) }}">{{ label }}</a>
|
||||
{% endfor %}
|
||||
<a class="btn btn--sm {{ 'btn--ghost' if method else '' }}"
|
||||
href="{{ url_for('tesmlogs.restarts') }}">Alle</a>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %}
|
||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill, nav_order_card %}
|
||||
|
||||
{% block page_title %}Systemeinstellungen{% endblock %}
|
||||
{% block page_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %}
|
||||
@@ -54,18 +54,23 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Switch-Konsole</div>
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Terminal</div>
|
||||
<div class="card__sub">
|
||||
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung,
|
||||
vollständige Mitschrift
|
||||
vollständige Mitschrift. Abgeschaltet bleibt das Freigeben von
|
||||
SSH-Host-Schlüsseln möglich – es wird für den PoE-Neustart gebraucht.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ checkbox('terminal_enabled', 'Browser-Konsole für Switche zulassen',
|
||||
checked=values.terminal_enabled) }}
|
||||
{{ checkbox('terminal_switches_enabled', 'Browser-Konsole für Switche zulassen',
|
||||
checked=values.terminal_switches_enabled,
|
||||
hint_text=specs['terminal_switches_enabled'].hint) }}
|
||||
{{ checkbox('terminal_clients_enabled', 'Browser-Konsole für Clients zulassen',
|
||||
checked=values.terminal_clients_enabled,
|
||||
hint_text=specs['terminal_clients_enabled'].hint) }}
|
||||
{{ field('terminal_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
|
||||
value=values.terminal_idle_timeout, type='number') }}
|
||||
</div>
|
||||
@@ -73,6 +78,58 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('pencil', size=17) }} Unterschriften</div>
|
||||
<div class="card__sub">
|
||||
Der Name in der Unterschrift ist eine Angabe des Unterschreibenden – wie auf
|
||||
Papier. Wer das Dokument in TESM abgelegt hat, ist dagegen belegt.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ checkbox('signature_witness_enabled', 'Unterschriften mit dem ablegenden Konto bezeugen',
|
||||
checked=values.signature_witness_enabled,
|
||||
hint_text=specs['signature_witness_enabled'].hint) }}
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{# Eigene Karte: die Abmeldung stand zwischen Protokollrotation und
|
||||
Papierkorb -- drei Dinge ohne Zusammenhang. Wer die Sitzungsdauer sucht,
|
||||
sucht sie nicht unter "Protokolle". #}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('clock', size=17) }} Sitzungen</div>
|
||||
<div class="card__sub">
|
||||
Wie lange eine Anmeldung gilt. Beides bestimmt auch, wie lange eine
|
||||
eingebundene Dateifreigabe bestehen bleibt -- sie endet mit der Sitzung.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ field('session_idle_minutes', 'Automatische Abmeldung nach Leerlauf (Minuten)',
|
||||
value=values.session_idle_minutes, type='number',
|
||||
attrs='min="5" max="1440" step="1"',
|
||||
hint_text=specs['session_idle_minutes'].hint) }}
|
||||
{{ field('session_absolute_hours', 'Längstmögliche Sitzungsdauer (Stunden)',
|
||||
value=values.session_absolute_hours, type='number',
|
||||
attrs='min="1" max="168" step="1"',
|
||||
hint_text=specs['session_absolute_hours'].hint) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
@@ -87,9 +144,6 @@
|
||||
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
|
||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||
type='number') }}
|
||||
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
|
||||
value=values.session_idle_minutes, type='number',
|
||||
hint_text=specs['session_idle_minutes'].hint) }}
|
||||
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
||||
value=values.trash_retention_days, type='number',
|
||||
hint_text=specs['trash_retention_days'].hint) }}
|
||||
@@ -133,39 +187,8 @@
|
||||
</section>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
||||
<div class="card__sub">Gilt für alle Benutzer; jeder sieht weiter nur, was er darf</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('admin.nav_order') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<ol class="stack stack--tight">
|
||||
{% for key in nav_order %}
|
||||
{% set item = nav_items | selectattr('key', 'equalto', key) | list | first %}
|
||||
{% if item %}
|
||||
<li class="row row--tight">
|
||||
<input class="input" type="number" name="position_{{ item.key }}" value="{{ loop.index }}"
|
||||
min="1" style="width:70px" disabled>
|
||||
<input type="hidden" name="order" value="{{ item.key }}">
|
||||
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<p class="fs-xs faint mt-3">
|
||||
Die Reihenfolge folgt der Liste. Zum Umsortieren die Einträge in der gewünschten
|
||||
Reihenfolge speichern (Drag-and-drop folgt in einer späteren Version).
|
||||
</p>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{{ nav_order_card(nav_items, nav_order, nav_child_order,
|
||||
url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,9 @@ set -euo pipefail
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
|
||||
#: Portsuche in einem Test wirklich ausfuehren laesst.
|
||||
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
|
||||
PORT=""
|
||||
HTTP_PORT=""
|
||||
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; }
|
||||
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() {
|
||||
cat <<'USAGE'
|
||||
Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
||||
@@ -112,13 +171,30 @@ MONITOR_UNIT="$NAME-monitor.service"
|
||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||
ENV_PREFIX="TESM_"
|
||||
[[ "$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
|
||||
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
|
||||
# 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
|
||||
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
|
||||
# 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
|
||||
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
|
||||
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)"
|
||||
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
|
||||
PORT=5000
|
||||
[[ "$APP" == "tesm-license" ]] && PORT=5001
|
||||
[[ -n "$INSTANCE" ]] && PORT=$((PORT + 100))
|
||||
BASISPORT=5000
|
||||
[[ "$APP" == "tesm-license" ]] && BASISPORT=5001
|
||||
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
|
||||
if [[ -z "$HTTP_PORT" ]]; then
|
||||
HTTP_PORT=80
|
||||
@@ -212,12 +308,41 @@ fi
|
||||
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
|
||||
# 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 ------------------------------------------------------------------
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
step "Sicherung vor der Aktualisierung"
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
cp -a "$ROOT" "$BACKUP_DIR"
|
||||
SICHERUNG_STEHT=1
|
||||
info "Sicherung: $BACKUP_DIR"
|
||||
fi
|
||||
|
||||
@@ -295,6 +420,42 @@ install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP"
|
||||
rm -f "$TMP_SUDO"
|
||||
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
|
||||
|
||||
# -- 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 ------------------------------------------------------------------
|
||||
step "Dateirechte"
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
|
||||
@@ -311,8 +472,8 @@ step "Umgebung"
|
||||
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
|
||||
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
|
||||
# Datenbank im Arbeitsverzeichnis anlegen.
|
||||
install -d -o root -g root -m 0755 /etc/tesm
|
||||
ENV_FILE="/etc/tesm/$NAME.env"
|
||||
install -d -o root -g root -m 0755 "$ETC_TESM"
|
||||
ENV_FILE="$ETC_TESM/$NAME.env"
|
||||
TMP_ENV="$(mktemp)"
|
||||
{
|
||||
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
|
||||
@@ -455,7 +616,11 @@ fi
|
||||
step "Gesundheitspruefung"
|
||||
HEALTHY=0
|
||||
for attempt in $(seq 1 20); do
|
||||
if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then
|
||||
# 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/health" >/dev/null 2>&1; then
|
||||
HEALTHY=1
|
||||
info "Antwort nach ${attempt}x"
|
||||
break
|
||||
@@ -466,13 +631,9 @@ done
|
||||
if [[ $HEALTHY -eq 0 ]]; then
|
||||
warn "Die Anwendung antwortet nicht."
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
warn "Rollback wird ausgefuehrt."
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
mv "$ROOT" "$FAILED_DIR"
|
||||
cp -a "$BACKUP_DIR" "$ROOT"
|
||||
systemctl start "$UNIT" || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true
|
||||
# Dieselbe Funktion wie die EXIT-Falle -- zwei Wege, die dasselbe tun
|
||||
# sollen, laufen sonst irgendwann auseinander.
|
||||
zurueckrollen_install
|
||||
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
|
||||
Fehlgeschlagene Version : $FAILED_DIR
|
||||
Sicherung : $BACKUP_DIR
|
||||
@@ -532,12 +693,41 @@ if [[ $HTTPS -eq 1 ]]; then
|
||||
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
|
||||
rm -rf "$BACKUP_DIR"
|
||||
info "Sicherung entfernt (Aktualisierung erfolgreich)"
|
||||
fi
|
||||
|
||||
# -- Abschluss ---------------------------------------------------------------
|
||||
INSTALL_FERTIG=1
|
||||
step "Fertig"
|
||||
ADMIN_CMD="tesm-admin"
|
||||
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
|
||||
@@ -553,14 +743,24 @@ cat <<SUMMARY
|
||||
Naechste Schritte:
|
||||
SUMMARY
|
||||
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
if [[ "$APP" == "tesm-license" && "$EDITION" == "master" ]]; then
|
||||
cat <<SUMMARY
|
||||
1. Signaturschluessel erzeugen und sofort sichern:
|
||||
sudo -u $SERVICE_USER $NAME-admin init-key
|
||||
2. Eigene Master-Lizenz ausstellen:
|
||||
sudo -u $SERVICE_USER $NAME-admin bootstrap
|
||||
3. Administrator anlegen:
|
||||
1. Administrator anlegen:
|
||||
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
|
||||
else
|
||||
cat <<SUMMARY
|
||||
|
||||
@@ -14,12 +14,21 @@ TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Vorgabe: der Name der einzigen Anwendung im Baum, sonst der Verzeichnisname.
|
||||
# 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
|
||||
NAME="$2"
|
||||
else
|
||||
HERKUNFT="$(git -C "$REPO_DIR" config --get remote.origin.url 2>/dev/null || true)"
|
||||
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
|
||||
|
||||
OUT="$NAME-$TAG.tar.gz"
|
||||
|
||||
@@ -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."
|
||||
@@ -5,5 +5,11 @@
|
||||
#
|
||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||
# 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
|
||||
@@ -5,5 +5,11 @@
|
||||
#
|
||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||
# 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
|
||||
@@ -47,7 +47,7 @@ check_in_list() {
|
||||
}
|
||||
|
||||
# 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
|
||||
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
|
||||
check_app_key() {
|
||||
|
||||
@@ -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)
|
||||
@@ -1,89 +1,199 @@
|
||||
#!/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.
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier bewusst vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt
|
||||
# denselben Fehler ein zweites Mal ab.
|
||||
# Eine einzelne Datei genuegt -- sie holt sich alles Weitere:
|
||||
#
|
||||
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||
# sudo bash update.sh tesm
|
||||
#
|
||||
# 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
|
||||
|
||||
# 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:-}"
|
||||
TAG="${2:-latest}"
|
||||
|
||||
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
||||
# 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
|
||||
# nginx-Site und demselben Port.
|
||||
shift $(( $# > 2 ? 2 : $# ))
|
||||
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" ]] || {
|
||||
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
|
||||
}
|
||||
|
||||
REPO="${TESM_RELEASE_REPO:-$APP}"
|
||||
|
||||
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
|
||||
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')"
|
||||
echo " $TAG"
|
||||
# Repositorium: die Master-Ausgabe des Lizenzservers liegt in einem eigenen,
|
||||
# privaten Repositorium, heisst als Installation aber weiter "tesm-license".
|
||||
# Erkannt wird sie an der Installation und nicht an einem Schalter -- wer
|
||||
# aktualisiert, soll nicht erst wissen muessen, welche Ausgabe hier laeuft.
|
||||
# Sonst holte der Aufruf die Ausgabe fuer Sub-Lizenzserver, und der Installer
|
||||
# muesste sie abweisen.
|
||||
REPO="$APP"
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
NAME="$APP"
|
||||
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
|
||||
|
||||
ASSET="$REPO-$TAG.tar.gz"
|
||||
echo "==> Lade $ASSET ..."
|
||||
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
||||
# --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}"
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$WORK/$ASSET" -C "$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 "$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; exit 1; }
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
# 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
|
||||
|
||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||
# 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
|
||||
exit 1
|
||||
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
|
||||
|
||||
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||
|
||||
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
# 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[@]}"}
|
||||
@@ -2,10 +2,17 @@
|
||||
# =============================================================================
|
||||
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
|
||||
#
|
||||
# Aufruf (aus dem entpackten Release-Verzeichnis):
|
||||
# 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-license
|
||||
# sudo bash deploy/upgrade.sh --app tesm --instance opus --source /srv/tesm
|
||||
# sudo bash deploy/upgrade.sh --app tesm --instance test --source /srv/tesm
|
||||
#
|
||||
# Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||
#
|
||||
@@ -44,6 +51,25 @@ 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)"
|
||||
@@ -73,6 +99,12 @@ Verwendung: upgrade.sh --app <tesm|tesm-license> [Optionen]
|
||||
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
|
||||
@@ -85,7 +117,7 @@ Beispiele:
|
||||
sudo bash deploy/upgrade.sh --app tesm
|
||||
|
||||
# Daneben, zum Vergleich, alte Installation bleibt unangetastet:
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance opus --keep-old-running
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -99,6 +131,12 @@ while [[ $# -gt 0 ]]; do
|
||||
--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 ;;
|
||||
@@ -108,37 +146,364 @@ done
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
|
||||
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
||||
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
|
||||
|
||||
[[ -n "$SOURCE" ]] || SOURCE="/srv/$APP"
|
||||
[[ -d "$SOURCE" ]] || fail "Die alte Installation $SOURCE gibt es nicht (--source setzen)"
|
||||
[[ -f "$SOURCE/sqlite.db" ]] || fail "In $SOURCE liegt keine sqlite.db -- das ist keine Installation der Vorgaengerfassung"
|
||||
|
||||
NAME="$APP"
|
||||
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||
ADMIN="$NAME-admin"
|
||||
NEW_ROOT="/srv/$NAME"
|
||||
IN_PLACE=0
|
||||
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
|
||||
|
||||
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.
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
# -- 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
|
||||
else
|
||||
ALTE_UNITS=(tesm-license.service)
|
||||
fi
|
||||
else
|
||||
ALTE_UNITS=()
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
step "Vorhaben"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
info "Alt : $SOURCE"
|
||||
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)"
|
||||
@@ -160,15 +525,45 @@ fi
|
||||
|
||||
# -- 1. Sichern -------------------------------------------------------------- #
|
||||
step "Sichern"
|
||||
mkdir -p "$ALT_KOPIE"
|
||||
mkdir -p "$ALT_KOPIE" "$SYSTEM_KOPIE"
|
||||
chmod 700 "$BACKUP"
|
||||
# -a: Rechte und Zeiten bleiben, damit die Kopie auch als Rueckweg taugt.
|
||||
# Das venv der alten Installation bleibt aussen vor -- es ist nachbaubar,
|
||||
# gross, und seine Shebangs zeigen ohnehin auf den alten Pfad.
|
||||
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
|
||||
|
||||
# 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"
|
||||
@@ -186,9 +581,12 @@ fi
|
||||
step "Neu installieren"
|
||||
INSTALL_ARGS=(--app "$APP" --yes)
|
||||
[[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE")
|
||||
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
# 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
|
||||
@@ -240,6 +638,7 @@ else
|
||||
fi
|
||||
|
||||
# -- Abschluss --------------------------------------------------------------- #
|
||||
UPGRADE_FERTIG=1
|
||||
step "Fertig"
|
||||
info "Sicherung der alten Installation : $ALT_KOPIE"
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
|
||||
@@ -105,19 +105,26 @@ Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
||||
|
||||
### Ohne Quellbaum: `update.sh`
|
||||
|
||||
Liegt kein entpacktes Release vor, holt `update.sh` es selbst -- es lädt das
|
||||
Release-Asset, entpackt es und ruft dessen `install.sh` auf:
|
||||
`update.sh` genügt als **einzelne Datei**. Auf einem System, auf dem noch nichts
|
||||
von diesem Projekt liegt:
|
||||
|
||||
```bash
|
||||
sudo bash /opt/tesm/deploy/update.sh tesm
|
||||
sudo bash /opt/tesm-license/deploy/update.sh tesm-license
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||
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,
|
||||
eine **benannte Instanz** zu aktualisieren:
|
||||
|
||||
```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
|
||||
@@ -147,11 +154,51 @@ 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:
|
||||
|
||||
| | |
|
||||
@@ -177,7 +224,7 @@ Export des Vorgängers und taugt deshalb auch als Rückweg.
|
||||
und die alte bleibt vollständig liegen:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance opus --keep-old-running --port 5100 --http-port 8080
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running --port 5100 --http-port 8080
|
||||
```
|
||||
|
||||
**Was nicht mitkommt** -- und warum:
|
||||
@@ -251,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
|
||||
|
||||
```bash
|
||||
@@ -322,7 +446,7 @@ Unverzichtbar:
|
||||
**Die Anwendung antwortet nicht.**
|
||||
```bash
|
||||
journalctl -u tesm -n 200 --no-pager
|
||||
curl -s http://127.0.0.1:5000/gesundheit
|
||||
curl -s http://127.0.0.1:5000/health
|
||||
```
|
||||
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
||||
|
||||
|
||||
@@ -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[@]}"}
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.0.3"
|
||||
version = "2.7.0"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -8,7 +8,20 @@ Design-System.
|
||||
|
||||
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 .config import CoreConfig, SecurityConfig, load_core_config
|
||||
|
||||
@@ -13,10 +13,12 @@ import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Sequence
|
||||
|
||||
from flask import Blueprint, Flask, g, render_template, request
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from flask import Blueprint, Flask, g, redirect, render_template, request
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from . import audit, logging_setup, ui
|
||||
from . import audit, legacy_urls, logging_setup, ui
|
||||
from .config import CoreConfig
|
||||
from .core_schema import CORE_MIGRATIONS
|
||||
from .db import Database
|
||||
@@ -31,6 +33,7 @@ from .security import csrf
|
||||
from .security import sessions as session_store
|
||||
from .security.sessions import SqliteSessionInterface
|
||||
from .settings_store import SettingSpec, SettingsStore
|
||||
from . import uploads
|
||||
from .sysops import SysOps
|
||||
from .auth import directory
|
||||
from .auth import routes as auth_routes
|
||||
@@ -61,6 +64,17 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
minimum=1,
|
||||
maximum=3650,
|
||||
),
|
||||
SettingSpec(
|
||||
"session_absolute_hours",
|
||||
12,
|
||||
"int",
|
||||
"Laengstmoegliche Sitzungsdauer (Stunden)",
|
||||
hint="Danach ist Schluss, auch bei staendiger Arbeit -- eine Sitzung soll nicht "
|
||||
"ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.",
|
||||
section="system",
|
||||
minimum=1,
|
||||
maximum=168,
|
||||
),
|
||||
SettingSpec(
|
||||
"session_idle_minutes",
|
||||
30,
|
||||
@@ -130,11 +144,11 @@ def create_base_app(
|
||||
import_name: str = "tesm_core",
|
||||
app_short: str = "TESM",
|
||||
app_edition: str = "",
|
||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = kein
|
||||
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
||||
#: Funktionsliste von TESM waere dort schlicht falsch.
|
||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = nur die
|
||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||
#: Anwendungen.
|
||||
auth_tagline: str = "",
|
||||
app_version: str = "2.0.3",
|
||||
app_version: str = "2.7.0",
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
@@ -163,6 +177,10 @@ def create_base_app(
|
||||
config.data_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(
|
||||
SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
|
||||
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
|
||||
@@ -172,6 +190,9 @@ def create_base_app(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SECURE=config.security.cookie_secure,
|
||||
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,
|
||||
JSON_SORT_KEYS=False,
|
||||
TRAP_HTTP_EXCEPTIONS=False,
|
||||
@@ -222,12 +243,21 @@ def create_base_app(
|
||||
wert = 0
|
||||
return wert if wert > 0 else config.security.session_idle_minutes
|
||||
|
||||
def _gesamtdauer(conn: Any) -> int:
|
||||
"""Gesamtdauer aus den Einstellungen -- ueber dieselbe offene Verbindung."""
|
||||
try:
|
||||
wert = int(settings.get(conn, "session_absolute_hours") or 0)
|
||||
except (TypeError, ValueError):
|
||||
wert = 0
|
||||
return wert if wert > 0 else config.security.session_absolute_hours
|
||||
|
||||
app.session_interface = SqliteSessionInterface(
|
||||
database,
|
||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||
idle_minutes=config.security.session_idle_minutes,
|
||||
absolute_hours=config.security.session_absolute_hours,
|
||||
idle_lookup=_leerlauf,
|
||||
absolute_lookup=_gesamtdauer,
|
||||
)
|
||||
|
||||
# -- Schema und Startzustand ------------------------------------------ #
|
||||
@@ -259,6 +289,17 @@ def create_base_app(
|
||||
|
||||
@app.after_request
|
||||
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 = ""
|
||||
if config.security.hsts_enabled:
|
||||
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
|
||||
@@ -317,6 +358,26 @@ def create_base_app(
|
||||
# -- Fehlerseiten ------------------------------------------------------ #
|
||||
@app.errorhandler(HTTPException)
|
||||
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
||||
if error.code == 404:
|
||||
# Vor der Fehlerseite: war das eine Adresse aus der Zeit vor 2.5.0?
|
||||
# Erst hier gefragt, damit die Tabelle nie eine bestehende Route
|
||||
# verdeckt -- auf dem Lizenzserver ist /lizenz/<ticket> echt.
|
||||
ziel = legacy_urls.neue_adresse(request.path)
|
||||
if ziel is not None:
|
||||
parameter = legacy_urls.neue_parameter(request.args.to_dict(flat=True))
|
||||
if parameter:
|
||||
ziel = ziel + "?" + urlencode(parameter)
|
||||
return redirect(ziel)
|
||||
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():
|
||||
return {
|
||||
"error": error.name,
|
||||
@@ -370,7 +431,7 @@ def create_base_app(
|
||||
with app.app_context():
|
||||
trash_views.register_core_providers()
|
||||
|
||||
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"])
|
||||
app.add_url_rule("/health", "health", _health, methods=["GET"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -200,7 +200,12 @@ def _deny(message: str) -> Any:
|
||||
abort(401, description="Sitzung abgelaufen.")
|
||||
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
|
||||
abort(401, description="Anmeldung erforderlich.")
|
||||
flash("Bitte melden Sie sich an.", "info")
|
||||
# "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"))
|
||||
abort(403, description=message)
|
||||
|
||||
@@ -215,6 +220,37 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
return wrapper
|
||||
|
||||
|
||||
def _verlangt_wiederanmeldung(permission: str) -> bool:
|
||||
"""Fragt das Rechtemodell -- ausserhalb einer Anwendung nie."""
|
||||
try:
|
||||
from ..extension import core
|
||||
|
||||
return bool(core().permissions.verlangt_wiederanmeldung(permission))
|
||||
except Exception: # noqa: BLE001 - ohne Modell keine zusaetzliche Huerde
|
||||
return False
|
||||
|
||||
|
||||
def _wiederanmeldung_frisch() -> bool:
|
||||
from .service import reauth_is_fresh # zyklischen Import vermeiden
|
||||
|
||||
return reauth_is_fresh()
|
||||
|
||||
|
||||
def _bitte_bestaetigen(permission: str) -> Any:
|
||||
"""Zur Passwortbestaetigung schicken -- oder es einem Skript sagen.
|
||||
|
||||
Ein Aufruf per fetch bekaeme sonst die Anmeldeseite als Antwort und wuesste
|
||||
nichts damit anzufangen.
|
||||
"""
|
||||
if is_automatic(request) or (
|
||||
request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html
|
||||
):
|
||||
abort(403, description="Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.")
|
||||
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
||||
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info")
|
||||
return redirect(url_for("auth.reauth"))
|
||||
|
||||
|
||||
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
||||
"""Erzwingt Rechte serverseitig.
|
||||
|
||||
@@ -233,6 +269,14 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
|
||||
)
|
||||
if not ok:
|
||||
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
|
||||
# Geheimnisse und Exporte verlangen eine frische Bestaetigung --
|
||||
# das steht im Rechtemodell und wird deshalb hier abgefragt, nicht
|
||||
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
|
||||
# Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach,
|
||||
# sie per Mail zu verschicken nicht.
|
||||
heikel = [p for p in permissions if _verlangt_wiederanmeldung(p)]
|
||||
if heikel and not _wiederanmeldung_frisch():
|
||||
return _bitte_bestaetigen(heikel[0])
|
||||
return view(*args, **kwargs)
|
||||
|
||||
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
||||
|
||||
@@ -66,7 +66,7 @@ def login():
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"])
|
||||
@bp.route("/login/second-factor", methods=["GET", "POST"])
|
||||
def second_factor():
|
||||
conn = db()
|
||||
user_row = service.pending_totp_user(conn)
|
||||
@@ -116,7 +116,7 @@ def logout_get():
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.route("/bestaetigen", methods=["GET", "POST"])
|
||||
@bp.route("/confirm", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def reauth():
|
||||
target = _safe_next(session.get("_after_reauth"))
|
||||
|
||||
@@ -257,6 +257,24 @@ def _verify_credentials(
|
||||
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
|
||||
|
||||
|
||||
|
||||
def _ist_erstanmeldung(user_row: dict[str, Any]) -> bool:
|
||||
"""Meldet sich dieses Konto zum ersten Mal ueberhaupt an?
|
||||
|
||||
Das Aenderungsprotokoll soll Aenderungen zeigen. Eine taegliche Anmeldung
|
||||
ist keine -- sie erzeugte aber jedes Mal eine Zeile und schob damit echte
|
||||
Eintraege aus dem Blickfeld. Interessant ist genau die erste: mit ihr
|
||||
entsteht das Konto aus dem Verzeichnisdienst und betritt jemand die
|
||||
Anwendung, der vorher nicht darin war.
|
||||
|
||||
Nicht betroffen: fehlgeschlagene Anmeldungen und Sperren. Die stehen
|
||||
weiterhin **jedes Mal** im Protokoll -- dort liegt der sicherheitsrelevante
|
||||
Teil, und Haeufung ist dort das Signal. Wer wann zuletzt angemeldet war,
|
||||
steht ausserdem unveraendert am Konto und in der Sitzungsuebersicht.
|
||||
"""
|
||||
return not str(user_row.get("last_login_at") or "").strip()
|
||||
|
||||
|
||||
def complete_login(
|
||||
conn: sqlite3.Connection,
|
||||
user_row: dict[str, Any],
|
||||
@@ -275,20 +293,25 @@ def complete_login(
|
||||
"""
|
||||
from ..security import csrf
|
||||
|
||||
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
|
||||
erstanmeldung = _ist_erstanmeldung(user_row)
|
||||
|
||||
with core().database.transaction(conn):
|
||||
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
||||
conn.execute(
|
||||
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
||||
)
|
||||
if erstanmeldung:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row["username"]),
|
||||
actor_id=int(user_row["id"]),
|
||||
action="auth.login",
|
||||
action="auth.first_login",
|
||||
target=str(user_row["username"]),
|
||||
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
|
||||
detail=f"Erste Anmeldung -- Quelle: {user_row.get('auth_source') or 'local'}",
|
||||
ip=ip,
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
session.clear()
|
||||
|
||||
@@ -76,7 +76,7 @@ class CoreConfig:
|
||||
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
|
||||
|
||||
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
|
||||
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Alte, deutsche Adressen auf die neuen englischen umlenken.
|
||||
|
||||
Mit 2.5.0 folgen die Adressen der Navigation und sind englisch:
|
||||
``/monitoring/host-keys`` statt ``/hostschluessel``. Das ist eine gute
|
||||
Aenderung und eine unangenehme zugleich -- Lesezeichen, offene Browsertabs und
|
||||
ein Link in einer Mail zeigen weiter auf die alte Adresse. Ein offener Tab ist
|
||||
dabei der unangenehmste Fall: die Statusuebersicht fragt alle 20 Sekunden ihre
|
||||
Kacheln ab und bekam nach dem Update jedes Mal einen 404.
|
||||
|
||||
Deshalb diese Tabelle. Sie greift **nur, wenn sonst ein 404 herauskaeme** --
|
||||
also nie vor einer bestehenden Route. Das ist keine Feinheit: auf dem
|
||||
Lizenzserver ist ``/lizenz/<ticket>`` eine echte, weiterhin gueltige Adresse
|
||||
(sie steht in verschickten Mails), waehrend dieselbe Wurzel in TESM zur
|
||||
Lizenzseite fuehrte. Wer zuerst das Routing fragt, muss sich nicht zwischen
|
||||
beidem entscheiden.
|
||||
|
||||
Die Tabelle ist bewusst vollstaendig und nicht nur auf Wurzeln gekuerzt: eine
|
||||
Umleitung von ``/dateifreigaben/x/vorschau`` auf eine Uebersichtsseite waere
|
||||
zwar kein 404 mehr, aber auch nicht das, was jemand angeklickt hat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Mapping
|
||||
|
||||
#: Alte Wurzel -> (neue Wurzel, Uebersetzung der Segmente darunter).
|
||||
WURZELN: dict[str, tuple[str, Mapping[str, str]]] = {
|
||||
"/dateifreigaben": (
|
||||
"/fileshares",
|
||||
{
|
||||
"herunterladen": "download",
|
||||
"vorschau": "preview",
|
||||
"hochladen": "upload",
|
||||
"formular": "form",
|
||||
"ordner": "folder",
|
||||
"umbenennen": "rename",
|
||||
"loeschen": "delete",
|
||||
"freigeben": "release",
|
||||
"zuordnungen": "mappings",
|
||||
},
|
||||
),
|
||||
"/hostschluessel": (
|
||||
"/monitoring/host-keys",
|
||||
{"pruefen": "probe", "freigeben": "trust", "verwerfen": "forget", "verwaist": "orphaned"},
|
||||
),
|
||||
"/zugangsdaten": (
|
||||
"/monitoring/credentials",
|
||||
{"neu": "new", "loeschen": "delete", "anzeigen": "reveal"},
|
||||
),
|
||||
"/switche": (
|
||||
"/monitoring/switches",
|
||||
{
|
||||
"neu": "new",
|
||||
"loeschen": "delete",
|
||||
"hostkey": "host-key",
|
||||
"pruefen": "probe",
|
||||
"freigeben": "trust",
|
||||
"verwerfen": "forget",
|
||||
"kommando": "command",
|
||||
"konsole": "console",
|
||||
},
|
||||
),
|
||||
"/clients": (
|
||||
"/monitoring/clients",
|
||||
{
|
||||
"neu": "new",
|
||||
"aktiv": "active",
|
||||
"loeschen": "delete",
|
||||
"neustart": "restart",
|
||||
"hostkey": "host-key",
|
||||
"pruefen": "probe",
|
||||
"freigeben": "trust",
|
||||
"verwerfen": "forget",
|
||||
"konsole": "console",
|
||||
},
|
||||
),
|
||||
"/wartung": (
|
||||
"/maintenance",
|
||||
{
|
||||
"paket-nachinstallieren": "install-package",
|
||||
"auftrag": "job",
|
||||
"ausgabe": "output",
|
||||
"aktualisieren": "update",
|
||||
"neustarten": "restart",
|
||||
},
|
||||
),
|
||||
"/protokolle": (
|
||||
"/logs",
|
||||
{
|
||||
"kea": "dhcp",
|
||||
"neustarts": "restarts",
|
||||
"laufend": "live",
|
||||
"inhalt": "content",
|
||||
"verlauf": "history",
|
||||
"datei": "file",
|
||||
"archiv": "archive",
|
||||
"archiv-export": "archive-export",
|
||||
"archiv-loeschen": "archive-delete",
|
||||
"aenderungen": "audit",
|
||||
},
|
||||
),
|
||||
"/verwaltung": (
|
||||
"/administration",
|
||||
{
|
||||
"benutzer": "users",
|
||||
"neu": "new",
|
||||
"loeschen": "delete",
|
||||
"sitzungen-beenden": "end-sessions",
|
||||
"verzeichnis-suche": "directory-search",
|
||||
"verzeichnis-uebernehmen": "directory-import",
|
||||
"gruppen": "groups",
|
||||
"vorlage-anwenden": "apply-preset",
|
||||
"sitzungen": "sessions",
|
||||
"beenden": "end",
|
||||
},
|
||||
),
|
||||
"/einstellungen": (
|
||||
"/administration",
|
||||
{
|
||||
"zeitzone": "timezone",
|
||||
"pruefung-jetzt": "check-now",
|
||||
"netzwerk": "network",
|
||||
"bestaetigen": "confirm",
|
||||
"webserver": "web",
|
||||
"uebernehmen": "apply",
|
||||
"zertifikat": "certificate",
|
||||
"selbstsigniert": "self-signed",
|
||||
"verzeichnisdienst": "directory",
|
||||
"gruppensuche": "group-search",
|
||||
"zuordnung": "mapping",
|
||||
"loeschen": "delete",
|
||||
"anbieter": "vendor",
|
||||
"logo-entfernen": "remove-logo",
|
||||
},
|
||||
),
|
||||
"/sicherung": (
|
||||
"/administration/backup",
|
||||
{"vorschau": "preview", "uebernehmen": "apply", "verwerfen": "discard"},
|
||||
),
|
||||
"/papierkorb": (
|
||||
"/administration/trash",
|
||||
{"wiederherstellen": "restore", "endgueltig-loeschen": "purge", "leeren": "empty"},
|
||||
),
|
||||
"/diagnose": (
|
||||
"/administration/diagnostics",
|
||||
{"aufraeumen": "cleanup", "schluessel-rotieren": "rotate-keys"},
|
||||
),
|
||||
"/eigene-lizenz": (
|
||||
"/own-license",
|
||||
{
|
||||
"einspielen": "install",
|
||||
"aktivieren": "activate",
|
||||
"deaktivieren": "deactivate",
|
||||
"offline-antwort": "offline-response",
|
||||
"signaturschluessel": "signing-key",
|
||||
},
|
||||
),
|
||||
"/lizenzen": (
|
||||
"/licenses",
|
||||
{
|
||||
"aus-ticket": "from-ticket",
|
||||
"datei": "file",
|
||||
"neu-signieren": "resign",
|
||||
"widerrufen": "revoke",
|
||||
"loeschen": "delete",
|
||||
"versenden": "send",
|
||||
"sub-lizenzserver": "sub-servers",
|
||||
},
|
||||
),
|
||||
"/lizenz": (
|
||||
"/administration/license",
|
||||
{
|
||||
"einspielen": "install",
|
||||
"aktivieren": "activate",
|
||||
"deaktivieren": "deactivate",
|
||||
"offline-antwort": "offline-response",
|
||||
"serverzertifikat": "server-certificate",
|
||||
"aussteller": "issuer",
|
||||
"entfernen": "remove",
|
||||
},
|
||||
),
|
||||
"/kunden": ("/customers", {"neu": "new", "loeschen": "delete"}),
|
||||
"/konto": (
|
||||
"/account",
|
||||
{
|
||||
"stammdaten": "profile",
|
||||
"profilbild": "avatar",
|
||||
"sicherheit": "security",
|
||||
"passwort": "password",
|
||||
"zwei-faktor": "two-factor",
|
||||
"starten": "start",
|
||||
"bestaetigen": "confirm",
|
||||
"deaktivieren": "disable",
|
||||
"codes-erneuern": "recovery-codes",
|
||||
"sitzungen": "sessions",
|
||||
"beenden": "end",
|
||||
"alle-beenden": "end-all",
|
||||
},
|
||||
),
|
||||
"/uebersicht": ("/overview", {"kacheln": "tiles"}),
|
||||
"/gesundheit": ("/health", {}),
|
||||
"/bestaetigen": ("/confirm", {}),
|
||||
}
|
||||
|
||||
#: Abfrageparameter, die mit umbenannt wurden.
|
||||
PARAMETER = {
|
||||
"freigabe": "share",
|
||||
"pfad": "path",
|
||||
"aktion": "action",
|
||||
"benutzer": "actor",
|
||||
"stufe": "severity",
|
||||
"seite": "page",
|
||||
"methode": "method",
|
||||
"kunde": "customer",
|
||||
"freischalten": "unlocked",
|
||||
}
|
||||
|
||||
|
||||
#: Wurzeln, unter denen eine unbekannte Unterseite **nicht** umgelenkt wird.
|
||||
#: ``/lizenz/<ticket>`` ist auf dem Lizenzserver eine echte Adresse; ein
|
||||
#: abgelaufenes oder falsches Ticket gehoert dort auf die Fehlerseite und nicht
|
||||
#: in die Verwaltung.
|
||||
STRENG = {"/lizenz"}
|
||||
|
||||
|
||||
def neue_adresse(pfad: str) -> str | None:
|
||||
"""Uebersetzt eine alte Adresse -- oder ``None``, wenn es keine ist."""
|
||||
for alt, (neu, segmente) in WURZELN.items():
|
||||
if pfad != alt and not pfad.startswith(alt + "/"):
|
||||
continue
|
||||
rest = pfad[len(alt) :]
|
||||
teile = rest.split("/")
|
||||
if alt in STRENG:
|
||||
erstes = next((teil for teil in teile if teil), "")
|
||||
if erstes and erstes not in segmente:
|
||||
return None
|
||||
return neu + "/".join(segmente.get(teil, teil) for teil in teile)
|
||||
return None
|
||||
|
||||
|
||||
def neue_parameter(args: Mapping[str, str]) -> dict[str, str]:
|
||||
return {PARAMETER.get(name, name): wert for name, wert in args.items()}
|
||||
@@ -77,7 +77,7 @@ SETTINGS_NETWORK = Resource(
|
||||
|
||||
BACKUP = Resource(
|
||||
key="backup",
|
||||
label="Sicherung und Uebertragung",
|
||||
label="Sicherung und Übertragung",
|
||||
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
||||
hint="Verschluesselter Export und Import",
|
||||
action_hints={
|
||||
@@ -121,7 +121,7 @@ LOGS_HISTORY = Resource(
|
||||
|
||||
LOGS_AUDIT = Resource(
|
||||
key="logs_audit",
|
||||
label="Aenderungsprotokoll",
|
||||
label="Änderungsprotokoll",
|
||||
actions=(A.VIEW, A.EXPORT),
|
||||
hint="Wer hat wann was geaendert",
|
||||
)
|
||||
|
||||
@@ -74,6 +74,11 @@ class Resource:
|
||||
hint: str = ""
|
||||
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
||||
action_hints: Mapping[Action, str] = field(default_factory=dict)
|
||||
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
|
||||
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
|
||||
#: stilles Weglassen in der Route: eine Ausnahme von einer
|
||||
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
|
||||
ohne_wiederanmeldung: tuple[Action, ...] = ()
|
||||
|
||||
def permission(self, action: Action) -> str:
|
||||
return f"{self.key}.{action.value}"
|
||||
@@ -159,6 +164,25 @@ class PermissionTree:
|
||||
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
||||
return self._resources.get(key)
|
||||
|
||||
def verlangt_wiederanmeldung(self, permission: str) -> bool:
|
||||
"""Braucht dieses Recht eine frische Passwortbestaetigung?
|
||||
|
||||
Die Antwort steht im Modell (``SENSITIVE_ACTIONS``) und nicht in der
|
||||
Route -- sonst vergisst sie die naechste, die dazukommt.
|
||||
"""
|
||||
resource_key, _, action_value = permission.rpartition(".")
|
||||
entry = self._resources.get(resource_key)
|
||||
if entry is None:
|
||||
return False
|
||||
_, resource = entry
|
||||
try:
|
||||
action = Action(action_value)
|
||||
except ValueError:
|
||||
return False
|
||||
if action in resource.ohne_wiederanmeldung:
|
||||
return False
|
||||
return action in SENSITIVE_ACTIONS
|
||||
|
||||
def label_for(self, permission: str) -> str:
|
||||
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
|
||||
if permission.endswith(".view") and permission[:-5] in self._by_key:
|
||||
|
||||
@@ -26,7 +26,8 @@ import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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 werkzeug.datastructures import CallbackDict
|
||||
|
||||
@@ -49,9 +50,63 @@ SESSION_ID_BYTES = 32
|
||||
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?"""
|
||||
return request.headers.get(AUTO_REFRESH_HEADER, "") == "1"
|
||||
"""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:
|
||||
@@ -108,6 +163,7 @@ class SqliteSessionInterface(SessionInterface):
|
||||
idle_minutes: int,
|
||||
absolute_hours: int,
|
||||
idle_lookup: Any = None,
|
||||
absolute_lookup: Any = None,
|
||||
) -> None:
|
||||
self.database = database
|
||||
self.cookie_name = cookie_name
|
||||
@@ -117,6 +173,16 @@ class SqliteSessionInterface(SessionInterface):
|
||||
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
||||
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
||||
self._idle_lookup = idle_lookup
|
||||
self._absolute_lookup = absolute_lookup
|
||||
|
||||
def _absolute(self, conn: Any) -> int:
|
||||
"""Gesamtdauer in Stunden -- einstellbar wie die Leerlaufgrenze."""
|
||||
if self._absolute_lookup is None:
|
||||
return self.absolute_hours
|
||||
try:
|
||||
return int(self._absolute_lookup(conn)) or self.absolute_hours
|
||||
except Exception: # pragma: no cover - Einstellung unlesbar
|
||||
return self.absolute_hours
|
||||
|
||||
def _idle(self, conn: Any) -> int:
|
||||
if self._idle_lookup is None:
|
||||
@@ -154,6 +220,11 @@ class SqliteSessionInterface(SessionInterface):
|
||||
"WHERE id=?",
|
||||
(_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)
|
||||
|
||||
try:
|
||||
@@ -220,6 +291,9 @@ class SqliteSessionInterface(SessionInterface):
|
||||
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
||||
with self.database.session() as conn, self.database.transaction(conn):
|
||||
if session.new or not session.sid:
|
||||
# Eine vorhandene ID nicht wegwerfen: nach einem Wechsel beim
|
||||
# Anmelden steht sie schon, und andere haben sie sich gemerkt.
|
||||
if not session.sid:
|
||||
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||
conn.execute(
|
||||
"INSERT INTO user_sessions "
|
||||
@@ -232,13 +306,38 @@ class SqliteSessionInterface(SessionInterface):
|
||||
_iso(now),
|
||||
_iso(now),
|
||||
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||
_iso(now + timedelta(hours=self.absolute_hours)),
|
||||
_iso(now + timedelta(hours=self._absolute(conn))),
|
||||
session.get("_ip", ""),
|
||||
session.get("_ua", ""),
|
||||
payload,
|
||||
),
|
||||
)
|
||||
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:
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
|
||||
@@ -247,7 +346,11 @@ class SqliteSessionInterface(SessionInterface):
|
||||
session.get("user_id"),
|
||||
payload,
|
||||
_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.sid,
|
||||
),
|
||||
@@ -282,7 +385,12 @@ def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "p
|
||||
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
|
||||
(_iso(_now()), reason, old_sid),
|
||||
)
|
||||
session.sid = ""
|
||||
# Die neue ID entsteht **jetzt** und nicht erst beim Speichern der Antwort.
|
||||
# Zwischen beidem laufen die Anmeldehaken, und einer davon bindet die
|
||||
# Dateifreigaben ein und schreibt sich die Sitzung dazu. Mit einer leeren
|
||||
# Sitzung hielt der Aufraeumer diese Einbindungen fuer verwaist und loeste
|
||||
# sie binnen Minuten wieder.
|
||||
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||
session.new = True
|
||||
session.modified = True
|
||||
return old_sid
|
||||
|
||||
@@ -412,7 +412,10 @@
|
||||
und beide sind rot. */
|
||||
.pill--critical {
|
||||
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;
|
||||
}
|
||||
.pill--critical::before { background: currentColor; }
|
||||
@@ -682,7 +685,10 @@
|
||||
.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
|
||||
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 {
|
||||
padding: 0 var(--sp-5) var(--sp-2);
|
||||
color: var(--text-muted);
|
||||
@@ -695,7 +701,177 @@
|
||||
width: 100%;
|
||||
height: min(70dvh, 760px);
|
||||
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);
|
||||
}
|
||||
/* Hochkant -- Tablet oder Monitor im Hochformat -- gibt es unter dem Dialog
|
||||
viel Platz, den die Obergrenze verschenkt. Beim Ausfuellen eines
|
||||
Formulars zaehlt jede sichtbare Zeile, also nehmen wir dort die Hoehe,
|
||||
die da ist. Der Rest (Kopf, Dateiname, Fusszeile) braucht rund 200 Pixel;
|
||||
die 240 lassen etwas Luft. */
|
||||
@media (orientation: portrait) {
|
||||
.viewer {
|
||||
height: calc(100dvh - 240px);
|
||||
}
|
||||
}
|
||||
.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 --
|
||||
der liegt deshalb *innen*. Aussen ein gewoehnlicher Flex-Kasten, damit
|
||||
Zoomleiste und Speicherzeile ihren Platz behalten. Vorher rechnete der
|
||||
Bereich mit "bottom: var(--sp-10)", also mit genau einer Leiste; die
|
||||
zweite Zeile lag dann unter dem Dialogrand. */
|
||||
.viewer__pdf {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
/* Ein PDF ist weisses Papier. pdf.js malt Formularfelder mit Systemfarben
|
||||
und ueberlaesst Ankreuzfelder dem Browser -- im dunklen Design heisst
|
||||
CanvasText weiss, und die Haken waren auf dem Papier unsichtbar. Fuer
|
||||
diesen Ausschnitt gilt deshalb immer das helle Schema. */
|
||||
color-scheme: light;
|
||||
}
|
||||
/* pdf.js rechnet fuer die Seite mit dem Standard "content-box": ihr
|
||||
9-Pixel-Rahmen liegt *ausserhalb* der angegebenen Breite. Unser globales
|
||||
"border-box" rechnete ihn hinein -- die Leinwand wurde 18 Pixel schmaler
|
||||
als die Anmerkungsebene, und die Formularfelder wanderten nach rechts
|
||||
unten, immer weiter je weiter unten sie lagen. */
|
||||
.viewer__pdf .pdfViewer .page {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.viewer__pdfscroll {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
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;
|
||||
}
|
||||
/* Bildfeld eines Formulars: der Kasten zeigt das gewaehlte Foto, die
|
||||
Schaltflaeche liegt darin. Acrobats eigener Knopf tut im Browser nichts --
|
||||
das hier ist der Ersatz. */
|
||||
.viewer__bildfeld [data-viewer-bildvorschau] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
.viewer__bildwahl {
|
||||
position: absolute;
|
||||
inset: auto 0 4px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.viewer__bildwahl > span {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--r-sm, 6px);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.3));
|
||||
}
|
||||
.viewer__bildfeld--belegt .viewer__bildwahl > span {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.viewer__save {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-2, var(--surface-1));
|
||||
}
|
||||
.viewer__save .viewer__feld {
|
||||
flex: 1 1 12rem;
|
||||
margin: 0;
|
||||
}
|
||||
/* Die Entscheidung steht rechts und der Haken darueber: er gehoert zum
|
||||
Speichern, nicht zu den Feldern davor. */
|
||||
.viewer__saveaktionen {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.viewer__saveknoepfe {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
/* Nach "gibt es schon": der Haken ist die Antwort -- also faellt er auf. */
|
||||
.viewer__saveaktionen .check.is-gefragt {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--r-xs);
|
||||
}
|
||||
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
|
||||
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
|
||||
.viewer__feld {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
|
||||
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
|
||||
@@ -849,7 +1025,10 @@
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
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);
|
||||
box-shadow: inset 0 1px 2px rgb(0 0 0 / 28%);
|
||||
vertical-align: -3px;
|
||||
@@ -943,9 +1122,15 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
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:
|
||||
radial-gradient(1100px 520px at 12% -10%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 70%),
|
||||
radial-gradient(900px 480px at 100% 110%, color-mix(in srgb, var(--info) 10%, transparent), transparent 70%),
|
||||
radial-gradient(circle at 15% 15%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 45%),
|
||||
radial-gradient(circle at 85% 85%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 40%),
|
||||
var(--canvas);
|
||||
}
|
||||
/* Die Wortmarke liegt hinter allem (z-index 0), die Anmeldebox davor.
|
||||
@@ -1026,24 +1211,35 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 4vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(72vw, 880px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
/* Volle Deckung: solange die Marke hinter der Karte lag, war sie ein
|
||||
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; }
|
||||
@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 weniger Reize moechte, bekommt die Marke ruhiger -- aber nicht
|
||||
unsichtbar: sie ist die Ueberschrift der Seite. */
|
||||
@media (prefers-contrast: more) {
|
||||
.auth-backdrop { opacity: 0.18; }
|
||||
.auth-backdrop { opacity: 0.75; }
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
@@ -1138,3 +1334,174 @@
|
||||
.hide-sm { display: none !important; }
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Sortierbare Liste (Navigationsreihenfolge) --------------------------- */
|
||||
/* Symbol und Beschriftung stehen in einer Zeile -- so wie im Menue selbst.
|
||||
Vorher brach die Beschriftung unter das Symbol, und die Liste sah aus wie
|
||||
etwas anderes als das, was sie sortiert. */
|
||||
|
||||
.reorder {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.reorder__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--surface-2);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.reorder__row > .flex-1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.reorder__row.is-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.reorder__grip {
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.reorder__pos {
|
||||
min-width: 1.6em;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.reorder__moves {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.reorder__group {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.reorder--child {
|
||||
margin: 4px 0 4px 28px;
|
||||
}
|
||||
|
||||
.reorder--child .reorder__row {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* -- Unterschrift --------------------------------------------------------- */
|
||||
/* Die Zeichenflaeche braucht "touch-action: none": sonst scrollt ein iPad die
|
||||
Seite, statt einen Strich zu zeichnen -- und es sieht aus, als reagierte das
|
||||
Feld nicht. */
|
||||
|
||||
.viewer__sign {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.viewer__signfelder {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.viewer__signpad {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
border: 1px dashed var(--border-strong, var(--border));
|
||||
border-radius: var(--r-sm);
|
||||
background: #fff;
|
||||
touch-action: none;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.viewer__signaktionen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.viewer__pdfscroll--setzen {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.viewer__pdfscroll--setzen .page {
|
||||
outline: 2px dashed var(--accent, #c8791f);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Die gesetzte Unterschrift liegt auf der Seite -- in Prozent, damit sie beim
|
||||
Zoomen an ihrer Stelle bleibt. */
|
||||
.viewer__unterschrift {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* -- Ergebnis der Signaturpruefung ---------------------------------------- */
|
||||
/* Der Schleier liegt ueber der Seite und nimmt keine Klicks an: ein
|
||||
unterschriebenes Dokument ist flach, aber bei einem Irrtum soll die
|
||||
Kennzeichnung nicht auch noch die Bedienung blockieren. */
|
||||
|
||||
.viewer__pdf--unveraendert {
|
||||
box-shadow: inset 0 0 0 3px var(--success);
|
||||
}
|
||||
|
||||
.viewer__pdf--unveraendert::after,
|
||||
.viewer__pdf--veraendert::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.viewer__pdf--unveraendert::after {
|
||||
background: rgba(47, 158, 68, 0.1);
|
||||
}
|
||||
|
||||
.viewer__pdf--veraendert {
|
||||
box-shadow: inset 0 0 0 3px var(--danger);
|
||||
}
|
||||
|
||||
.viewer__pdf--veraendert::after {
|
||||
background: rgba(201, 42, 42, 0.08);
|
||||
}
|
||||
|
||||
.viewer__pruefung {
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
font-size: var(--fs-xs);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.viewer__pruefung--gut {
|
||||
color: var(--success);
|
||||
background: rgba(47, 158, 68, 0.12);
|
||||
}
|
||||
|
||||
.viewer__pruefung--schlecht {
|
||||
color: var(--danger);
|
||||
background: rgba(201, 42, 42, 0.12);
|
||||
}
|
||||
@@ -66,6 +66,15 @@ export async function api(url, options = {}) {
|
||||
...(options.headers || {}),
|
||||
};
|
||||
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 payload = type.includes("application/json") ? await response.json() : await response.text();
|
||||
if (!response.ok) {
|
||||
@@ -847,6 +856,81 @@ function initTileFilters() {
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Reihenfolge aendern -------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Eine Liste, deren Reihenfolge man aendern kann -- ohne Bibliothek.
|
||||
*
|
||||
* Die Reihenfolge steckt in der Reihenfolge der versteckten Felder: das
|
||||
* Formular schickt sie als wiederholtes Feld, und der Server liest sie als
|
||||
* Liste. Verschiebt man einen Eintrag, verschiebt sich das Feld mit ihm.
|
||||
*
|
||||
* Zwei Wege, weil es zwei Arten von Geraeten gibt: Ziehen mit der Maus und
|
||||
* zwei Knoepfe je Zeile. Die Knoepfe sind nicht die Notloesung -- auf einem
|
||||
* Tablet sind sie der einzige Weg, der zuverlaessig funktioniert, und mit der
|
||||
* Tastatur ebenso.
|
||||
*/
|
||||
function initReorder() {
|
||||
$$('[data-behavior="reorder"]').forEach((liste) => {
|
||||
// Nur die eigenen Zeilen: Gruppen enthalten wiederum eine sortierbare
|
||||
// Liste, und deren Zeilen gehoeren der inneren Liste, nicht dieser.
|
||||
const zeilen = () => Array.from(liste.querySelectorAll(":scope > [data-reorder-item]"));
|
||||
|
||||
const nummerieren = () => {
|
||||
zeilen().forEach((zeile, index) => {
|
||||
const nummer = zeile.querySelector("[data-reorder-position-text]");
|
||||
if (nummer) nummer.textContent = String(index + 1);
|
||||
const hoch = zeile.querySelector('[data-reorder-move="up"]');
|
||||
const runter = zeile.querySelector('[data-reorder-move="down"]');
|
||||
if (hoch) hoch.disabled = index === 0;
|
||||
if (runter) runter.disabled = index === zeilen().length - 1;
|
||||
});
|
||||
};
|
||||
|
||||
liste.addEventListener("click", (event) => {
|
||||
const knopf = event.target.closest("[data-reorder-move]");
|
||||
if (!knopf) return;
|
||||
const zeile = knopf.closest("[data-reorder-item]");
|
||||
if (!zeile || zeile.parentElement !== liste) return;
|
||||
event.preventDefault();
|
||||
if (knopf.dataset.reorderMove === "up") {
|
||||
const davor = zeile.previousElementSibling;
|
||||
if (davor) liste.insertBefore(zeile, davor);
|
||||
} else {
|
||||
const danach = zeile.nextElementSibling;
|
||||
if (danach) liste.insertBefore(danach, zeile);
|
||||
}
|
||||
nummerieren();
|
||||
zeile.querySelector(`[data-reorder-move="${knopf.dataset.reorderMove}"]`)?.focus();
|
||||
});
|
||||
|
||||
let gezogen = null;
|
||||
liste.addEventListener("dragstart", (event) => {
|
||||
const zeile = event.target.closest("[data-reorder-item]");
|
||||
if (!zeile || zeile.parentElement !== liste) return;
|
||||
gezogen = zeile;
|
||||
gezogen.classList.add("is-dragging");
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
liste.addEventListener("dragend", () => {
|
||||
if (gezogen) gezogen.classList.remove("is-dragging");
|
||||
gezogen = null;
|
||||
nummerieren();
|
||||
});
|
||||
liste.addEventListener("dragover", (event) => {
|
||||
if (!gezogen) return;
|
||||
event.preventDefault();
|
||||
const ziel = event.target.closest("[data-reorder-item]");
|
||||
if (!ziel || ziel === gezogen || ziel.parentElement !== liste) return;
|
||||
const kasten = ziel.getBoundingClientRect();
|
||||
const obereHaelfte = event.clientY < kasten.top + kasten.height / 2;
|
||||
liste.insertBefore(gezogen, obereHaelfte ? ziel : ziel.nextElementSibling);
|
||||
});
|
||||
|
||||
nummerieren();
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Start ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
@@ -884,8 +968,23 @@ function boot() {
|
||||
initPalette();
|
||||
initSubmitGuards();
|
||||
initTileFilters();
|
||||
initReorder();
|
||||
initLogTails();
|
||||
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;
|
||||
// Dieselbe Version wie diese Datei: app.js wird als ".../app.js?v=<Zeit>"
|
||||
// eingebunden, und nginx liefert /core-assets/ mit "expires 7d". Ohne das
|
||||
// Anhaengsel bliebe der alte Betrachter nach einem Update im Cache liegen.
|
||||
const version = new URL(import.meta.url).search;
|
||||
import(`./viewer.js${version}`)
|
||||
.then((modul) => modul.initFilePreview())
|
||||
.catch((fehler) => console.error("Dateibetrachter", fehler));
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
@@ -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 |
|
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
|
||||
@@ -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.
|
||||
|
||||