Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2d5324449 | ||
|
|
342abb0206 | ||
|
|
8dd8df9e9f | ||
|
|
0de90ab6d4 | ||
|
|
e60a93120c |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-license"
|
name = "tesm-license"
|
||||||
version = "2.3.0"
|
version = "2.4.1"
|
||||||
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -57,7 +57,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():
|
||||||
conn = db()
|
conn = db()
|
||||||
allowed = {category.key for category in _allowed_categories()}
|
allowed = {category.key for category in _allowed_categories()}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ def issue(ticket_id: str):
|
|||||||
|
|
||||||
@bp.get("/<license_id>/datei")
|
@bp.get("/<license_id>/datei")
|
||||||
@permission_required("licenses.export")
|
@permission_required("licenses.export")
|
||||||
@reauth_required
|
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||||
def download(license_id: str):
|
def download(license_id: str):
|
||||||
"""Laedt das vollstaendige Auslieferungs-Bundle herunter.
|
"""Laedt das vollstaendige Auslieferungs-Bundle herunter.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.3.0"
|
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.3.0",
|
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;
|
||||||
@@ -763,6 +773,19 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
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 {
|
.viewer__pdfscroll {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -781,6 +804,36 @@
|
|||||||
.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 {
|
.viewer__save {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -142,6 +142,135 @@ 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;
|
||||||
@@ -190,8 +319,13 @@ 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 adressen = pdfjsAdressen(basis);
|
||||||
const eventBus = new komponenten.EventBus();
|
const eventBus = new komponenten.EventBus();
|
||||||
@@ -201,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);
|
||||||
@@ -244,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);
|
||||||
@@ -254,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;
|
||||||
@@ -294,6 +507,7 @@ export function initFilePreview(wurzel = document) {
|
|||||||
const ersetzenFeld = stueck("ersetzen");
|
const ersetzenFeld = stueck("ersetzen");
|
||||||
const jetzt = stueck("jetzt");
|
const jetzt = stueck("jetzt");
|
||||||
const abbrechen = stueck("abbrechen");
|
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),
|
||||||
@@ -357,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(pdfScroll, 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
|
||||||
@@ -405,9 +621,10 @@ export function initFilePreview(wurzel = document) {
|
|||||||
const teile = aktuell.pfad.split("/");
|
const teile = aktuell.pfad.split("/");
|
||||||
const datei = teile.pop() || "formular.pdf";
|
const datei = teile.pop() || "formular.pdf";
|
||||||
ordnerFeld.value = teile.join("/");
|
ordnerFeld.value = teile.join("/");
|
||||||
// Der Name der Vorlage mit Zusatz -- die Vorlage selbst bleibt so heil,
|
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
|
||||||
// auch wenn jemand blind auf Speichern drueckt.
|
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
|
||||||
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt.pdf";
|
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
|
||||||
|
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt";
|
||||||
ersetzenFeld.checked = false;
|
ersetzenFeld.checked = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -430,7 +647,9 @@ export function initFilePreview(wurzel = document) {
|
|||||||
const vorher = jetzt.textContent;
|
const vorher = jetzt.textContent;
|
||||||
jetzt.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("ordner", ordnerFeld.value.trim());
|
||||||
@@ -439,7 +658,7 @@ export function initFilePreview(wurzel = document) {
|
|||||||
daten.append(
|
daten.append(
|
||||||
"datei",
|
"datei",
|
||||||
new Blob([bytes], { type: "application/pdf" }),
|
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 });
|
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");
|
||||||
|
|||||||
@@ -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
|
||||||
+16
@@ -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
|
||||||
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing-server"
|
name = "tesm-licensing-server"
|
||||||
version = "2.3.0"
|
version = "2.4.1"
|
||||||
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["tesm-licensing==2.3.0", "cryptography>=41"]
|
dependencies = ["tesm-licensing==2.4.1", "cryptography>=41"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.3.0"
|
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"]
|
||||||
|
|||||||
Reference in new issue
Block a user