From 7b455cd43149834ccd2163b4e5d197809bd34a37 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Sun, 9 Aug 2026 08:33:37 +0200 Subject: 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 --- src/main/java/eu/mulk/aendggner/AendGgner.java | 87 +------- src/main/java/eu/mulk/aendggner/Pipeline.java | 104 ++++++++++ src/main/java/eu/mulk/aendggner/web/Multipart.java | 90 ++++++++ .../java/eu/mulk/aendggner/web/StaticHandler.java | 59 ++++++ .../java/eu/mulk/aendggner/web/UploadHandler.java | 228 +++++++++++++++++++++ src/main/java/eu/mulk/aendggner/web/WebMain.java | 48 +++++ 6 files changed, 539 insertions(+), 77 deletions(-) create mode 100644 src/main/java/eu/mulk/aendggner/Pipeline.java create mode 100644 src/main/java/eu/mulk/aendggner/web/Multipart.java create mode 100644 src/main/java/eu/mulk/aendggner/web/StaticHandler.java create mode 100644 src/main/java/eu/mulk/aendggner/web/UploadHandler.java create mode 100644 src/main/java/eu/mulk/aendggner/web/WebMain.java (limited to 'src/main/java') 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 { 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 { } 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 { } 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 { 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(); - var warnungen = new ArrayList(); - var quellen = new ArrayList(); - - 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. + * + *

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 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(); + var warnungen = new ArrayList(); + var quellen = new ArrayList(); + + 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 parse(byte[] body, String boundary) { + var delimiter = ("--" + boundary).getBytes(StandardCharsets.ISO_8859_1); + + var positions = new ArrayList(); + for (int i = indexOf(body, delimiter, 0); i >= 0; i = indexOf(body, delimiter, i + delimiter.length)) { + positions.add(i); + } + + var parts = new ArrayList(); + 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. + * + *

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(); + 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 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(); + 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 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. + * + *

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; + } +} -- cgit v1.2.1