From 2b1841e17533543d1cb15220ca86a2a37c853daf Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Sat, 15 Aug 2026 10:51:11 +0200 Subject: Die Web-App rechnet im Browser statt auf dem Server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher lief die Weboberfläche auf einem JDK-HttpServer: Uploads landeten als temporäre Dateien auf dem Server, die Synopse entstand dort. Das kostete Betrieb (systemd, Reverse Proxy, Rate-Limiting, Timeouts) und verlangte ein Datenschutzversprechen, das nur zusicherbar, nicht nachprüfbar war — gerade Entwurfstexte verließen den Rechner der Nutzer:innen. Neu übersetzt `./mvnw -Pwasm package` dieselbe Pipeline mit GraalVM Web Image (`native-image --tool:svm-wasm`) nach WebAssembly, PDFBox eingeschlossen. Ausgeliefert werden nur noch statische Dateien; gerechnet wird im Browser. Die erzeugte Synopse ist byteweise identisch mit der der Befehlszeile (SHA-256 verglichen für IfSG 48/27/21 und BayJG 151/3/54). Die Befehlszeile bleibt unberührt: `./mvnw package` erzeugt unverändert das JAR, alle Optionen und Meldungstexte sind gleich, das Wasm-Profil ist rein additiv und verlangt Oracle GraalVM 25.1+ (die CE hat kein Web Image). Portabilitätsschnitt (nützt beiden Fassungen): * `Quelle` (Name + Bytes) ersetzt `Path` in der Pipeline; nur die Befehlszeile kennt noch ein Dateisystem. Der Name trägt genau den bisherigen `getFileName()`-Text, damit Warnungen und Quellenzeile wortgleich bleiben. * `DateiTyp` erkennt PDF/XML/Klartext an den Signaturbytes. Tika entfällt — eine schwergewichtige Abhängigkeit samt ServiceLoader- und XML-Konfiguration weniger, was der Wasm-Übersetzung unmittelbar zugutekommt. Vier Eigenheiten von Web Image, die der Quelltext jeweils an Ort und Stelle vermerkt: * `java.util.zip.Inflater` ist nicht angebunden (GR-65205), ohne Inflate ist kein PDF lesbar. `InflaterErsatz` substituiert ihn durch jzlib. * Typisierte JS-Felder lassen sich nicht nach `byte[]` umsetzen („byteArrayHub is not defined“); der Dateiinhalt wandert als Base64. * JULs Standardformatter ruft `StackWalker`, den es dort nicht gibt. * Im Worker fehlt `document.currentScript`, worauf die Laufzeit das Wasm-Modul neben `worker.js` sucht; die VM wird deshalb mit ausdrücklichem Pfad ein zweites Mal gestartet. Die Reachability-Metadaten stammen aus einem Lauf des Tracing-Agents über die Pipeline; die PDFBox- und FontBox-Ressourcen sind als Globs ergänzt, sonst scheitern PDFs an „Could not find referenced cmap stream Identity-H“. Entfallen: WebMain, UploadHandler, StaticHandler, Multipart und die systemd-Unit. Die nginx-Vorlage liefert jetzt statische Dateien aus, und die Datenschutzseite sagt, was nun stimmt: Die Dateien verlassen den Rechner nicht. Co-Authored-By: Claude Opus 5 Change-Id: I38faf2ac0f764d601f080d4276babe4747773683 --- .../eu.mulk/aendggner/reachability-metadata.json | 361 +++++++++++++++++++++ src/main/resources/eu/mulk/aendggner/web/app.js | 90 +++++ .../eu/mulk/aendggner/web/datenschutz.html | 23 +- .../resources/eu/mulk/aendggner/web/impressum.html | 4 +- .../resources/eu/mulk/aendggner/web/index.html | 19 +- src/main/resources/eu/mulk/aendggner/web/style.css | 26 ++ src/main/resources/eu/mulk/aendggner/web/worker.js | 42 +++ 7 files changed, 545 insertions(+), 20 deletions(-) create mode 100644 src/main/resources/META-INF/native-image/eu.mulk/aendggner/reachability-metadata.json create mode 100644 src/main/resources/eu/mulk/aendggner/web/app.js create mode 100644 src/main/resources/eu/mulk/aendggner/web/worker.js (limited to 'src/main/resources') diff --git a/src/main/resources/META-INF/native-image/eu.mulk/aendggner/reachability-metadata.json b/src/main/resources/META-INF/native-image/eu.mulk/aendggner/reachability-metadata.json new file mode 100644 index 0000000..4f85735 --- /dev/null +++ b/src/main/resources/META-INF/native-image/eu.mulk/aendggner/reachability-metadata.json @@ -0,0 +1,361 @@ +{ + "reflection": [ + { + "type": "ch.qos.logback.classic.Logger" + }, + { + "type": "com.sun.crypto.provider.AESCipher$General", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "java.awt.image.BufferedImage", + "jniAccessible": true, + "fields": [ + { + "name": "colorModel" + }, + { + "name": "imageType" + }, + { + "name": "raster" + } + ], + "methods": [ + { + "name": "getRGB", + "parameterTypes": [ + "int", + "int", + "int", + "int", + "int[]", + "int", + "int" + ] + }, + { + "name": "setRGB", + "parameterTypes": [ + "int", + "int", + "int", + "int", + "int[]", + "int", + "int" + ] + } + ] + }, + { + "type": "java.awt.image.ColorModel", + "jniAccessible": true, + "fields": [ + { + "name": "colorSpace" + }, + { + "name": "colorSpaceType" + }, + { + "name": "isAlphaPremultiplied" + }, + { + "name": "is_sRGB" + }, + { + "name": "nBits" + }, + { + "name": "numComponents" + }, + { + "name": "supportsAlpha" + }, + { + "name": "transparency" + } + ], + "methods": [ + { + "name": "getRGBdefault", + "parameterTypes": [] + } + ] + }, + { + "type": "java.awt.image.Raster", + "jniAccessible": true, + "fields": [ + { + "name": "dataBuffer" + }, + { + "name": "height" + }, + { + "name": "minX" + }, + { + "name": "minY" + }, + { + "name": "numBands" + }, + { + "name": "numDataElements" + }, + { + "name": "sampleModel" + }, + { + "name": "sampleModelTranslateX" + }, + { + "name": "sampleModelTranslateY" + }, + { + "name": "width" + } + ] + }, + { + "type": "java.awt.image.SampleModel", + "jniAccessible": true, + "fields": [ + { + "name": "height" + }, + { + "name": "width" + } + ], + "methods": [ + { + "name": "getPixels", + "parameterTypes": [ + "int", + "int", + "int", + "int", + "int[]", + "java.awt.image.DataBuffer" + ] + }, + { + "name": "setPixels", + "parameterTypes": [ + "int", + "int", + "int", + "int", + "int[]", + "java.awt.image.DataBuffer" + ] + } + ] + }, + { + "type": "java.awt.image.SinglePixelPackedSampleModel", + "jniAccessible": true, + "fields": [ + { + "name": "bitMasks" + }, + { + "name": "bitOffsets" + }, + { + "name": "bitSizes" + }, + { + "name": "maxBitSize" + } + ] + }, + { + "type": "java.lang.Boolean", + "jniAccessible": true, + "methods": [ + { + "name": "getBoolean", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, + { + "type": "java.lang.System", + "jniAccessible": true, + "methods": [ + { + "name": "load", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, + { + "type": "org.apache.commons.logging.LogFactory" + }, + { + "type": "org.apache.commons.logging.impl.SLF4JLogFactory" + }, + { + "type": "org.apache.commons.logging.impl.WeakHashtable", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "org.apache.log4j.LogManager" + }, + { + "type": "org.apache.logging.log4j.Logger" + }, + { + "type": "org.apache.pdfbox.pdmodel.encryption.StandardSecurityHandler", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "org.jboss.logmanager.LogManager" + }, + { + "type": "org.slf4j.jul.JULServiceProvider" + }, + { + "type": "sun.awt.image.IntegerComponentRaster", + "jniAccessible": true, + "fields": [ + { + "name": "data" + }, + { + "name": "dataOffsets" + }, + { + "name": "pixelStride" + }, + { + "name": "scanlineStride" + }, + { + "name": "type" + } + ] + }, + { + "type": "sun.misc.Unsafe", + "fields": [ + { + "name": "theUnsafe" + } + ], + "methods": [ + { + "name": "invokeCleaner", + "parameterTypes": [ + "java.nio.ByteBuffer" + ] + } + ] + }, + { + "type": "sun.security.provider.MD5", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "sun.security.provider.NativePRNG", + "methods": [ + { + "name": "", + "parameterTypes": [ + "java.security.SecureRandomParameters" + ] + } + ] + }, + { + "type": "sun.security.provider.SHA2$SHA256", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "sun.text.resources.cldr.FormatData" + }, + { + "type": "sun.util.resources.cldr.CalendarData" + } + ], + "resources": [ + { + "glob": "META-INF/services/java.net.spi.URLStreamHandlerProvider" + }, + { + "glob": "META-INF/services/java.time.zone.ZoneRulesProvider" + }, + { + "glob": "META-INF/services/javax.xml.parsers.DocumentBuilderFactory" + }, + { + "glob": "META-INF/services/org.apache.commons.logging.LogFactory" + }, + { + "glob": "META-INF/services/org.jboss.logging.LoggerProvider" + }, + { + "glob": "META-INF/services/org.slf4j.spi.SLF4JServiceProvider" + }, + { + "glob": "commons-logging.properties" + }, + { + "module": "java.base", + "glob": "jdk/internal/icu/impl/data/icudt76b/nfc.nrm" + }, + { + "module": "java.base", + "glob": "jdk/internal/icu/impl/data/icudt76b/ubidi.icu" + }, + { + "glob": "org/apache/fontbox/**" + }, + { + "glob": "org/apache/pdfbox/resources/**" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/eu/mulk/aendggner/web/app.js b/src/main/resources/eu/mulk/aendggner/web/app.js new file mode 100644 index 0000000..c59f6e3 --- /dev/null +++ b/src/main/resources/eu/mulk/aendggner/web/app.js @@ -0,0 +1,90 @@ +// Nimmt die Dateien aus dem Formular entgegen, reicht sie an den Worker und zeigt die Synopse. +// Es gibt keinen Server: Alles läuft im Browser. + +const formular = document.querySelector("#synopse-formular"); +const knopf = formular.querySelector("button"); +const meldung = document.querySelector("#meldung"); + +let worker = null; + +function zeige(text, art) { + meldung.textContent = text; + meldung.className = art ? "meldung " + art : "meldung"; + meldung.hidden = !text; +} + +/** + * Der Dateiinhalt geht als Base64 an das Wasm-Modul: Die Umsetzung typisierter Felder nach + * byte[] ist in Web Image derzeit defekt, Zeichenketten überqueren die Grenze zuverlässig. + */ +async function alsBase64(datei) { + const bytes = new Uint8Array(await datei.arrayBuffer()); + let roh = ""; + const block = 0x8000; // btoa verträgt keine beliebig langen Argumentlisten. + for (let i = 0; i < bytes.length; i += block) { + roh += String.fromCharCode.apply(null, bytes.subarray(i, i + block)); + } + return { name: datei.name, base64: btoa(roh) }; +} + +formular.addEventListener("submit", async (ereignis) => { + ereignis.preventDefault(); + + const stammDatei = document.querySelector("#stamm").files[0]; + const patchDateien = Array.from(document.querySelector("#aenderung").files); + if (!stammDatei || patchDateien.length === 0) { + zeige("Bitte ein Stammgesetz und mindestens ein Änderungsdokument wählen.", "fehler"); + return; + } + + knopf.disabled = true; + zeige("Lade das Rechenwerk (einmalig einige Megabyte) und werte aus …", "arbeit"); + + try { + const [stamm, patches] = await Promise.all([ + alsBase64(stammDatei), + Promise.all(patchDateien.map(alsBase64)), + ]); + + if (!worker) { + worker = new Worker("worker.js"); + } + + const ergebnis = await new Promise((aufloesen, ablehnen) => { + worker.onmessage = (nachricht) => aufloesen(nachricht.data); + worker.onerror = (fehler) => ablehnen(new Error(fehler.message || "Worker-Fehler")); + worker.postMessage({ + stamm, + patches, + vollstaendig: document.querySelector("#vollstaendig").checked, + artikel: null, + }); + }); + + if (ergebnis.fehler) { + zeige("Verarbeitung fehlgeschlagen: " + ergebnis.fehler, "fehler"); + return; + } + + const blob = new Blob([ergebnis.html], { type: "text/html;charset=utf-8" }); + const adresse = URL.createObjectURL(blob); + + // Kein window.open: Der Aufruf käme nach dem Warten auf den Worker und gälte dem Browser + // nicht mehr als Nutzerhandlung — er würde als Popup blockiert. Stattdessen ein Link, der + // sich öffnen und ebenso gut speichern lässt. + zeige( + `${ergebnis.angewandt} Befehle angewandt, ${ergebnis.manuell} manuell zu prüfen, ` + + `${ergebnis.normen} geänderte Normen. `, + "fertig", + ); + const verweis = document.createElement("a"); + verweis.href = adresse; + verweis.target = "_blank"; + verweis.textContent = "Synopse öffnen"; + meldung.append(verweis); + } catch (e) { + zeige("Verarbeitung fehlgeschlagen: " + (e && e.message ? e.message : e), "fehler"); + } finally { + knopf.disabled = false; + } +}); diff --git a/src/main/resources/eu/mulk/aendggner/web/datenschutz.html b/src/main/resources/eu/mulk/aendggner/web/datenschutz.html index 65ec0c9..5b9bbac 100644 --- a/src/main/resources/eu/mulk/aendggner/web/datenschutz.html +++ b/src/main/resources/eu/mulk/aendggner/web/datenschutz.html @@ -4,7 +4,7 @@ Datenschutz — ÄndGgner - +
@@ -15,19 +15,22 @@ Zugriffslogs, Hosting-Anbieter).

-

Hochgeladene Dateien

+

Ihre Dateien

- Stammgesetz und Änderungsgesetz(e) werden ausschließlich im Arbeitsspeicher bzw. in - temporären Dateien verarbeitet, um die Synopse zu erzeugen. Nach Auslieferung der Antwort - werden diese temporären Dateien sofort gelöscht. Es findet keine dauerhafte Speicherung des - Inhalts statt. + Die gewählten Dateien werden nicht übertragen. Die gesamte Auswertung — + PDF-Textgewinnung, Anwendung der Änderungsbefehle, Erzeugung der Synopse — läuft als + WebAssembly-Programm in Ihrem Browser. Der Server sieht die Dateien nicht, kann sie nicht + sehen und speichert folglich auch nichts davon. Wer das prüfen möchte, öffne die + Netzwerkanzeige der Entwicklerwerkzeuge: Nach dem Laden der Seite und des Rechenwerks + entsteht kein weiterer Netzverkehr.

Zugriffsprotokolle

- Wie bei jedem Webserver werden beim Aufruf technische Zugriffsdaten (u. a. IP-Adresse, - Zeitpunkt, aufgerufene Adresse) für einen begrenzten Zeitraum zur Absicherung des Betriebs - protokolliert. + Beim Abruf der Seite selbst und der zugehörigen Dateien werden wie bei jedem Webserver + technische Zugriffsdaten (u. a. IP-Adresse, Zeitpunkt, aufgerufene Adresse) für einen + begrenzten Zeitraum zur Absicherung des Betriebs protokolliert. Diese Protokolle enthalten + keine Angaben über die von Ihnen ausgewerteten Dokumente.

Keine Cookies, keine Konten, kein Tracking

@@ -36,6 +39,6 @@ Analyse- oder Tracking-Dienste Dritter ein.

- + diff --git a/src/main/resources/eu/mulk/aendggner/web/impressum.html b/src/main/resources/eu/mulk/aendggner/web/impressum.html index 303db8e..525ef77 100644 --- a/src/main/resources/eu/mulk/aendggner/web/impressum.html +++ b/src/main/resources/eu/mulk/aendggner/web/impressum.html @@ -4,7 +4,7 @@ Impressum — ÄndGgner - +
@@ -21,6 +21,6 @@ [E-Mail-Adresse]
- + diff --git a/src/main/resources/eu/mulk/aendggner/web/index.html b/src/main/resources/eu/mulk/aendggner/web/index.html index 02f8f75..9f26889 100644 --- a/src/main/resources/eu/mulk/aendggner/web/index.html +++ b/src/main/resources/eu/mulk/aendggner/web/index.html @@ -4,7 +4,8 @@ ÄndGgner — Änderungsgesetze konsolidieren - + +
@@ -15,7 +16,7 @@
-
+
@@ -39,7 +40,7 @@
@@ -47,18 +48,20 @@ + +

Die Synopse wird automatisch erstellt und ist ohne Gewähr für Vollständigkeit und Richtigkeit; sie ersetzt keine Rechtsberatung. Uneindeutige Änderungsbefehle werden zur - manuellen Prüfung markiert statt stillschweigend verworfen. Hochgeladene Dateien werden - ausschließlich zur Erzeugung der Synopse verarbeitet und danach sofort wieder gelöscht — es - findet keine dauerhafte Speicherung statt. Bitte nur Rechtstexte hochladen. + manuellen Prüfung markiert statt stillschweigend verworfen. Die gewählten Dateien + verlassen Ihren Rechner nicht: Die Auswertung läuft vollständig im Browser, + es wird nichts hochgeladen und nichts gespeichert.

diff --git a/src/main/resources/eu/mulk/aendggner/web/style.css b/src/main/resources/eu/mulk/aendggner/web/style.css index 6e2ec70..cdea20e 100644 --- a/src/main/resources/eu/mulk/aendggner/web/style.css +++ b/src/main/resources/eu/mulk/aendggner/web/style.css @@ -112,3 +112,29 @@ footer { footer a { color: var(--accent); } + +.meldung { + margin-top: 1.25rem; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.9375rem; +} + +.meldung.arbeit { + color: var(--muted); +} + +.meldung.fertig { + border-color: var(--accent); +} + +.meldung.fehler { + border-color: #a33; + color: #a33; +} + +button[disabled] { + filter: grayscale(0.6); + cursor: progress; +} diff --git a/src/main/resources/eu/mulk/aendggner/web/worker.js b/src/main/resources/eu/mulk/aendggner/web/worker.js new file mode 100644 index 0000000..12ba400 --- /dev/null +++ b/src/main/resources/eu/mulk/aendggner/web/worker.js @@ -0,0 +1,42 @@ +// Führt das Wasm-Modul in einem eigenen Thread aus: Web Image ist einthreadig, und ein Lauf über +// mehrere Sekunden würde die Oberfläche sonst einfrieren. + +const bereit = new Promise((aufloesen) => { + self.aendggnerBereit = aufloesen; +}); + +// Die Laufzeit von aendggner.js sucht das Wasm-Modul neben ihrer eigenen Datei und findet die +// über `document.currentScript` — den es im Worker nicht gibt, wo sie stattdessen auf +// `location.href` (also diese Datei) zurückfällt und ins Leere greift. Der Selbststart beim +// Import scheitert deshalb; danach starten wir die VM mit ausdrücklichem Pfad noch einmal. +self.importScripts("aendggner.js"); + +const gestartet = GraalVM.run([], Object.assign(new GraalVM.Config(), { + wasm_path: new URL("aendggner.js.wasm", self.location.href).href, +})); + +self.onmessage = async (nachricht) => { + const { stamm, patches, vollstaendig, artikel } = nachricht.data; + try { + await gestartet; + await bereit; + const ergebnis = self.aendggnerSynopse({ + stamm, + patches, + artikel: artikel ?? null, + vollstaendig: Boolean(vollstaendig), + }); + if (ergebnis.fehler) { + self.postMessage({ fehler: String(ergebnis.fehler) }); + } else { + self.postMessage({ + html: String(ergebnis.html), + angewandt: Number(ergebnis.angewandt), + manuell: Number(ergebnis.manuell), + normen: Number(ergebnis.normen), + }); + } + } catch (e) { + self.postMessage({ fehler: e && e.message ? e.message : String(e) }); + } +}; -- cgit v1.2.1