diff options
Diffstat (limited to 'src/main/java')
10 files changed, 155 insertions, 494 deletions
diff --git a/src/main/java/eu/mulk/aendggner/DateiTyp.java b/src/main/java/eu/mulk/aendggner/DateiTyp.java new file mode 100644 index 0000000..2157fc4 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/DateiTyp.java @@ -0,0 +1,48 @@ +package eu.mulk.aendggner; + +import java.nio.charset.StandardCharsets; + +/** + * Die drei Eingabeformate, die ÄndGgner unterscheidet, erkannt an den ersten Bytes. + * + * <p>Ersetzt die frühere Tika-Erkennung: Unterschieden werden muss nur zwischen PDF, gii-XML und + * Klartext, und dafür genügen die Signaturbytes. Das spart eine schwergewichtige Abhängigkeit samt + * ServiceLoader- und XML-Konfiguration — was der Wasm-Übersetzung zugutekommt, für die jede + * dynamisch aufgelöste Abhängigkeit Handarbeit bedeutet. + */ +public enum DateiTyp { + PDF, + XML, + KLARTEXT; + + /** Wie weit hinein nach der Signatur gesucht wird (PDFs tragen gelegentlich Vorspann). */ + private static final int VORSCHAU_BYTES = 1024; + + public static DateiTyp erkenne(byte[] inhalt) { + // ISO-8859-1 bildet jedes Byte auf genau ein Zeichen ab — hier geht es um Signaturen, nicht + // um lesbaren Text, und die Zeichenzählung soll der Byteposition entsprechen. + var vorschau = + new String(inhalt, 0, Math.min(inhalt.length, VORSCHAU_BYTES), StandardCharsets.ISO_8859_1); + + if (vorschau.contains("%PDF-")) { + return PDF; + } + + // BOM und führenden Leerraum überspringen: Das erste bedeutungstragende Zeichen einer + // XML-Datei ist die Deklaration oder das Wurzelelement. + int i = vorschau.startsWith("") ? 3 : 0; + while (i < vorschau.length() && Character.isWhitespace(vorschau.charAt(i))) { + i++; + } + if (i < vorschau.length() && vorschau.charAt(i) == '<') { + return XML; + } + + return KLARTEXT; + } + + /** Für Fehlermeldungen: „PDF“, „XML“, „Klartext“. */ + public String anzeigeName() { + return this == KLARTEXT ? "Klartext" : name(); + } +} diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java index c92ca8c..2427c4e 100644 --- a/src/main/java/eu/mulk/aendggner/Pipeline.java +++ b/src/main/java/eu/mulk/aendggner/Pipeline.java @@ -16,7 +16,6 @@ import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader; import eu.mulk.aendggner.gesetz.land.LandesRechtLoader; import eu.mulk.aendggner.synopse.HtmlRenderer; import eu.mulk.aendggner.synopse.SynopseBuilder; -import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -24,9 +23,9 @@ import java.util.List; /** * Kernpipeline: Stammgesetz laden → Änderungsgesetze parsen und anwenden → Synopse rendern. * - * <p>Wird sowohl von der CLI ({@link AendGgner}) als auch vom Webserver ( {@code - * eu.mulk.aendggner.web.UploadHandler}) verwendet, damit die Anwendungslogik nur an einer Stelle - * existiert. + * <p>Wird sowohl von der Befehlszeile ({@link AendGgner}) als auch von der Browserfassung ({@code + * eu.mulk.aendggner.wasm.BrowserMain}) verwendet, damit die Anwendungslogik nur an einer Stelle + * existiert. Sie kennt kein Dateisystem: Eingaben kommen als {@link Quelle} (Name und Bytes). */ public final class Pipeline { @@ -49,14 +48,14 @@ public final class Pipeline { * — bei einem Entwurf die Änderungsanträge, die ihn geändert haben. Sie gehen in die * Quellenzeile ein, denn die gezeigte Fassung ist ohne sie nicht nachvollziehbar. */ - record Quelldokument(Path datei, DokumentKopf kopf, String text, List<String> eingearbeitet) { + record Quelldokument(Quelle quelle, DokumentKopf kopf, String text, List<String> eingearbeitet) { - Quelldokument(Path datei, DokumentKopf kopf, String text) { - this(datei, kopf, text, List.of()); + Quelldokument(Quelle quelle, DokumentKopf kopf, String text) { + this(quelle, kopf, text, List.of()); } String quellenAngabe(List<String> artikel) { - var sb = new StringBuilder(datei.getFileName().toString()); + var sb = new StringBuilder(quelle.name()); sb.append(" [").append(kopf.anzeigeName()).append("]"); for (var zusatz : eingearbeitet) { sb.append(" + ").append(zusatz); @@ -65,8 +64,19 @@ public final class Pipeline { } } + /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */ public static Ergebnis erzeugeSynopse( Path baseFile, List<Path> patches, String artikel, boolean vollstaendig) throws Exception { + var patchQuellen = new ArrayList<Quelle>(); + for (var patch : patches) { + patchQuellen.add(Quelle.lies(patch)); + } + return erzeugeSynopse(Quelle.lies(baseFile), patchQuellen, artikel, vollstaendig); + } + + public static Ergebnis erzeugeSynopse( + Quelle baseFile, List<Quelle> patches, String artikel, boolean vollstaendig) + throws Exception { var altesGesetz = ladeStammgesetz(baseFile); var extraktor = new PatchTextExtraktor(superskriptModus(altesGesetz)); var parser = new AenderungsgesetzParser(); @@ -85,9 +95,7 @@ public final class Pipeline { warnungen.add( "In %s (%s) wurde kein auf %s anwendbarer Artikel gefunden." .formatted( - dokument.datei().getFileName(), - dokument.kopf().anzeigeName(), - gesetz.jurabk())); + dokument.quelle().name(), dokument.kopf().anzeigeName(), gesetz.jurabk())); } var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); gesetz = anwendung.neu(); @@ -99,8 +107,8 @@ public final class Pipeline { var gesamtErgebnis = new BefehlAnwender.AnwendungsErgebnis(gesetz, protokoll); var synopse = SynopseBuilder.baue(altesGesetz, gesamtErgebnis, warnungen, vollstaendig); - var quelle = baseFile.getFileName() + " + " + String.join(" + ", quellen); - var html = HtmlRenderer.rendere(synopse, quelle, entwurfsfassung); + var quellenZeile = baseFile.name() + " + " + String.join(" + ", quellen); + var html = HtmlRenderer.rendere(synopse, quellenZeile, entwurfsfassung); return new Ergebnis( html, @@ -116,18 +124,18 @@ public final class Pipeline { * hinterlässt eine Warnung, die in der Synopse erscheint. */ private static List<Quelldokument> leseDokumente( - List<Path> patches, PatchTextExtraktor extraktor, List<String> warnungen) throws Exception { + List<Quelle> patches, PatchTextExtraktor extraktor, List<String> warnungen) throws Exception { var dokumente = new ArrayList<Quelldokument>(); for (var datei : patches) { var rohText = extraktor.extrahiere(datei); // Die Erkennung arbeitet auf dem Rohtext: Der Bereiniger entfernt genau die // Drucksachenköpfe, aus denen Art und Nummer hervorgehen. var kopf = DokumentErkenner.erkenne(rohText); - log.infof("Datei %s erkannt als %s.", datei, kopf.anzeigeName()); + log.infof("Datei %s erkannt als %s.", datei.name(), kopf.anzeigeName()); if (kopf.art() == DokumentArt.OHNE_BEFEHLE) { warnungen.add( "%s ist ein %s und enthält keine Änderungsbefehle; die Datei wurde übergangen." - .formatted(datei.getFileName(), kopf.art().anzeigeName())); + .formatted(datei.name(), kopf.art().anzeigeName())); continue; } if (kopf.art() == DokumentArt.BESCHLUSSEMPFEHLUNG) { @@ -141,7 +149,7 @@ public final class Pipeline { + " übergangen. Für eine Synopse eignet sich der zugrunde liegende" + " Gesetzentwurf%s.") .formatted( - datei.getFileName(), + datei.name(), kopf.bezugsDrucksachen().isEmpty() ? "" : " (Drs. " + kopf.bezugsDrucksachen().get(0) + ")")); @@ -179,7 +187,7 @@ public final class Pipeline { ("%s ist ein Änderungsantrag zu %s; der zugehörige Gesetzentwurf wurde nicht" + " mitgegeben, der Antrag blieb daher unberücksichtigt.") .formatted( - antrag.datei().getFileName(), + antrag.quelle().name(), antrag.kopf().bezugsDrucksachen().isEmpty() ? "einer Drucksache" : "Drs. " + String.join(", ", antrag.kopf().bezugsDrucksachen()))); @@ -192,15 +200,15 @@ public final class Pipeline { warnungen.addAll(patch.warnungen()); log.infof( "%s: %d von %d Antragsbefehlen auf %s angewandt.", - antrag.datei().getFileName(), + antrag.quelle().name(), patch.angewandt(), parseErgebnis.befehle().size(), - ziel.datei().getFileName()); + ziel.quelle().name()); var eingearbeitet = new ArrayList<>(ziel.eingearbeitet()); - eingearbeitet.add(antrag.datei().getFileName() + " [" + antrag.kopf().anzeigeName() + "]"); + eingearbeitet.add(antrag.quelle().name() + " [" + antrag.kopf().anzeigeName() + "]"); ergebnis.set( zielIndex, - new Quelldokument(ziel.datei(), ziel.kopf(), patch.text(), List.copyOf(eingearbeitet))); + new Quelldokument(ziel.quelle(), ziel.kopf(), patch.text(), List.copyOf(eingearbeitet))); } return ergebnis; } @@ -234,9 +242,14 @@ public final class Pipeline { return dokument.kopf().art().istEntwurfsfassung(); } - /** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}. */ + /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */ static Gesetz ladeStammgesetz(Path baseFile) throws Exception { - return istGiiXml(baseFile) + return ladeStammgesetz(Quelle.lies(baseFile)); + } + + /** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}. */ + static Gesetz ladeStammgesetz(Quelle baseFile) throws Exception { + return DateiTyp.erkenne(baseFile.inhalt()) == DateiTyp.XML ? new GiiXmlLoader().load(baseFile) : new LandesRechtLoader().load(baseFile); } @@ -258,9 +271,4 @@ public final class Pipeline { } return SuperskriptModus.ENTFERNEN; } - - static boolean istGiiXml(Path baseFile) throws IOException { - var mimeType = new org.apache.tika.Tika().detect(baseFile); - return mimeType.equals("application/xml") || mimeType.equals("text/xml"); - } } diff --git a/src/main/java/eu/mulk/aendggner/Quelle.java b/src/main/java/eu/mulk/aendggner/Quelle.java new file mode 100644 index 0000000..d81647b --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/Quelle.java @@ -0,0 +1,24 @@ +package eu.mulk.aendggner; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Ein Eingabedokument als Name und Inhalt — die Form, in der die Pipeline Dateien entgegennimmt. + * + * <p>Die Pipeline kennt bewusst kein Dateisystem: Auf der Befehlszeile kommen die Bytes aus einer + * Datei ({@link #lies(Path)}), im Browser aus einem Datei-Upload, den JavaScript übergibt. Der + * {@code name} dient allein der Anzeige (Quellenzeile der Synopse, Warnungen) und trägt deshalb + * genau das, was auf der Befehlszeile {@code Path.getFileName()} liefern würde. + * + * <p>{@code equals}/{@code hashCode} sind für {@code byte[]} identitätsbasiert; auf Gleichheit von + * {@code Quelle}n verlässt sich niemand. + */ +public record Quelle(String name, byte[] inhalt) { + + /** Liest eine Datei vollständig ein. Nur für JVM-Aufrufer (Befehlszeile, Tests). */ + public static Quelle lies(Path datei) throws IOException { + return new Quelle(datei.getFileName().toString(), Files.readAllBytes(datei)); + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java index e519e2e..e756126 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java @@ -1,11 +1,11 @@ package eu.mulk.aendggner.aenderung.parse; +import eu.mulk.aendggner.DateiTyp; +import eu.mulk.aendggner.Quelle; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.nio.file.Path; import org.apache.pdfbox.Loader; -import org.apache.tika.Tika; import org.jboss.logging.Logger; /** @@ -20,7 +20,6 @@ public final class PatchTextExtraktor { private static final Logger log = Logger.getLogger(PatchTextExtraktor.class); - private final Tika tika = new Tika(); private final SuperskriptModus superskriptModus; public PatchTextExtraktor() { @@ -31,22 +30,27 @@ public final class PatchTextExtraktor { this.superskriptModus = superskriptModus; } + /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */ public String extrahiere(Path datei) throws IOException { - var mimeType = tika.detect(datei); - log.infof("Datei %s hat Typ %s.", datei, mimeType); + return extrahiere(Quelle.lies(datei)); + } + + public String extrahiere(Quelle quelle) throws IOException { + var typ = DateiTyp.erkenne(quelle.inhalt()); + log.infof("Datei %s hat Typ %s.", quelle.name(), typ.anzeigeName()); - return switch (mimeType) { - case "application/pdf" -> extrahierePdf(datei); - case "text/plain" -> Files.readString(datei, StandardCharsets.UTF_8); - default -> + return switch (typ) { + case PDF -> extrahierePdf(quelle.inhalt()); + case KLARTEXT -> new String(quelle.inhalt(), StandardCharsets.UTF_8); + case XML -> throw new IOException( "Nicht unterstützter Dateityp %s für %s (unterstützt: PDF, Klartext)" - .formatted(mimeType, datei)); + .formatted(typ.anzeigeName(), quelle.name())); }; } - private String extrahierePdf(Path datei) throws IOException { - try (var dokument = Loader.loadPDF(datei.toFile())) { + private String extrahierePdf(byte[] inhalt) throws IOException { + try (var dokument = Loader.loadPDF(inhalt)) { return FontgroessenFilter.extrahiere(dokument, superskriptModus); } } @@ -61,7 +65,11 @@ public final class PatchTextExtraktor { * @return links = Entwurfsspalte, rechts = Ausschussspalte. */ public Spalten extrahiereSpalten(Path datei) throws IOException { - try (var dokument = Loader.loadPDF(datei.toFile())) { + return extrahiereSpalten(Quelle.lies(datei)); + } + + public Spalten extrahiereSpalten(Quelle quelle) throws IOException { + try (var dokument = Loader.loadPDF(quelle.inhalt())) { return new Spalten( FontgroessenFilter.extrahiere( dokument, superskriptModus, FontgroessenFilter.Spalte.LINKS), diff --git a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java index 6f5b55a..c6dea69 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java @@ -1,9 +1,11 @@ package eu.mulk.aendggner.gesetz.gii; +import eu.mulk.aendggner.Quelle; import eu.mulk.aendggner.gesetz.Absatz; import eu.mulk.aendggner.gesetz.Gesetz; import eu.mulk.aendggner.gesetz.Gliederung; import eu.mulk.aendggner.gesetz.Norm; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.StringReader; import java.nio.file.Path; @@ -30,9 +32,14 @@ public final class GiiXmlLoader { private static final Pattern ABSATZ_MARKER = Pattern.compile("^\\((\\d+[a-z]?)\\)\\s+", Pattern.UNICODE_CASE); + /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */ public Gesetz load(Path datei) throws IOException, SAXException { + return load(Quelle.lies(datei)); + } + + public Gesetz load(Quelle quelle) throws IOException, SAXException { var builder = neuerDocumentBuilder(); - var dokument = builder.parse(datei.toFile()); + var dokument = builder.parse(new ByteArrayInputStream(quelle.inhalt())); var wurzel = dokument.getDocumentElement(); String jurabk = null; @@ -80,7 +87,7 @@ public final class GiiXmlLoader { } if (jurabk == null) { - throw new SAXException("Keine <norm>-Elemente mit Metadaten gefunden: " + datei); + throw new SAXException("Keine <norm>-Elemente mit Metadaten gefunden: " + quelle.name()); } return new Gesetz(jurabk, langue, kurzue, normen, gliederungen); } diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java index c64ca21..22c1e41 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java @@ -1,15 +1,15 @@ package eu.mulk.aendggner.gesetz.land; +import eu.mulk.aendggner.DateiTyp; +import eu.mulk.aendggner.Quelle; import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor; import eu.mulk.aendggner.aenderung.parse.SuperskriptModus; import eu.mulk.aendggner.aenderung.parse.TextBereiniger; import eu.mulk.aendggner.gesetz.Gesetz; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.nio.file.Path; import java.text.Normalizer; -import org.apache.tika.Tika; import org.jboss.logging.Logger; /** @@ -29,27 +29,30 @@ public final class LandesRechtLoader { private static final Logger log = Logger.getLogger(LandesRechtLoader.class); - private final Tika tika = new Tika(); - + /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */ public Gesetz load(Path datei) throws IOException { - var mimeType = tika.detect(datei); - log.infof("Stammgesetz %s hat Typ %s.", datei, mimeType); + return load(Quelle.lies(datei)); + } + + public Gesetz load(Quelle quelle) throws IOException { + var typ = DateiTyp.erkenne(quelle.inhalt()); + log.infof("Stammgesetz %s hat Typ %s.", quelle.name(), typ.anzeigeName()); var text = - switch (mimeType) { - case "application/pdf" -> + switch (typ) { + case PDF -> nachSatzendeGetrennteNormkoepfe( TextBereiniger.bereinige( - new PatchTextExtraktor(SuperskriptModus.BEHALTEN).extrahiere(datei))); + new PatchTextExtraktor(SuperskriptModus.BEHALTEN).extrahiere(quelle))); // Auch der handgepflegte Klartext wird kanonisch zusammengesetzt (NFC), damit Stammtext // und Befehlstext gleich kodiert sind — der PDF-Zweig erledigt das über bereinige(). - case "text/plain" -> + case KLARTEXT -> Normalizer.normalize( - Files.readString(datei, StandardCharsets.UTF_8), Normalizer.Form.NFC); - default -> + new String(quelle.inhalt(), StandardCharsets.UTF_8), Normalizer.Form.NFC); + case XML -> throw new IOException( "Nicht unterstützter Dateityp %s für Stammgesetz %s (unterstützt: PDF, Klartext)" - .formatted(mimeType, datei)); + .formatted(typ.anzeigeName(), quelle.name())); }; return LandesRechtTextParser.parse(text); } diff --git a/src/main/java/eu/mulk/aendggner/web/Multipart.java b/src/main/java/eu/mulk/aendggner/web/Multipart.java deleted file mode 100644 index 745443b..0000000 --- a/src/main/java/eu/mulk/aendggner/web/Multipart.java +++ /dev/null @@ -1,93 +0,0 @@ -package eu.mulk.aendggner.web; - -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.regex.Pattern; - -/** - * Minimaler {@code multipart/form-data}-Parser für Datei- und Textfelder — bewusst ohne externe - * Abhängigkeit (siehe Web-App-Plan: nur JDK-Bordmittel). - */ -final class Multipart { - - record Part(String name, String filename, byte[] data) {} - - private static final Pattern BOUNDARY_PATTERN = Pattern.compile("boundary=\"?([^\";]+)\"?"); - private static final Pattern NAME_PATTERN = Pattern.compile("name=\"([^\"]*)\""); - private static final Pattern FILENAME_PATTERN = Pattern.compile("filename=\"([^\"]*)\""); - private static final byte[] HEADER_BODY_SEPARATOR = - "\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1); - - private Multipart() {} - - static String extraktBoundary(String contentType) { - if (contentType == null) { - return null; - } - var matcher = BOUNDARY_PATTERN.matcher(contentType); - return matcher.find() ? matcher.group(1) : null; - } - - static List<Part> parse(byte[] body, String boundary) { - var delimiter = ("--" + boundary).getBytes(StandardCharsets.ISO_8859_1); - - var positions = new ArrayList<Integer>(); - for (int i = indexOf(body, delimiter, 0); - i >= 0; - i = indexOf(body, delimiter, i + delimiter.length)) { - positions.add(i); - } - - var parts = new ArrayList<Part>(); - for (int i = 0; i < positions.size() - 1; i++) { - int start = positions.get(i) + delimiter.length; - int end = positions.get(i + 1); - - if (start + 1 < end && body[start] == '\r' && body[start + 1] == '\n') { - start += 2; - } - int contentEnd = end; - if (contentEnd >= start + 2 && body[contentEnd - 2] == '\r' && body[contentEnd - 1] == '\n') { - contentEnd -= 2; - } - - int headerEnd = indexOf(body, HEADER_BODY_SEPARATOR, start); - if (headerEnd < 0 || headerEnd > contentEnd) { - continue; - } - var headerText = new String(body, start, headerEnd - start, StandardCharsets.UTF_8); - var dataStart = headerEnd + HEADER_BODY_SEPARATOR.length; - - var nameMatcher = NAME_PATTERN.matcher(headerText); - if (!nameMatcher.find()) { - continue; - } - - String filename = null; - var filenameMatcher = FILENAME_PATTERN.matcher(headerText); - if (filenameMatcher.find()) { - filename = filenameMatcher.group(1); - } - - var data = Arrays.copyOfRange(body, dataStart, Math.max(dataStart, contentEnd)); - parts.add(new Part(nameMatcher.group(1), filename, data)); - } - - return parts; - } - - private static int indexOf(byte[] haystack, byte[] needle, int fromIndex) { - outer: - for (int i = fromIndex; i <= haystack.length - needle.length; i++) { - for (int j = 0; j < needle.length; j++) { - if (haystack[i + j] != needle[j]) { - continue outer; - } - } - return i; - } - return -1; - } -} diff --git a/src/main/java/eu/mulk/aendggner/web/StaticHandler.java b/src/main/java/eu/mulk/aendggner/web/StaticHandler.java deleted file mode 100644 index 24a6e41..0000000 --- a/src/main/java/eu/mulk/aendggner/web/StaticHandler.java +++ /dev/null @@ -1,63 +0,0 @@ -package eu.mulk.aendggner.web; - -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; - -/** - * Liefert eine einzelne, beim Start einmal geladene statische Ressource aus (Formular, Impressum, - * CSS, …). - */ -final class StaticHandler implements HttpHandler { - - private final byte[] content; - private final String contentType; - - StaticHandler(String resourcePath, String contentType) { - try (InputStream in = StaticHandler.class.getResourceAsStream(resourcePath)) { - if (in == null) { - throw new IllegalStateException("Ressource nicht gefunden: " + resourcePath); - } - this.content = in.readAllBytes(); - } catch (IOException e) { - throw new UncheckedIOException("Ressource konnte nicht geladen werden: " + resourcePath, e); - } - this.contentType = contentType; - } - - @Override - public void handle(HttpExchange exchange) throws IOException { - try { - if (!"GET".equals(exchange.getRequestMethod())) { - sendText(exchange, 405, "Methode nicht erlaubt."); - return; - } - // com.sun.net.httpserver routet nach dem längsten passenden Präfix; ohne diesen Vergleich - // würde z. B. "/irgendwas" auf den Kontext "/" fallen und fälschlich die Startseite liefern. - if (!exchange.getRequestURI().getPath().equals(exchange.getHttpContext().getPath())) { - sendText(exchange, 404, "Nicht gefunden."); - return; - } - exchange.getResponseHeaders().set("Content-Type", contentType); - exchange.sendResponseHeaders(200, content.length); - try (var os = exchange.getResponseBody()) { - os.write(content); - } - } finally { - exchange.close(); - } - } - - private static void sendText(HttpExchange exchange, int status, String message) - throws IOException { - var body = message.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); - exchange.sendResponseHeaders(status, body.length); - try (var os = exchange.getResponseBody()) { - os.write(body); - } - } -} diff --git a/src/main/java/eu/mulk/aendggner/web/UploadHandler.java b/src/main/java/eu/mulk/aendggner/web/UploadHandler.java deleted file mode 100644 index efcb512..0000000 --- a/src/main/java/eu/mulk/aendggner/web/UploadHandler.java +++ /dev/null @@ -1,232 +0,0 @@ -package eu.mulk.aendggner.web; - -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import eu.mulk.aendggner.Pipeline; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -/** - * {@code POST /synopse} — nimmt Stammgesetz- und Änderungsgesetz-Datei(en) per {@code - * multipart/form-data} entgegen, ruft {@link Pipeline#erzeugeSynopse} auf und liefert das erzeugte - * HTML zurück. - * - * <p>Hochgeladene Dateien landen ausschließlich als temporäre Dateien für die Dauer der Anfrage und - * werden danach in jedem Fall gelöscht — es wird nichts dauerhaft gespeichert. Die eigentliche - * Verarbeitung läuft auf einem auf die Kernzahl begrenzten Thread-Pool mit fester Warteschlange; - * bei Überlast wird sofort mit {@code 503} abgelehnt, statt unbegrenzt Arbeit anzunehmen. - */ -final class UploadHandler implements HttpHandler { - - private static final long MAX_PART_BYTES = 15L * 1024 * 1024; // 15 MB je Datei - private static final long MAX_BODY_BYTES = - 40L * 1024 * 1024; // Sicherheitsnetz für die gesamte Anfrage - private static final long TIMEOUT_SECONDS = 30; - - private final ExecutorService pipelinePool; - - UploadHandler() { - var poolSize = Math.max(1, Runtime.getRuntime().availableProcessors()); - this.pipelinePool = - new ThreadPoolExecutor( - poolSize, - poolSize, - 0L, - TimeUnit.MILLISECONDS, - new ArrayBlockingQueue<>(poolSize), - new ThreadPoolExecutor.AbortPolicy()); - } - - @Override - public void handle(HttpExchange exchange) throws IOException { - var tempDirs = new ArrayList<Path>(); - try { - handleInternal(exchange, tempDirs); - } catch (Throwable e) { - // Ohne diesen Fang schließt com.sun.net.httpserver die Verbindung bei einer - // unerwarteten Exception kommentarlos ("Empty reply from server" beim Client). - e.printStackTrace(); - sendText(exchange, 500, "Unerwarteter Fehler bei der Verarbeitung."); - } finally { - for (var tempDir : tempDirs) { - deleteRecursively(tempDir); - } - exchange.close(); - } - } - - private static void deleteRecursively(Path dir) { - try (var files = Files.walk(dir)) { - files - .sorted(Comparator.reverseOrder()) - .forEach( - path -> { - try { - Files.deleteIfExists(path); - } catch (IOException ignored) { - // Aufräumen ist best effort; ein verwaistes Temp-File blockiert die Antwort - // nicht. - } - }); - } catch (IOException ignored) { - // Verzeichnis existiert eventuell schon nicht mehr (z. B. bei frühem Abbruch) — egal. - } - } - - private void handleInternal(HttpExchange exchange, ArrayList<Path> tempDirs) throws IOException { - if (!"POST".equals(exchange.getRequestMethod())) { - sendText(exchange, 405, "Methode nicht erlaubt."); - return; - } - - var boundary = Multipart.extraktBoundary(exchange.getRequestHeaders().getFirst("Content-Type")); - if (boundary == null) { - sendText(exchange, 400, "Ungültige Anfrage: multipart/form-data mit boundary erwartet."); - return; - } - - byte[] body; - try { - body = readLimited(exchange.getRequestBody(), MAX_BODY_BYTES); - } catch (PayloadTooLargeException e) { - sendText( - exchange, - 413, - "Die Anfrage ist zu groß (Limit: " + (MAX_BODY_BYTES / 1024 / 1024) + " MB insgesamt)."); - return; - } - - var parts = Multipart.parse(body, boundary); - - Path stammFile = null; - var aenderungFiles = new ArrayList<Path>(); - var vollstaendig = false; - - for (var part : parts) { - if ("vollstaendig".equals(part.name())) { - var value = new String(part.data(), StandardCharsets.UTF_8).trim(); - vollstaendig = !value.isEmpty() && !value.equals("off") && !value.equals("false"); - continue; - } - if (part.filename() == null || part.filename().isBlank() || part.data().length == 0) { - continue; - } - if (part.data().length > MAX_PART_BYTES) { - sendText( - exchange, - 413, - "Die Datei „" - + part.filename() - + "“ ist zu groß (Limit: " - + (MAX_PART_BYTES / 1024 / 1024) - + " MB)."); - return; - } - - // Jede Datei bekommt ein eigenes Temp-Verzeichnis, damit sie unter ihrem ursprünglichen - // Namen abgelegt werden kann (erscheint so in der "Quelle"-Zeile der Synopse) und - // gleichnamige Uploads sich nicht überschreiben. - var tempDir = Files.createTempDirectory("aendggner-"); - tempDirs.add(tempDir); - var tempFile = tempDir.resolve(sanitize(part.filename())); - Files.write(tempFile, part.data()); - - if ("stamm".equals(part.name())) { - stammFile = tempFile; - } else if ("aenderung".equals(part.name())) { - aenderungFiles.add(tempFile); - } - } - - if (stammFile == null) { - sendText(exchange, 400, "Bitte ein Stammgesetz hochladen."); - return; - } - if (aenderungFiles.isEmpty()) { - sendText(exchange, 400, "Bitte mindestens ein Änderungsgesetz hochladen."); - return; - } - - var finalStammFile = stammFile; - var finalVollstaendig = vollstaendig; - Callable<Pipeline.Ergebnis> job = - () -> Pipeline.erzeugeSynopse(finalStammFile, aenderungFiles, null, finalVollstaendig); - - Pipeline.Ergebnis ergebnis; - try { - var future = pipelinePool.submit(job); - ergebnis = future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); - } catch (RejectedExecutionException e) { - sendText( - exchange, 503, "Der Dienst ist gerade ausgelastet. Bitte in Kürze erneut versuchen."); - return; - } catch (TimeoutException e) { - sendText(exchange, 504, "Die Verarbeitung hat zu lange gedauert und wurde abgebrochen."); - return; - } catch (ExecutionException e) { - var cause = e.getCause(); - var message = cause != null ? cause.getMessage() : e.getMessage(); - sendText( - exchange, - 422, - "Verarbeitung fehlgeschlagen: " + (message == null ? "unbekannter Fehler." : message)); - return; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - sendText(exchange, 500, "Anfrage wurde unterbrochen."); - return; - } - - var responseBody = ergebnis.html().getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); - exchange.sendResponseHeaders(200, responseBody.length); - try (var os = exchange.getResponseBody()) { - os.write(responseBody); - } - } - - private static byte[] readLimited(InputStream in, long limit) throws IOException { - var buffer = new ByteArrayOutputStream(); - var chunk = new byte[8192]; - long total = 0; - int read; - while ((read = in.read(chunk)) != -1) { - total += read; - if (total > limit) { - throw new PayloadTooLargeException(); - } - buffer.write(chunk, 0, read); - } - return buffer.toByteArray(); - } - - private static String sanitize(String filename) { - return filename.replaceAll("[^A-Za-z0-9._-]", "_"); - } - - private static void sendText(HttpExchange exchange, int status, String message) - throws IOException { - var body = message.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); - exchange.sendResponseHeaders(status, body.length); - try (var os = exchange.getResponseBody()) { - os.write(body); - } - } - - private static final class PayloadTooLargeException extends IOException {} -} diff --git a/src/main/java/eu/mulk/aendggner/web/WebMain.java b/src/main/java/eu/mulk/aendggner/web/WebMain.java deleted file mode 100644 index 815f0db..0000000 --- a/src/main/java/eu/mulk/aendggner/web/WebMain.java +++ /dev/null @@ -1,49 +0,0 @@ -package eu.mulk.aendggner.web; - -import com.sun.net.httpserver.HttpServer; -import java.io.IOException; -import java.net.InetSocketAddress; -import java.util.concurrent.Executors; - -/** - * Eigenständiger Einstiegspunkt für den ÄndGgner-Webserver. - * - * <p>Nutzt ausschließlich JDK-Bordmittel ({@link HttpServer}) — keine zusätzliche - * Web-Framework-Abhängigkeit. TLS-Terminierung und Rate-Limiting übernimmt ein vorgeschalteter - * Reverse Proxy (siehe {@code deploy/nginx-aendggner.conf}); dieser Prozess bindet standardmäßig - * nur an {@code localhost}. - */ -public final class WebMain { - - private WebMain() {} - - public static void main(String... args) throws IOException { - var port = Integer.parseInt(envOr("AENDGGNER_WEB_PORT", "8080")); - var bindAddress = envOr("AENDGGNER_WEB_BIND", "127.0.0.1"); - - var server = HttpServer.create(new InetSocketAddress(bindAddress, port), 0); - server.setExecutor( - Executors.newFixedThreadPool(Math.max(4, Runtime.getRuntime().availableProcessors() * 2))); - - server.createContext( - "/", new StaticHandler("/eu/mulk/aendggner/web/index.html", "text/html; charset=utf-8")); - server.createContext( - "/impressum", - new StaticHandler("/eu/mulk/aendggner/web/impressum.html", "text/html; charset=utf-8")); - server.createContext( - "/datenschutz", - new StaticHandler("/eu/mulk/aendggner/web/datenschutz.html", "text/html; charset=utf-8")); - server.createContext( - "/style.css", - new StaticHandler("/eu/mulk/aendggner/web/style.css", "text/css; charset=utf-8")); - server.createContext("/synopse", new UploadHandler()); - - server.start(); - System.out.printf("ÄndGgner-Webserver läuft auf http://%s:%d/%n", bindAddress, port); - } - - private static String envOr(String name, String fallback) { - var value = System.getenv(name); - return value == null || value.isBlank() ? fallback : value; - } -} |
