Compare commits

...
4 Commits
11 changed files with 151 additions and 42 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-license"
version = "2.3.1"
version = "2.4.1"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11"
dependencies = [
@@ -57,7 +57,7 @@ def overview():
@bp.post("/export")
@permission_required("backup.export")
@reauth_required
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def export():
conn = db()
allowed = {category.key for category in _allowed_categories()}
@@ -142,7 +142,7 @@ def issue(ticket_id: str):
@bp.get("/<license_id>/datei")
@permission_required("licenses.export")
@reauth_required
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def download(license_id: str):
"""Laedt das vollstaendige Auslieferungs-Bundle herunter.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.3.1"
version = "2.4.1"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -135,7 +135,7 @@ def create_base_app(
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Anwendungen.
auth_tagline: str = "",
app_version: str = "2.3.1",
app_version: str = "2.4.1",
built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True,
template_folder: str | None = None,
@@ -220,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.
@@ -238,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]
@@ -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:
@@ -720,6 +720,16 @@
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;
@@ -763,6 +773,11 @@
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
@@ -215,44 +215,59 @@ async function pdflibLaden(adresse) {
return pdflibGeladen;
}
/** Stempelt die gewaehlten Fotos in die Bildfelder.
/** Setzt die Fotos ein und schliesst das Formular auf Wunsch ab.
*
* Eingabe sind die Bytes, die pdf.js nach dem Ausfuellen geschrieben hat --
* die Formularwerte bleiben also erhalten.
* 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 fotosEinsetzen(bytes, bilder, adresse) {
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 seite = dokument.getPage(eintrag.seite - 1);
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);
const breite = jpeg.width * faktor;
const hoehe = jpeg.height * faktor;
seite.drawImage(jpeg, {
x: Math.min(x1, x2) + (kastenBreite - breite) / 2,
y: Math.min(y1, y2) + (kastenHoehe - hoehe) / 2,
width: breite,
height: hoehe,
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,
});
// Den Knopf aus der fertigen Kopie nehmen: seine Flaeche liegt ueber dem
// Seiteninhalt und wuerde das Foto verdecken. Zu tun haette er dort auch
// nichts mehr -- seine Aktion fuehrt kein Browser aus, und das Bild steht
// jetzt in der Seite. Die Vorlage bleibt unberuehrt.
try {
const feld = formular.getFieldMaybe(eintrag.feldname);
if (feld) formular.removeField(feld);
} catch (fehler) {
// Bleibt der Knopf liegen, ist das Foto verdeckt -- aber die Datei ist
// heil. Das ist der bessere von zwei schlechten Ausgaengen.
console.warn("Bildknopf liess sich nicht entfernen", fehler);
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();
}
@@ -304,8 +319,13 @@ async function pdfjsLaden(basis) {
return pdfjsGeladen;
}
/** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */
async function pdfBetrachter(behaelter, seiten, url, basis) {
/** Zeigt ein PDF -- mit ausfuellbaren Feldern, sofern das erlaubt ist.
*
* 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 adressen = pdfjsAdressen(basis);
const eventBus = new komponenten.EventBus();
@@ -315,8 +335,11 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
viewer: seiten,
eventBus,
linkService,
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern.
annotationMode: lib.AnnotationMode.ENABLE_FORMS,
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern --
// wer nicht ausfuellen darf, bekommt die Darstellung aus dem Dokument.
annotationMode: formulare
? lib.AnnotationMode.ENABLE_FORMS
: lib.AnnotationMode.ENABLE,
textLayerMode: 1,
});
linkService.setViewer(viewer);
@@ -364,6 +387,7 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
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);
@@ -439,10 +463,11 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
*/
speichern: async (pdflibAdresse) => {
speichern: async (pdflibAdresse, { abschliessen = false } = {}) => {
const bytes = await dokument.saveDocument();
if (!bilder.size) return bytes;
return fotosEinsetzen(bytes, [...bilder.values()], pdflibAdresse);
// 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? */
veraendert: () => {
@@ -482,6 +507,7 @@ export function initFilePreview(wurzel = document) {
const ersetzenFeld = stueck("ersetzen");
const jetzt = stueck("jetzt");
const abbrechen = stueck("abbrechen");
const modusFeld = stueck("modus");
const basis = rumpf.dataset.viewerPdfjs || "";
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
@@ -545,7 +571,9 @@ export function initFilePreview(wurzel = document) {
}
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
try {
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis);
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
formulare: rumpf.dataset.viewerFormulare === "1",
});
if (hinweis) hinweis.textContent = "";
} catch (fehler) {
// Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige
@@ -593,9 +621,10 @@ export function initFilePreview(wurzel = document) {
const teile = aktuell.pfad.split("/");
const datei = teile.pop() || "formular.pdf";
ordnerFeld.value = teile.join("/");
// Der Name der Vorlage mit Zusatz -- die Vorlage selbst bleibt so heil,
// auch wenn jemand blind auf Speichern drueckt.
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt.pdf";
// 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;
};
@@ -618,7 +647,9 @@ export function initFilePreview(wurzel = document) {
const vorher = jetzt.textContent;
jetzt.textContent = "Speichern ...";
try {
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "");
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
abschliessen: modusFeld?.value === "fertig",
});
const daten = new FormData();
daten.append("pfad", aktuell.pfad);
daten.append("ordner", ordnerFeld.value.trim());
@@ -627,7 +658,7 @@ export function initFilePreview(wurzel = document) {
daten.append(
"datei",
new Blob([bytes], { type: "application/pdf" }),
nameFeld.value.trim() || "formular.pdf"
`${nameFeld.value.trim() || "formular"}.pdf`
);
const antwort = await window.tesm.api(ziel, { method: "POST", body: daten });
window.tesm.showToast(antwort.message || "Gespeichert.", "success");
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing-server"
version = "2.3.1"
version = "2.4.1"
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
requires-python = ">=3.11"
dependencies = ["tesm-licensing==2.3.1", "cryptography>=41"]
dependencies = ["tesm-licensing==2.4.1", "cryptography>=41"]
[tool.setuptools.packages.find]
where = ["src"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing"
version = "2.3.1"
version = "2.4.1"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = ["cryptography>=42"]