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
diff --git a/README.adoc b/README.adoc
index 77540fe..b092904 100644
--- a/README.adoc
+++ b/README.adoc
@@ -178,6 +178,46 @@
 `src/main/resources/sampledata/Landesrecht-Beispiele.adoc`.
 
 
+[[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.
+
+Lokal starten nach <<building,dem Bauen>> der JAR:
+
+[source,shell script]
+----
+java -cp "target/aendggner-0.1.0-SNAPSHOT.jar:target/libs/*" \
+  eu.mulk.aendggner.web.WebMain
+----
+
+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`).
+
+Vor dem produktiven, öffentlichen Betrieb sind zwingend zu erledigen:
+
+* `impressum.html` und `datenschutz.html`
+  (`src/main/resources/eu/mulk/aendggner/web/`) mit den tatsächlich
+  zutreffenden Angaben statt der `TODO`-Platzhalter füllen (Pflicht
+  nach § 5 DDG bzw. DSGVO für öffentlich erreichbare Dienste).
+* Den Quellcode-Link im Footer von `index.html` auf das tatsächlich
+  öffentlich erreichbare Repository setzen — das Projekt steht unter
+  AGPLv3 (`COPYING`), dessen §13 bei Netzwerkbetrieb einen
+  Quellcode-Zugriff für Nutzer:innen verlangt.
+
+
 == Running the Tests
 
 To build and run the tests:
diff --git a/deploy/aendggner-web.service b/deploy/aendggner-web.service
new file mode 100644
index 0000000..f87bbb8
--- /dev/null
+++ b/deploy/aendggner-web.service
@@ -0,0 +1,41 @@
+# 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
new file mode 100644
index 0000000..f4667fb
--- /dev/null
+++ b/deploy/nginx-aendggner.conf
@@ -0,0 +1,39 @@
+# 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).
+#
+# 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;
+
+server {
+    listen 80;
+    listen [::]:80;
+    server_name aendggner.example.org;
+    return 301 https://$host$request_uri;
+}
+
+server {
+    listen 443 ssl http2;
+    listen [::]:443 ssl http2;
+    server_name aendggner.example.org;
+
+    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;
+
+    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;
+    }
+}
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 @@
 
 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 @@
     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 @@
     }
 
     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 @@
     }
 
     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 @@
       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());
+        ergebnis.anzahlAngewandt(), ergebnis.anzahlManuell(), ergebnis.anzahlGeaenderteNormen());
 
-    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;
-  }
-
-  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;
+  }
+}
diff --git a/src/main/resources/eu/mulk/aendggner/web/datenschutz.html b/src/main/resources/eu/mulk/aendggner/web/datenschutz.html
new file mode 100644
index 0000000..65ec0c9
--- /dev/null
+++ b/src/main/resources/eu/mulk/aendggner/web/datenschutz.html
@@ -0,0 +1,41 @@
+<!doctype html>
+<html lang="de">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Datenschutz — ÄndGgner</title>
+  <link rel="stylesheet" href="/style.css">
+</head>
+<body>
+  <main>
+    <h1>Datenschutzerklärung</h1>
+    <p>
+      <strong>TODO vor dem Livegang:</strong> Bitte prüfen und um die für diesen konkreten Server
+      zutreffenden Angaben ergänzen (Betreiber, Kontakt, tatsächliche Speicherdauer der
+      Zugriffslogs, Hosting-Anbieter).
+    </p>
+
+    <h2>Hochgeladene 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.
+    </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.
+    </p>
+
+    <h2>Keine Cookies, keine Konten, kein Tracking</h2>
+    <p>
+      Dieser Dienst verwendet keine Cookies, erfordert keine Registrierung und bindet keine
+      Analyse- oder Tracking-Dienste Dritter ein.
+    </p>
+  </main>
+  <footer><a href="/">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
new file mode 100644
index 0000000..303db8e
--- /dev/null
+++ b/src/main/resources/eu/mulk/aendggner/web/impressum.html
@@ -0,0 +1,26 @@
+<!doctype html>
+<html lang="de">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Impressum — ÄndGgner</title>
+  <link rel="stylesheet" href="/style.css">
+</head>
+<body>
+  <main>
+    <h1>Impressum</h1>
+    <p>
+      <strong>TODO vor dem Livegang:</strong> Diese Seite muss durch die nach § 5 DDG
+      (Digitale-Dienste-Gesetz, vormals § 5 TMG) vorgeschriebenen Angaben ersetzt werden, bevor
+      dieser Dienst öffentlich betrieben wird.
+    </p>
+    <address>
+      [Name]<br>
+      [Straße, Hausnummer]<br>
+      [PLZ, Ort]<br>
+      [E-Mail-Adresse]
+    </address>
+  </main>
+  <footer><a href="/">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
new file mode 100644
index 0000000..ec2511a
--- /dev/null
+++ b/src/main/resources/eu/mulk/aendggner/web/index.html
@@ -0,0 +1,62 @@
+<!doctype html>
+<html lang="de">
+<head>
+  <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">
+</head>
+<body>
+  <header>
+    <h1>ÄndGgner</h1>
+    <p class="tagline">
+      Änderungsgesetze automatisch auf ein Stammgesetz anwenden und als Synopse darstellen.
+    </p>
+  </header>
+
+  <main>
+    <form method="post" action="/synopse" enctype="multipart/form-data">
+      <div class="field">
+        <label for="stamm">Stammgesetz</label>
+        <input type="file" id="stamm" name="stamm" required>
+        <p class="hint">
+          gii-Norm-XML von gesetze-im-internet.de, oder — für bayerisches Landesrecht — die
+          konsolidierte Fassung von gesetze-bayern.de (PDF oder Klartext).
+        </p>
+      </div>
+
+      <div class="field">
+        <label for="aenderung">Änderungsgesetz(e)</label>
+        <input type="file" id="aenderung" name="aenderung" multiple required>
+        <p class="hint">
+          Ein oder mehrere BGBl-PDFs oder Klartextdateien. Werden bei Mehrfachauswahl in der
+          gewählten Reihenfolge nacheinander angewendet.
+        </p>
+      </div>
+
+      <div class="field checkbox">
+        <label>
+          <input type="checkbox" name="vollstaendig">
+          Unveränderte Vorschriften mit in die Synopse aufnehmen
+        </label>
+      </div>
+
+      <button type="submit">Synopse erzeugen</button>
+    </form>
+
+    <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.
+    </p>
+  </main>
+
+  <footer>
+    <a href="/impressum">Impressum</a> ·
+    <a href="/datenschutz">Datenschutz</a> ·
+    <a href="https://TODO-QUELLCODE-URL-EINTRAGEN">Quellcode (AGPLv3)</a>
+  </footer>
+</body>
+</html>
diff --git a/src/main/resources/eu/mulk/aendggner/web/style.css b/src/main/resources/eu/mulk/aendggner/web/style.css
new file mode 100644
index 0000000..6e2ec70
--- /dev/null
+++ b/src/main/resources/eu/mulk/aendggner/web/style.css
@@ -0,0 +1,114 @@
+:root {
+  color-scheme: light dark;
+  --fg: #1b1b1b;
+  --bg: #fdfdfb;
+  --muted: #5a5a54;
+  --accent: #6b4423;
+  --border: #d8d3c4;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --fg: #e8e6df;
+    --bg: #1c1b18;
+    --muted: #a6a296;
+    --accent: #c99a6c;
+    --border: #3a382f;
+  }
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0 auto;
+  padding: 2rem 1.25rem 3rem;
+  max-width: 40rem;
+  font-family: "Georgia", "Times New Roman", serif;
+  color: var(--fg);
+  background: var(--bg);
+  line-height: 1.5;
+}
+
+header {
+  margin-bottom: 2rem;
+}
+
+h1 {
+  margin: 0 0 0.25rem;
+  font-size: 1.75rem;
+}
+
+.tagline {
+  color: var(--muted);
+  margin: 0;
+}
+
+.field {
+  margin-bottom: 1.25rem;
+}
+
+label {
+  display: block;
+  font-weight: bold;
+  margin-bottom: 0.35rem;
+}
+
+.field.checkbox label {
+  font-weight: normal;
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.hint {
+  margin: 0.35rem 0 0;
+  font-size: 0.875rem;
+  color: var(--muted);
+}
+
+input[type="file"] {
+  width: 100%;
+  padding: 0.4rem;
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  background: transparent;
+  color: inherit;
+}
+
+button {
+  font: inherit;
+  font-weight: bold;
+  padding: 0.6rem 1.5rem;
+  border: 1px solid var(--accent);
+  border-radius: 4px;
+  background: var(--accent);
+  color: var(--bg);
+  cursor: pointer;
+}
+
+button:hover {
+  filter: brightness(1.1);
+}
+
+.disclaimer {
+  margin-top: 2rem;
+  padding: 1rem;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  font-size: 0.875rem;
+  color: var(--muted);
+}
+
+footer {
+  margin-top: 2.5rem;
+  padding-top: 1rem;
+  border-top: 1px solid var(--border);
+  font-size: 0.875rem;
+  color: var(--muted);
+}
+
+footer a {
+  color: var(--accent);
+}