aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/eu
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/eu')
-rw-r--r--src/main/java/eu/mulk/aendggner/DateiTyp.java48
-rw-r--r--src/main/java/eu/mulk/aendggner/Pipeline.java66
-rw-r--r--src/main/java/eu/mulk/aendggner/Quelle.java24
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java34
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java11
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java29
-rw-r--r--src/main/java/eu/mulk/aendggner/web/Multipart.java93
-rw-r--r--src/main/java/eu/mulk/aendggner/web/StaticHandler.java63
-rw-r--r--src/main/java/eu/mulk/aendggner/web/UploadHandler.java232
-rw-r--r--src/main/java/eu/mulk/aendggner/web/WebMain.java49
10 files changed, 155 insertions, 494 deletions
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.gii.GiiXmlLoader;
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 @@ import java.util.List;
/**
* 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 @@ public final class Pipeline {
* — 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 @@ public final class Pipeline {
}
}
+ /** 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 @@ public final class Pipeline {
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 @@ public final class Pipeline {
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 @@ public final class Pipeline {
* 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 @@ public final class Pipeline {
+ " ü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 @@ public final class Pipeline {
("%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 @@ public final class Pipeline {
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 @@ public final class Pipeline {
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 @@ public final class Pipeline {
}
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 @@ public final class PatchTextExtraktor {
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 @@ public final class PatchTextExtraktor {
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));
+ }
+
+ 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 (mimeType) {
- case "application/pdf" -> extrahierePdf(datei);
- case "text/plain" -> Files.readString(datei, StandardCharsets.UTF_8);
- default ->
+ 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 @@ public final class PatchTextExtraktor {
* @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 @@ public final class GiiXmlLoader {
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 @@ public final class GiiXmlLoader {
}
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 @@ public final class LandesRechtLoader {
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;
- }
-}