Die Web-App rechnet im Browser statt auf dem Server
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 <noreply@anthropic.com>
Change-Id: I38faf2ac0f764d601f080d4276babe4747773683
diff --git a/README.adoc b/README.adoc
index a29b7ac..24083c7 100644
--- a/README.adoc
+++ b/README.adoc
@@ -272,30 +272,59 @@
[[web]]
== Web-App
-Neben der CLI gibt es einen eigenständigen Webserver
-(`eu.mulk.aendggner.web.WebMain`), der dieselbe Pipeline
-(`eu.mulk.aendggner.Pipeline`) über ein einfaches Upload-Formular
-zugänglich macht: Stammgesetz- und Änderungsgesetz-Datei(en) hochladen,
-Synopse erhalten. Er nutzt ausschließlich JDK-Bordmittel
-(`com.sun.net.httpserver.HttpServer`) — keine zusätzliche
-Web-Framework-Abhängigkeit — und hält keine Rechtsdaten Dritter
-serverseitig vor; hochgeladene Dateien werden nur für die Dauer der
-Anfrage als temporäre Dateien gehalten und danach sofort gelöscht.
+Neben der CLI gibt es eine Browserfassung, die dieselbe Pipeline
+(`eu.mulk.aendggner.Pipeline`) über ein Upload-Formular zugänglich macht:
+Stammgesetz- und Änderungsgesetz-Datei(en) wählen, Synopse erhalten.
-Lokal starten nach <<building,dem Bauen>> der JAR:
+Sie braucht keinen Server: Die vollständige Verarbeitung — PDF-Textgewinnung
+mit PDFBox eingeschlossen — läuft als WebAssembly-Modul im Browser, übersetzt
+mit GraalVM Web Image aus demselben Java-Quelltext. Ausgeliefert werden nur
+statische Dateien; die gewählten Dokumente verlassen den Rechner der
+Nutzer:innen nicht.
+
+Bauen (verlangt Oracle GraalVM 25.1 oder neuer — Web Image ist dort enthalten,
+in der Community Edition nicht):
[source,shell script]
----
-java -cp "target/aendggner-0.1.0-SNAPSHOT.jar:target/libs/*" \
- eu.mulk.aendggner.web.WebMain
+JAVA_HOME=/pfad/zu/oracle-graalvm ./mvnw -Pwasm package
----
-Der Server bindet standardmäßig nur an `127.0.0.1:8080`; Adresse und
-Port lassen sich über die Umgebungsvariablen `AENDGGNER_WEB_BIND` und
-`AENDGGNER_WEB_PORT` ändern. Für den öffentlichen Betrieb gehört davor
-ein TLS-terminierender Reverse Proxy mit Rate-Limiting — eine
-`nginx`-Vorlage sowie eine `systemd`-Unit liegen unter `deploy/`
-(`deploy/nginx-aendggner.conf`, `deploy/aendggner-web.service`).
+Ergebnis ist `target/web/` mit `index.html`, `app.js`, `worker.js`,
+`style.css`, `aendggner.js` und `aendggner.js.wasm` (rund 20 MB, komprimiert
+etwa 6 MB). Die daneben liegende `aendggner.js.wat` ist ein Zwischenschritt des
+Übersetzers und gehört nicht auf den Server.
+
+Lokal ansehen — `file://` genügt nicht, Browser laden Wasm-Module und Worker
+nur über HTTP:
+
+[source,shell script]
+----
+python3 -m http.server --directory target/web 8000
+----
+
+Der öffentliche Betrieb braucht nur einen Webserver für statische Dateien;
+eine `nginx`-Vorlage liegt unter `deploy/nginx-aendggner.conf`. Die
+Befehlszeilenfassung bleibt davon unberührt und ist weiterhin der Weg für
+Massenläufe.
+
+=== Warum WebAssembly und nicht ein Java-Server
+
+Der Kern ist reines Java ohne Dateisystem- oder Netzzugriff; nur vier Stellen
+berührten die Plattform (PDFBox, MIME-Erkennung, XML-Parser, Dateizugriff).
+Sie sind hinter `eu.mulk.aendggner.Quelle` (Name + Bytes) und
+`eu.mulk.aendggner.DateiTyp` (Signaturbytes statt Tika) gebündelt, sodass
+Befehlszeile und Browser dieselbe Pipeline speisen.
+
+Zwei Eigenheiten von Web Image sind dabei zu beachten und im Quelltext
+vermerkt:
+
+* Die nativen zlib-Bindungen des JDK fehlen (`java.util.zip.Inflater`), ohne
+ die kein PDF lesbar ist. `src/wasm/java/.../InflaterErsatz.java` ersetzt sie
+ durch die reine Java-Umsetzung von jzlib.
+* Typisierte Felder lassen sich derzeit nicht nach `byte[]` umsetzen; der
+ Dateiinhalt wandert deshalb als Base64-Text über die JS-Grenze.
+
Vor dem produktiven, öffentlichen Betrieb sind zwingend zu erledigen:
diff --git a/deploy/aendggner-web.service b/deploy/aendggner-web.service
deleted file mode 100644
index f87bbb8..0000000
--- a/deploy/aendggner-web.service
+++ /dev/null
@@ -1,41 +0,0 @@
-# systemd-Unit für den ÄndGgner-Webserver.
-#
-# Installation (Beispiel):
-# sudo cp deploy/aendggner-web.service /etc/systemd/system/
-# sudo useradd --system --home /opt/aendggner --shell /usr/sbin/nologin aendggner
-# sudo cp -r . /opt/aendggner && sudo chown -R aendggner:aendggner /opt/aendggner
-# sudo systemctl daemon-reload
-# sudo systemctl enable --now aendggner-web
-#
-# Erwartet, dass zuvor "mvn package" gelaufen ist, sodass target/aendggner-*.jar
-# und target/libs/* existieren.
-
-[Unit]
-Description=AendGgner Web-App
-After=network.target
-
-[Service]
-Type=simple
-User=aendggner
-Group=aendggner
-WorkingDirectory=/opt/aendggner
-ExecStart=/bin/sh -c 'exec java -cp "target/aendggner-*.jar:target/libs/*" eu.mulk.aendggner.web.WebMain'
-Environment=AENDGGNER_WEB_PORT=8080
-Environment=AENDGGNER_WEB_BIND=127.0.0.1
-Restart=on-failure
-RestartSec=5
-
-# Ressourcen-Obergrenzen: hält den Dienst auf einem kleinen Server in Schach,
-# selbst wenn die Anwendungs- und Proxy-seitigen Limits einmal nicht greifen.
-MemoryMax=512M
-CPUQuota=150%
-TasksMax=128
-
-# Härtung
-NoNewPrivileges=true
-PrivateTmp=true
-ProtectSystem=strict
-ProtectHome=true
-
-[Install]
-WantedBy=multi-user.target
diff --git a/deploy/nginx-aendggner.conf b/deploy/nginx-aendggner.conf
index f4667fb..46eacb5 100644
--- a/deploy/nginx-aendggner.conf
+++ b/deploy/nginx-aendggner.conf
@@ -1,9 +1,8 @@
-# Reverse-Proxy-Vorlage für den ÄndGgner-Webserver. Übernimmt TLS-Terminierung und
-# Rate-Limiting; der Java-Prozess selbst bindet nur an 127.0.0.1 (siehe aendggner-web.service).
+# Vorlage für die Auslieferung der ÄndGgner-Browserfassung. Es gibt keinen
+# Anwendungsprozess mehr: Der Server liefert nur statische Dateien aus
+# (Inhalt von target/web/), gerechnet wird im Browser.
#
-# Vor dem Einsatz anpassen: server_name, ssl_certificate(_key) (z. B. via certbot).
-
-limit_req_zone $binary_remote_addr zone=aendggner:10m rate=10r/m;
+# Vor dem Einsatz anpassen: server_name, ssl_certificate(_key) (z. B. via certbot), root.
server {
listen 80;
@@ -20,20 +19,29 @@
ssl_certificate /etc/letsencrypt/live/aendggner.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/aendggner.example.org/privkey.pem;
- # Muss zum Upload-Limit von UploadHandler (MAX_BODY_BYTES, aktuell 40 MB) passen.
- client_max_body_size 40m;
+ # Inhalt von target/web/ — ohne aendggner.js.wat, das nur ein Zwischenschritt
+ # des Übersetzers ist.
+ root /var/www/aendggner;
+ index index.html;
+
+ # Ohne diesen Typ lehnt der Browser die Streaming-Instanziierung des Moduls ab.
+ types {
+ application/wasm wasm;
+ }
+
+ gzip on;
+ gzip_types application/wasm application/javascript text/css text/html;
+ gzip_min_length 1024;
+
+ # Das Wasm-Modul ist einige Megabyte groß und ändert sich nur mit einer neuen
+ # Fassung; vorkomprimierte .gz/.br daneben zu legen lohnt sich (ngx_brotli bzw.
+ # gzip_static).
+ location ~ \.(wasm|js|css)$ {
+ expires 7d;
+ add_header Cache-Control "public";
+ }
location / {
- limit_req zone=aendggner burst=5 nodelay;
-
- proxy_pass http://127.0.0.1:8080;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
-
- # Etwas großzügiger als der 30s-Timeout der Anwendung, damit deren eigene
- # 504-Antwort den Nutzer erreicht statt eines nginx-eigenen Timeouts.
- proxy_read_timeout 35s;
+ try_files $uri $uri/ =404;
}
}
diff --git a/pom.xml b/pom.xml
index 02478cb..601b9e9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -19,6 +19,7 @@
<enforced.maven-version>3.9.0</enforced.maven-version>
+ <build-helper-plugin.version>3.6.0</build-helper-plugin.version>
<compiler-plugin.version>3.15.0</compiler-plugin.version>
<dependency-plugin.version>3.11.0</dependency-plugin.version>
<enforcer-plugin.version>3.6.3</enforcer-plugin.version>
@@ -33,12 +34,12 @@
<assertj.version>3.27.7</assertj.version>
<java-diff-utils.version>4.17</java-diff-utils.version>
<jboss-logging.version>3.6.3.Final</jboss-logging.version>
+ <jzlib.version>1.1.5</jzlib.version>
<jspecify.version>1.0.0</jspecify.version>
<junit.version>5.13.4</junit.version>
<pdfbox.version>3.0.8</pdfbox.version>
<picocli.version>4.7.7</picocli.version>
<slf4j.version>2.0.18</slf4j.version>
- <tika.version>3.3.1</tika.version>
</properties>
<dependencyManagement>
@@ -86,13 +87,6 @@
<version>${slf4j.version}</version>
</dependency>
- <!-- Document type detection -->
- <dependency>
- <groupId>org.apache.tika</groupId>
- <artifactId>tika-core</artifactId>
- <version>${tika.version}</version>
- </dependency>
-
<!-- PDF text extraction -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
@@ -267,4 +261,144 @@
</build>
+ <profiles>
+
+ <!--
+ Browserfassung: übersetzt dieselbe Pipeline mit GraalVM Web Image nach WebAssembly.
+ Ausdrücklich anzufordern (`-Pwasm`); der Standard-Build bleibt unberührt und braucht
+ kein GraalVM.
+
+ Voraussetzung: Oracle GraalVM 25.1+ (Web Image ist dort enthalten, in der CE nicht):
+
+ JAVA_HOME=/pfad/zu/oracle-graalvm ./mvnw -Pwasm package
+
+ Ergebnis: target/web/ — aendggner.js, aendggner.js.wasm und die statischen Seiten.
+ -->
+ <profile>
+ <id>wasm</id>
+
+ <properties>
+ <native-image.executable>${java.home}/bin/native-image</native-image.executable>
+ <wasm.output.directory>${project.build.directory}/web</wasm.output.directory>
+ <!--
+ Die Option „release 21“ beschränkte den Modulpfad auf die dokumentierte JDK-API; die
+ Web-Image-API des GraalVM wäre damit unsichtbar. Deshalb hier source/target statt
+ release — übersetzt wird ohnehin nur mit dem GraalVM, gegen dessen eigene Klassen.
+ -->
+ <maven.compiler.release/>
+ <maven.compiler.source>21</maven.compiler.source>
+ <maven.compiler.target>21</maven.compiler.target>
+ </properties>
+
+ <dependencies>
+ <!--
+ Reines Java-zlib. Web Image bindet die nativen zlib-Funktionen des JDK nicht an
+ (GR-65205); ohne Inflate ist kein PDF lesbar. Siehe
+ src/wasm/java/.../InflaterErsatz.java.
+ -->
+ <dependency>
+ <groupId>org.jruby</groupId>
+ <artifactId>jzlib</artifactId>
+ <version>${jzlib.version}</version>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+
+ <!-- Der Wasm-Quellbaum kommt nur in diesem Profil hinzu: Er braucht die
+ Web-Image-API und svm.jar, die es nur im GraalVM gibt. -->
+ <plugin>
+ <groupId>org.codehaus.mojo</groupId>
+ <artifactId>build-helper-maven-plugin</artifactId>
+ <version>${build-helper-plugin.version}</version>
+ <executions>
+ <execution>
+ <id>wasm-quellen</id>
+ <phase>generate-sources</phase>
+ <goals>
+ <goal>add-source</goal>
+ </goals>
+ <configuration>
+ <sources>
+ <source>src/wasm/java</source>
+ </sources>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+
+ <plugin>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <configuration>
+ <compilerArgs>
+ <arg>-Aproject=${project.groupId}/${project.artifactId}</arg>
+ <arg>--add-modules</arg>
+ <arg>org.graalvm.webimage.api</arg>
+ </compilerArgs>
+ <annotationProcessorPaths>
+ <path>
+ <groupId>info.picocli</groupId>
+ <artifactId>picocli-codegen</artifactId>
+ <version>${picocli.version}</version>
+ </path>
+ </annotationProcessorPaths>
+ </configuration>
+ </plugin>
+
+ <plugin>
+ <artifactId>maven-resources-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>statische-seiten</id>
+ <phase>package</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${wasm.output.directory}</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/resources/eu/mulk/aendggner/web</directory>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+
+ <plugin>
+ <groupId>org.codehaus.mojo</groupId>
+ <artifactId>exec-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>web-image</id>
+ <phase>package</phase>
+ <goals>
+ <goal>exec</goal>
+ </goals>
+ <configuration>
+ <executable>${native-image.executable}</executable>
+ <arguments>
+ <argument>--tool:svm-wasm</argument>
+ <!-- Der Textzwischenschritt (aendggner.js.wat) wird sonst mehrere hundert
+ Megabyte groß; auf die Größe des .wasm hat das keinen Einfluss. -->
+ <argument>-H:WasmComments=NONE</argument>
+ <argument>-classpath</argument>
+ <classpath/>
+ <argument>-o</argument>
+ <argument>${wasm.output.directory}/aendggner</argument>
+ <argument>eu.mulk.aendggner.wasm.BrowserMain</argument>
+ </arguments>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+
+ </plugins>
+ </build>
+ </profile>
+
+ </profiles>
+
</project>
diff --git a/src/main/java/eu/mulk/aendggner/DateiTyp.java b/src/main/java/eu/mulk/aendggner/DateiTyp.java
new file mode 100644
index 0000000..2157fc4
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/DateiTyp.java
@@ -0,0 +1,48 @@
+package eu.mulk.aendggner;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Die drei Eingabeformate, die ÄndGgner unterscheidet, erkannt an den ersten Bytes.
+ *
+ * <p>Ersetzt die frühere Tika-Erkennung: Unterschieden werden muss nur zwischen PDF, gii-XML und
+ * Klartext, und dafür genügen die Signaturbytes. Das spart eine schwergewichtige Abhängigkeit samt
+ * ServiceLoader- und XML-Konfiguration — was der Wasm-Übersetzung zugutekommt, für die jede
+ * dynamisch aufgelöste Abhängigkeit Handarbeit bedeutet.
+ */
+public enum DateiTyp {
+ PDF,
+ XML,
+ KLARTEXT;
+
+ /** Wie weit hinein nach der Signatur gesucht wird (PDFs tragen gelegentlich Vorspann). */
+ private static final int VORSCHAU_BYTES = 1024;
+
+ public static DateiTyp erkenne(byte[] inhalt) {
+ // ISO-8859-1 bildet jedes Byte auf genau ein Zeichen ab — hier geht es um Signaturen, nicht
+ // um lesbaren Text, und die Zeichenzählung soll der Byteposition entsprechen.
+ var vorschau =
+ new String(inhalt, 0, Math.min(inhalt.length, VORSCHAU_BYTES), StandardCharsets.ISO_8859_1);
+
+ if (vorschau.contains("%PDF-")) {
+ return PDF;
+ }
+
+ // BOM und führenden Leerraum überspringen: Das erste bedeutungstragende Zeichen einer
+ // XML-Datei ist die Deklaration oder das Wurzelelement.
+ int i = vorschau.startsWith("") ? 3 : 0;
+ while (i < vorschau.length() && Character.isWhitespace(vorschau.charAt(i))) {
+ i++;
+ }
+ if (i < vorschau.length() && vorschau.charAt(i) == '<') {
+ return XML;
+ }
+
+ return KLARTEXT;
+ }
+
+ /** Für Fehlermeldungen: „PDF“, „XML“, „Klartext“. */
+ public String anzeigeName() {
+ return this == KLARTEXT ? "Klartext" : name();
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java
index c92ca8c..2427c4e 100644
--- a/src/main/java/eu/mulk/aendggner/Pipeline.java
+++ b/src/main/java/eu/mulk/aendggner/Pipeline.java
@@ -16,7 +16,6 @@
import eu.mulk.aendggner.gesetz.land.LandesRechtLoader;
import eu.mulk.aendggner.synopse.HtmlRenderer;
import eu.mulk.aendggner.synopse.SynopseBuilder;
-import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@@ -24,9 +23,9 @@
/**
* Kernpipeline: Stammgesetz laden → Änderungsgesetze parsen und anwenden → Synopse rendern.
*
- * <p>Wird sowohl von der CLI ({@link AendGgner}) als auch vom Webserver ( {@code
- * eu.mulk.aendggner.web.UploadHandler}) verwendet, damit die Anwendungslogik nur an einer Stelle
- * existiert.
+ * <p>Wird sowohl von der Befehlszeile ({@link AendGgner}) als auch von der Browserfassung ({@code
+ * eu.mulk.aendggner.wasm.BrowserMain}) verwendet, damit die Anwendungslogik nur an einer Stelle
+ * existiert. Sie kennt kein Dateisystem: Eingaben kommen als {@link Quelle} (Name und Bytes).
*/
public final class Pipeline {
@@ -49,14 +48,14 @@
* — bei einem Entwurf die Änderungsanträge, die ihn geändert haben. Sie gehen in die
* Quellenzeile ein, denn die gezeigte Fassung ist ohne sie nicht nachvollziehbar.
*/
- record Quelldokument(Path datei, DokumentKopf kopf, String text, List<String> eingearbeitet) {
+ record Quelldokument(Quelle quelle, DokumentKopf kopf, String text, List<String> eingearbeitet) {
- Quelldokument(Path datei, DokumentKopf kopf, String text) {
- this(datei, kopf, text, List.of());
+ Quelldokument(Quelle quelle, DokumentKopf kopf, String text) {
+ this(quelle, kopf, text, List.of());
}
String quellenAngabe(List<String> artikel) {
- var sb = new StringBuilder(datei.getFileName().toString());
+ var sb = new StringBuilder(quelle.name());
sb.append(" [").append(kopf.anzeigeName()).append("]");
for (var zusatz : eingearbeitet) {
sb.append(" + ").append(zusatz);
@@ -65,8 +64,19 @@
}
}
+ /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */
public static Ergebnis erzeugeSynopse(
Path baseFile, List<Path> patches, String artikel, boolean vollstaendig) throws Exception {
+ var patchQuellen = new ArrayList<Quelle>();
+ for (var patch : patches) {
+ patchQuellen.add(Quelle.lies(patch));
+ }
+ return erzeugeSynopse(Quelle.lies(baseFile), patchQuellen, artikel, vollstaendig);
+ }
+
+ public static Ergebnis erzeugeSynopse(
+ Quelle baseFile, List<Quelle> patches, String artikel, boolean vollstaendig)
+ throws Exception {
var altesGesetz = ladeStammgesetz(baseFile);
var extraktor = new PatchTextExtraktor(superskriptModus(altesGesetz));
var parser = new AenderungsgesetzParser();
@@ -85,9 +95,7 @@
warnungen.add(
"In %s (%s) wurde kein auf %s anwendbarer Artikel gefunden."
.formatted(
- dokument.datei().getFileName(),
- dokument.kopf().anzeigeName(),
- gesetz.jurabk()));
+ dokument.quelle().name(), dokument.kopf().anzeigeName(), gesetz.jurabk()));
}
var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
gesetz = anwendung.neu();
@@ -99,8 +107,8 @@
var gesamtErgebnis = new BefehlAnwender.AnwendungsErgebnis(gesetz, protokoll);
var synopse = SynopseBuilder.baue(altesGesetz, gesamtErgebnis, warnungen, vollstaendig);
- var quelle = baseFile.getFileName() + " + " + String.join(" + ", quellen);
- var html = HtmlRenderer.rendere(synopse, quelle, entwurfsfassung);
+ var quellenZeile = baseFile.name() + " + " + String.join(" + ", quellen);
+ var html = HtmlRenderer.rendere(synopse, quellenZeile, entwurfsfassung);
return new Ergebnis(
html,
@@ -116,18 +124,18 @@
* hinterlässt eine Warnung, die in der Synopse erscheint.
*/
private static List<Quelldokument> leseDokumente(
- List<Path> patches, PatchTextExtraktor extraktor, List<String> warnungen) throws Exception {
+ List<Quelle> patches, PatchTextExtraktor extraktor, List<String> warnungen) throws Exception {
var dokumente = new ArrayList<Quelldokument>();
for (var datei : patches) {
var rohText = extraktor.extrahiere(datei);
// Die Erkennung arbeitet auf dem Rohtext: Der Bereiniger entfernt genau die
// Drucksachenköpfe, aus denen Art und Nummer hervorgehen.
var kopf = DokumentErkenner.erkenne(rohText);
- log.infof("Datei %s erkannt als %s.", datei, kopf.anzeigeName());
+ log.infof("Datei %s erkannt als %s.", datei.name(), kopf.anzeigeName());
if (kopf.art() == DokumentArt.OHNE_BEFEHLE) {
warnungen.add(
"%s ist ein %s und enthält keine Änderungsbefehle; die Datei wurde übergangen."
- .formatted(datei.getFileName(), kopf.art().anzeigeName()));
+ .formatted(datei.name(), kopf.art().anzeigeName()));
continue;
}
if (kopf.art() == DokumentArt.BESCHLUSSEMPFEHLUNG) {
@@ -141,7 +149,7 @@
+ " übergangen. Für eine Synopse eignet sich der zugrunde liegende"
+ " Gesetzentwurf%s.")
.formatted(
- datei.getFileName(),
+ datei.name(),
kopf.bezugsDrucksachen().isEmpty()
? ""
: " (Drs. " + kopf.bezugsDrucksachen().get(0) + ")"));
@@ -179,7 +187,7 @@
("%s ist ein Änderungsantrag zu %s; der zugehörige Gesetzentwurf wurde nicht"
+ " mitgegeben, der Antrag blieb daher unberücksichtigt.")
.formatted(
- antrag.datei().getFileName(),
+ antrag.quelle().name(),
antrag.kopf().bezugsDrucksachen().isEmpty()
? "einer Drucksache"
: "Drs. " + String.join(", ", antrag.kopf().bezugsDrucksachen())));
@@ -192,15 +200,15 @@
warnungen.addAll(patch.warnungen());
log.infof(
"%s: %d von %d Antragsbefehlen auf %s angewandt.",
- antrag.datei().getFileName(),
+ antrag.quelle().name(),
patch.angewandt(),
parseErgebnis.befehle().size(),
- ziel.datei().getFileName());
+ ziel.quelle().name());
var eingearbeitet = new ArrayList<>(ziel.eingearbeitet());
- eingearbeitet.add(antrag.datei().getFileName() + " [" + antrag.kopf().anzeigeName() + "]");
+ eingearbeitet.add(antrag.quelle().name() + " [" + antrag.kopf().anzeigeName() + "]");
ergebnis.set(
zielIndex,
- new Quelldokument(ziel.datei(), ziel.kopf(), patch.text(), List.copyOf(eingearbeitet)));
+ new Quelldokument(ziel.quelle(), ziel.kopf(), patch.text(), List.copyOf(eingearbeitet)));
}
return ergebnis;
}
@@ -234,9 +242,14 @@
return dokument.kopf().art().istEntwurfsfassung();
}
- /** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}. */
+ /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */
static Gesetz ladeStammgesetz(Path baseFile) throws Exception {
- return istGiiXml(baseFile)
+ return ladeStammgesetz(Quelle.lies(baseFile));
+ }
+
+ /** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}. */
+ static Gesetz ladeStammgesetz(Quelle baseFile) throws Exception {
+ return DateiTyp.erkenne(baseFile.inhalt()) == DateiTyp.XML
? new GiiXmlLoader().load(baseFile)
: new LandesRechtLoader().load(baseFile);
}
@@ -258,9 +271,4 @@
}
return SuperskriptModus.ENTFERNEN;
}
-
- static boolean istGiiXml(Path baseFile) throws IOException {
- var mimeType = new org.apache.tika.Tika().detect(baseFile);
- return mimeType.equals("application/xml") || mimeType.equals("text/xml");
- }
}
diff --git a/src/main/java/eu/mulk/aendggner/Quelle.java b/src/main/java/eu/mulk/aendggner/Quelle.java
new file mode 100644
index 0000000..d81647b
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/Quelle.java
@@ -0,0 +1,24 @@
+package eu.mulk.aendggner;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Ein Eingabedokument als Name und Inhalt — die Form, in der die Pipeline Dateien entgegennimmt.
+ *
+ * <p>Die Pipeline kennt bewusst kein Dateisystem: Auf der Befehlszeile kommen die Bytes aus einer
+ * Datei ({@link #lies(Path)}), im Browser aus einem Datei-Upload, den JavaScript übergibt. Der
+ * {@code name} dient allein der Anzeige (Quellenzeile der Synopse, Warnungen) und trägt deshalb
+ * genau das, was auf der Befehlszeile {@code Path.getFileName()} liefern würde.
+ *
+ * <p>{@code equals}/{@code hashCode} sind für {@code byte[]} identitätsbasiert; auf Gleichheit von
+ * {@code Quelle}n verlässt sich niemand.
+ */
+public record Quelle(String name, byte[] inhalt) {
+
+ /** Liest eine Datei vollständig ein. Nur für JVM-Aufrufer (Befehlszeile, Tests). */
+ public static Quelle lies(Path datei) throws IOException {
+ return new Quelle(datei.getFileName().toString(), Files.readAllBytes(datei));
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
index e519e2e..e756126 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
@@ -1,11 +1,11 @@
package eu.mulk.aendggner.aenderung.parse;
+import eu.mulk.aendggner.DateiTyp;
+import eu.mulk.aendggner.Quelle;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.Loader;
-import org.apache.tika.Tika;
import org.jboss.logging.Logger;
/**
@@ -20,7 +20,6 @@
private static final Logger log = Logger.getLogger(PatchTextExtraktor.class);
- private final Tika tika = new Tika();
private final SuperskriptModus superskriptModus;
public PatchTextExtraktor() {
@@ -31,22 +30,27 @@
this.superskriptModus = superskriptModus;
}
+ /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */
public String extrahiere(Path datei) throws IOException {
- var mimeType = tika.detect(datei);
- log.infof("Datei %s hat Typ %s.", datei, mimeType);
+ return extrahiere(Quelle.lies(datei));
+ }
- return switch (mimeType) {
- case "application/pdf" -> extrahierePdf(datei);
- case "text/plain" -> Files.readString(datei, StandardCharsets.UTF_8);
- default ->
+ public String extrahiere(Quelle quelle) throws IOException {
+ var typ = DateiTyp.erkenne(quelle.inhalt());
+ log.infof("Datei %s hat Typ %s.", quelle.name(), typ.anzeigeName());
+
+ return switch (typ) {
+ case PDF -> extrahierePdf(quelle.inhalt());
+ case KLARTEXT -> new String(quelle.inhalt(), StandardCharsets.UTF_8);
+ case XML ->
throw new IOException(
"Nicht unterstützter Dateityp %s für %s (unterstützt: PDF, Klartext)"
- .formatted(mimeType, datei));
+ .formatted(typ.anzeigeName(), quelle.name()));
};
}
- private String extrahierePdf(Path datei) throws IOException {
- try (var dokument = Loader.loadPDF(datei.toFile())) {
+ private String extrahierePdf(byte[] inhalt) throws IOException {
+ try (var dokument = Loader.loadPDF(inhalt)) {
return FontgroessenFilter.extrahiere(dokument, superskriptModus);
}
}
@@ -61,7 +65,11 @@
* @return links = Entwurfsspalte, rechts = Ausschussspalte.
*/
public Spalten extrahiereSpalten(Path datei) throws IOException {
- try (var dokument = Loader.loadPDF(datei.toFile())) {
+ return extrahiereSpalten(Quelle.lies(datei));
+ }
+
+ public Spalten extrahiereSpalten(Quelle quelle) throws IOException {
+ try (var dokument = Loader.loadPDF(quelle.inhalt())) {
return new Spalten(
FontgroessenFilter.extrahiere(
dokument, superskriptModus, FontgroessenFilter.Spalte.LINKS),
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java
index 6f5b55a..c6dea69 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java
@@ -1,9 +1,11 @@
package eu.mulk.aendggner.gesetz.gii;
+import eu.mulk.aendggner.Quelle;
import eu.mulk.aendggner.gesetz.Absatz;
import eu.mulk.aendggner.gesetz.Gesetz;
import eu.mulk.aendggner.gesetz.Gliederung;
import eu.mulk.aendggner.gesetz.Norm;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.StringReader;
import java.nio.file.Path;
@@ -30,9 +32,14 @@
private static final Pattern ABSATZ_MARKER =
Pattern.compile("^\\((\\d+[a-z]?)\\)\\s+", Pattern.UNICODE_CASE);
+ /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */
public Gesetz load(Path datei) throws IOException, SAXException {
+ return load(Quelle.lies(datei));
+ }
+
+ public Gesetz load(Quelle quelle) throws IOException, SAXException {
var builder = neuerDocumentBuilder();
- var dokument = builder.parse(datei.toFile());
+ var dokument = builder.parse(new ByteArrayInputStream(quelle.inhalt()));
var wurzel = dokument.getDocumentElement();
String jurabk = null;
@@ -80,7 +87,7 @@
}
if (jurabk == null) {
- throw new SAXException("Keine <norm>-Elemente mit Metadaten gefunden: " + datei);
+ throw new SAXException("Keine <norm>-Elemente mit Metadaten gefunden: " + quelle.name());
}
return new Gesetz(jurabk, langue, kurzue, normen, gliederungen);
}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java
index c64ca21..22c1e41 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java
@@ -1,15 +1,15 @@
package eu.mulk.aendggner.gesetz.land;
+import eu.mulk.aendggner.DateiTyp;
+import eu.mulk.aendggner.Quelle;
import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor;
import eu.mulk.aendggner.aenderung.parse.SuperskriptModus;
import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
import eu.mulk.aendggner.gesetz.Gesetz;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
import java.nio.file.Path;
import java.text.Normalizer;
-import org.apache.tika.Tika;
import org.jboss.logging.Logger;
/**
@@ -29,27 +29,30 @@
private static final Logger log = Logger.getLogger(LandesRechtLoader.class);
- private final Tika tika = new Tika();
-
+ /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */
public Gesetz load(Path datei) throws IOException {
- var mimeType = tika.detect(datei);
- log.infof("Stammgesetz %s hat Typ %s.", datei, mimeType);
+ return load(Quelle.lies(datei));
+ }
+
+ public Gesetz load(Quelle quelle) throws IOException {
+ var typ = DateiTyp.erkenne(quelle.inhalt());
+ log.infof("Stammgesetz %s hat Typ %s.", quelle.name(), typ.anzeigeName());
var text =
- switch (mimeType) {
- case "application/pdf" ->
+ switch (typ) {
+ case PDF ->
nachSatzendeGetrennteNormkoepfe(
TextBereiniger.bereinige(
- new PatchTextExtraktor(SuperskriptModus.BEHALTEN).extrahiere(datei)));
+ new PatchTextExtraktor(SuperskriptModus.BEHALTEN).extrahiere(quelle)));
// Auch der handgepflegte Klartext wird kanonisch zusammengesetzt (NFC), damit Stammtext
// und Befehlstext gleich kodiert sind — der PDF-Zweig erledigt das über bereinige().
- case "text/plain" ->
+ case KLARTEXT ->
Normalizer.normalize(
- Files.readString(datei, StandardCharsets.UTF_8), Normalizer.Form.NFC);
- default ->
+ new String(quelle.inhalt(), StandardCharsets.UTF_8), Normalizer.Form.NFC);
+ case XML ->
throw new IOException(
"Nicht unterstützter Dateityp %s für Stammgesetz %s (unterstützt: PDF, Klartext)"
- .formatted(mimeType, datei));
+ .formatted(typ.anzeigeName(), quelle.name()));
};
return LandesRechtTextParser.parse(text);
}
diff --git a/src/main/java/eu/mulk/aendggner/web/Multipart.java b/src/main/java/eu/mulk/aendggner/web/Multipart.java
deleted file mode 100644
index 745443b..0000000
--- a/src/main/java/eu/mulk/aendggner/web/Multipart.java
+++ /dev/null
@@ -1,93 +0,0 @@
-package eu.mulk.aendggner.web;
-
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.regex.Pattern;
-
-/**
- * Minimaler {@code multipart/form-data}-Parser für Datei- und Textfelder — bewusst ohne externe
- * Abhängigkeit (siehe Web-App-Plan: nur JDK-Bordmittel).
- */
-final class Multipart {
-
- record Part(String name, String filename, byte[] data) {}
-
- private static final Pattern BOUNDARY_PATTERN = Pattern.compile("boundary=\"?([^\";]+)\"?");
- private static final Pattern NAME_PATTERN = Pattern.compile("name=\"([^\"]*)\"");
- private static final Pattern FILENAME_PATTERN = Pattern.compile("filename=\"([^\"]*)\"");
- private static final byte[] HEADER_BODY_SEPARATOR =
- "\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1);
-
- private Multipart() {}
-
- static String extraktBoundary(String contentType) {
- if (contentType == null) {
- return null;
- }
- var matcher = BOUNDARY_PATTERN.matcher(contentType);
- return matcher.find() ? matcher.group(1) : null;
- }
-
- static List<Part> parse(byte[] body, String boundary) {
- var delimiter = ("--" + boundary).getBytes(StandardCharsets.ISO_8859_1);
-
- var positions = new ArrayList<Integer>();
- for (int i = indexOf(body, delimiter, 0);
- i >= 0;
- i = indexOf(body, delimiter, i + delimiter.length)) {
- positions.add(i);
- }
-
- var parts = new ArrayList<Part>();
- for (int i = 0; i < positions.size() - 1; i++) {
- int start = positions.get(i) + delimiter.length;
- int end = positions.get(i + 1);
-
- if (start + 1 < end && body[start] == '\r' && body[start + 1] == '\n') {
- start += 2;
- }
- int contentEnd = end;
- if (contentEnd >= start + 2 && body[contentEnd - 2] == '\r' && body[contentEnd - 1] == '\n') {
- contentEnd -= 2;
- }
-
- int headerEnd = indexOf(body, HEADER_BODY_SEPARATOR, start);
- if (headerEnd < 0 || headerEnd > contentEnd) {
- continue;
- }
- var headerText = new String(body, start, headerEnd - start, StandardCharsets.UTF_8);
- var dataStart = headerEnd + HEADER_BODY_SEPARATOR.length;
-
- var nameMatcher = NAME_PATTERN.matcher(headerText);
- if (!nameMatcher.find()) {
- continue;
- }
-
- String filename = null;
- var filenameMatcher = FILENAME_PATTERN.matcher(headerText);
- if (filenameMatcher.find()) {
- filename = filenameMatcher.group(1);
- }
-
- var data = Arrays.copyOfRange(body, dataStart, Math.max(dataStart, contentEnd));
- parts.add(new Part(nameMatcher.group(1), filename, data));
- }
-
- return parts;
- }
-
- private static int indexOf(byte[] haystack, byte[] needle, int fromIndex) {
- outer:
- for (int i = fromIndex; i <= haystack.length - needle.length; i++) {
- for (int j = 0; j < needle.length; j++) {
- if (haystack[i + j] != needle[j]) {
- continue outer;
- }
- }
- return i;
- }
- return -1;
- }
-}
diff --git a/src/main/java/eu/mulk/aendggner/web/StaticHandler.java b/src/main/java/eu/mulk/aendggner/web/StaticHandler.java
deleted file mode 100644
index 24a6e41..0000000
--- a/src/main/java/eu/mulk/aendggner/web/StaticHandler.java
+++ /dev/null
@@ -1,63 +0,0 @@
-package eu.mulk.aendggner.web;
-
-import com.sun.net.httpserver.HttpExchange;
-import com.sun.net.httpserver.HttpHandler;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.UncheckedIOException;
-import java.nio.charset.StandardCharsets;
-
-/**
- * Liefert eine einzelne, beim Start einmal geladene statische Ressource aus (Formular, Impressum,
- * CSS, …).
- */
-final class StaticHandler implements HttpHandler {
-
- private final byte[] content;
- private final String contentType;
-
- StaticHandler(String resourcePath, String contentType) {
- try (InputStream in = StaticHandler.class.getResourceAsStream(resourcePath)) {
- if (in == null) {
- throw new IllegalStateException("Ressource nicht gefunden: " + resourcePath);
- }
- this.content = in.readAllBytes();
- } catch (IOException e) {
- throw new UncheckedIOException("Ressource konnte nicht geladen werden: " + resourcePath, e);
- }
- this.contentType = contentType;
- }
-
- @Override
- public void handle(HttpExchange exchange) throws IOException {
- try {
- if (!"GET".equals(exchange.getRequestMethod())) {
- sendText(exchange, 405, "Methode nicht erlaubt.");
- return;
- }
- // com.sun.net.httpserver routet nach dem längsten passenden Präfix; ohne diesen Vergleich
- // würde z. B. "/irgendwas" auf den Kontext "/" fallen und fälschlich die Startseite liefern.
- if (!exchange.getRequestURI().getPath().equals(exchange.getHttpContext().getPath())) {
- sendText(exchange, 404, "Nicht gefunden.");
- return;
- }
- exchange.getResponseHeaders().set("Content-Type", contentType);
- exchange.sendResponseHeaders(200, content.length);
- try (var os = exchange.getResponseBody()) {
- os.write(content);
- }
- } finally {
- exchange.close();
- }
- }
-
- private static void sendText(HttpExchange exchange, int status, String message)
- throws IOException {
- var body = message.getBytes(StandardCharsets.UTF_8);
- exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
- exchange.sendResponseHeaders(status, body.length);
- try (var os = exchange.getResponseBody()) {
- os.write(body);
- }
- }
-}
diff --git a/src/main/java/eu/mulk/aendggner/web/UploadHandler.java b/src/main/java/eu/mulk/aendggner/web/UploadHandler.java
deleted file mode 100644
index efcb512..0000000
--- a/src/main/java/eu/mulk/aendggner/web/UploadHandler.java
+++ /dev/null
@@ -1,232 +0,0 @@
-package eu.mulk.aendggner.web;
-
-import com.sun.net.httpserver.HttpExchange;
-import com.sun.net.httpserver.HttpHandler;
-import eu.mulk.aendggner.Pipeline;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Comparator;
-import java.util.concurrent.ArrayBlockingQueue;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.RejectedExecutionException;
-import java.util.concurrent.ThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-
-/**
- * {@code POST /synopse} — nimmt Stammgesetz- und Änderungsgesetz-Datei(en) per {@code
- * multipart/form-data} entgegen, ruft {@link Pipeline#erzeugeSynopse} auf und liefert das erzeugte
- * HTML zurück.
- *
- * <p>Hochgeladene Dateien landen ausschließlich als temporäre Dateien für die Dauer der Anfrage und
- * werden danach in jedem Fall gelöscht — es wird nichts dauerhaft gespeichert. Die eigentliche
- * Verarbeitung läuft auf einem auf die Kernzahl begrenzten Thread-Pool mit fester Warteschlange;
- * bei Überlast wird sofort mit {@code 503} abgelehnt, statt unbegrenzt Arbeit anzunehmen.
- */
-final class UploadHandler implements HttpHandler {
-
- private static final long MAX_PART_BYTES = 15L * 1024 * 1024; // 15 MB je Datei
- private static final long MAX_BODY_BYTES =
- 40L * 1024 * 1024; // Sicherheitsnetz für die gesamte Anfrage
- private static final long TIMEOUT_SECONDS = 30;
-
- private final ExecutorService pipelinePool;
-
- UploadHandler() {
- var poolSize = Math.max(1, Runtime.getRuntime().availableProcessors());
- this.pipelinePool =
- new ThreadPoolExecutor(
- poolSize,
- poolSize,
- 0L,
- TimeUnit.MILLISECONDS,
- new ArrayBlockingQueue<>(poolSize),
- new ThreadPoolExecutor.AbortPolicy());
- }
-
- @Override
- public void handle(HttpExchange exchange) throws IOException {
- var tempDirs = new ArrayList<Path>();
- try {
- handleInternal(exchange, tempDirs);
- } catch (Throwable e) {
- // Ohne diesen Fang schließt com.sun.net.httpserver die Verbindung bei einer
- // unerwarteten Exception kommentarlos ("Empty reply from server" beim Client).
- e.printStackTrace();
- sendText(exchange, 500, "Unerwarteter Fehler bei der Verarbeitung.");
- } finally {
- for (var tempDir : tempDirs) {
- deleteRecursively(tempDir);
- }
- exchange.close();
- }
- }
-
- private static void deleteRecursively(Path dir) {
- try (var files = Files.walk(dir)) {
- files
- .sorted(Comparator.reverseOrder())
- .forEach(
- path -> {
- try {
- Files.deleteIfExists(path);
- } catch (IOException ignored) {
- // Aufräumen ist best effort; ein verwaistes Temp-File blockiert die Antwort
- // nicht.
- }
- });
- } catch (IOException ignored) {
- // Verzeichnis existiert eventuell schon nicht mehr (z. B. bei frühem Abbruch) — egal.
- }
- }
-
- private void handleInternal(HttpExchange exchange, ArrayList<Path> tempDirs) throws IOException {
- if (!"POST".equals(exchange.getRequestMethod())) {
- sendText(exchange, 405, "Methode nicht erlaubt.");
- return;
- }
-
- var boundary = Multipart.extraktBoundary(exchange.getRequestHeaders().getFirst("Content-Type"));
- if (boundary == null) {
- sendText(exchange, 400, "Ungültige Anfrage: multipart/form-data mit boundary erwartet.");
- return;
- }
-
- byte[] body;
- try {
- body = readLimited(exchange.getRequestBody(), MAX_BODY_BYTES);
- } catch (PayloadTooLargeException e) {
- sendText(
- exchange,
- 413,
- "Die Anfrage ist zu groß (Limit: " + (MAX_BODY_BYTES / 1024 / 1024) + " MB insgesamt).");
- return;
- }
-
- var parts = Multipart.parse(body, boundary);
-
- Path stammFile = null;
- var aenderungFiles = new ArrayList<Path>();
- var vollstaendig = false;
-
- for (var part : parts) {
- if ("vollstaendig".equals(part.name())) {
- var value = new String(part.data(), StandardCharsets.UTF_8).trim();
- vollstaendig = !value.isEmpty() && !value.equals("off") && !value.equals("false");
- continue;
- }
- if (part.filename() == null || part.filename().isBlank() || part.data().length == 0) {
- continue;
- }
- if (part.data().length > MAX_PART_BYTES) {
- sendText(
- exchange,
- 413,
- "Die Datei „"
- + part.filename()
- + "“ ist zu groß (Limit: "
- + (MAX_PART_BYTES / 1024 / 1024)
- + " MB).");
- return;
- }
-
- // Jede Datei bekommt ein eigenes Temp-Verzeichnis, damit sie unter ihrem ursprünglichen
- // Namen abgelegt werden kann (erscheint so in der "Quelle"-Zeile der Synopse) und
- // gleichnamige Uploads sich nicht überschreiben.
- var tempDir = Files.createTempDirectory("aendggner-");
- tempDirs.add(tempDir);
- var tempFile = tempDir.resolve(sanitize(part.filename()));
- Files.write(tempFile, part.data());
-
- if ("stamm".equals(part.name())) {
- stammFile = tempFile;
- } else if ("aenderung".equals(part.name())) {
- aenderungFiles.add(tempFile);
- }
- }
-
- if (stammFile == null) {
- sendText(exchange, 400, "Bitte ein Stammgesetz hochladen.");
- return;
- }
- if (aenderungFiles.isEmpty()) {
- sendText(exchange, 400, "Bitte mindestens ein Änderungsgesetz hochladen.");
- return;
- }
-
- var finalStammFile = stammFile;
- var finalVollstaendig = vollstaendig;
- Callable<Pipeline.Ergebnis> job =
- () -> Pipeline.erzeugeSynopse(finalStammFile, aenderungFiles, null, finalVollstaendig);
-
- Pipeline.Ergebnis ergebnis;
- try {
- var future = pipelinePool.submit(job);
- ergebnis = future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
- } catch (RejectedExecutionException e) {
- sendText(
- exchange, 503, "Der Dienst ist gerade ausgelastet. Bitte in Kürze erneut versuchen.");
- return;
- } catch (TimeoutException e) {
- sendText(exchange, 504, "Die Verarbeitung hat zu lange gedauert und wurde abgebrochen.");
- return;
- } catch (ExecutionException e) {
- var cause = e.getCause();
- var message = cause != null ? cause.getMessage() : e.getMessage();
- sendText(
- exchange,
- 422,
- "Verarbeitung fehlgeschlagen: " + (message == null ? "unbekannter Fehler." : message));
- return;
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- sendText(exchange, 500, "Anfrage wurde unterbrochen.");
- return;
- }
-
- var responseBody = ergebnis.html().getBytes(StandardCharsets.UTF_8);
- exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
- exchange.sendResponseHeaders(200, responseBody.length);
- try (var os = exchange.getResponseBody()) {
- os.write(responseBody);
- }
- }
-
- private static byte[] readLimited(InputStream in, long limit) throws IOException {
- var buffer = new ByteArrayOutputStream();
- var chunk = new byte[8192];
- long total = 0;
- int read;
- while ((read = in.read(chunk)) != -1) {
- total += read;
- if (total > limit) {
- throw new PayloadTooLargeException();
- }
- buffer.write(chunk, 0, read);
- }
- return buffer.toByteArray();
- }
-
- private static String sanitize(String filename) {
- return filename.replaceAll("[^A-Za-z0-9._-]", "_");
- }
-
- private static void sendText(HttpExchange exchange, int status, String message)
- throws IOException {
- var body = message.getBytes(StandardCharsets.UTF_8);
- exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
- exchange.sendResponseHeaders(status, body.length);
- try (var os = exchange.getResponseBody()) {
- os.write(body);
- }
- }
-
- private static final class PayloadTooLargeException extends IOException {}
-}
diff --git a/src/main/java/eu/mulk/aendggner/web/WebMain.java b/src/main/java/eu/mulk/aendggner/web/WebMain.java
deleted file mode 100644
index 815f0db..0000000
--- a/src/main/java/eu/mulk/aendggner/web/WebMain.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package eu.mulk.aendggner.web;
-
-import com.sun.net.httpserver.HttpServer;
-import java.io.IOException;
-import java.net.InetSocketAddress;
-import java.util.concurrent.Executors;
-
-/**
- * Eigenständiger Einstiegspunkt für den ÄndGgner-Webserver.
- *
- * <p>Nutzt ausschließlich JDK-Bordmittel ({@link HttpServer}) — keine zusätzliche
- * Web-Framework-Abhängigkeit. TLS-Terminierung und Rate-Limiting übernimmt ein vorgeschalteter
- * Reverse Proxy (siehe {@code deploy/nginx-aendggner.conf}); dieser Prozess bindet standardmäßig
- * nur an {@code localhost}.
- */
-public final class WebMain {
-
- private WebMain() {}
-
- public static void main(String... args) throws IOException {
- var port = Integer.parseInt(envOr("AENDGGNER_WEB_PORT", "8080"));
- var bindAddress = envOr("AENDGGNER_WEB_BIND", "127.0.0.1");
-
- var server = HttpServer.create(new InetSocketAddress(bindAddress, port), 0);
- server.setExecutor(
- Executors.newFixedThreadPool(Math.max(4, Runtime.getRuntime().availableProcessors() * 2)));
-
- server.createContext(
- "/", new StaticHandler("/eu/mulk/aendggner/web/index.html", "text/html; charset=utf-8"));
- server.createContext(
- "/impressum",
- new StaticHandler("/eu/mulk/aendggner/web/impressum.html", "text/html; charset=utf-8"));
- server.createContext(
- "/datenschutz",
- new StaticHandler("/eu/mulk/aendggner/web/datenschutz.html", "text/html; charset=utf-8"));
- server.createContext(
- "/style.css",
- new StaticHandler("/eu/mulk/aendggner/web/style.css", "text/css; charset=utf-8"));
- server.createContext("/synopse", new UploadHandler());
-
- server.start();
- System.out.printf("ÄndGgner-Webserver läuft auf http://%s:%d/%n", bindAddress, port);
- }
-
- private static String envOr(String name, String fallback) {
- var value = System.getenv(name);
- return value == null || value.isBlank() ? fallback : value;
- }
-}
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": "<init>",
+ "parameterTypes": []
+ }
+ ]
+ },
+ {
+ "type": "com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl",
+ "methods": [
+ {
+ "name": "<init>",
+ "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": "<init>",
+ "parameterTypes": []
+ }
+ ]
+ },
+ {
+ "type": "org.apache.log4j.LogManager"
+ },
+ {
+ "type": "org.apache.logging.log4j.Logger"
+ },
+ {
+ "type": "org.apache.pdfbox.pdmodel.encryption.StandardSecurityHandler",
+ "methods": [
+ {
+ "name": "<init>",
+ "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": "<init>",
+ "parameterTypes": []
+ }
+ ]
+ },
+ {
+ "type": "sun.security.provider.NativePRNG",
+ "methods": [
+ {
+ "name": "<init>",
+ "parameterTypes": [
+ "java.security.SecureRandomParameters"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "sun.security.provider.SHA2$SHA256",
+ "methods": [
+ {
+ "name": "<init>",
+ "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 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Datenschutz — ÄndGgner</title>
- <link rel="stylesheet" href="/style.css">
+ <link rel="stylesheet" href="style.css">
</head>
<body>
<main>
@@ -15,19 +15,22 @@
Zugriffslogs, Hosting-Anbieter).
</p>
- <h2>Hochgeladene Dateien</h2>
+ <h2>Ihre Dateien</h2>
<p>
- 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 <strong>nicht übertragen</strong>. 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.
</p>
<h2>Zugriffsprotokolle</h2>
<p>
- 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.
</p>
<h2>Keine Cookies, keine Konten, kein Tracking</h2>
@@ -36,6 +39,6 @@
Analyse- oder Tracking-Dienste Dritter ein.
</p>
</main>
- <footer><a href="/">Zurück zum Formular</a></footer>
+ <footer><a href="index.html">Zurück zum Formular</a></footer>
</body>
</html>
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 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Impressum — ÄndGgner</title>
- <link rel="stylesheet" href="/style.css">
+ <link rel="stylesheet" href="style.css">
</head>
<body>
<main>
@@ -21,6 +21,6 @@
[E-Mail-Adresse]
</address>
</main>
- <footer><a href="/">Zurück zum Formular</a></footer>
+ <footer><a href="index.html">Zurück zum Formular</a></footer>
</body>
</html>
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 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ÄndGgner — Änderungsgesetze konsolidieren</title>
- <link rel="stylesheet" href="/style.css">
+ <link rel="stylesheet" href="style.css">
+ <script src="app.js" defer></script>
</head>
<body>
<header>
@@ -15,7 +16,7 @@
</header>
<main>
- <form method="post" action="/synopse" enctype="multipart/form-data">
+ <form id="synopse-formular">
<div class="field">
<label for="stamm">Stammgesetz</label>
<input type="file" id="stamm" name="stamm" required>
@@ -39,7 +40,7 @@
<div class="field checkbox">
<label>
- <input type="checkbox" name="vollstaendig">
+ <input type="checkbox" id="vollstaendig" name="vollstaendig">
Unveränderte Vorschriften mit in die Synopse aufnehmen
</label>
</div>
@@ -47,18 +48,20 @@
<button type="submit">Synopse erzeugen</button>
</form>
+ <p id="meldung" class="meldung" hidden></p>
+
<p class="disclaimer">
Die Synopse wird automatisch erstellt und ist <strong>ohne Gewähr</strong> 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
+ <strong>verlassen Ihren Rechner nicht</strong>: Die Auswertung läuft vollständig im Browser,
+ es wird nichts hochgeladen und nichts gespeichert.
</p>
</main>
<footer>
- <a href="/impressum">Impressum</a> ·
- <a href="/datenschutz">Datenschutz</a> ·
+ <a href="impressum.html">Impressum</a> ·
+ <a href="datenschutz.html">Datenschutz</a> ·
<a href="https://TODO-QUELLCODE-URL-EINTRAGEN">Quellcode (AGPLv3)</a>
</footer>
</body>
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 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) });
+ }
+};
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.
+ *
+ * <p>Erwartet ein JS-Objekt <code>{stamm: {name, base64}, patches: [{name, base64}], artikel,
+ * vollstaendig}</code> und liefert <code>{html, angewandt, manuell, normen}</code> oder
+ * <code>{fehler}</code> 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.
+ *
+ * <p>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<JSObject, JSObject> 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<Quelle>();
+ 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.
+ *
+ * <p>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();
+ }
+}