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 --- .../java/eu/mulk/aendggner/wasm/BrowserMain.java | 155 +++++++++++++++++++++ .../eu/mulk/aendggner/wasm/InflaterErsatz.java | 146 +++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java create mode 100644 src/wasm/java/eu/mulk/aendggner/wasm/InflaterErsatz.java (limited to 'src/wasm/java') diff --git a/src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java b/src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java new file mode 100644 index 0000000..c4f5a9a --- /dev/null +++ b/src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java @@ -0,0 +1,155 @@ +package eu.mulk.aendggner.wasm; + +import eu.mulk.aendggner.Pipeline; +import eu.mulk.aendggner.Quelle; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.function.Function; +import java.util.logging.LogManager; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.graalvm.webimage.api.JS; +import org.graalvm.webimage.api.JSNumber; +import org.graalvm.webimage.api.JSObject; +import org.graalvm.webimage.api.JSString; +import org.graalvm.webimage.api.JSValue; + +/** + * Einstiegspunkt der Browserfassung: stellt {@code globalThis.aendggnerSynopse} bereit und ruft + * damit dieselbe {@link Pipeline} auf wie Befehlszeile und Tests. + * + *

Erwartet ein JS-Objekt {stamm: {name, base64}, patches: [{name, base64}], artikel, + * vollstaendig} und liefert {html, angewandt, manuell, normen} oder + * {fehler} zurück. Geworfen wird nichts: Eine Ausnahme im Wasm hinterlässt auf der + * JS-Seite nur einen unlesbaren Stapel, also wird jeder Fehler als Text zurückgereicht. + * + *

Der Dateiinhalt wandert als Base64-Text über die Grenze, nicht als {@code Uint8Array}: Die + * Umsetzung typisierter Felder nach {@code byte[]} ist in Web Image derzeit defekt + * („byteArrayHub is not defined“). Zeichenketten überqueren die Grenze zuverlässig, und die + * Base64-Dekodierung ist reines Java. + */ +public final class BrowserMain { + + private BrowserMain() {} + + public static void main(String... args) { + // JULs Standardformatter ermittelt den Aufrufer über StackWalker, den Web Image nicht kennt; + // im Browser gibt es ohnehin kein Logdatei-Ziel. + LogManager.getLogManager().reset(); + Logger.getLogger("").setLevel(Level.OFF); + + exportiere(BrowserMain::synopse); + + // Die Erreichbarkeitsanalyse sieht nur Aufrufe aus Java; dass JavaScript die exportierte + // Funktion aufruft, weiß sie nicht — ohne diesen (nie durchlaufenen) Zweig bliebe die + // gesamte Pipeline aus dem Image heraus und der erste Aufruf endete in einem + // NoClassDefFoundError. + melde(); + } + + @JS(args = "fn", value = "globalThis.aendggnerSynopse = fn;") + private static native void exportiere(Function fn); + + @JS( + value = + "if (typeof globalThis.aendggnerBereit === 'function') { globalThis.aendggnerBereit(); }") + private static native void melde(); + + private static JSObject synopse(JSObject eingabe) { + var antwort = JSObject.create(); + try { + var stamm = quelle(eingabe.get("stamm")); + var patches = new ArrayList(); + var patchListe = eingabe.get("patches"); + for (int i = 0; i < anzahl(patchListe); i++) { + patches.add(quelle(element(patchListe, i))); + } + + var artikel = text(eingabe.get("artikel")); + var vollstaendig = Boolean.TRUE.equals(wahrheitswert(eingabe.get("vollstaendig"))); + + var ergebnis = + Pipeline.erzeugeSynopse( + stamm, List.copyOf(patches), artikel == null || artikel.isBlank() ? null : artikel, + vollstaendig); + + // Java-Werte kämen auf der JS-Seite als undurchsichtige Proxys an; JSString/JSNumber + // erzeugen echte JS-Werte. + antwort.set("html", JSString.of(ergebnis.html())); + antwort.set("angewandt", JSNumber.of(ergebnis.anzahlAngewandt())); + antwort.set("manuell", JSNumber.of(ergebnis.anzahlManuell())); + antwort.set("normen", JSNumber.of(ergebnis.anzahlGeaenderteNormen())); + } catch (Throwable e) { + e.printStackTrace(); + var meldung = e.getMessage(); + antwort.set( + "fehler", + JSString.of( + e.getClass().getSimpleName() + + (meldung == null || meldung.isBlank() ? "" : ": " + meldung))); + } + return antwort; + } + + private static Quelle quelle(Object datei) { + if (!(datei instanceof JSObject objekt)) { + throw new IllegalArgumentException("Datei fehlt oder ist kein Objekt."); + } + var name = text(objekt.get("name")); + var base64 = text(objekt.get("base64")); + if (base64 == null || base64.isEmpty()) { + throw new IllegalArgumentException( + "Datei „" + (name == null ? "?" : name) + "“ enthält keine Daten."); + } + return new Quelle(name == null ? "Datei" : name, Base64.getDecoder().decode(base64)); + } + + /** + * Die Interop reicht JS-Werte je nach Typ als {@link JSValue} oder als bereits umgesetztes + * Java-Objekt herüber; die folgenden Helfer nehmen beides an, damit sich die Browserfassung nicht + * an einer Fassung der experimentellen Web-Image-API festmacht. + */ + private static String text(Object wert) { + if (wert == null) { + return null; + } + if (wert instanceof String s) { + return s; + } + if (wert instanceof JSValue v) { + return "undefined".equals(v.typeof()) ? null : v.asString(); + } + return wert.toString(); + } + + private static Boolean wahrheitswert(Object wert) { + if (wert instanceof Boolean b) { + return b; + } + if (wert instanceof JSValue v) { + return v.asBoolean(); + } + return Boolean.FALSE; + } + + private static int anzahl(Object liste) { + if (!(liste instanceof JSObject objekt)) { + return 0; + } + var laenge = objekt.get("length"); + if (laenge instanceof Number n) { + return n.intValue(); + } + if (laenge instanceof JSValue v) { + return v.asInt(); + } + return 0; + } + + private static Object element(Object liste, int index) { + var objekt = (JSObject) liste; + var wert = objekt.get(Integer.valueOf(index)); + return wert != null ? wert : objekt.get(String.valueOf(index)); + } +} diff --git a/src/wasm/java/eu/mulk/aendggner/wasm/InflaterErsatz.java b/src/wasm/java/eu/mulk/aendggner/wasm/InflaterErsatz.java new file mode 100644 index 0000000..8817e4d --- /dev/null +++ b/src/wasm/java/eu/mulk/aendggner/wasm/InflaterErsatz.java @@ -0,0 +1,146 @@ +package eu.mulk.aendggner.wasm; + +import com.jcraft.jzlib.JZlib; +import com.oracle.svm.core.annotate.Inject; +import com.oracle.svm.core.annotate.RecomputeFieldValue; +import com.oracle.svm.core.annotate.Substitute; +import com.oracle.svm.core.annotate.TargetClass; +import java.util.zip.DataFormatException; + +/** + * Ersetzt java.util.zip.Inflater durch die reine Java-Umsetzung von jzlib. + * + *

Web Image kennt die nativen zlib-Bindungen des JDK nicht (GR-65205); ohne Inflate ist kein + * PDF lesbar, denn nahezu jeder Inhaltsstrom ist FlateDecode-komprimiert. + */ +@TargetClass(java.util.zip.Inflater.class) +final class Target_java_util_zip_Inflater { + + @Inject @RecomputeFieldValue(kind = RecomputeFieldValue.Kind.Reset) + com.jcraft.jzlib.Inflater impl; + + @Inject @RecomputeFieldValue(kind = RecomputeFieldValue.Kind.Reset) + boolean nowrap; + + @Inject @RecomputeFieldValue(kind = RecomputeFieldValue.Kind.Reset) + boolean fertig; + + @Inject @RecomputeFieldValue(kind = RecomputeFieldValue.Kind.Reset) + boolean braucheWoerterbuch; + + @Substitute + Target_java_util_zip_Inflater(boolean nowrap) { + this.nowrap = nowrap; + this.impl = new com.jcraft.jzlib.Inflater(); + this.impl.init(nowrap); + } + + @Substitute + Target_java_util_zip_Inflater() { + this(false); + } + + @Substitute + public void setInput(byte[] input, int off, int len) { + impl.next_in = input; + impl.next_in_index = off; + impl.avail_in = len; + } + + @Substitute + public void setInput(byte[] input) { + setInput(input, 0, input.length); + } + + @Substitute + public int inflate(byte[] output, int off, int len) throws DataFormatException { + impl.next_out = output; + impl.next_out_index = off; + impl.avail_out = len; + int err = impl.inflate(JZlib.Z_NO_FLUSH); + int erzeugt = len - impl.avail_out; + switch (err) { + case JZlib.Z_STREAM_END: + fertig = true; + return erzeugt; + case JZlib.Z_NEED_DICT: + braucheWoerterbuch = true; + return erzeugt; + case JZlib.Z_OK: + case JZlib.Z_BUF_ERROR: + return erzeugt; + default: + throw new DataFormatException(impl.msg == null ? "Inflate-Fehler " + err : impl.msg); + } + } + + @Substitute + public int inflate(byte[] output) throws DataFormatException { + return inflate(output, 0, output.length); + } + + @Substitute + public boolean needsInput() { + return impl.avail_in <= 0; + } + + @Substitute + public boolean needsDictionary() { + return braucheWoerterbuch; + } + + @Substitute + public boolean finished() { + return fertig; + } + + @Substitute + public int getRemaining() { + return Math.max(impl.avail_in, 0); + } + + @Substitute + public long getBytesRead() { + return impl.total_in; + } + + @Substitute + public long getBytesWritten() { + return impl.total_out; + } + + @Substitute + public int getTotalIn() { + return (int) impl.total_in; + } + + @Substitute + public int getTotalOut() { + return (int) impl.total_out; + } + + @Substitute + public void setDictionary(byte[] dictionary, int off, int len) { + var kopie = new byte[len]; + System.arraycopy(dictionary, off, kopie, 0, len); + impl.setDictionary(kopie, len); + braucheWoerterbuch = false; + } + + @Substitute + public void setDictionary(byte[] dictionary) { + setDictionary(dictionary, 0, dictionary.length); + } + + @Substitute + public void reset() { + impl.init(nowrap); + fertig = false; + braucheWoerterbuch = false; + } + + @Substitute + public void end() { + impl.end(); + } +} -- cgit v1.2.1