aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/eu
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-09 08:33:37 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-09 08:33:37 +0200
commit7b455cd43149834ccd2163b4e5d197809bd34a37 (patch)
tree5194dad78446c9631da454a523f122f9de46057c /src/main/java/eu
parent5b2e6ef635026a62e23fe5ea6df2c85d839ff78c (diff)
Add a public web front end alongside the CLI
Extracts the CLI pipeline into a reusable Pipeline class and exposes it via a dependency-free JDK HttpServer (upload form, bounded worker pool with 503 on overload, per-request size/time limits, no persisted uploads), plus nginx/systemd deploy templates and Impressum/Datenschutz placeholders for public operation. Change-Id: I6e8e7afa3c4b1082cdf9e82da0fae0b5b49470ad
Diffstat (limited to 'src/main/java/eu')
-rw-r--r--src/main/java/eu/mulk/aendggner/AendGgner.java87
-rw-r--r--src/main/java/eu/mulk/aendggner/Pipeline.java104
-rw-r--r--src/main/java/eu/mulk/aendggner/web/Multipart.java90
-rw-r--r--src/main/java/eu/mulk/aendggner/web/StaticHandler.java59
-rw-r--r--src/main/java/eu/mulk/aendggner/web/UploadHandler.java228
-rw-r--r--src/main/java/eu/mulk/aendggner/web/WebMain.java48
6 files changed, 539 insertions, 77 deletions
diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java
index efa95a9..5f71e37 100644
--- a/src/main/java/eu/mulk/aendggner/AendGgner.java
+++ b/src/main/java/eu/mulk/aendggner/AendGgner.java
@@ -2,20 +2,12 @@ package eu.mulk.aendggner;
import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser;
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.anwendung.BefehlAnwender;
-import eu.mulk.aendggner.gesetz.Gesetz;
-import eu.mulk.aendggner.gesetz.Superskript;
-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.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.logging.LogManager;
@@ -111,7 +103,7 @@ public class AendGgner implements Callable<Integer> {
log.debugf("Logging configured.");
if (dumpGesetz) {
- var gesetz = ladeStammgesetz();
+ var gesetz = Pipeline.ladeStammgesetz(baseFile);
System.out.printf(
"%s — %s (%d Normen)%n", gesetz.jurabk(), gesetz.langue(), gesetz.normen().size());
for (var norm : gesetz.normen()) {
@@ -126,8 +118,8 @@ public class AendGgner implements Callable<Integer> {
}
if (extractOnly) {
- var gesetz = ladeStammgesetz();
- var extraktor = new PatchTextExtraktor(superskriptModus(gesetz));
+ var gesetz = Pipeline.ladeStammgesetz(baseFile);
+ var extraktor = new PatchTextExtraktor(Pipeline.superskriptModus(gesetz));
for (var file : patches) {
var text = extraktor.extrahiere(file);
System.out.println(raw ? text : TextBereiniger.bereinige(text));
@@ -136,8 +128,8 @@ public class AendGgner implements Callable<Integer> {
}
if (dumpBefehle) {
- var gesetz = ladeStammgesetz();
- var extraktor = new PatchTextExtraktor(superskriptModus(gesetz));
+ var gesetz = Pipeline.ladeStammgesetz(baseFile);
+ var extraktor = new PatchTextExtraktor(Pipeline.superskriptModus(gesetz));
var parser = new AenderungsgesetzParser();
for (var file : patches) {
var text = TextBereiniger.bereinige(extraktor.extrahiere(file));
@@ -177,79 +169,20 @@ public class AendGgner implements Callable<Integer> {
return 1;
}
- // Pipeline: Stammgesetz laden → Befehle parsen → anwenden → Synopse rendern.
- var altesGesetz = ladeStammgesetz();
- var extraktor = new PatchTextExtraktor(superskriptModus(altesGesetz));
- var parser = new AenderungsgesetzParser();
-
- var gesetz = altesGesetz;
- var protokoll = new ArrayList<BefehlAnwender.AngewandteAenderung>();
- var warnungen = new ArrayList<String>();
- var quellen = new ArrayList<String>();
-
- for (var file : patches) {
- var text = TextBereiniger.bereinige(extraktor.extrahiere(file));
- var parseErgebnis = parser.parse(text, gesetz, artikel);
- if (parseErgebnis.befehle().isEmpty()) {
- System.err.printf(
- "Warnung: in %s wurde kein auf %s anwendbarer Artikel gefunden.%n",
- file, gesetz.jurabk());
- }
- var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
- gesetz = anwendung.neu();
- protokoll.addAll(anwendung.protokoll());
- warnungen.addAll(parseErgebnis.warnungen());
- quellen.add(
- file.getFileName() + " (Artikel " + String.join(", ", parseErgebnis.artikel()) + ")");
- }
-
- 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);
+ var ergebnis = Pipeline.erzeugeSynopse(baseFile, patches, artikel, vollstaendig);
if (output.equals("-")) {
- System.out.println(html);
+ System.out.println(ergebnis.html());
} else {
- Files.writeString(Path.of(output), html, StandardCharsets.UTF_8);
+ Files.writeString(Path.of(output), ergebnis.html(), StandardCharsets.UTF_8);
log.infof("Synopse nach %s geschrieben.", output);
}
System.err.printf(
"%d Befehle angewandt, %d manuell zu prüfen, %d geänderte Normen.%n",
- gesamtErgebnis.anzahlAngewandt(),
- gesamtErgebnis.anzahlManuell(),
- synopse.eintraege().size());
-
- return gesamtErgebnis.anzahlAngewandt() == 0 && !protokoll.isEmpty() ? 2 : 0;
- }
-
- /** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}. */
- private Gesetz ladeStammgesetz() throws Exception {
- return istGiiXml() ? new GiiXmlLoader().load(baseFile) : new LandesRechtLoader().load(baseFile);
- }
-
- /**
- * Der Superskriptmodus folgt der Schreibweise des Stammgesetzes: Trägt es amtliche Satznummern
- * (bayerisches Landesrecht, Niedersachsen u.a.), werden auch die Änderungsgesetze mit
- * Superskript-Erhalt extrahiert, damit Zitate und Stammtext dieselbe Schreibweise tragen; sonst
- * (Bundesrecht, Länder ohne amtliche Satzzählung) sind hochgestellte Ziffern bloße
- * Fußnotenmarker und werden verworfen.
- */
- private SuperskriptModus superskriptModus(Gesetz gesetz) {
- for (var norm : gesetz.normen()) {
- for (var absatz : norm.absaetze()) {
- if (Superskript.traegtSatznummern(absatz.text())) {
- return SuperskriptModus.BEHALTEN;
- }
- }
- }
- return SuperskriptModus.ENTFERNEN;
- }
+ ergebnis.anzahlAngewandt(), ergebnis.anzahlManuell(), ergebnis.anzahlGeaenderteNormen());
- private boolean istGiiXml() throws IOException {
- var mimeType = new org.apache.tika.Tika().detect(baseFile);
- return mimeType.equals("application/xml") || mimeType.equals("text/xml");
+ return ergebnis.anzahlAngewandt() == 0 && ergebnis.anzahlProtokollEintraege() > 0 ? 2 : 0;
}
private static String kuerze(String text) {
diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java
new file mode 100644
index 0000000..eb8d232
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/Pipeline.java
@@ -0,0 +1,104 @@
+package eu.mulk.aendggner;
+
+import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser;
+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.anwendung.BefehlAnwender;
+import eu.mulk.aendggner.gesetz.Gesetz;
+import eu.mulk.aendggner.gesetz.Superskript;
+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;
+
+/**
+ * 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.
+ */
+public final class Pipeline {
+
+ private Pipeline() {}
+
+ public record Ergebnis(
+ String html,
+ long anzahlAngewandt,
+ long anzahlManuell,
+ int anzahlGeaenderteNormen,
+ int anzahlProtokollEintraege) {}
+
+ public static Ergebnis erzeugeSynopse(
+ Path baseFile, List<Path> patches, String artikel, boolean vollstaendig) throws Exception {
+ var altesGesetz = ladeStammgesetz(baseFile);
+ var extraktor = new PatchTextExtraktor(superskriptModus(altesGesetz));
+ var parser = new AenderungsgesetzParser();
+
+ var gesetz = altesGesetz;
+ var protokoll = new ArrayList<BefehlAnwender.AngewandteAenderung>();
+ var warnungen = new ArrayList<String>();
+ var quellen = new ArrayList<String>();
+
+ for (var file : patches) {
+ var text = TextBereiniger.bereinige(extraktor.extrahiere(file));
+ var parseErgebnis = parser.parse(text, gesetz, artikel);
+ if (parseErgebnis.befehle().isEmpty()) {
+ System.err.printf(
+ "Warnung: in %s wurde kein auf %s anwendbarer Artikel gefunden.%n",
+ file, gesetz.jurabk());
+ }
+ var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
+ gesetz = anwendung.neu();
+ protokoll.addAll(anwendung.protokoll());
+ warnungen.addAll(parseErgebnis.warnungen());
+ quellen.add(
+ file.getFileName() + " (Artikel " + String.join(", ", parseErgebnis.artikel()) + ")");
+ }
+
+ 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);
+
+ return new Ergebnis(
+ html,
+ gesamtErgebnis.anzahlAngewandt(),
+ gesamtErgebnis.anzahlManuell(),
+ synopse.eintraege().size(),
+ protokoll.size());
+ }
+
+ /** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}. */
+ static Gesetz ladeStammgesetz(Path baseFile) throws Exception {
+ return istGiiXml(baseFile) ? new GiiXmlLoader().load(baseFile) : new LandesRechtLoader().load(baseFile);
+ }
+
+ /**
+ * Der Superskriptmodus folgt der Schreibweise des Stammgesetzes: Trägt es amtliche Satznummern
+ * (bayerisches Landesrecht, Niedersachsen u.a.), werden auch die Änderungsgesetze mit
+ * Superskript-Erhalt extrahiert, damit Zitate und Stammtext dieselbe Schreibweise tragen; sonst
+ * (Bundesrecht, Länder ohne amtliche Satzzählung) sind hochgestellte Ziffern bloße
+ * Fußnotenmarker und werden verworfen.
+ */
+ static SuperskriptModus superskriptModus(Gesetz gesetz) {
+ for (var norm : gesetz.normen()) {
+ for (var absatz : norm.absaetze()) {
+ if (Superskript.traegtSatznummern(absatz.text())) {
+ return SuperskriptModus.BEHALTEN;
+ }
+ }
+ }
+ 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/web/Multipart.java b/src/main/java/eu/mulk/aendggner/web/Multipart.java
new file mode 100644
index 0000000..3e5a059
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/web/Multipart.java
@@ -0,0 +1,90 @@
+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
new file mode 100644
index 0000000..8cdf6e5
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/web/StaticHandler.java
@@ -0,0 +1,59 @@
+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
new file mode 100644
index 0000000..8765ac3
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/web/UploadHandler.java
@@ -0,0 +1,228 @@
+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
new file mode 100644
index 0000000..9d71558
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/web/WebMain.java
@@ -0,0 +1,48 @@
+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;
+ }
+}