Compare commits

...
6 Commits
21 changed files with 793 additions and 68 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.2.1" version = "2.4.1"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+22 -9
View File
@@ -353,19 +353,32 @@ def _start_fileshare_sweep(app: Flask) -> None:
weiter eingebunden. weiter eingebunden.
""" """
import threading import threading
import time
from .services import fileshare from .services import fileshare
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: def schleife() -> None:
while True: # Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner
time.sleep(FILESHARE_SWEEP_SECONDS) # Durchlauf scheitert; das aeussere sorgt dafuer, dass sein Ende
try: # ueberhaupt auffaellt. Vorher fehlte der Import von "time" -- der
with app.app_context(): # NameError kam beim ersten sleep, also vor jedem try, und der Faden
geloest = fileshare.sweep_expired(core(app).database) # war weg, ohne eine Zeile zu hinterlassen.
if geloest: try:
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest) while True:
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben time.sleep(FILESHARE_SWEEP_SECONDS)
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen") 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() threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
+1 -1
View File
@@ -78,7 +78,7 @@ def overview():
@bp.post("/export") @bp.post("/export")
@permission_required("backup.export") @permission_required("backup.export")
@reauth_required # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def export(): def export():
extension = core() extension = core()
conn = db() conn = db()
+2 -2
View File
@@ -6,7 +6,7 @@ from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from tesm_core import audit 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.auth.service import client_ip
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
@@ -111,7 +111,7 @@ def delete(credential_id: int):
@bp.post("/<int:credential_id>/anzeigen") @bp.post("/<int:credential_id>/anzeigen")
@permission_required("credentials.secrets") @permission_required("credentials.secrets")
@reauth_required # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def reveal(credential_id: int): def reveal(credential_id: int):
"""Zeigt ein gespeichertes Passwort im Klartext. """Zeigt ein gespeichertes Passwort im Klartext.
+51 -5
View File
@@ -80,11 +80,16 @@ def browse():
# vorher versprach diese Seite 512 MB, waehrend bei 64 MB abgebrochen # vorher versprach diese Seite 512 MB, waehrend bei 64 MB abgebrochen
# wurde. # wurde.
max_upload_mb=uploads.limit_mb(conn), max_upload_mb=uploads.limit_mb(conn),
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
verzeichnisse=(service.all_directories(mount) if listing else []),
) )
@bp.get("/<token>/herunterladen") @bp.get("/<token>/herunterladen")
@permission_required("fileshare.view") # 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): def download(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.args.get("pfad") or "" relative = request.args.get("pfad") or ""
@@ -182,7 +187,7 @@ def upload(token: str):
@bp.post("/<token>/formular") @bp.post("/<token>/formular")
@permission_required("fileshare.create") @permission_required("fileshare_pdf.create")
def save_form(token: str): def save_form(token: str):
"""Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab. """Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab.
@@ -205,15 +210,56 @@ def save_form(token: str):
# waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen. # waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen.
return {"error": "Das ist keine PDF-Datei."}, 415 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: try:
original = service.resolve_path(mount, relative) original = service.resolve_path(mount, relative)
ziel = service.free_name(original.parent, original.stem, "-ausgefuellt", ".pdf") # 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(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 dieser Freigabe 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"'{name}' gibt es dort 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) ziel.write_bytes(daten)
except service.FileshareError as exc: except service.FileshareError as exc:
return {"error": str(exc)}, 400 return {"error": str(exc)}, 400
_audit("fileshare.form_saved", f"{mount.label}:{relative}", ziel.name, severity="notice") wohin = ziel.relative_to(mount.path.resolve()).as_posix()
return {"message": f"Als {ziel.name} gespeichert."} _audit("fileshare.form_saved", f"{mount.label}:{relative}", wohin, severity="notice")
return {"message": f"Gespeichert als {wohin}.", "pfad": wohin}
@bp.post("/<token>/ordner") @bp.post("/<token>/ordner")
+27 -2
View File
@@ -85,11 +85,36 @@ DHCP = Resource(
FILESHARE = Resource( FILESHARE = Resource(
key="fileshare", key="fileshare",
label="Dateifreigaben", 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)", hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
action_hints={ action_hints={
A.CREATE: "Ordner anlegen und Dateien hochladen.", A.CREATE: "Ordner anlegen und Dateien hochladen.",
A.EDIT: "Umbenennen und verschieben.", 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.",
}, },
) )
@@ -119,7 +144,7 @@ FILES_AREA = Area(
key="files", key="files",
label="Dateifreigaben", label="Dateifreigaben",
icon="folder", icon="folder",
resources=(FILESHARE,), resources=(FILESHARE, FILESHARE_PDF),
) )
PERMISSIONS = PermissionTree( PERMISSIONS = PermissionTree(
+24
View File
@@ -250,8 +250,32 @@ 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';
"""
TESM_MIGRATIONS: tuple[Migration, ...] = ( TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1), Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
Migration(version=3, name="restart-events", sql=V3), Migration(version=3, name="restart-events", sql=V3),
Migration(version=4, name="fileshare-pdf-rechte", sql=V4),
) )
+77 -1
View File
@@ -21,6 +21,7 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
from __future__ import annotations from __future__ import annotations
import zlib
import logging import logging
import mimetypes import mimetypes
import os import os
@@ -548,6 +549,10 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
else: else:
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
record["preview_kind"] = preview_kind(entry.name) 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) files.append(record)
except OSError as exc: except OSError as exc:
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
@@ -588,6 +593,59 @@ def safe_filename(name: str) -> str:
return cleaned 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: def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path:
"""Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``. """Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``.
@@ -626,8 +684,26 @@ def make_directory(mount: Mount, relative: str, name: str) -> Path:
def rename_entry(mount: Mount, relative: str, new_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) 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(): if target.exists():
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.") raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
path.rename(target) path.rename(target)
@@ -106,14 +106,22 @@
data-fill-Kind="{{ entry.preview_kind }}" data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}" data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}" 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, pfad=entry.relative) }}"{% endif %}>
{{ icon('file-text', size=15) }} {{ entry.name }} {{ icon('file-text', size=15) }} {{ entry.name }}
</button> </button>
{% else %} {% else %}
<a class="link-icon" {% if current_user.has('fileshare.export') %}
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"> <a class="link-icon"
{{ icon('file-text', size=15) }} {{ entry.name }} href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
</a> {{ 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 %} {% endif %}
</td> </td>
<td class="numeric fs-sm">{{ entry.size | bytes }}</td> <td class="numeric fs-sm">{{ entry.size | bytes }}</td>
@@ -126,19 +134,22 @@
data-fill-Kind="{{ entry.preview_kind }}" data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}" data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}" 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, pfad=entry.relative) }}"{% endif %}
aria-label="Vorschau">{{ icon('eye', size=15) }}</button> aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
{% endif %} {% endif %}
{% if current_user.has('fileshare.edit') %} {% if current_user.has('fileshare.edit') %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-rename" data-dialog-open="file-rename"
data-fill-Pfad="{{ entry.relative }}" 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> aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
{% endif %} {% endif %}
<a class="btn btn--sm btn--ghost" {% if current_user.has('fileshare.export') %}
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}" <a class="btn btn--sm btn--ghost"
aria-label="Herunterladen">{{ icon('download', size=15) }}</a> href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -209,16 +220,62 @@
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #} nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
<div class="modal__body modal__body--flush" data-viewer <div class="modal__body modal__body--flush" data-viewer
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/') }}"> {# 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') }}">
<div class="viewer"> <div class="viewer">
<div class="viewer__stage" data-viewer-bildbuehne hidden> <div class="viewer__stage" data-viewer-bildbuehne hidden>
<img class="viewer__img" alt="" data-viewer-bild> <img class="viewer__img" alt="" data-viewer-bild>
</div> </div>
<div class="viewer__pdf" data-viewer-pdf hidden> <div class="viewer__pdf" data-viewer-pdf hidden>
<div class="pdfViewer" data-viewer-pdfseiten></div> {# 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> </div>
<iframe class="file-preview" title="Vorschau" data-viewer-rahmen <iframe class="file-preview" title="Vorschau" data-viewer-rahmen
src="about:blank" referrerpolicy="same-origin" hidden></iframe> 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') %}
<div class="viewer__save" data-viewer-speichernzeile hidden>
<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>
<label class="check">
<input type="checkbox" data-viewer-ersetzen>
<span>Vorhandene ersetzen</span>
</label>
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>Speichern</button>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>Abbrechen</button>
</div>
{% endif %}
<div class="viewer__bar" data-viewer-leiste hidden> <div class="viewer__bar" data-viewer-leiste hidden>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus" <button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button> aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
@@ -230,10 +287,12 @@
</div> </div>
</div> </div>
<div class="modal__footer"> <div class="modal__footer">
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download> {% if current_user.has('fileshare.export') %}
{{ icon('download', size=15) }} Herunterladen <a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
</a> {{ icon('download', size=15) }} Herunterladen
{% if current_user.has('fileshare.create') %} </a>
{% endif %}
{% if current_user.has('fileshare_pdf.create') %}
<button class="btn btn--sm" type="button" data-viewer-speichern hidden <button class="btn btn--sm" type="button" data-viewer-speichern hidden
data-url="{{ url_for('fileshare.save_form', token=token) }}"> data-url="{{ url_for('fileshare.save_form', token=token) }}">
{{ icon('save', size=15) }} Ausgefüllt speichern {{ icon('save', size=15) }} Ausgefüllt speichern
@@ -242,6 +301,10 @@
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button> <button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
</div> </div>
{% endcall %} {% 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 %} {% endif %}
{% if listing and current_user.has('fileshare.edit') %} {% if listing and current_user.has('fileshare.edit') %}
@@ -251,9 +314,12 @@
<input type="hidden" name="parent" value="{{ listing.relative }}"> <input type="hidden" name="parent" value="{{ listing.relative }}">
<input type="hidden" name="pfad" data-fill-target="Pfad" value=""> <input type="hidden" name="pfad" data-fill-target="Pfad" value="">
<div class="modal__body"> <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"> <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> </p>
</div> </div>
<div class="modal__footer"> <div class="modal__footer">
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.2.1" version = "2.4.1"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -135,7 +135,7 @@ def create_base_app(
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Anwendungen. #: Anwendungen.
auth_tagline: str = "", auth_tagline: str = "",
app_version: str = "2.2.1", app_version: str = "2.4.1",
built_in_trusted_keys: tuple[str, ...] = (), built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True, enable_license: bool = True,
template_folder: str | None = None, template_folder: str | None = None,
@@ -220,6 +220,37 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
return wrapper 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]: def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
"""Erzwingt Rechte serverseitig. """Erzwingt Rechte serverseitig.
@@ -238,6 +269,14 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
) )
if not ok: if not ok:
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.") 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) return view(*args, **kwargs)
wrapper._required_permissions = permissions # type: ignore[attr-defined] wrapper._required_permissions = permissions # type: ignore[attr-defined]
@@ -74,6 +74,11 @@ class Resource:
hint: str = "" hint: str = ""
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint). #: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
action_hints: Mapping[Action, str] = field(default_factory=dict) 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: def permission(self, action: Action) -> str:
return f"{self.key}.{action.value}" return f"{self.key}.{action.value}"
@@ -159,6 +164,25 @@ class PermissionTree:
def resource(self, key: str) -> tuple[Area, Resource] | None: def resource(self, key: str) -> tuple[Area, Resource] | None:
return self._resources.get(key) 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: def label_for(self, permission: str) -> str:
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs.""" """Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
if permission.endswith(".view") and permission[:-5] in self._by_key: if permission.endswith(".view") and permission[:-5] in self._by_key:
@@ -720,6 +720,16 @@
min-height: 220px; min-height: 220px;
background: var(--surface-1); 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 { .viewer > .file-preview {
flex: 1; flex: 1;
height: auto; height: auto;
@@ -754,11 +764,32 @@
user-select: none; user-select: none;
-webkit-user-drag: none; -webkit-user-drag: none;
} }
/* pdf.js verlangt einen absolut positionierten, scrollenden Behaelter. */ /* 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 { .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; position: absolute;
inset: 0; inset: 0;
bottom: var(--sp-10);
overflow: auto; overflow: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
@@ -773,6 +804,56 @@
.viewer__hint { .viewer__hint {
margin-inline-start: auto; 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;
}
/* 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 /* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
unruhig. */ unruhig. */
@@ -902,7 +902,11 @@ function boot() {
einem PDF noch pdf.js nach. Beides erst dann, wenn es gebraucht wird. */ einem PDF noch pdf.js nach. Beides erst dann, wenn es gebraucht wird. */
function initViewer() { function initViewer() {
if (!document.querySelector("[data-viewer]")) return; if (!document.querySelector("[data-viewer]")) return;
import("./viewer.js") // 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()) .then((modul) => modul.initFilePreview())
.catch((fehler) => console.error("Dateibetrachter", fehler)); .catch((fehler) => console.error("Dateibetrachter", fehler));
} }
@@ -142,25 +142,174 @@ function bildBetrachter(buehne, bild) {
}; };
} }
/* -- Bildfelder in Formularen ---------------------------------------------- */
//: Groesser muss ein Foto in einem Formular nicht sein.
const FOTO_KANTE = 1600;
/** Macht aus einer gewaehlten Datei ein JPEG in vertretbarer Groesse.
*
* Ueber eine Leinwand, nicht roh: damit wird aus allem, was der Browser
* anzeigen kann, ein JPEG -- auf einem iPad also auch aus HEIC, das pdf-lib
* nicht kennt. Und ein 12-Megapixel-Foto blaeht das Formular nicht auf.
*/
async function fotoAlsJpeg(datei) {
// Keine Objektadresse: die CSP erlaubt Bilder nur von "self" und als data:.
// createImageBitmap nimmt die Datei unmittelbar; wo es fehlt, tut es eine
// data:-Adresse.
let bild;
if (typeof createImageBitmap === "function") {
try {
bild = await createImageBitmap(datei);
} catch {
bild = null;
}
}
if (!bild) {
const daten = await new Promise((fertig, schiefgegangen) => {
const leser = new FileReader();
leser.onload = () => fertig(String(leser.result));
leser.onerror = () => schiefgegangen(new Error("Die Datei liess sich nicht lesen."));
leser.readAsDataURL(datei);
});
bild = await new Promise((fertig, schiefgegangen) => {
const el = new Image();
el.onload = () => fertig(el);
el.onerror = () => schiefgegangen(new Error("Dieses Bild kann der Browser nicht anzeigen."));
el.src = daten;
});
}
const breiteRoh = bild.width || bild.naturalWidth;
const hoeheRoh = bild.height || bild.naturalHeight;
const faktor = Math.min(1, FOTO_KANTE / Math.max(breiteRoh, hoeheRoh));
const leinwand = document.createElement("canvas");
leinwand.width = Math.max(1, Math.round(breiteRoh * faktor));
leinwand.height = Math.max(1, Math.round(hoeheRoh * faktor));
leinwand.getContext("2d").drawImage(bild, 0, 0, leinwand.width, leinwand.height);
bild.close?.();
const klecks = await new Promise((fertig) => leinwand.toBlob(fertig, "image/jpeg", 0.85));
return {
bytes: new Uint8Array(await klecks.arrayBuffer()),
breite: leinwand.width,
hoehe: leinwand.height,
vorschau: leinwand.toDataURL("image/jpeg", 0.6),
};
}
let pdflibGeladen = null;
/** pdf-lib nachladen -- nur wenn wirklich ein Bild eingesetzt wird. */
async function pdflibLaden(adresse) {
if (!pdflibGeladen) {
pdflibGeladen = new Promise((fertig, schiefgegangen) => {
const skript = document.createElement("script");
skript.src = adresse;
skript.onload = () => fertig(window.PDFLib);
skript.onerror = () => {
pdflibGeladen = null;
schiefgegangen(new Error("pdf-lib liess sich nicht laden."));
};
document.head.append(skript);
});
}
return pdflibGeladen;
}
/** Setzt die Fotos ein und schliesst das Formular auf Wunsch ab.
*
* Eingabe sind die Bytes, die pdf.js nach dem Ausfuellen geschrieben hat --
* die Feldwerte bleiben also erhalten.
*
* Das Foto wird das **Aussehen des Bildknopfes** und nicht in die Seite
* gestempelt: so bleibt der Knopf erhalten, das Bild ist trotzdem zu sehen,
* und beim naechsten Oeffnen laesst es sich austauschen. Genau das tut
* Acrobat mit "buttonImportIcon".
*
* ``abschliessen`` rechnet danach alle Felder flach in die Seite: heraus kommt
* ein PDF ohne Formular -- nichts mehr zu verstellen.
*/
async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } = {}) {
const PDFLib = await pdflibLaden(adresse);
const dokument = await PDFLib.PDFDocument.load(bytes);
const formular = dokument.getForm();
for (const eintrag of bilder) {
const jpeg = await dokument.embedJpg(eintrag.foto.bytes);
const knopf = eintrag.feldname ? formular.getFieldMaybe(eintrag.feldname) : null;
if (knopf && typeof knopf.setImage === "function") {
knopf.setImage(jpeg);
continue;
}
// Kein Knopf gefunden: dann eben in die Seite. Damit nichts darueber
// liegt, muss das Feld weichen.
const seite = dokument.getPage(eintrag.seite - 1);
const [x1, y1, x2, y2] = eintrag.rechteck;
const kastenBreite = Math.abs(x2 - x1);
const kastenHoehe = Math.abs(y2 - y1);
// Einpassen statt verzerren: das Seitenverhaeltnis des Fotos bleibt.
const faktor = Math.min(kastenBreite / jpeg.width, kastenHoehe / jpeg.height);
seite.drawImage(jpeg, {
x: Math.min(x1, x2) + (kastenBreite - jpeg.width * faktor) / 2,
y: Math.min(y1, y2) + (kastenHoehe - jpeg.height * faktor) / 2,
width: jpeg.width * faktor,
height: jpeg.height * faktor,
});
if (knopf) {
try {
formular.removeField(knopf);
} catch (fehler) {
console.warn("Bildknopf liess sich nicht entfernen", fehler);
}
}
}
if (abschliessen) {
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
// gewollt: so kann niemand mehr etwas verstellen.
formular.flatten();
}
return dokument.save();
}
/* -- PDF ------------------------------------------------------------------- */ /* -- PDF ------------------------------------------------------------------- */
let pdfjsGeladen = null; let pdfjsGeladen = null;
/** Die Adressen der pdf.js-Dateien -- alle mit derselben Version.
*
* Uebergeben wird die Adresse von pdf.min.js samt "?v=<Zeit>"; die
* Geschwister liegen daneben. Ohne die Version bliebe nach einem Update die
* alte Fassung im Cache von nginx ("expires 7d") haengen.
*/
function pdfjsAdressen(adresse) {
const url = new URL(adresse, location.href);
const verzeichnis = url.pathname.replace(/[^/]*$/, "");
const mit = (datei) => `${url.origin}${verzeichnis}${datei}${url.search}`;
return {
lib: mit("pdf.min.js"),
komponenten: mit("pdf_viewer.js"),
worker: mit("pdf.worker.min.js"),
stil: mit("pdf_viewer.css"),
schriften: `${url.origin}${verzeichnis}standard_fonts/`,
};
}
/** Laedt pdf.js einmalig nach; das Paket liegt neben dieser Datei. */ /** Laedt pdf.js einmalig nach; das Paket liegt neben dieser Datei. */
async function pdfjsLaden(basis) { async function pdfjsLaden(basis) {
const adressen = pdfjsAdressen(basis);
if (!pdfjsGeladen) { if (!pdfjsGeladen) {
pdfjsGeladen = (async () => { pdfjsGeladen = (async () => {
const lib = await import(`${basis}pdf.min.js`); const lib = await import(adressen.lib);
// Der Komponentenbau von pdf.js sucht die Bibliothek unter diesem Namen. // Der Komponentenbau von pdf.js sucht die Bibliothek unter diesem Namen.
globalThis.pdfjsLib = lib; globalThis.pdfjsLib = lib;
const komponenten = await import(`${basis}pdf_viewer.js`); const komponenten = await import(adressen.komponenten);
// Das Stylesheet von pdf.js zeichnet die Formularfelder. 100 KB gehoeren // Das Stylesheet von pdf.js zeichnet die Formularfelder. 100 KB gehoeren
// nicht in jeden Seitenaufruf -- also erst hier. // nicht in jeden Seitenaufruf -- also erst hier.
const stil = document.createElement("link"); const stil = document.createElement("link");
stil.rel = "stylesheet"; stil.rel = "stylesheet";
stil.href = `${basis}pdf_viewer.css`; stil.href = adressen.stil;
document.head.append(stil); document.head.append(stil);
lib.GlobalWorkerOptions.workerSrc = `${basis}pdf.worker.min.js`; lib.GlobalWorkerOptions.workerSrc = adressen.worker;
return { lib, komponenten }; return { lib, komponenten };
})().catch((fehler) => { })().catch((fehler) => {
pdfjsGeladen = null; pdfjsGeladen = null;
@@ -170,9 +319,15 @@ async function pdfjsLaden(basis) {
return pdfjsGeladen; return pdfjsGeladen;
} }
/** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */ /** Zeigt ein PDF -- mit ausfuellbaren Feldern, sofern das erlaubt ist.
async function pdfBetrachter(behaelter, seiten, url, basis) { *
* Ohne ``formulare`` bleibt es bei der Darstellung aus dem Dokument: pdf.js
* malt dann die Marken, die im PDF stehen (etwa rotes Kreuz und gruener
* Haken), statt Bedienelemente des Browsers einzusetzen.
*/
async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true } = {}) {
const { lib, komponenten } = await pdfjsLaden(basis); const { lib, komponenten } = await pdfjsLaden(basis);
const adressen = pdfjsAdressen(basis);
const eventBus = new komponenten.EventBus(); const eventBus = new komponenten.EventBus();
const linkService = new komponenten.PDFLinkService({ eventBus }); const linkService = new komponenten.PDFLinkService({ eventBus });
const viewer = new komponenten.PDFViewer({ const viewer = new komponenten.PDFViewer({
@@ -180,8 +335,11 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
viewer: seiten, viewer: seiten,
eventBus, eventBus,
linkService, linkService,
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern. // Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern --
annotationMode: lib.AnnotationMode.ENABLE_FORMS, // wer nicht ausfuellen darf, bekommt die Darstellung aus dem Dokument.
annotationMode: formulare
? lib.AnnotationMode.ENABLE_FORMS
: lib.AnnotationMode.ENABLE,
textLayerMode: 1, textLayerMode: 1,
}); });
linkService.setViewer(viewer); linkService.setViewer(viewer);
@@ -190,7 +348,7 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
url, url,
// Ohne die Standardschriften bleibt jede Seite mit Text leer: Helvetica // Ohne die Standardschriften bleibt jede Seite mit Text leer: Helvetica
// und Verwandte liegen als Datei im Paket, nicht im Browser. // und Verwandte liegen als Datei im Paket, nicht im Browser.
standardFontDataUrl: `${basis}standard_fonts/`, standardFontDataUrl: adressen.schriften,
isEvalSupported: false, isEvalSupported: false,
}).promise; }).promise;
viewer.setDocument(dokument); viewer.setDocument(dokument);
@@ -223,6 +381,73 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
start = null; start = null;
}); });
/* Bildknoepfe: eigene Schaltflaeche darueber, denn Acrobats Aktion
("buttonImportIcon") fuehrt kein Browser aus. Gesammelt wird, was beim
Speichern in die Datei gestempelt werden soll. */
const bilder = new Map();
const bildknoepfeAufsetzen = async (nurSeite) => {
if (!formulare) return;
for (let nummer = 1; nummer <= dokument.numPages; nummer++) {
if (nurSeite && nummer !== nurSeite) continue;
const seite = await dokument.getPage(nummer);
const annots = await seite.getAnnotations({ intent: "display" });
for (const annot of annots) {
const quelltext = (annot.actions?.Action || []).join(" ");
if (!quelltext.includes("buttonImportIcon")) continue;
const kasten = behaelter.querySelector(
`section[data-annotation-id="${annot.id}"]`
);
if (!kasten || kasten.querySelector("[data-viewer-bildwahl]")) continue;
kasten.classList.add("viewer__bildfeld");
const knopf = document.createElement("label");
knopf.className = "viewer__bildwahl";
knopf.setAttribute("data-viewer-bildwahl", "");
knopf.innerHTML =
'<input type="file" accept="image/*" hidden>' +
"<span>Bild einsetzen</span>";
const feld = knopf.querySelector("input");
feld.addEventListener("change", async () => {
const datei = feld.files?.[0];
if (!datei) return;
try {
const foto = await fotoAlsJpeg(datei);
bilder.set(annot.id, {
seite: nummer,
rechteck: annot.rect,
feldname: annot.fieldName,
foto,
});
// Als eigenes Bild und nicht als Hintergrund: der Kasten traegt
// eine Leinwand von pdf.js (das Aussehen des Knopfes aus dem PDF),
// und die liegt ueber jedem Hintergrund.
let vorschau = kasten.querySelector("[data-viewer-bildvorschau]");
if (!vorschau) {
vorschau = document.createElement("img");
vorschau.setAttribute("data-viewer-bildvorschau", "");
vorschau.alt = "";
kasten.append(vorschau);
}
vorschau.src = foto.vorschau;
kasten.classList.add("viewer__bildfeld--belegt");
knopf.querySelector("span").textContent = "Anderes Bild";
} catch (fehler) {
window.tesm.showToast(fehler.message || "Bild nicht lesbar.", "danger");
}
});
kasten.append(knopf);
}
}
};
// Die Knoepfe koennen erst auf eine gerenderte Anmerkungsebene -- die
// entsteht je Seite, wenn sie sichtbar wird.
eventBus.on("annotationlayerrendered", (ereignis) => {
bildknoepfeAufsetzen(ereignis.pageNumber).catch((fehler) =>
console.error("Bildfelder", fehler)
);
});
await bildknoepfeAufsetzen();
return { return {
hinein: () => { hinein: () => {
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2); viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
@@ -233,8 +458,17 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
anpassen: () => { anpassen: () => {
viewer.currentScaleValue = "page-width"; viewer.currentScaleValue = "page-width";
}, },
/** Das ausgefuellte Formular als Bytes -- pdf.js schreibt die Werte hinein. */ /** Das ausgefuellte Formular als Bytes.
speichern: () => dokument.saveDocument(), *
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
*/
speichern: async (pdflibAdresse, { abschliessen = false } = {}) => {
const bytes = await dokument.saveDocument();
// Ohne Bild und ohne Abschluss braucht es pdf-lib gar nicht.
if (!bilder.size && !abschliessen) return bytes;
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, { abschliessen });
},
/** Wurde ueberhaupt etwas eingetragen? */ /** Wurde ueberhaupt etwas eingetragen? */
veraendert: () => { veraendert: () => {
const s = dokument.annotationStorage; const s = dokument.annotationStorage;
@@ -261,11 +495,19 @@ export function initFilePreview(wurzel = document) {
const bildBuehne = stueck("bildbuehne"); const bildBuehne = stueck("bildbuehne");
const bild = stueck("bild"); const bild = stueck("bild");
const pdfBehaelter = stueck("pdf"); const pdfBehaelter = stueck("pdf");
const pdfScroll = stueck("pdfscroll");
const pdfSeiten = stueck("pdfseiten"); const pdfSeiten = stueck("pdfseiten");
const rahmen = stueck("rahmen"); const rahmen = stueck("rahmen");
const leiste = stueck("leiste"); const leiste = stueck("leiste");
const hinweis = stueck("hinweis"); const hinweis = stueck("hinweis");
const speichern = stueck("speichern"); const speichern = stueck("speichern");
const zeile = stueck("speichernzeile");
const ordnerFeld = stueck("ordner");
const nameFeld = stueck("name");
const ersetzenFeld = stueck("ersetzen");
const jetzt = stueck("jetzt");
const abbrechen = stueck("abbrechen");
const modusFeld = stueck("modus");
const basis = rumpf.dataset.viewerPdfjs || ""; const basis = rumpf.dataset.viewerPdfjs || "";
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari), // Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
@@ -309,6 +551,7 @@ export function initFilePreview(wurzel = document) {
} }
pdfSeiten.replaceChildren(); pdfSeiten.replaceChildren();
if (hinweis) hinweis.textContent = ""; if (hinweis) hinweis.textContent = "";
if (zeile) zeile.hidden = true;
aktuell = null; aktuell = null;
}; };
@@ -328,7 +571,9 @@ export function initFilePreview(wurzel = document) {
} }
if (hinweis) hinweis.textContent = "PDF wird geladen ..."; if (hinweis) hinweis.textContent = "PDF wird geladen ...";
try { try {
pdf = await pdfBetrachter(pdfBehaelter, pdfSeiten, quelle, basis); pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
formulare: rumpf.dataset.viewerFormulare === "1",
});
if (hinweis) hinweis.textContent = ""; if (hinweis) hinweis.textContent = "";
} catch (fehler) { } catch (fehler) {
// Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige // Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige
@@ -337,8 +582,12 @@ export function initFilePreview(wurzel = document) {
zeigen("text"); zeigen("text");
rahmen.src = quelle; rahmen.src = quelle;
if (hinweis) { if (hinweis) {
// Den Grund mitgeben: auf einem fremden Geraet ist die Meldung des
// Browsers die einzige Spur, die man hat.
const grund = String(fehler?.message || fehler || "").slice(0, 120);
hinweis.textContent = hinweis.textContent =
"Die Formularansicht liess sich nicht laden -- es bleibt bei der einfachen Anzeige."; "Die Formularansicht liess sich nicht laden -- es bleibt bei der einfachen Anzeige." +
(grund ? ` (${grund})` : "");
// Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade // Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade
// verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und // verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und
// es sah aus, als koennte die Vorschau einfach keine Formulare. // es sah aus, als koennte die Vorschau einfach keine Formulare.
@@ -365,33 +614,64 @@ export function initFilePreview(wurzel = document) {
dialog.addEventListener("close", aufraeumen); dialog.addEventListener("close", aufraeumen);
/* Ausgefuelltes Formular zurueck in die Freigabe. */ /* Ausgefuelltes Formular zurueck in die Freigabe -- erst fragen, wohin. */
speichern?.addEventListener("click", async () => {
const vorschlagen = () => {
if (!aktuell) return;
const teile = aktuell.pfad.split("/");
const datei = teile.pop() || "formular.pdf";
ordnerFeld.value = teile.join("/");
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt";
ersetzenFeld.checked = false;
};
speichern?.addEventListener("click", () => {
if (!pdf || !aktuell) return;
vorschlagen();
zeile.hidden = false;
nameFeld.focus();
nameFeld.select();
});
abbrechen?.addEventListener("click", () => {
zeile.hidden = true;
});
jetzt?.addEventListener("click", async () => {
if (!pdf || !aktuell) return; if (!pdf || !aktuell) return;
const ziel = speichern.dataset.url; const ziel = speichern.dataset.url;
speichern.disabled = true; jetzt.disabled = true;
const vorher = speichern.textContent; const vorher = jetzt.textContent;
speichern.textContent = "Speichern ..."; jetzt.textContent = "Speichern ...";
try { try {
const bytes = await pdf.speichern(); const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
abschliessen: modusFeld?.value === "fertig",
});
const daten = new FormData(); const daten = new FormData();
daten.append("pfad", aktuell.pfad); daten.append("pfad", aktuell.pfad);
daten.append("ordner", ordnerFeld.value.trim());
daten.append("name", nameFeld.value.trim());
if (ersetzenFeld.checked) daten.append("ersetzen", "1");
daten.append( daten.append(
"datei", "datei",
new Blob([bytes], { type: "application/pdf" }), new Blob([bytes], { type: "application/pdf" }),
(aktuell.pfad.split("/").pop() || "formular.pdf") `${nameFeld.value.trim() || "formular"}.pdf`
); );
const antwort = await window.tesm.api(ziel, { method: "POST", body: daten }); const antwort = await window.tesm.api(ziel, { method: "POST", body: daten });
window.tesm.showToast(antwort.message || "Gespeichert.", "success"); window.tesm.showToast(antwort.message || "Gespeichert.", "success");
dialog.close(); dialog.close();
window.location.reload(); window.location.reload();
} catch (fehler) { } catch (fehler) {
// Die Meldung fuer den Benutzer, die Einzelheiten fuer die Konsole. // Die Meldung fuer den Benutzer, die Einzelheiten fuer die Konsole. Die
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
console.error("Formular speichern", fehler); console.error("Formular speichern", fehler);
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger"); window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
} finally { } finally {
speichern.disabled = false; jetzt.disabled = false;
speichern.textContent = vorher; jetzt.textContent = vorher;
} }
}); });
} }
@@ -0,0 +1,9 @@
pdf-lib 1.17.1 -- https://github.com/Hopding/pdf-lib (MIT, siehe LICENSE)
Aus dem CDN-Bau "pdf-lib.min.js" (UMD, setzt window.PDFLib).
Wofuer: pdf.js kann Formulare ausfuellen, aber nichts in ein PDF zeichnen. Fuer
den Bildknopf in Formularen (Acrobats "buttonImportIcon", das kein Browser
ausfuehren kann) wird das gewaehlte Foto damit in das Feld gestempelt.
Geladen wird die Datei erst, wenn wirklich ein Bild eingesetzt wird.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Andrew Dillon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1 @@
1.17.1
@@ -0,0 +1,16 @@
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).PDFLib={})}(this,(function(t){"use strict";
/*! *****************************************************************************
Copyright (c) Microsoft Corporation. All rights reserved.
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
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
MERCHANTABLITY OR NON-INFRINGEMENT.
See the Apache Version 2.0 License for specific language governing permissions
and limitations under the License.
***************************************************************************** */var e=function(t,n){return(e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n])})(t,n)};function n(t,n){function r(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(r.prototype=n.prototype,new r)}var r=function(){return(r=Object.assign||function(t){for(var e,n=1,r=arguments.length;n<r;n++)for(var i in e=arguments[n])Object.prototype.hasOwnProperty.call(e,i)&&(t[i]=e[i]);return t}).apply(this,arguments)};function i(t,e,n,r){return new(n||(n=Promise))((function(i,o){function a(t){try{l(r.next(t))}catch(t){o(t)}}function s(t){try{l(r.throw(t))}catch(t){o(t)}}function l(t){var e;t.done?i(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(a,s)}l((r=r.apply(t,e||[])).next())}))}function o(t,e){var n,r,i,o,a={label:0,sent:function(){if(1&i[0])throw i[1];return i[1]},trys:[],ops:[]};return o={next:s(0),throw:s(1),return:s(2)},"function"==typeof Symbol&&(o[Symbol.iterator]=function(){return this}),o;function s(o){return function(s){return function(o){if(n)throw new TypeError("Generator is already executing.");for(;a;)try{if(n=1,r&&(i=2&o[0]?r.return:o[0]?r.throw||((i=r.return)&&i.call(r),0):r.next)&&!(i=i.call(r,o[1])).done)return i;switch(r=0,i&&(o=[2&o[0],i.value]),o[0]){case 0:case 1:i=o;break;case 4:return a.label++,{value:o[1],done:!1};case 5:a.label++,r=o[1],o=[0];continue;case 7:o=a.ops.pop(),a.trys.pop();continue;default:if(!(i=a.trys,(i=i.length>0&&i[i.length-1])||6!==o[0]&&2!==o[0])){a=0;continue}if(3===o[0]&&(!i||o[1]>i[0]&&o[1]<i[3])){a.label=o[1];break}if(6===o[0]&&a.label<i[1]){a.label=i[1],i=o;break}if(i&&a.label<i[2]){a.label=i[2],a.ops.push(o);break}i[2]&&a.ops.pop(),a.trys.pop();continue}o=e.call(t,a)}catch(t){o=[6,t],r=0}finally{n=i=0}if(5&o[0])throw o[1];return{value:o[0]?o[1]:void 0,done:!0}}([o,s])}}}function a(){for(var t=0,e=0,n=arguments.length;e<n;e++)t+=arguments[e].length;var r=Array(t),i=0;for(e=0;e<n;e++)for(var o=arguments[e],a=0,s=o.length;a<s;a++,i++)r[i]=o[a];return r}for(var s="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",l=new Uint8Array(256),h=0;h<s.length;h++)l[s.charCodeAt(h)]=h;var u,c=function(t){for(var e="",n=t.length,r=0;r<n;r+=3)e+=s[t[r]>>2],e+=s[(3&t[r])<<4|t[r+1]>>4],e+=s[(15&t[r+1])<<2|t[r+2]>>6],e+=s[63&t[r+2]];return n%3==2?e=e.substring(0,e.length-1)+"=":n%3==1&&(e=e.substring(0,e.length-2)+"=="),e},d=function(t){var e,n,r,i,o,a=.75*t.length,s=t.length,h=0;"="===t[t.length-1]&&(a--,"="===t[t.length-2]&&a--);var u=new Uint8Array(a);for(e=0;e<s;e+=4)n=l[t.charCodeAt(e)],r=l[t.charCodeAt(e+1)],i=l[t.charCodeAt(e+2)],o=l[t.charCodeAt(e+3)],u[h++]=n<<2|r>>4,u[h++]=(15&r)<<4|i>>2,u[h++]=(3&i)<<6|63&o;return u},f=/^(data)?:?([\w\/\+]+)?;?(charset=[\w-]+|base64)?.*,/i,p=function(t){var e=t.trim(),n=e.substring(0,100).match(f);if(!n)return d(e);var r=n[0],i=e.substring(r.length);return d(i)},g=function(t){return t.charCodeAt(0)},v=function(t){return t.codePointAt(0)},y=function(t,e){return x(t.toString(16),e,"0").toUpperCase()},m=function(t){return y(t,2)},b=function(t){return String.fromCharCode(t)},w=function(t){return b(parseInt(t,16))},x=function(t,e,n){for(var r="",i=0,o=e-t.length;i<o;i++)r+=n;return r+t},k=function(t,e,n){for(var r=t.length,i=0;i<r;i++)e[n++]=t.charCodeAt(i);return r},F=function(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")},S=function(t){return t.replace(/\t|\u0085|\u2028|\u2029/g," ").replace(/[\b\v]/g,"")},C=["\\n","\\f","\\r","\\u000B"],A=function(t){return/^[\n\f\r\u000B]$/.test(t)},T=function(t){return t.split(/[\n\f\r\u000B]/)},P=function(t){return t.replace(/[\n\f\r\u000B]/g," ")},z=function(t,e){var n,r=t.charCodeAt(e),i=e+1,o=1;return r>=55296&&r<=56319&&t.length>i&&(n=t.charCodeAt(i))>=56320&&n<=57343&&(o=2),[t.slice(e,e+o),o]},R=function(t){for(var e=[],n=0,r=t.length;n<r;){var i=z(t,n),o=i[0],a=i[1];e.push(o),n+=a}return e},O=function(t,e,n,r){for(var i=function(t){for(var e=C.join("|"),n=["$"],r=0,i=t.length;r<i;r++){var o=t[r];if(A(o))throw new TypeError("`wordBreak` must not include "+e);n.push(""===o?".":F(o))}var a=n.join("|");return new RegExp("("+e+")|((.*?)("+a+"))","gm")}(e),o=S(t).match(i),a="",s=0,l=[],h=function(){""!==a&&l.push(a),a="",s=0},u=0,c=o.length;u<c;u++){var d=o[u];if(A(d))h();else{var f=r(d);s+f>n&&h(),a+=d,s+=f}}return h(),l},D=/^D:(\d\d\d\d)(\d\d)?(\d\d)?(\d\d)?(\d\d)?(\d\d)?([+\-Z])?(\d\d)?'?(\d\d)?'?$/,B=function(t){var e=t.match(D);if(e){var n=e[1],r=e[2],i=void 0===r?"01":r,o=e[3],a=void 0===o?"01":o,s=e[4],l=void 0===s?"00":s,h=e[5],u=void 0===h?"00":h,c=e[6],d=void 0===c?"00":c,f=e[7],p=void 0===f?"Z":f,g=e[8],v=void 0===g?"00":g,y=e[9];return new Date(n+"-"+i+"-"+a+"T"+l+":"+u+":"+d+("Z"===p?"Z":""+p+v+":"+(void 0===y?"00":y)))}},N=function(t,e){for(var n,r,i=0;i<t.length;){var o=t.substring(i).match(e);if(!o)return{match:r,pos:i};r=o,i+=(null!==(n=o.index)&&void 0Line truncated
//# sourceMappingURL=pdf-lib.min.js.map
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing" name = "tesm-licensing"
version = "2.2.1" version = "2.4.1"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["cryptography>=42"] dependencies = ["cryptography>=42"]