MIME_TYPES = new HashMap<>();
+
+ static {
+ // Text types
+ MIME_TYPES.put("html", "text/html");
+ MIME_TYPES.put("htm", "text/html");
+ MIME_TYPES.put("css", "text/css");
+ MIME_TYPES.put("js", "application/javascript");
+ MIME_TYPES.put("json", "application/json");
+ MIME_TYPES.put("xml", "application/xml");
+ MIME_TYPES.put("txt", "text/plain");
+
+ // Image types
+ MIME_TYPES.put("png", "image/png");
+ MIME_TYPES.put("jpg", "image/jpeg");
+ MIME_TYPES.put("jpeg", "image/jpeg");
+ MIME_TYPES.put("gif", "image/gif");
+ MIME_TYPES.put("svg", "image/svg+xml");
+ MIME_TYPES.put("ico", "image/x-icon");
+ MIME_TYPES.put("webp", "image/webp");
+
+ // Font types
+ MIME_TYPES.put("woff", "font/woff");
+ MIME_TYPES.put("woff2", "font/woff2");
+ MIME_TYPES.put("ttf", "font/ttf");
+ MIME_TYPES.put("otf", "font/otf");
+
+ // Other common types
+ MIME_TYPES.put("pdf", "application/pdf");
+ MIME_TYPES.put("zip", "application/zip");
+ }
+
+ private MimeTypeResolver() {
+ // Utility class - prevent instantiation
+ }
+
+ /**
+ * Gets the MIME type for a given filename.
+ *
+ * @param filename the name of the file (e.g., "index.html", "styles.css")
+ * @return the MIME type (e.g., "text/html", "text/css")
+ * or "application/octet-stream" if unknown
+ */
+ public static String getMimeType(@Nullable String filename) {
+ if (filename == null || filename.isEmpty()) {
+ return "application/octet-stream";
+ }
+
+ int lastDot = filename.lastIndexOf('.');
+ if (lastDot == -1 || lastDot == filename.length() - 1) {
+ // No extension or dot is last character
+ return "application/octet-stream";
+ }
+
+ String extension = filename.substring(lastDot + 1).toLowerCase();
+ return MIME_TYPES.getOrDefault(extension, "application/octet-stream");
+ }
+}
diff --git a/src/main/java/org/juv25d/handler/StaticFileHandler.java b/src/main/java/org/juv25d/handler/StaticFileHandler.java
new file mode 100644
index 00000000..e656e82b
--- /dev/null
+++ b/src/main/java/org/juv25d/handler/StaticFileHandler.java
@@ -0,0 +1,292 @@
+package org.juv25d.handler;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+import org.juv25d.logging.ServerLogging;
+
+import org.jspecify.annotations.Nullable;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Handles serving static files from the /resources/static/ directory.
+ *
+ * URL mapping:
+ * - GET / β /resources/static/index.html
+ * - GET /about.html β /resources/static/about.html
+ * - GET /css/styles.css β /resources/static/css/styles.css
+ *
+ * Security: Validates paths to prevent directory traversal attacks.
+ */
+public class StaticFileHandler {
+
+ private static final Logger logger = ServerLogging.getLogger();
+ private static final String STATIC_DIR = "/static/";
+
+ // Cache static files for 5 seconds, short max_age so that changes appear quickly and is easier to develop around
+ private static final int MAX_AGE_SECONDS = 5;
+
+ private StaticFileHandler() {
+ // Utility class - prevent instantiation
+ }
+
+ /**
+ * Handles a static file request.
+ *
+ * @param request the HTTP request
+ * @return an HTTP response with the file content or an error response
+ */
+ public static HttpResponse handle(HttpRequest request) {
+ String path = request.path();
+
+ // Only handle GET requests
+ if (!request.method().equalsIgnoreCase("GET")) {
+ return createErrorResponse(405, "Method Not Allowed");
+ }
+
+ // Validate path for security
+ if (!isPathSafe(path)) {
+ logger.warning("Security violation: Attempted path traversal with path: " + path);
+ return createErrorResponse(403, "Forbidden");
+ }
+
+ // Map URL path to resource path
+ String resourcePath = mapToResourcePath(path);
+
+ logger.info("Attempting to serve static file: " + resourcePath);
+
+ // Try to load and serve the file
+ try {
+ byte[] fileContent = loadResource(resourcePath);
+ String mimeType = MimeTypeResolver.getMimeType(resourcePath);
+
+ // Add charset for text-based content types
+ if (mimeType.startsWith("text/") ||
+ mimeType.contains("javascript") ||
+ mimeType.contains("json")) {
+ mimeType += "; charset=utf-8";
+ }
+
+ String etag = computeStrongEtag(fileContent);
+
+ String ifNoneMatch = getHeaderIgnoreCase(request.headers(), "If-None-Match");
+ if (etagMatches(ifNoneMatch, etag)) {
+ Map headers = new HashMap<>();
+ headers.put("ETag", etag);
+ headers.put("Cache-Control", "public, max-age=" + MAX_AGE_SECONDS);
+
+ logger.info("ETag match for " + resourcePath + " -> 304 Not Modified");
+ return new HttpResponse(304, "Not Modified", headers, new byte[0]);
+ }
+
+ Map headers = new HashMap<>();
+ headers.put("Content-Type", mimeType);
+ headers.put("ETag", etag);
+ headers.put("Cache-Control", "public, max-age=" + MAX_AGE_SECONDS);
+
+ logger.info("Successfully served: " + resourcePath + " (" + mimeType + ")");
+ return new HttpResponse(200, "OK", headers, fileContent);
+
+ } catch (IOException e) {
+ logger.log(Level.WARNING, "File not found: " + resourcePath);
+ return createNotFoundResponse(path);
+ }
+ }
+
+ /**
+ * Validates that the path is safe and doesn't contain directory traversal attempts.
+ *
+ * @param path the requested path
+ * @return true if the path is safe, false otherwise
+ */
+ private static boolean isPathSafe(String path) {
+ if (path == null || path.isEmpty()) {
+ return false;
+ }
+
+ // Reject paths with directory traversal attempts
+ if (path.contains("..") || path.contains("//") || path.contains("\\")) {
+ return false;
+ }
+
+ // Reject absolute paths (should start with /)
+ if (!path.startsWith("/")) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Maps a URL path to a resource path in /resources/static/.
+ *
+ * Examples:
+ * - "/" β "/static/index.html"
+ * - "/about.html" β "/static/about.html"
+ * - "/css/styles.css" β "/static/css/styles.css"
+ *
+ * @param urlPath the URL path from the request
+ * @return the resource path
+ */
+ private static String mapToResourcePath(String urlPath) {
+ // Handle root path - serve index.html
+ if (urlPath.equals("/")) {
+ return STATIC_DIR + "index.html";
+ }
+
+ // Remove leading slash and prepend /static/
+ String cleanPath = urlPath.startsWith("/") ? urlPath.substring(1) : urlPath;
+ return STATIC_DIR + cleanPath;
+ }
+
+ /**
+ * Loads a resource from the classpath.
+ *
+ * @param resourcePath the path to the resource (e.g., "/static/index.html")
+ * @return the file content as bytes
+ * @throws IOException if the resource cannot be loaded
+ */
+ private static byte[] loadResource(String resourcePath) throws IOException {
+ InputStream inputStream = StaticFileHandler.class.getResourceAsStream(resourcePath);
+
+ if (inputStream == null) {
+ throw new IOException("Resource not found: " + resourcePath);
+ }
+
+ try (inputStream) {
+ return inputStream.readAllBytes();
+ }
+ }
+
+ @Nullable private static String getHeaderIgnoreCase(Map headers, String name) {
+ if (headers == null || headers.isEmpty() || name == null) {
+ return null;
+ }
+ for (Map.Entry e : headers.entrySet()) {
+ if (e.getKey() != null && e.getKey().equalsIgnoreCase(name)) {
+ return e.getValue();
+ }
+ }
+ return null;
+ }
+
+ private static String computeStrongEtag(byte[] content) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(content);
+ return "\"" + toHex(hash) + "\"";
+ }catch (NoSuchAlgorithmException e) {
+ throw new AssertionError("SHA-256 not available", e);
+ }
+ }
+
+ private static String toHex(byte[] bytes) {
+ char[] hex = "0123456789abcdef".toCharArray();
+ char[] out = new char[bytes.length * 2];
+ for (int i = 0; i < bytes.length; i++) {
+ int v = bytes[i] & 0xFF;
+ out[i * 2] = hex[v >>> 4];
+ out[i * 2 + 1] = hex[v & 0x0F];
+ }
+ return new String(out);
+ }
+
+ @Nullable private static String opaqueTag(@Nullable String etag) {
+ if (etag == null) return null;
+ String e = etag.trim();
+ return e.startsWith("W/") ? e.substring(2) : e;
+ }
+
+ private static boolean etagMatches(@Nullable String ifNoneMatchHeader, String currentEtag) {
+ if (ifNoneMatchHeader == null || ifNoneMatchHeader.isBlank()) {
+ return false;
+ }
+ String value = ifNoneMatchHeader.trim();
+ if (value.equals("*")) {
+ return true;
+ }
+
+ String[] parts = value.split(",");
+ for (String part : parts) {
+ String tag = opaqueTag(part);
+ if (part != null && tag != null && tag.equals(opaqueTag(currentEtag))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Creates a 404 Not Found error response with HTML content.
+ */
+ private static HttpResponse createNotFoundResponse(String path) {
+ String html = """
+
+
+
+
+ 404 Not Found
+
+
+
+ 404 - Not Found
+ The requested resource %s was not found on this server.
+
+
+ """.formatted(path);
+
+ Map headers = new HashMap<>();
+ headers.put("Content-Type", "text/html; charset=utf-8");
+
+ return new HttpResponse(404, "Not Found", headers, html.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Creates a generic error response.
+ */
+ private static HttpResponse createErrorResponse(int statusCode, String statusText) {
+ String html = """
+
+
+
+
+ %d %s
+
+
+
+ %d - %s
+
+
+ """.formatted(statusCode, statusText, statusCode, statusText);
+
+ Map headers = new HashMap<>();
+ headers.put("Content-Type", "text/html; charset=utf-8");
+
+ return new HttpResponse(statusCode, statusText, headers, html.getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/src/main/java/org/juv25d/handler/package-info.java b/src/main/java/org/juv25d/handler/package-info.java
new file mode 100644
index 00000000..e9fa4971
--- /dev/null
+++ b/src/main/java/org/juv25d/handler/package-info.java
@@ -0,0 +1,4 @@
+@NullMarked
+package org.juv25d.handler;
+
+import org.jspecify.annotations.NullMarked;
\ No newline at end of file
diff --git a/src/main/java/org/juv25d/http/HttpParser.java b/src/main/java/org/juv25d/http/HttpParser.java
new file mode 100644
index 00000000..4d3972b0
--- /dev/null
+++ b/src/main/java/org/juv25d/http/HttpParser.java
@@ -0,0 +1,83 @@
+package org.juv25d.http;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Map;
+import java.util.TreeMap;
+import org.jspecify.annotations.Nullable;
+
+public class HttpParser {
+
+ private static final String CONTENT_LENGTH = "Content-Length";
+
+ public HttpRequest parse(InputStream in) throws IOException {
+
+ String requestLine = readLine(in);
+ if (requestLine == null || requestLine.isEmpty()) {
+ throw new IOException("The request is empty");
+ }
+
+ String[] parts = requestLine.split("\\s+");
+ if (parts.length < 3) {
+ throw new IOException("Malformed request line: " + requestLine);
+ }
+ String method = parts[0];
+ String fullPath = parts[1];
+ String version = parts[2];
+
+ String path;
+ String query = null;
+
+ int qIndex = fullPath.indexOf('?');
+ if (qIndex >= 0) {
+ path = fullPath.substring(0, qIndex);
+ query = fullPath.substring(qIndex + 1);
+ } else {
+ path = fullPath;
+ }
+
+ Map headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ String line;
+ while ((line = readLine(in)) != null && !line.isEmpty()) {
+ int colon = line.indexOf(':');
+ if (colon <= 0) {
+ throw new IOException("Malformed header line: " + line);
+ }
+ String key = line.substring(0, colon).trim();
+ String value = line.substring(colon + 1).trim();
+ headers.put(key, value);
+ }
+
+ byte[] body = new byte[0];
+ if (headers.containsKey(CONTENT_LENGTH)) {
+ int length;
+ try {
+ length = Integer.parseInt(headers.get(CONTENT_LENGTH));
+ } catch (NumberFormatException e) {
+ throw new IOException("Invalid Content-Length: " + headers.get(CONTENT_LENGTH), e);
+ }
+ if (length < 0) {
+ throw new IOException("Negative Content-Length: " + length);
+ }
+ body = in.readNBytes(length);
+ }
+ return new HttpRequest(method, path, query, version, headers, body, "UNKNOWN");
+ }
+
+ @Nullable private String readLine(InputStream in) throws IOException {
+ StringBuilder sb = new StringBuilder();
+ int b;
+ while ((b = in.read()) != -1) {
+ if (b == '\n') {
+ break;
+ }
+ if (b != '\r') {
+ sb.append((char) b);
+ }
+ }
+ if (b == -1 && sb.isEmpty()) {
+ return null;
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/org/juv25d/http/HttpRequest.java b/src/main/java/org/juv25d/http/HttpRequest.java
new file mode 100644
index 00000000..fac5b10f
--- /dev/null
+++ b/src/main/java/org/juv25d/http/HttpRequest.java
@@ -0,0 +1,19 @@
+package org.juv25d.http;
+
+import java.util.Map;
+import org.jspecify.annotations.Nullable;
+
+public record HttpRequest(
+ String method,
+ String path,
+ @Nullable String queryString,
+ String httpVersion,
+ Map headers,
+ byte[] body,
+ String remoteIp,
+ long creationTimeNanos
+) {
+ public HttpRequest(String method, String path, @Nullable String queryString, String httpVersion, Map headers, byte[] body, String remoteIp) {
+ this(method, path, queryString, httpVersion, headers, body, remoteIp, System.nanoTime());
+ }
+}
diff --git a/src/main/java/org/juv25d/http/HttpResponse.java b/src/main/java/org/juv25d/http/HttpResponse.java
new file mode 100644
index 00000000..98cb56fe
--- /dev/null
+++ b/src/main/java/org/juv25d/http/HttpResponse.java
@@ -0,0 +1,75 @@
+package org.juv25d.http;
+
+import java.util.*;
+
+/**
+ * Represents an HTTP Response.
+ * Changed to be mutable to allow Filters and Plugins in the Pipeline
+ * to modify status, headers, and body during processing.
+ */
+public class HttpResponse {
+
+ private int statusCode;
+ private String statusText;
+ private Map headers;
+ private byte[] body;
+
+ public HttpResponse() {
+ this.statusCode = 200;
+ this.statusText = "OK";
+ this.headers = new LinkedHashMap<>();
+ this.body = new byte[0];
+ }
+
+ public HttpResponse(int statusCode, String statusText, Map headers, byte[] body) {
+ this.statusCode = statusCode;
+ this.statusText = Objects.requireNonNull(statusText, "statusText must not be null");
+ this.headers = new LinkedHashMap<>(headers != null ? headers : Map.of());
+ this.body = body != null ? body.clone() : new byte[0];
+ }
+
+ public int statusCode() {
+ return statusCode;
+ }
+
+ public void setStatusCode(int statusCode) {
+ this.statusCode = statusCode;
+ }
+
+ public String statusText() {
+ return statusText;
+ }
+
+ public void setStatusText(String statusText) {
+ Objects.requireNonNull(statusText, "statusText must not be null");
+ this.statusText = statusText;
+ }
+
+ public Map headers() {
+ return headers;
+ }
+
+ @org.jspecify.annotations.Nullable public String getHeader(@org.jspecify.annotations.Nullable String name) {
+ if (name == null) {
+ return null;
+ }
+ for (var entry : headers.entrySet()) {
+ if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(name)) {
+ return entry.getValue();
+ }
+ }
+ return null;
+ }
+
+ public void setHeader(String name, String value) {
+ headers.put(name, value);
+ }
+
+ public byte[] body() {
+ return body != null ? body.clone() : new byte[0];
+ }
+
+ public void setBody(byte[] body) {
+ this.body = body != null ? body.clone() : new byte[0];
+ }
+}
diff --git a/src/main/java/org/juv25d/http/HttpResponseWriter.java b/src/main/java/org/juv25d/http/HttpResponseWriter.java
new file mode 100644
index 00000000..08aa7ab9
--- /dev/null
+++ b/src/main/java/org/juv25d/http/HttpResponseWriter.java
@@ -0,0 +1,50 @@
+package org.juv25d.http;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+public class HttpResponseWriter {
+
+ private HttpResponseWriter() {
+ }
+
+ // This method should be called by SocketServer/ConnectionHandler later
+ public static void write(OutputStream out, HttpResponse response) throws IOException {
+ writeStatusLine(out, response);
+ writeHeaders(out, response.headers(), response.body());
+ writeBody(out, response.body());
+ out.flush();
+ }
+
+ private static void writeStatusLine(OutputStream out, HttpResponse response) throws IOException {
+ String statusLine =
+ "HTTP/1.1 " + response.statusCode() + " " + response.statusText() + "\r\n";
+ out.write(statusLine.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static void writeHeaders(
+ OutputStream out,
+ Map headers,
+ byte[] body
+ ) throws IOException {
+
+ for (Map.Entry header : headers.entrySet()) {
+ if (!header.getKey().equalsIgnoreCase("Content-Length")) {
+ String line = header.getKey() + ": " + header.getValue() + "\r\n";
+ out.write(line.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+
+ String contentLength = "Content-Length: " + body.length + "\r\n";
+ out.write(contentLength.getBytes(StandardCharsets.UTF_8));
+
+ out.write("\r\n".getBytes(StandardCharsets.UTF_8));
+ }
+
+
+ private static void writeBody(OutputStream out, byte[] body) throws IOException {
+ out.write(body);
+ }
+}
diff --git a/src/main/java/org/juv25d/http/HttpStatus.java b/src/main/java/org/juv25d/http/HttpStatus.java
new file mode 100644
index 00000000..f07c9933
--- /dev/null
+++ b/src/main/java/org/juv25d/http/HttpStatus.java
@@ -0,0 +1,46 @@
+package org.juv25d.http;
+
+public enum HttpStatus {
+ CONTINUE(100, "Continue"),
+ SWITCHING_PROTOCOLS(101, "Switching Protocols"),
+ OK(200, "OK"),
+ CREATED(201, "Created"),
+ ACCEPTED(202, "Accepted"),
+ MULTIPLE_CHOICES(300, "Multiple Choices"),
+ MOVED_PERMANENTLY(301, "Moved Permanently"),
+ FOUND(302, "Found"),
+ BAD_REQUEST(400, "Bad Request"),
+ UNAUTHORIZED(401, "Unauthorized"),
+ FORBIDDEN(403, "Forbidden"),
+ NOT_FOUND(404, "Not Found"),
+ INTERNAL_SERVER_ERROR(500, "Internal Server Error"),
+ NOT_IMPLEMENTED(501, "Not Implemented"),
+ BAD_GATEWAY(502, "Bad Gateway"),
+ GATEWAY_TIMEOUT(504, "Gateway Timeout"),
+ UNKNOWN(-1, "Unknown Status");
+
+ private final int code;
+ private final String description;
+
+ HttpStatus(int code, String description) {
+ this.code = code;
+ this.description = description;
+ }
+
+ public static HttpStatus getStatusFromCode(int code) {
+ for (HttpStatus status : HttpStatus.values()) {
+ if (status.getCode() == code) {
+ return status;
+ }
+ }
+ return UNKNOWN;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+}
diff --git a/src/main/java/org/juv25d/http/package-info.java b/src/main/java/org/juv25d/http/package-info.java
new file mode 100644
index 00000000..aa016c0b
--- /dev/null
+++ b/src/main/java/org/juv25d/http/package-info.java
@@ -0,0 +1,4 @@
+@NullMarked
+package org.juv25d.http;
+
+import org.jspecify.annotations.NullMarked;
\ No newline at end of file
diff --git a/src/main/java/org/juv25d/logging/LogContext.java b/src/main/java/org/juv25d/logging/LogContext.java
new file mode 100644
index 00000000..8311daec
--- /dev/null
+++ b/src/main/java/org/juv25d/logging/LogContext.java
@@ -0,0 +1,19 @@
+package org.juv25d.logging;
+
+import org.jspecify.annotations.Nullable;
+
+public class LogContext {
+ private static final ThreadLocal connectionId = new ThreadLocal<>();
+
+ public static void setConnectionId(String id) {
+ connectionId.set(id);
+ }
+
+ @Nullable public static String getConnectionId() {
+ return connectionId.get();
+ }
+
+ public static void clear() {
+ connectionId.remove();
+ }
+}
diff --git a/src/main/java/org/juv25d/logging/ServerLogFormatter.java b/src/main/java/org/juv25d/logging/ServerLogFormatter.java
new file mode 100644
index 00000000..3cdc9a4d
--- /dev/null
+++ b/src/main/java/org/juv25d/logging/ServerLogFormatter.java
@@ -0,0 +1,33 @@
+package org.juv25d.logging;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.logging.Formatter;
+import java.util.logging.LogRecord;
+
+public class ServerLogFormatter extends Formatter {
+ private static final DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ @Override
+ public String format(LogRecord record) {
+ String connectionId = LogContext.getConnectionId();
+ String idPart = (connectionId != null) ? " [" + connectionId + "]" : "";
+
+ StringBuilder sb = new StringBuilder(
+ String.format("%s %s%s: %s%n",
+ ZonedDateTime.ofInstant(record.getInstant(), ZoneId.systemDefault()).format(dtf),
+ record.getLevel(),
+ idPart,
+ formatMessage(record)));
+
+ if (record.getThrown() != null) {
+ StringWriter sw = new StringWriter();
+ record.getThrown().printStackTrace(new PrintWriter(sw));
+ sb.append(sw);
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/org/juv25d/logging/ServerLogging.java b/src/main/java/org/juv25d/logging/ServerLogging.java
new file mode 100644
index 00000000..06ae83da
--- /dev/null
+++ b/src/main/java/org/juv25d/logging/ServerLogging.java
@@ -0,0 +1,46 @@
+package org.juv25d.logging;
+
+import org.juv25d.util.ConfigLoader;
+
+import java.util.logging.ConsoleHandler;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+public class ServerLogging {
+ private static final Logger logger =
+ Logger.getLogger(ServerLogging.class.getName());
+
+ static {
+ configure(logger);
+ }
+
+ static void configure(Logger logger) {
+ logger.setUseParentHandlers(false);
+
+ if (logger.getHandlers().length == 0) {
+ ConsoleHandler handler = new ConsoleHandler();
+ handler.setFormatter(new ServerLogFormatter());
+ logger.addHandler(handler);
+ }
+
+ String levelName = System.getProperty(
+ "log.level",
+ System.getenv().getOrDefault("LOG_LEVEL", ConfigLoader.getInstance().getLogLevel())
+ );
+
+ try {
+ Level level = Level.parse(levelName.toUpperCase());
+ logger.setLevel(level);
+ } catch (IllegalArgumentException e) {
+ logger.setLevel(Level.INFO);
+ logger.warning("Invalid log level: '" + levelName + "', defaulting to INFO");
+ }
+ }
+
+ private ServerLogging() {}
+
+ public static Logger getLogger() {
+ return logger;
+ }
+}
+
diff --git a/src/main/java/org/juv25d/logging/package-info.java b/src/main/java/org/juv25d/logging/package-info.java
new file mode 100644
index 00000000..d7e3b8f1
--- /dev/null
+++ b/src/main/java/org/juv25d/logging/package-info.java
@@ -0,0 +1,4 @@
+@NullMarked
+package org.juv25d.logging;
+
+import org.jspecify.annotations.NullMarked;
\ No newline at end of file
diff --git a/src/main/java/org/juv25d/package-info.java b/src/main/java/org/juv25d/package-info.java
new file mode 100644
index 00000000..c498de98
--- /dev/null
+++ b/src/main/java/org/juv25d/package-info.java
@@ -0,0 +1,4 @@
+@NullMarked
+package org.juv25d;
+
+import org.jspecify.annotations.NullMarked;
\ No newline at end of file
diff --git a/src/main/java/org/juv25d/plugin/HealthCheckPlugin.java b/src/main/java/org/juv25d/plugin/HealthCheckPlugin.java
new file mode 100644
index 00000000..e8b2aa47
--- /dev/null
+++ b/src/main/java/org/juv25d/plugin/HealthCheckPlugin.java
@@ -0,0 +1,33 @@
+package org.juv25d.plugin;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * HealthCheckPlugin provides a simple JSON endpoint to verify the server's status.
+ * Responds to /health by default.
+ */
+public class HealthCheckPlugin implements Plugin {
+
+
+ @Override
+ public void handle(HttpRequest req, HttpResponse res) throws IOException {
+
+
+ String jsonBody = """
+ {
+ "status": "UP"
+ }
+ """;
+
+ byte[] bodyBytes = jsonBody.getBytes(StandardCharsets.UTF_8);
+
+ res.setStatusCode(200);
+ res.setHeader("Content-Type", "application/json");
+ res.setHeader("Content-Length", String.valueOf(bodyBytes.length));
+ res.setBody(bodyBytes);
+ }
+}
diff --git a/src/main/java/org/juv25d/plugin/HelloPlugin.java b/src/main/java/org/juv25d/plugin/HelloPlugin.java
new file mode 100644
index 00000000..d267bf33
--- /dev/null
+++ b/src/main/java/org/juv25d/plugin/HelloPlugin.java
@@ -0,0 +1,14 @@
+package org.juv25d.plugin;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+
+import java.io.IOException;
+
+public class HelloPlugin implements Plugin {
+
+ @Override
+ public void handle(HttpRequest req, HttpResponse res) throws IOException {
+ // placeholder response logic
+ }
+}
diff --git a/src/main/java/org/juv25d/plugin/MetricPlugin.java b/src/main/java/org/juv25d/plugin/MetricPlugin.java
new file mode 100644
index 00000000..2ad09857
--- /dev/null
+++ b/src/main/java/org/juv25d/plugin/MetricPlugin.java
@@ -0,0 +1,98 @@
+package org.juv25d.plugin;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+import org.juv25d.logging.ServerLogging;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Properties;
+import java.util.logging.Logger;
+
+public class MetricPlugin implements Plugin {
+
+
+ private static final String SERVER_NAME = "juv25d-webserver";
+ private static final DateTimeFormatter TIME_FORMAT =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z");
+
+ private final String version;
+ private final String commit;
+
+ public MetricPlugin() {
+ Logger logger = ServerLogging.getLogger();
+ Properties props = new Properties();
+ try (InputStream is = getClass().getClassLoader().getResourceAsStream("build.properties")) {
+ if (is != null) {
+ props.load(is);
+ }
+ } catch (IOException e) {
+ logger.warning("Error loading build.properties: " + e.getMessage());
+ }
+ this.version = escapeJson(props.getProperty("build.version", "dev"));
+ String commitValue = props.getProperty("build.commit");
+ if (commitValue == null || commitValue.isBlank()) {
+ this.commit = "unknown";
+ } else {
+ this.commit = escapeJson(commitValue);
+ }
+ }
+
+ private String escapeJson(String value) {
+ if (value == null) {
+ return "";
+ }
+ return value.replace("\\", "\\\\").replace("\"", "\\\"");
+ }
+
+ @Override
+ public void handle(HttpRequest req, HttpResponse res) throws IOException {
+
+ Runtime runtime = Runtime.getRuntime();
+ long usedMemory = runtime.totalMemory() - runtime.freeMemory();
+ long maxMemory = runtime.maxMemory();
+ long responseTimeUs =
+ (System.nanoTime() - req.creationTimeNanos()) / 1_000;
+ String localTime = ZonedDateTime
+ .now(ZoneId.systemDefault())
+ .format(TIME_FORMAT);
+ String utcTime = ZonedDateTime
+ .now(ZoneId.of("UTC"))
+ .format(TIME_FORMAT);
+
+ String jsonBody = String.format("""
+ {
+ "localTime": "%s",
+ "utcTime": "%s",
+ "server": "%s",
+ "buildVersion": "%s",
+ "gitCommit": "%s",
+ "responseTimeUs": %d,
+ "memory": {
+ "usedBytes": %d,
+ "maxBytes": %d
+ }
+ }
+ """,
+ localTime,
+ utcTime,
+ SERVER_NAME,
+ version,
+ commit,
+ responseTimeUs,
+ usedMemory,
+ maxMemory
+ );
+
+ byte[] bodyBytes = jsonBody.getBytes(StandardCharsets.UTF_8);
+
+ res.setStatusCode(200);
+ res.setHeader("Content-Type", "application/json");
+ res.setHeader("Content-Length", String.valueOf(bodyBytes.length));
+ res.setBody(bodyBytes);
+ }
+}
diff --git a/src/main/java/org/juv25d/plugin/NotFoundPlugin.java b/src/main/java/org/juv25d/plugin/NotFoundPlugin.java
new file mode 100644
index 00000000..e48637bc
--- /dev/null
+++ b/src/main/java/org/juv25d/plugin/NotFoundPlugin.java
@@ -0,0 +1,15 @@
+package org.juv25d.plugin;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+
+import java.io.IOException;
+
+public class NotFoundPlugin implements Plugin {
+ @Override
+ public void handle(HttpRequest req, HttpResponse res) throws IOException {
+ res.setStatusCode(404);
+ res.setStatusText("Not Found");
+ res.setBody("404 - Resource Not Found".getBytes());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/juv25d/plugin/Plugin.java b/src/main/java/org/juv25d/plugin/Plugin.java
new file mode 100644
index 00000000..a2dceb5c
--- /dev/null
+++ b/src/main/java/org/juv25d/plugin/Plugin.java
@@ -0,0 +1,10 @@
+package org.juv25d.plugin;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+
+import java.io.IOException;
+
+public interface Plugin {
+ void handle (HttpRequest req, HttpResponse res) throws IOException;
+}
diff --git a/src/main/java/org/juv25d/plugin/StaticFilesPlugin.java b/src/main/java/org/juv25d/plugin/StaticFilesPlugin.java
new file mode 100644
index 00000000..00ae8012
--- /dev/null
+++ b/src/main/java/org/juv25d/plugin/StaticFilesPlugin.java
@@ -0,0 +1,32 @@
+package org.juv25d.plugin;
+
+import org.juv25d.handler.StaticFileHandler;
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * Plugin that serves static files using StaticFileHandler.
+ * Integrates with the Pipeline architecture to handle GET requests for static resources.
+ *
+ */
+public class StaticFilesPlugin implements Plugin {
+
+ @Override
+ public void handle(HttpRequest request, HttpResponse response) throws IOException {
+ // Use StaticFileHandler to handle the request
+ HttpResponse staticResponse = StaticFileHandler.handle(request);
+
+ // Copy the response from StaticFileHandler to the pipeline response
+ response.setStatusCode(staticResponse.statusCode());
+ response.setStatusText(staticResponse.statusText());
+
+ for (Map.Entry header : staticResponse.headers().entrySet()) {
+ response.setHeader(header.getKey(), header.getValue());
+ }
+ response.setBody(staticResponse.body());
+ }
+}
+
diff --git a/src/main/java/org/juv25d/plugin/package-info.java b/src/main/java/org/juv25d/plugin/package-info.java
new file mode 100644
index 00000000..1875f95a
--- /dev/null
+++ b/src/main/java/org/juv25d/plugin/package-info.java
@@ -0,0 +1,4 @@
+@NullMarked
+package org.juv25d.plugin;
+
+import org.jspecify.annotations.NullMarked;
\ No newline at end of file
diff --git a/src/main/java/org/juv25d/proxy/ProxyPlugin.java b/src/main/java/org/juv25d/proxy/ProxyPlugin.java
new file mode 100644
index 00000000..216fa677
--- /dev/null
+++ b/src/main/java/org/juv25d/proxy/ProxyPlugin.java
@@ -0,0 +1,139 @@
+package org.juv25d.proxy;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+import org.juv25d.http.HttpStatus;
+import org.juv25d.logging.ServerLogging;
+import org.juv25d.plugin.Plugin;
+
+import java.io.IOException;
+import java.net.ConnectException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest.BodyPublishers;
+import java.net.http.HttpRequest.Builder;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.net.http.HttpTimeoutException;
+import java.util.Map;
+import java.util.logging.Logger;
+
+
+public class ProxyPlugin implements Plugin {
+ private static final Logger logger = ServerLogging.getLogger();
+ private final ProxyRoute proxyRoute;
+ private final HttpClient httpClient;
+
+ public ProxyPlugin(ProxyRoute proxyRoute) {
+ this.proxyRoute = proxyRoute;
+ this.httpClient = HttpClient.newHttpClient();
+ }
+
+ public ProxyPlugin(ProxyRoute proxyRoute, HttpClient httpClient) {
+ this.proxyRoute = proxyRoute;
+ this.httpClient = httpClient;
+ }
+
+ @Override
+ public void handle(HttpRequest req, HttpResponse res) throws IOException {
+ String baseRoute = proxyRoute.getBaseRoute();
+ String targetPath = req.path().substring(baseRoute.length());
+ String upstreamUrl = proxyRoute.buildUrl(targetPath, req.queryString());
+
+ String query = req.queryString();
+ String fullPath = req.path();
+
+ if (query != null && !query.isEmpty()) {
+ fullPath += "?" + query;
+ }
+
+ logger.info(String.format("Proxying %s %s -> %s",
+ req.method(), fullPath, upstreamUrl));
+
+ Builder requestBuilder = java.net.http.HttpRequest.newBuilder()
+ .uri(URI.create(upstreamUrl))
+ .method(req.method(),
+ req.body().length > 0
+ ? BodyPublishers.ofByteArray(req.body())
+ : BodyPublishers.noBody()
+ );
+
+ // copy request headers and pass to new HttpRequest
+ for (Map.Entry header : req.headers().entrySet()) {
+ if (!isRestrictedHeader(header.getKey())) {
+ requestBuilder.header(header.getKey(), header.getValue());
+ }
+ }
+
+ try {
+ java.net.http.HttpResponse upstreamResponse = httpClient.send(
+ requestBuilder.build(),
+ BodyHandlers.ofByteArray()
+ );
+
+ // relay the upstream response back to the client including the headers
+ res.setStatusCode(upstreamResponse.statusCode());
+ res.setStatusText(String.valueOf(
+ HttpStatus.getStatusFromCode(upstreamResponse.statusCode()).getDescription()
+ )
+ );
+ res.setBody(upstreamResponse.body());
+
+ upstreamResponse.headers().map().forEach((name, values) -> {
+ if (!values.isEmpty()) {
+ res.setHeader(name, values.get(0));
+ }
+ });
+
+ logger.info(String.format("Successful proxy %s %s -> %s (upstream: %d %s)",
+ req.method(), req.path(), upstreamUrl, res.statusCode(), res.statusText()));
+
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ res.setStatusCode(500);
+ res.setStatusText(String.valueOf(
+ HttpStatus.getStatusFromCode(res.statusCode()).getDescription()
+ ));
+
+ logger.warning(String.format("Request interrupted while proxying %s %s -> %s",
+ req.method(), req.path(), upstreamUrl));
+
+ } catch (ConnectException e) {
+ res.setStatusCode(502);
+ res.setStatusText(String.valueOf(
+ HttpStatus.getStatusFromCode(res.statusCode()).getDescription()
+ ));
+ logger.warning(String.format("Connection failed to upstream server %s",
+ upstreamUrl));
+
+ } catch (HttpTimeoutException e) {
+ res.setStatusCode(504);
+ res.setStatusText(String.valueOf(
+ HttpStatus.getStatusFromCode(res.statusCode()).getDescription()
+ ));
+
+ logger.warning(String.format("Timeout connecting to upstream server %s",
+ upstreamUrl));
+
+ } catch (Exception e) {
+ res.setStatusCode(502);
+ res.setStatusText(String.valueOf(
+ HttpStatus.getStatusFromCode(res.statusCode()).getDescription()
+ ));
+
+ logger.warning(String.format("Proxy error for %s %s -> %s",
+ req.method(), req.path(), upstreamUrl));
+ }
+ }
+
+ private boolean isRestrictedHeader(String headerName) {
+ String lower = headerName.toLowerCase();
+ return lower.equals("connection") ||
+ lower.equals("content-length") ||
+ lower.equals("host") ||
+ lower.equals("upgrade") ||
+ lower.equals("http2-settings") ||
+ lower.equals("te") ||
+ lower.equals("trailer") ||
+ lower.equals("transfer-encoding");
+ }
+}
diff --git a/src/main/java/org/juv25d/proxy/ProxyRoute.java b/src/main/java/org/juv25d/proxy/ProxyRoute.java
new file mode 100644
index 00000000..cd97118b
--- /dev/null
+++ b/src/main/java/org/juv25d/proxy/ProxyRoute.java
@@ -0,0 +1,33 @@
+package org.juv25d.proxy;
+
+import org.jspecify.annotations.Nullable;
+
+/*
+ * Maps a baseRoute with its respective proxy upstream target URL.
+ * The mappings are initialized in ConfigLoader.java when reading them from
+ * application-properties.
+ *
+ * Example:
+ * baseRoute: /api/v1
+ * upstreamUrl: https://external-server-url
+ */
+public class ProxyRoute {
+ private final String baseRoute;
+ private final String upstreamUrl;
+
+ public ProxyRoute(String baseRoute, String upstreamUrl) {
+ this.baseRoute = baseRoute;
+ this.upstreamUrl = upstreamUrl;
+ }
+
+ public String buildUrl(String targetPath, @Nullable String query) {
+ String url = upstreamUrl + targetPath;
+ if (query != null && !query.isEmpty()) url += "?" + query;
+
+ return url;
+ }
+
+ public String getBaseRoute() {
+ return baseRoute;
+ }
+}
diff --git a/src/main/java/org/juv25d/proxy/package-info.java b/src/main/java/org/juv25d/proxy/package-info.java
new file mode 100644
index 00000000..85df655c
--- /dev/null
+++ b/src/main/java/org/juv25d/proxy/package-info.java
@@ -0,0 +1,4 @@
+@NullMarked
+package org.juv25d.proxy;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/src/main/java/org/juv25d/router/Router.java b/src/main/java/org/juv25d/router/Router.java
new file mode 100644
index 00000000..34534a30
--- /dev/null
+++ b/src/main/java/org/juv25d/router/Router.java
@@ -0,0 +1,19 @@
+package org.juv25d.router;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.plugin.Plugin;
+
+/**
+ * The Router interface defines a contract for components that resolve an incoming HTTP request
+ * to a specific Plugin instance responsible for handling that request.
+ */
+public interface Router {
+
+ /**
+ * Resolves the given HttpRequest to a Plugin that can handle it.
+ *
+ * @param request The incoming HttpRequest.
+ * @return The Plugin instance responsible for handling the request. Must not be null.
+ */
+ Plugin resolve(HttpRequest request);
+}
diff --git a/src/main/java/org/juv25d/router/RouterConfig.java b/src/main/java/org/juv25d/router/RouterConfig.java
new file mode 100644
index 00000000..805529e8
--- /dev/null
+++ b/src/main/java/org/juv25d/router/RouterConfig.java
@@ -0,0 +1,30 @@
+package org.juv25d.router;
+
+import org.juv25d.di.Inject;
+import org.juv25d.plugin.HealthCheckPlugin;
+import org.juv25d.plugin.MetricPlugin;
+import org.juv25d.plugin.NotFoundPlugin;
+import org.juv25d.plugin.StaticFilesPlugin;
+import org.juv25d.proxy.ProxyPlugin;
+import org.juv25d.proxy.ProxyRoute;
+import org.juv25d.util.ConfigLoader;
+
+public class RouterConfig {
+
+ @Inject
+ public RouterConfig(SimpleRouter router) {
+
+ for (ProxyRoute proxyRoute : ConfigLoader.getInstance().getProxyRoutes()) {
+ router.registerPlugin(proxyRoute.getBaseRoute(), new ProxyPlugin(proxyRoute));
+ router.registerPlugin(proxyRoute.getBaseRoute() + "/*", new ProxyPlugin(proxyRoute));
+ }
+
+ router.registerPlugin("/metric", new MetricPlugin());
+ router.registerPlugin("/health", new HealthCheckPlugin());
+ router.registerPlugin("/", new StaticFilesPlugin());
+ router.registerPlugin("/*", new StaticFilesPlugin());
+ router.registerPlugin("/notfound", new NotFoundPlugin());
+
+ System.out.println("Router configured");
+ }
+}
diff --git a/src/main/java/org/juv25d/router/SimpleRouter.java b/src/main/java/org/juv25d/router/SimpleRouter.java
new file mode 100644
index 00000000..c6f7cd1d
--- /dev/null
+++ b/src/main/java/org/juv25d/router/SimpleRouter.java
@@ -0,0 +1,69 @@
+package org.juv25d.router;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.plugin.NotFoundPlugin;
+import org.juv25d.plugin.Plugin;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Comparator;
+
+/**
+ * A simple router implementation that maps request paths to specific Plugin instances.
+ * If no specific plugin is registered for a path, it defaults to a NotFoundPlugin.
+ */
+public class SimpleRouter implements Router {
+
+ private final Map routes;
+ private final Plugin notFoundPlugin;
+
+ public SimpleRouter() {
+ this.routes = new HashMap<>();
+ this.notFoundPlugin = new NotFoundPlugin();
+ }
+
+ /**
+ * Registers a plugin for a specific path.
+ *
+ * @param path The path for which the plugin should handle requests.
+ * @param plugin The plugin to handle requests for the given path.
+ */
+ public void registerPlugin(String path, Plugin plugin) {
+ routes.put(path, plugin);
+ }
+
+ /**
+ * Resolves the given HttpRequest to a Plugin that can handle it.
+ * Resolution order:
+ * 1. Exact path match
+ * 2. Wildcard match (longest prefix wins)
+ * 3. NotFoundPlugin
+ *
+ * @param request The incoming HttpRequest.
+ * @return The Plugin instance responsible for handling the request.
+ */
+ @Override
+ public Plugin resolve(HttpRequest request) {
+ String path = request.path();
+
+ // 1. Exact match
+ Plugin exactMatch = routes.get(path);
+ if (exactMatch != null) {
+ return exactMatch;
+ }
+
+ // 2. Wildcard match (deterministic: longest prefix first)
+ return routes.entrySet().stream()
+ .filter(entry -> entry.getKey().endsWith("/*"))
+ .sorted(Comparator.comparingInt(
+ entry -> -entry.getKey().length()
+ )) // longest (most specific) first
+ .filter(entry -> {
+ String prefix = entry.getKey().substring(0, entry.getKey().length() - 1);
+ return path.startsWith(prefix);
+ })
+ .map(Map.Entry::getValue)
+ .findFirst()
+ .orElse(notFoundPlugin);
+ }
+}
diff --git a/src/main/java/org/juv25d/router/package-info.java b/src/main/java/org/juv25d/router/package-info.java
new file mode 100644
index 00000000..f5a45cfe
--- /dev/null
+++ b/src/main/java/org/juv25d/router/package-info.java
@@ -0,0 +1,4 @@
+@NullMarked
+package org.juv25d.router;
+
+import org.jspecify.annotations.NullMarked;
\ No newline at end of file
diff --git a/src/main/java/org/juv25d/util/ConfigLoader.java b/src/main/java/org/juv25d/util/ConfigLoader.java
new file mode 100644
index 00000000..b89550fc
--- /dev/null
+++ b/src/main/java/org/juv25d/util/ConfigLoader.java
@@ -0,0 +1,160 @@
+package org.juv25d.util;
+
+import org.jspecify.annotations.Nullable;
+import org.juv25d.proxy.ProxyRoute;
+import org.yaml.snakeyaml.Yaml;
+
+import java.io.InputStream;
+import java.util.*;
+
+public class ConfigLoader {
+ @Nullable private static ConfigLoader instance;
+ private int port;
+ private String logLevel = "INFO";
+ private String rootDirectory = "static";
+ private long requestsPerMinute;
+ private long burstCapacity;
+ private boolean rateLimitingEnabled;
+ private List trustedProxies;
+ private List proxyRoutes = new ArrayList<>();
+
+ private ConfigLoader() {
+ loadConfiguration(getClass().getClassLoader()
+ .getResourceAsStream("application-properties.yml"));
+ }
+
+ // new constructor for testing
+ ConfigLoader(InputStream input) {
+ loadConfiguration(input);
+ }
+
+
+ public static synchronized ConfigLoader getInstance() {
+ if (instance == null) {
+ instance = new ConfigLoader();
+ }
+ return instance;
+ }
+
+ private void loadConfiguration(InputStream input) {
+ Yaml yaml = new Yaml();
+
+ if (input == null) {
+ throw new IllegalArgumentException("Did not find application-properties.yml");
+ }
+ try (input) {
+
+ Map config = yaml.load(input);
+ if (config == null) config = Map.of();
+
+ // defaults always
+ this.port = 8080;
+ this.rootDirectory = "static";
+ this.logLevel = "INFO";
+ this.trustedProxies = List.of();
+
+ // server
+ Object serverObj = config.get("server");
+ if (serverObj != null) {
+ Map serverConfig = asStringObjectMap(serverObj);
+ Object portValue = serverConfig.get("port");
+ if (portValue instanceof Number n) this.port = n.intValue();
+
+ Object root = serverConfig.get("root-dir");
+ if (root != null) this.rootDirectory = String.valueOf(root);
+
+ Object trustedProxiesValue = serverConfig.get("trusted-proxies");
+ if (trustedProxiesValue instanceof List> list) {
+ this.trustedProxies = list.stream()
+ .filter(Objects::nonNull)
+ .map(String::valueOf)
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .toList();
+ }
+
+ // proxy routes
+ Object proxyObj = serverConfig.get("proxy");
+ if (proxyObj != null) {
+ Map proxyConfig = asStringObjectMap(proxyObj);
+ List> routes = (List>) proxyConfig.get("routes");
+ if (routes != null) {
+ for (Map route : routes) {
+ String baseRoute = String.valueOf(route.get("base-route"));
+ String upstreamUrl = String.valueOf(route.get("upstream-url"));
+ this.proxyRoutes.add(new ProxyRoute(baseRoute, upstreamUrl));
+ }
+ }
+ }
+ }
+
+ // logging
+ Object loggingObj = config.get("logging");
+ if (loggingObj != null) {
+ Map loggingConfig = asStringObjectMap(loggingObj);
+ Object level = loggingConfig.get("level");
+ if (level != null) this.logLevel = String.valueOf(level);
+ }
+
+ // rate-limiting
+ // defaults (consistent pattern)
+ this.rateLimitingEnabled = false;
+
+ Object rateLimitObj = config.get("rate-limiting");
+ if (rateLimitObj != null) {
+ Map rateLimitingConfig = asStringObjectMap(rateLimitObj);
+ this.rateLimitingEnabled =
+ Boolean.parseBoolean(String.valueOf(rateLimitingConfig.getOrDefault("enabled", false)));
+
+ this.requestsPerMinute =
+ Long.parseLong(String.valueOf(rateLimitingConfig.getOrDefault("requests-per-minute", 60L)));
+
+ this.burstCapacity =
+ Long.parseLong(String.valueOf(rateLimitingConfig.getOrDefault("burst-capacity", 100L)));
+ }
+
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to load application config", e);
+ }
+ }
+
+ public int getPort() {
+ return port;
+ }
+
+ public String getLogLevel() {
+ return logLevel;
+ }
+
+ public String getRootDirectory() {
+ return rootDirectory;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map asStringObjectMap(@Nullable Object value) {
+ if (value instanceof Map, ?> map) {
+ return (Map) map;
+ }
+ return Collections.emptyMap();
+ }
+
+ public long getRequestsPerMinute() {
+ return requestsPerMinute;
+ }
+
+ public List getTrustedProxies() {
+ return trustedProxies;
+ }
+
+ public long getBurstCapacity() {
+ return burstCapacity;
+ }
+
+ public boolean isRateLimitingEnabled() {
+ return rateLimitingEnabled;
+ }
+
+ public List getProxyRoutes() {
+ return Collections.unmodifiableList(proxyRoutes);
+ }
+}
diff --git a/src/main/java/org/juv25d/util/package-info.java b/src/main/java/org/juv25d/util/package-info.java
new file mode 100644
index 00000000..e84dc2ef
--- /dev/null
+++ b/src/main/java/org/juv25d/util/package-info.java
@@ -0,0 +1,4 @@
+@NullMarked
+package org.juv25d.util;
+
+import org.jspecify.annotations.NullMarked;
\ No newline at end of file
diff --git a/src/main/resources-filtered/build.properties b/src/main/resources-filtered/build.properties
new file mode 100644
index 00000000..8e3da59a
--- /dev/null
+++ b/src/main/resources-filtered/build.properties
@@ -0,0 +1,3 @@
+build.version=${project.version}
+build.commit=${buildNumber}
+build.time=${build.time}
diff --git a/src/main/resources/application-properties.yml b/src/main/resources/application-properties.yml
new file mode 100644
index 00000000..b1363129
--- /dev/null
+++ b/src/main/resources/application-properties.yml
@@ -0,0 +1,17 @@
+server:
+ port: 8080
+ root-dir: static
+ trusted-proxies:
+ - 192.168.1.109
+ proxy:
+ routes:
+ - base-route: /api/test
+ upstream-url: https://jsonplaceholder.typicode.com
+
+logging:
+ level: INFO
+
+rate-limiting:
+ enabled: true
+ requests-per-minute: 60
+ burst-capacity: 100
diff --git a/src/main/resources/static/README.md b/src/main/resources/static/README.md
new file mode 100644
index 00000000..42b35656
--- /dev/null
+++ b/src/main/resources/static/README.md
@@ -0,0 +1,406 @@
+# π Java HTTP Server β Team juv25d
+
+A lightweight, modular HTTP server built from scratch in Java.
+
+This project demonstrates how web servers and backend frameworks work internally β without using Spring, Tomcat, or other high-level frameworks.
+
+The server is distributed as a Docker image via GitHub Container Registry (GHCR).
+
+---
+
+# π Project Purpose
+
+The goal of this project is to deeply understand:
+
+- How HTTP works
+- How requests are parsed
+- How responses are constructed
+- How middleware (filters) operate
+- How backend frameworks structure request lifecycles
+- How static file serving works
+- How architectural decisions are documented (ADR)
+- How Java services are containerized with Docker
+
+This is an educational backend architecture project.
+
+---
+
+# β Requirements
+
+- Java 21+ (uses Virtual Threads via Project Loom)
+- Docker (for running the official container image)
+
+---
+
+# π Architecture Overview
+
+## Request Lifecycle
+
+```
+Client
+ β
+ServerSocket
+ β
+ConnectionHandler (Virtual Thread)
+ β
+Pipeline
+ β
+FilterChain
+ β
+Plugin
+ β
+HttpResponseWriter
+ β
+Client
+```
+
+---
+
+## π§© Core Components
+
+### Server
+- Listens on a configurable port
+- Accepts incoming socket connections
+- Spawns a virtual thread per request (`Thread.ofVirtual()`)
+
+### ConnectionHandler
+- Parses the HTTP request using `HttpParser`
+- Creates a default `HttpResponse`
+- Executes the `Pipeline`
+
+### Pipeline
+- Holds global filters
+- Holds route-specific filters
+- Creates and executes a `FilterChain`
+- Executes the active plugin
+
+### Filters
+Used for cross-cutting concerns such as:
+- Logging
+- Authentication
+- Rate limiting
+- Validation
+- Compression
+- Security headers
+
+### Plugin
+Responsible for generating the final HTTP response.
+
+### HttpParser
+Custom HTTP request parser that:
+- Parses request line
+- Parses headers
+- Handles `Content-Length`
+- Extracts path and query parameters
+
+### HttpResponseWriter
+Responsible for:
+- Writing status line
+- Writing headers
+- Automatically setting `Content-Length`
+- Writing response body
+
+---
+
+# π³ Running the Server (Official Method)
+
+The official way to run the server is via Docker using GitHub Container Registry.
+
+Docker must be installed and running.
+
+---
+
+## Step 1 β Login to GHCR
+
+```bash
+docker login ghcr.io -u
+```
+
+Use your GitHub Personal Access Token (classic) as password.
+
+---
+
+## Step 2 β Pull the latest image
+
+```bash
+docker pull ghcr.io/ithsjava25/project-webserver-juv25d:latest
+```
+
+---
+
+## Step 3 β Run the container
+
+```bash
+docker run -p 3000:3000 ghcr.io/ithsjava25/project-webserver-juv25d:latest
+```
+
+---
+
+## Step 4 β Open in browser
+
+```
+http://localhost:3000
+```
+
+The server runs on port **3000**.
+
+---
+
+# π Running in Development (IDE)
+
+For development purposes, you can run the server directly from your IDE:
+
+1. Open the project.
+2. Run the class:
+
+```
+org.juv25d.App
+```
+
+3. Open:
+
+```
+http://localhost:3000
+```
+
+Note: The project is packaged as a fat JAR using the Maven Shade Plugin, so you can run it with `java -jar target/JavaHttpServer-1.0.2-beta.jar`.
+
+---
+
+# π Static File Serving
+
+The `StaticFilesPlugin` serves files from:
+
+```
+src/main/resources/static/
+```
+
+### Example Mapping
+
+| File | URL |
+|------|------|
+| index.html | `/` |
+| css/styles.css | `/css/styles.css` |
+| js/app.js | `/js/app.js` |
+
+### Security Features
+
+- Path traversal prevention
+- MIME type detection
+- 404 handling
+- 403 handling
+- Clean URLs (no `/static/` prefix)
+
+For full architectural reasoning, see:
+
+β‘ `docs/adr/ADR-001-static-file-serving-architecture.md`
+
+---
+
+# π Creating a Filter
+
+Filters intercept requests before they reach the plugin.
+
+A filter can:
+
+- Inspect or modify `HttpRequest`
+- Inspect or modify `HttpResponse`
+- Stop the chain (e.g., return 403)
+- Continue processing by calling `chain.doFilter(req, res)`
+
+---
+
+## Filter Interface
+
+```java
+public interface Filter {
+ void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException;
+}
+```
+
+---
+
+## Example: LoggingFilter
+
+```java
+public class LoggingFilter implements Filter {
+ @Override
+ public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException {
+ System.out.println(req.method() + " " + req.path());
+ chain.doFilter(req, res);
+ }
+}
+```
+
+---
+
+## Registering a Global Filter
+
+```java
+pipeline.addGlobalFilter(new LoggingFilter(), 100);
+```
+
+Lower order values execute first.
+
+---
+
+# π― Route-Specific Filters
+
+Route filters only execute when the request path matches a pattern.
+
+### Supported Patterns
+
+- `/api/*` β matches paths starting with `/api/`
+- `/login` β exact match
+- `/admin/*` β wildcard support (prefix-based)
+
+---
+
+## Example
+
+```java
+pipeline.addRouteFilter(new JwtAuthFilter(), 100, "/api/*");
+```
+
+---
+
+## Execution Flow
+
+```
+Client β Filter 1 β Filter 2 β ... β Plugin β Response β Client
+```
+
+---
+
+# π§ Creating a Plugin
+
+Plugins generate the final HTTP response.
+
+They run after all filters have completed.
+
+---
+
+## Plugin Interface
+
+```java
+public interface Plugin {
+ void handle(HttpRequest req, HttpResponse res) throws IOException;
+}
+```
+
+---
+
+## Example: HelloPlugin
+
+```java
+public class HelloPlugin implements Plugin {
+
+ @Override
+ public void handle(HttpRequest req, HttpResponse res) throws IOException {
+ res.setStatusCode(200);
+ res.setStatusText("OK");
+ res.setHeader("Content-Type", "text/plain");
+ res.setBody("Hello from juv25d server".getBytes());
+ }
+}
+```
+
+---
+
+## Registering a Plugin
+
+```java
+pipeline.setPlugin(new HelloPlugin());
+```
+
+---
+
+# β Configuration
+
+Configuration is loaded from:
+
+```
+application-properties.yml
+```
+
+Example:
+
+```yaml
+server:
+ port: 3000
+ root-dir: static
+
+logging:
+ level: INFO
+```
+
+---
+
+# π¦ Features
+
+- Custom HTTP request parser (`HttpParser`)
+- Custom HTTP response writer (`HttpResponseWriter`)
+- Mutable HTTP response model
+- Filter chain architecture
+- Plugin system
+- Static file serving
+- MIME type resolution
+- Path traversal protection
+- Virtual threads (Project Loom)
+- YAML configuration (SnakeYAML)
+- Dockerized distribution
+- Published container image (GHCR)
+
+---
+
+# π Documentation & Architecture Decisions
+
+Additional technical documentation is available in the `docs/` directory.
+
+## Architecture Decision Records (ADR)
+
+Contains architectural decisions and their reasoning.
+
+```
+docs/adr/
+```
+
+Main index:
+
+```
+docs/adr/README.md
+```
+
+Includes:
+
+- Static file serving architecture
+- ADR template
+- Future architecture decisions
+
+---
+
+## Technical Notes
+
+Advanced filter configuration examples:
+
+```
+docs/notes/
+```
+
+---
+
+# π Educational Value
+
+This project demonstrates:
+
+- How web servers work internally
+- How middleware pipelines are implemented
+- How static file serving works
+- How architectural decisions are documented
+- How Java services are containerized and distributed
+
+---
+
+# π₯ Team juv25d
+
+Built as a learning project to deeply understand HTTP, backend systems, and modular server architecture.
diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css
new file mode 100644
index 00000000..6e4d93bb
--- /dev/null
+++ b/src/main/resources/static/css/styles.css
@@ -0,0 +1,461 @@
+/* Reset and Base Styles */
+* {
+ --base-color: 23, 23, 46;
+ --base-color-white: #f7f7f7fc;
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+html {
+ scrollbar-gutter: stable;
+}
+
+body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ background-image: url("../images/tech-bg.jpg");
+ background-attachment: fixed;
+ background-size: cover;
+ min-height: 100vh;
+ padding: 20px;
+ margin: 0;
+}
+
+.nav-menu a {
+ display: block;
+ padding: 0.25rem 1rem 0.5rem 1rem;
+ text-decoration: none;
+ color: var(--base-color-white);
+ font-weight: bold;
+ font-family: system-ui;
+}
+/* Health box */
+
+#health-box {
+ position: fixed;
+ top: 10px;
+ right: 10px;
+ font-family: monospace;
+ z-index: 9999;
+}
+
+#health-box button {
+ background: rgba(34, 34, 34, 1);
+ color: white;
+ border: none;
+ border-radius: 50%;
+ width: 36px;
+ height: 36px;
+ cursor: pointer;
+ font-size: 1.2rem;
+ transition: 150ms ease;
+ anchor-name: --health-button;
+}
+
+#health-box button:hover {
+ box-shadow: 0 0 4px 0px rgba(255, 255, 255, 0.2);
+}
+
+#health-box button:active {
+ font-size: 1.4rem;
+}
+
+.health-content {
+ visibility: hidden;
+ opacity: 0;
+ background: #111;
+ color: #00eaff;
+ padding: 10px;
+ border-radius: 6px;
+ font-size: 12px;
+ position: absolute;
+ margin-right: 8px;
+ inset: auto;
+ top: anchor(top);
+ right: anchor(left);
+ position-anchor: --health-button;
+ filter: grayscale(100%);
+ transition: 250ms ease-in-out allow-discrete;
+}
+
+.health-content:popover-open {
+ animation: fade-in 350ms ease;
+ visibility: visible;
+ opacity: 1;
+ filter: grayscale(0%);
+}
+
+/* Container */
+.container {
+ max-width: 900px;
+ margin: 0 auto;
+ backdrop-filter: blur(4px);
+ background: linear-gradient(135deg, rgba(23, 23, 46, 0.6), rgba(23, 23, 46, 0.9), rgba(23, 23, 46, 0.6));
+ color: white;
+ text-align: center;
+ border-radius: 10px;
+
+ @supports (corner-shape: squircle) {
+ corner-shape: squircle;
+ border-radius: 24px;
+ }
+
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
+ overflow: hidden;
+}
+/* Nav menu */
+
+.nav-menu.disable-anchors::before,
+.nav-menu.disable-anchors::after {
+ transition: none !important;
+}
+
+.disable-anchors a {
+ pointer-events: none;
+}
+
+.nav-menu {
+
+ width: fit-content;
+ margin: 0.4rem auto 1rem auto;
+ display: flex;
+ align-content: center;
+ justify-content: center;
+ isolation: isolate;
+
+ anchor-name: --hovered-link;
+
+ li:hover {
+ anchor-name: --hovered-link;
+ }
+
+ &::before,
+ &::after {
+ content: "";
+ position: absolute;
+ top: calc(anchor(bottom) - 6px);
+ left: calc(anchor(left) + 30px);
+ right: calc(anchor(right) + 30px);
+ bottom: calc(anchor(bottom));
+ border-radius: 10px;
+
+ position-anchor: --hovered-link;
+
+ transition: 900ms
+ linear(
+ 0,
+ 0.029 1.6%,
+ 0.123 3.5%,
+ 0.651 10.6%,
+ 0.862 14.1%,
+ 1.002 17.7%,
+ 1.046 19.6%,
+ 1.074 21.6%,
+ 1.087 23.9%,
+ 1.086 26.6%,
+ 1.014 38.5%,
+ 0.994 46.3%,
+ 1
+ );
+ }
+
+ &::before {
+ z-index: -1;
+ background: rgba(0, 0, 0, 0.2);
+ /*backdrop-filter: blur(2px);*/
+ }
+
+ &::after {
+ z-index: -2;
+ background-image: url("../images/tech-bg.jpg");
+ background-attachment: fixed;
+ background-size: cover;
+ }
+ &:has(a:hover)::before,
+ &:has(a:hover)::after {
+ top: anchor(top);
+ left: anchor(left);
+ right: anchor(right);
+ bottom: anchor(bottom);
+
+
+ @supports (corner-shape: squircle) {
+ corner-shape: squircle;
+ border-radius: 50%;
+ }
+ }
+
+ /* &:has(a:active)::before {
+ background: transparent;
+ }
+
+ &:has(a:active)::before,
+ &:has(a:active)::after {
+ top: calc(anchor(top) - 6px);
+ left: calc(anchor(left) - 6px);
+ right: calc(anchor(right) - 6px);
+ bottom: calc(anchor(bottom) - 6px);
+
+ transition: 50ms;
+ backdrop-filter: none;
+
+ @supports (corner-shape: squircle) {
+ corner-shape: squircle;
+ border-radius: 50%;
+ }
+ }
+
+ &:has(a:active) > ul > li * {
+ color: black;
+ }*/
+ & ul {
+ display: flex;
+ flex-direction: row;
+ padding: 0;
+ align-content: center;
+ justify-content: space-around;
+ list-style: none;
+ margin: 0;
+
+ @supports (corner-shape: squircle) {
+ corner-shape: squircle;
+ border-radius: 24px;
+ }
+ }
+
+ & ul > li {
+ margin: auto 2rem;
+ }
+}
+/* Header */
+header {
+ background: transparent;
+ color: var(--base-color-white);
+ padding: 40px 30px;
+ text-align: center;
+
+ border-top: solid #f7f7f717;
+ border-width: 1px;
+}
+
+header h1 {
+ font-size: 2.5em;
+ margin-bottom: 10px;
+}
+
+.subtitle {
+ font-size: 1.2em;
+ opacity: 0.9;
+}
+
+/* Main Content */
+main {
+ padding: 40px 30px;
+ opacity: 1;
+ transition: opacity 200ms ease;
+}
+
+main.fade-out {
+ opacity: 0;
+}
+
+
+.features {
+ isolation: isolate;
+
+ anchor-name: --hovered-feature;
+
+ li:hover {
+ anchor-name: --hovered-feature;
+ }
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: calc(anchor(top) + 20px);
+ left: calc(anchor(left) + 4px);
+ right: calc(anchor(left) - 12px);
+ bottom: calc(anchor(bottom) + 20px);
+
+ border-radius: 10px;
+
+ position-anchor: --hovered-feature;
+
+ transition: 700ms
+ linear(0, 0.012 0.9%, 0.05 2%, 0.411 9.2%, 0.517 11.8%, 0.611 14.6%, 0.694 17.7%, 0.765 21.1%, 0.824 24.8%, 0.872 28.9%, 0.91 33.4%, 0.939 38.4%, 0.977 50.9%, 0.994 68.4%, 1);
+ }
+
+ &::after {
+ background-color: rgba(0, 150, 255, 0.3);
+ z-index: -1;
+ }
+
+ &:has(li:hover)::after {
+ top: calc(anchor(top) + 10px);
+ left: calc(anchor(left) + 10px);
+ right: calc(anchor(left) - 16px);
+ bottom: calc(anchor(bottom) + 4px);
+ background-color: rgba(100, 200, 255, 0.7);
+
+ border-radius: 10px;
+ }
+
+ & ul {
+ display: flex;
+ flex-direction: column;
+ align-content: center;
+ justify-content: space-around;
+ list-style: none;
+ margin: 0;
+
+ @supports (corner-shape: squircle) {
+ corner-shape: squircle;
+ border-radius: 24px;
+ }
+ }
+
+ & ul > li {
+ background: none;
+ color: var(--base-color-white);
+ padding: 24px 20px;
+ margin: 0;
+ border-radius: 5px;
+
+ @supports (corner-shape: squircle) {
+ corner-shape: squircle;
+ border-radius: 24px;
+ }
+ }
+}
+
+section {
+ margin-bottom: 40px;
+}
+
+section:last-child {
+ margin-bottom: 0;
+}
+
+h2 {
+ color: white;
+ margin-bottom: 15px;
+ font-size: 1.8em;
+}
+
+p {
+ margin-bottom: 15px;
+ font-size: 1.1em;
+}
+
+/* Lists */
+ul {
+ list-style-position: inside;
+ text-align: left;
+ margin-left: 20px;
+ color: rgba(var(--base-color), 0.9);
+}
+
+li {
+ margin-bottom: 10px;
+ font-size: 1.05em;
+ color: rgba(var(--base-color), 0.9);
+}
+
+/* Welcome Section */
+.welcome {
+ text-align: center;
+ padding: 20px 0;
+}
+
+/* Features Section */
+.features ul {
+ list-style: none;
+ margin-left: 0;
+}
+
+/* .features li {
+ background: #f7f7f7;
+ padding: 12px 20px;
+ margin: 10px 0;
+ border-radius: 5px;
+ border-left: 4px solid #667eea;
+} */
+
+/* Footer */
+footer {
+ background: transparent;
+ color: var(--base-color-white);
+ padding: 40px 30px;
+ text-align: center;
+ border-radius: 10px;
+ border-top: solid #f7f7f717;
+ border-width: 1px;
+
+ @supports (corner-shape: squircle) {
+ corner-shape: squircle;
+ border-radius: 0 0 24px 24px;
+ }
+}
+
+/* Markdown */
+.readme_content {
+ max-width: 700px;
+ margin: 0 auto;
+}
+
+.readme_content li {
+ color: var(--base-color-white);
+}
+
+pre {
+ background-color: #f7f7f777;
+ border-radius: 10px;
+ padding: 0.75rem;
+ text-wrap: auto;
+ margin: 1rem auto;
+
+ @supports (corner-shape: squircle) {
+ corner-shape: squircle;
+ border-radius: 24px;
+ }
+}
+
+code {
+ font-size: 14px;
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+ body {
+ padding: 10px;
+ }
+
+ header h1 {
+ font-size: 2em;
+ }
+
+ main {
+ padding: 20px 15px;
+ }
+
+ h2 {
+ font-size: 1.5em;
+ }
+}
+
+/* Keyframes */
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ transform: translateX(1rem);
+ visibility: hidden;
+ filter: grayscale(100%);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0rem);
+ visibility: visible;
+ filter: grayscale(0%);
+ }
+}
diff --git a/src/main/resources/static/images/tech-bg.jpg b/src/main/resources/static/images/tech-bg.jpg
new file mode 100644
index 00000000..08a60b97
Binary files /dev/null and b/src/main/resources/static/images/tech-bg.jpg differ
diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html
new file mode 100644
index 00000000..38b328b8
--- /dev/null
+++ b/src/main/resources/static/index.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+ Java HTTP Server - Team juv25d
+
+
+
+
+
π©Ί
+
+
+
Local Time:
+
UTC Time:
+
Version:
+
Commit:
+
Response:
+
Memory:
+
+
+
+
+
+
+ π Java HTTP Server
+ Built by Team juv25d
+
+
+
+
+ Welcome to Our HTTP Server!
+ This server is built from scratch in Java as a learning project.
+
+
+ Features
+
+
+ β
HTTP Request Parsing
+ β
HTTP Response Writing
+ β
Static File Serving
+ β
MIME Type Detection
+ β
Security (Path Traversal Prevention)
+ β
Logging
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js
new file mode 100644
index 00000000..41a2c2cb
--- /dev/null
+++ b/src/main/resources/static/js/app.js
@@ -0,0 +1,100 @@
+// app.js - JavaScript for Java HTTP Server
+// This file tests that JavaScript files are being served correctly
+
+console.log('β
JavaScript loaded successfully!');
+
+// Log some server info when page loads
+document.addEventListener("DOMContentLoaded", () => {
+ console.log('π Server: Java HTTP Server');
+ console.log('π₯ Team: juv25d');
+ console.log('π Current path:', window.location.pathname);
+ console.log('β¨ Static file serving is working!');
+
+ route(window.location.pathname);
+});
+
+window.addEventListener("popstate", () => {
+ navigate(window.location.pathname);
+});
+
+
+const routes = {
+ "/index.html": () => {},
+ "/readme.html": initReadme,
+};
+
+function route(path) {
+ const cleanPath = path.startsWith("/") ? path : "/" + path;
+ const handler = routes[cleanPath];
+ if (handler) handler();
+}
+
+
+const nav = document.querySelector(".nav-menu");
+
+function navigate(href) {
+ nav.classList.add("disable-anchors");
+ const main = document.getElementById("main-content");
+
+ main.classList.add("fade-out");
+
+ setTimeout(() => {
+ fetch(href)
+ .then(res => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.text();
+ })
+ .then(html => {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ const newMain = doc.querySelector("main");
+
+ if (!newMain) throw new Error("No found in " + href);
+
+ main.innerHTML = newMain.innerHTML;
+ history.pushState(null, "", href);
+ route(href);
+
+ main.classList.remove("fade-out");
+
+ setTimeout(() => {
+ nav.classList.remove("disable-anchors");
+ }, 150);
+ })
+ .catch(err => {
+ console.error("Navigation failed:", err);
+ main.classList.remove("fade-out");
+ nav.classList.remove("disable-anchors");
+ });
+ }, 200);
+}
+
+
+document.addEventListener("click", (e) => {
+ const link = e.target.closest("a");
+ if (!link) return;
+
+ const href = link.getAttribute("href");
+ if (!href || !href.endsWith(".html")) return;
+
+ e.preventDefault();
+ navigate(href);
+});
+
+
+
+function initReadme() {
+ const container = document.getElementById("readme_content");
+ if (!container) return;
+
+ fetch("/README.md")
+ .then(res => {
+ if (!res.ok) throw new Error("Failed to load README.md");
+ return res.text();
+ })
+ .then(md => {
+ container.innerHTML = DOMPurify.sanitize(marked.parse(md));
+ })
+ .catch(err => console.error("Failed to load README.md", err));
+}
+
+
diff --git a/src/main/resources/static/js/marked.min.js b/src/main/resources/static/js/marked.min.js
new file mode 100644
index 00000000..2260afef
--- /dev/null
+++ b/src/main/resources/static/js/marked.min.js
@@ -0,0 +1,69 @@
+/**
+ * marked v15.0.12 - a markdown parser
+ * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed)
+ * https://github.com/markedjs/marked
+ */
+
+/**
+ * DO NOT EDIT THIS FILE
+ * The code in this file is generated from files in ./src/
+ */
+(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports};
+ "use strict";var H=Object.defineProperty;var be=Object.getOwnPropertyDescriptor;var Te=Object.getOwnPropertyNames;var we=Object.prototype.hasOwnProperty;var ye=(l,e)=>{for(var t in e)H(l,t,{get:e[t],enumerable:!0})},Re=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of Te(e))!we.call(l,s)&&s!==t&&H(l,s,{get:()=>e[s],enumerable:!(n=be(e,s))||n.enumerable});return l};var Se=l=>Re(H({},"__esModule",{value:!0}),l);var kt={};ye(kt,{Hooks:()=>L,Lexer:()=>x,Marked:()=>E,Parser:()=>b,Renderer:()=>$,TextRenderer:()=>_,Tokenizer:()=>S,defaults:()=>w,getDefaults:()=>z,lexer:()=>ht,marked:()=>k,options:()=>it,parse:()=>pt,parseInline:()=>ct,parser:()=>ut,setOptions:()=>ot,use:()=>lt,walkTokens:()=>at});module.exports=Se(kt);function z(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var w=z();function N(l){w=l}var I={exec:()=>null};function h(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,i)=>{let r=typeof i=="string"?i:i.source;return r=r.replace(m.caret,"$1"),t=t.replace(s,r),n},getRegex:()=>new RegExp(t,e)};return n}var m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}#`),htmlBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}<(?:[a-z].*>|!--)`,"i")},$e=/^(?:[ \t]*(?:\n|$))+/,_e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Le=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,O=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,ze=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,F=/(?:[*+-]|\d{1,9}[.)])/,ie=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,oe=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Me=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Q=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Pe=/^[^\n]+/,U=/(?!\s*\])(?:\\.|[^\[\]\\])+/,Ae=h(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",U).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ee=h(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,F).getRegex(),v="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",K=/|$))/,Ce=h("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",K).replace("tag",v).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),le=h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Ie=h(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",le).getRegex(),X={blockquote:Ie,code:_e,def:Ae,fences:Le,heading:ze,hr:O,html:Ce,lheading:oe,list:Ee,newline:$e,paragraph:le,table:I,text:Pe},re=h("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Oe={...X,lheading:Me,table:re,paragraph:h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",re).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex()},Be={...X,html:h(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?\\1> *(?:\\n{2,}|\\s*$)| \\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",K).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:I,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:h(Q).replace("hr",O).replace("heading",` *#{1,6} *[^
+]`).replace("lheading",oe).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},qe=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,ve=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,De=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,ue=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,je=h(ue,"u").replace(/punct/g,D).getRegex(),Fe=h(ue,"u").replace(/punct/g,pe).getRegex(),he="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Qe=h(he,"gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Ue=h(he,"gu").replace(/notPunctSpace/g,He).replace(/punctSpace/g,Ge).replace(/punct/g,pe).getRegex(),Ke=h("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Xe=h(/\\(punct)/,"gu").replace(/punct/g,D).getRegex(),We=h(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Je=h(K).replace("(?:-->|$)","-->").getRegex(),Ve=h("^comment|^[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",Je).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),q=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Ye=h(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",q).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ke=h(/^!?\[(label)\]\[(ref)\]/).replace("label",q).replace("ref",U).getRegex(),ge=h(/^!?\[(ref)\](?:\[\])?/).replace("ref",U).getRegex(),et=h("reflink|nolink(?!\\()","g").replace("reflink",ke).replace("nolink",ge).getRegex(),J={_backpedal:I,anyPunctuation:Xe,autolink:We,blockSkip:Ne,br:ae,code:ve,del:I,emStrongLDelim:je,emStrongRDelimAst:Qe,emStrongRDelimUnd:Ke,escape:qe,link:Ye,nolink:ge,punctuation:Ze,reflink:ke,reflinkSearch:et,tag:Ve,text:De,url:I},tt={...J,link:h(/^!?\[(label)\]\((.*?)\)/).replace("label",q).getRegex(),reflink:h(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",q).getRegex()},j={...J,emStrongRDelimAst:Ue,emStrongLDelim:Fe,url:h(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},fe=l=>st[l];function R(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,fe)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,fe);return l}function V(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function Y(l,e){let t=l.replace(m.findPipe,(i,r,o)=>{let a=!1,c=r;for(;--c>=0&&o[c]==="\\";)a=!a;return a?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function me(l,e,t,n,s){let i=e.href,r=e.title||null,o=l[1].replace(s.other.outputLinkReplace,"$1");n.state.inLink=!0;let a={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:i,title:r,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,a}function rt(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let s=n[1];return e.split(`
+`).map(i=>{let r=i.match(t.other.beginningSpace);if(r===null)return i;let[o]=r;return o.length>=s.length?i.slice(s.length):i}).join(`
+`)}var S=class{options;rules;lexer;constructor(e){this.options=e||w}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:A(n,`
+`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=rt(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=A(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:A(t[0],`
+`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=A(t[0],`
+`).split(`
+`),s="",i="",r=[];for(;n.length>0;){let o=!1,a=[],c;for(c=0;c1,i={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let r=this.rules.other.listItemRegex(n),o=!1;for(;e;){let c=!1,p="",u="";if(!(t=r.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let d=t[2].split(`
+`,1)[0].replace(this.rules.other.listReplaceTabs,Z=>" ".repeat(3*Z.length)),g=e.split(`
+`,1)[0],T=!d.trim(),f=0;if(this.options.pedantic?(f=2,u=d.trimStart()):T?f=t[1].length+1:(f=t[2].search(this.rules.other.nonSpaceChar),f=f>4?1:f,u=d.slice(f),f+=t[1].length),T&&this.rules.other.blankLine.test(g)&&(p+=g+`
+`,e=e.substring(g.length+1),c=!0),!c){let Z=this.rules.other.nextBulletRegex(f),te=this.rules.other.hrRegex(f),ne=this.rules.other.fencesBeginRegex(f),se=this.rules.other.headingBeginRegex(f),xe=this.rules.other.htmlBeginRegex(f);for(;e;){let G=e.split(`
+`,1)[0],C;if(g=G,this.options.pedantic?(g=g.replace(this.rules.other.listReplaceNesting," "),C=g):C=g.replace(this.rules.other.tabCharGlobal," "),ne.test(g)||se.test(g)||xe.test(g)||Z.test(g)||te.test(g))break;if(C.search(this.rules.other.nonSpaceChar)>=f||!g.trim())u+=`
+`+C.slice(f);else{if(T||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||ne.test(d)||se.test(d)||te.test(d))break;u+=`
+`+g}!T&&!g.trim()&&(T=!0),p+=G+`
+`,e=e.substring(G.length+1),d=C.slice(f)}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(o=!0));let y=null,ee;this.options.gfm&&(y=this.rules.other.listIsTask.exec(u),y&&(ee=y[0]!=="[ ] ",u=u.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:p,task:!!y,checked:ee,loose:!1,text:u,tokens:[]}),i.raw+=p}let a=i.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let c=0;cd.type==="space"),u=p.length>0&&p.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=u}if(i.loose)for(let c=0;c({text:a,tokens:this.lexer.inline(a),header:!1,align:r.align[c]})));return r}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===`
+`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let r=A(n.slice(0,-1),"\\");if((n.length-r.length)%2===0)return}else{let r=de(t[2],"()");if(r===-2)return;if(r>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+r;t[2]=t[2].substring(0,r),t[0]=t[0].substring(0,a).trim(),t[3]=""}}let s=t[2],i="";if(this.options.pedantic){let r=this.rules.other.pedanticHrefTitle.exec(s);r&&(s=r[1],i=r[3])}else i=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),me(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[s.toLowerCase()];if(!i){let r=n[0].charAt(0);return{type:"text",raw:r,text:r}}return me(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let r=[...s[0]].length-1,o,a,c=r,p=0,u=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(u.lastIndex=0,t=t.slice(-1*e.length+r);(s=u.exec(t))!=null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(a=[...o].length,s[3]||s[4]){c+=a;continue}else if((s[5]||s[6])&&r%3&&!((r+a)%3)){p+=a;continue}if(c-=a,c>0)continue;a=Math.min(a,a+c+p);let d=[...s[0]][0].length,g=e.slice(0,r+s.index+d+a);if(Math.min(r,a)%2){let f=g.slice(1,-1);return{type:"em",raw:g,text:f,tokens:this.lexer.inlineTokens(f)}}let T=g.slice(2,-2);return{type:"strong",raw:g,text:T,tokens:this.lexer.inlineTokens(T)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||w,this.options.tokenizer=this.options.tokenizer||new S,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:B.normal,inline:P.normal};this.options.pedantic?(t.block=B.pedantic,t.inline=P.pedantic):this.options.gfm&&(t.block=B.gfm,this.options.breaks?t.inline=P.breaks:t.inline=P.gfm),this.tokenizer.rules=t}static get rules(){return{block:B,inline:P}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,`
+`),this.blockTokens(e,this.tokens);for(let t=0;t(s=r.call({lexer:this},e,t))?(e=e.substring(s.raw.length),t.push(s),!0):!1))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);let r=t.at(-1);s.raw.length===1&&r!==void 0?r.raw+=`
+`:t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.text,this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.raw,this.inlineQueue.at(-1).src=r.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let i=e;if(this.options.extensions?.startBlock){let r=1/0,o=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},o),typeof a=="number"&&a>=0&&(r=Math.min(r,a))}),r<1/0&&r>=0&&(i=e.substring(0,r+1))}if(this.state.top&&(s=this.tokenizer.paragraph(i))){let r=t.at(-1);n&&r?.type==="paragraph"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s),n=i.length!==e.length,e=e.substring(s.raw.length);continue}if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="text"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(e){let r="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(r);break}else throw new Error(r)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,r="";for(;e;){i||(r=""),i=!1;let o;if(this.options.extensions?.inline?.some(c=>(o=c.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let c=t.at(-1);o.type==="text"&&c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,r)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let a=e;if(this.options.extensions?.startInline){let c=1/0,p=e.slice(1),u;this.options.extensions.startInline.forEach(d=>{u=d.call({lexer:this},p),typeof u=="number"&&u>=0&&(c=Math.min(c,u))}),c<1/0&&c>=0&&(a=e.substring(0,c+1))}if(o=this.tokenizer.inlineText(a)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(r=o.raw.slice(-1)),i=!0;let c=t.at(-1);c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(e){let c="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return t}};var $=class{options;parser;constructor(e){this.options=e||w}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+`
+`;return s?''+(n?i:R(i,!0))+`
+`:""+(n?i:R(i,!0))+`
+`}blockquote({tokens:e}){return`
+${this.parser.parse(e)}
+`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)}
+`}hr(e){return`
+`}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o
+`+s+""+i+`>
+`}listitem(e){let t="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?e.tokens[0]?.type==="paragraph"?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=n+" "+R(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`${t}
+`}checkbox({checked:e}){return" '}paragraph({tokens:e}){return`${this.parser.parseInline(e)}
+`}table(e){let t="",n="";for(let i=0;i${s}`),`
+`}tablerow({text:e}){return`
+${e}
+`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`${n}>
+`}strong({tokens:e}){return`${this.parser.parseInline(e)} `}em({tokens:e}){return`${this.parser.parseInline(e)} `}codespan({text:e}){return`${R(e,!0)}`}br(e){return" "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),i=V(e);if(i===null)return s;e=i;let r='"+s+" ",r}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let i=V(e);if(i===null)return R(n);e=i;let r=` ",r}text(e){return"tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):"escaped"in e&&e.escaped?e.text:R(e.text)}};var _=class{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return""+e}image({text:e}){return""+e}br(){return""}};var b=class l{options;renderer;textRenderer;constructor(e){this.options=e||w,this.options.renderer=this.options.renderer||new $,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new _}static parse(e,t){return new l(t).parse(e)}static parseInline(e,t){return new l(t).parseInline(e)}parse(e,t=!0){let n="";for(let s=0;s{let o=i[r].flat(1/0);n=n.concat(this.walkTokens(o,t))}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let r=t.renderers[i.name];r?t.renderers[i.name]=function(...o){let a=i.renderer.apply(this,o);return a===!1&&(a=r.apply(this,o)),a}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let r=t[i.level];r?r.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens)}),s.extensions=t),n.renderer){let i=this.defaults.renderer||new $(this.defaults);for(let r in n.renderer){if(!(r in i))throw new Error(`renderer '${r}' does not exist`);if(["options","parser"].includes(r))continue;let o=r,a=n.renderer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u||""}}s.renderer=i}if(n.tokenizer){let i=this.defaults.tokenizer||new S(this.defaults);for(let r in n.tokenizer){if(!(r in i))throw new Error(`tokenizer '${r}' does not exist`);if(["options","rules","lexer"].includes(r))continue;let o=r,a=n.tokenizer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.tokenizer=i}if(n.hooks){let i=this.defaults.hooks||new L;for(let r in n.hooks){if(!(r in i))throw new Error(`hook '${r}' does not exist`);if(["options","block"].includes(r))continue;let o=r,a=n.hooks[o],c=i[o];L.passThroughHooks.has(r)?i[o]=p=>{if(this.defaults.async)return Promise.resolve(a.call(i,p)).then(d=>c.call(i,d));let u=a.call(i,p);return c.call(i,u)}:i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.hooks=i}if(n.walkTokens){let i=this.defaults.walkTokens,r=n.walkTokens;s.walkTokens=function(o){let a=[];return a.push(r.call(this,o)),i&&(a=a.concat(i.call(this,o))),a}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let i={...s},r={...this.defaults,...i},o=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&i.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);let a=r.hooks?r.hooks.provideLexer():e?x.lex:x.lexInline,c=r.hooks?r.hooks.provideParser():e?b.parse:b.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(n):n).then(p=>a(p,r)).then(p=>r.hooks?r.hooks.processAllTokens(p):p).then(p=>r.walkTokens?Promise.all(this.walkTokens(p,r.walkTokens)).then(()=>p):p).then(p=>c(p,r)).then(p=>r.hooks?r.hooks.postprocess(p):p).catch(o);try{r.hooks&&(n=r.hooks.preprocess(n));let p=a(n,r);r.hooks&&(p=r.hooks.processAllTokens(p)),r.walkTokens&&this.walkTokens(p,r.walkTokens);let u=c(p,r);return r.hooks&&(u=r.hooks.postprocess(u)),u}catch(p){return o(p)}}}onError(e,t){return n=>{if(n.message+=`
+Please report this to https://github.com/markedjs/marked.`,e){let s="An error occurred:
"+R(n.message+"",!0)+" ";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new E;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,N(k.defaults),k};k.getDefaults=z;k.defaults=w;k.use=function(...l){return M.use(...l),k.defaults=M.defaults,N(k.defaults),k};k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=b;k.parser=b.parse;k.Renderer=$;k.TextRenderer=_;k.Lexer=x;k.lexer=x.lex;k.Tokenizer=S;k.Hooks=L;k.parse=k;var it=k.options,ot=k.setOptions,lt=k.use,at=k.walkTokens,ct=k.parseInline,pt=k,ut=b.parse,ht=x.lex;
+
+ if(__exports != exports)module.exports = exports;return module.exports}));
diff --git a/src/main/resources/static/js/metric.js b/src/main/resources/static/js/metric.js
new file mode 100644
index 00000000..1ccff5f6
--- /dev/null
+++ b/src/main/resources/static/js/metric.js
@@ -0,0 +1,38 @@
+async function loadHealth() {
+ try {
+ const res = await fetch("/metric");
+ if (res.status === 429) {
+ globalThis.location.href = "/";
+ return;
+ }
+ if (!res.ok) {
+ throw new Error(`HTTP ${res.status}`)
+ }
+ const data = await res.json();
+
+ document.getElementById("local-time").textContent = data.localTime;
+ document.getElementById("utc-time").textContent = data.utcTime;
+ document.getElementById("health-version").textContent = data.buildVersion;
+ document.getElementById("health-commit").textContent = data.gitCommit;
+ document.getElementById("health-response").textContent =
+ data.responseTimeUs + " Β΅s";
+
+ const usedMb = data.memory
+ ? (data.memory.usedBytes / 1024 / 1024).toFixed(1)
+ : "N/A";
+ const maxMb = data.memory
+ ? (data.memory.maxBytes / 1024 / 1024).toFixed(1)
+ : "N/A";
+
+ document.getElementById("health-memory").textContent =
+ `${usedMb} / ${maxMb} MB`;
+ document.getElementById("health-status").textContent = "UP";
+
+ } catch (e) {
+ document.getElementById("health-status").textContent = "DOWN";
+ }
+}
+
+// Update every second
+setInterval(loadHealth, 1_000);
+loadHealth();
diff --git a/src/main/resources/static/js/purify.min.js b/src/main/resources/static/js/purify.min.js
new file mode 100644
index 00000000..65b3163d
--- /dev/null
+++ b/src/main/resources/static/js/purify.min.js
@@ -0,0 +1,2 @@
+/*! @license DOMPurify 3.0.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.0.6/LICENSE */
+!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=N(Array.prototype.forEach),m=N(Array.prototype.pop),f=N(Array.prototype.push),p=N(String.prototype.toLowerCase),d=N(String.prototype.toString),h=N(String.prototype.match),g=N(String.prototype.replace),T=N(String.prototype.indexOf),y=N(String.prototype.trim),E=N(RegExp.prototype.test),A=(_=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:p;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function S(t){const n=l(null);for(const[o,i]of e(t))void 0!==r(t,o)&&(n[o]=i);return n}function R(e,t){for(;null!==e;){const n=r(e,t);if(n){if(n.get)return N(n.get);if("function"==typeof n.value)return N(n.value)}e=o(e)}return function(e){return console.warn("fallback value for",e),null}}const w=i(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","section","select","shadow","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),D=i(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","filter","font","g","glyph","glyphref","hkern","image","line","lineargradient","marker","mask","metadata","mpath","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),L=i(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),v=i(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),x=i(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),k=i(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),C=i(["#text"]),O=i(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","face","for","headers","height","hidden","high","href","hreflang","id","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","pattern","placeholder","playsinline","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","xmlns","slot"]),I=i(["accent-height","accumulate","additive","alignment-baseline","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clippathunits","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dur","edgemode","elevation","end","fill","fill-opacity","fill-rule","filter","filterunits","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","points","preservealpha","preserveaspectratio","primitiveunits","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","specularconstant","specularexponent","spreadmethod","startoffset","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","systemlanguage","tabindex","targetx","targety","transform","transform-origin","text-anchor","text-decoration","text-rendering","textlength","type","u1","u2","unicode","values","viewbox","visibility","version","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"]),M=i(["accent","accentunder","align","bevelled","close","columnsalign","columnlines","columnspan","denomalign","depth","dir","display","displaystyle","encoding","fence","frame","height","href","id","largeop","length","linethickness","lspace","lquote","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"]),U=i(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),P=a(/\{\{[\w\W]*|[\w\W]*\}\}/gm),F=a(/<%[\w\W]*|[\w\W]*%>/gm),H=a(/\${[\w\W]*}/gm),z=a(/^data-[\-\w.\u00B7-\uFFFF]/),B=a(/^aria-[\-\w]+$/),W=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),G=a(/^(?:\w+script|data):/i),Y=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),j=a(/^html$/i);var q=Object.freeze({__proto__:null,MUSTACHE_EXPR:P,ERB_EXPR:F,TMPLIT_EXPR:H,DATA_ATTR:z,ARIA_ATTR:B,IS_ALLOWED_URI:W,IS_SCRIPT_OR_DATA:G,ATTR_WHITESPACE:Y,DOCTYPE_NAME:j});const X=function(){return"undefined"==typeof window?null:window},K=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}};var V=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:X();const o=e=>t(e);if(o.version="3.0.6",o.removed=[],!n||!n.document||9!==n.document.nodeType)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:_,Node:N,Element:P,NodeFilter:F,NamedNodeMap:H=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:z,DOMParser:B,trustedTypes:G}=n,Y=P.prototype,V=R(Y,"cloneNode"),$=R(Y,"nextSibling"),Z=R(Y,"childNodes"),J=R(Y,"parentNode");if("function"==typeof _){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let Q,ee="";const{implementation:te,createNodeIterator:ne,createDocumentFragment:oe,getElementsByTagName:re}=r,{importNode:ie}=a;let ae={};o.isSupported="function"==typeof e&&"function"==typeof J&&te&&void 0!==te.createHTMLDocument;const{MUSTACHE_EXPR:le,ERB_EXPR:ce,TMPLIT_EXPR:se,DATA_ATTR:ue,ARIA_ATTR:me,IS_SCRIPT_OR_DATA:fe,ATTR_WHITESPACE:pe}=q;let{IS_ALLOWED_URI:de}=q,he=null;const ge=b({},[...w,...D,...L,...x,...C]);let Te=null;const ye=b({},[...O,...I,...M,...U]);let Ee=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Ae=null,_e=null,Ne=!0,be=!0,Se=!1,Re=!0,we=!1,De=!1,Le=!1,ve=!1,xe=!1,ke=!1,Ce=!1,Oe=!0,Ie=!1;const Me="user-content-";let Ue=!0,Pe=!1,Fe={},He=null;const ze=b({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Be=null;const We=b({},["audio","video","img","source","image","track"]);let Ge=null;const Ye=b({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),je="http://www.w3.org/1998/Math/MathML",qe="http://www.w3.org/2000/svg",Xe="http://www.w3.org/1999/xhtml";let Ke=Xe,Ve=!1,$e=null;const Ze=b({},[je,qe,Xe],d);let Je=null;const Qe=["application/xhtml+xml","text/html"],et="text/html";let tt=null,nt=null;const ot=r.createElement("form"),rt=function(e){return e instanceof RegExp||e instanceof Function},it=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!nt||nt!==e){if(e&&"object"==typeof e||(e={}),e=S(e),Je=Je=-1===Qe.indexOf(e.PARSER_MEDIA_TYPE)?et:e.PARSER_MEDIA_TYPE,tt="application/xhtml+xml"===Je?d:p,he="ALLOWED_TAGS"in e?b({},e.ALLOWED_TAGS,tt):ge,Te="ALLOWED_ATTR"in e?b({},e.ALLOWED_ATTR,tt):ye,$e="ALLOWED_NAMESPACES"in e?b({},e.ALLOWED_NAMESPACES,d):Ze,Ge="ADD_URI_SAFE_ATTR"in e?b(S(Ye),e.ADD_URI_SAFE_ATTR,tt):Ye,Be="ADD_DATA_URI_TAGS"in e?b(S(We),e.ADD_DATA_URI_TAGS,tt):We,He="FORBID_CONTENTS"in e?b({},e.FORBID_CONTENTS,tt):ze,Ae="FORBID_TAGS"in e?b({},e.FORBID_TAGS,tt):{},_e="FORBID_ATTR"in e?b({},e.FORBID_ATTR,tt):{},Fe="USE_PROFILES"in e&&e.USE_PROFILES,Ne=!1!==e.ALLOW_ARIA_ATTR,be=!1!==e.ALLOW_DATA_ATTR,Se=e.ALLOW_UNKNOWN_PROTOCOLS||!1,Re=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,we=e.SAFE_FOR_TEMPLATES||!1,De=e.WHOLE_DOCUMENT||!1,xe=e.RETURN_DOM||!1,ke=e.RETURN_DOM_FRAGMENT||!1,Ce=e.RETURN_TRUSTED_TYPE||!1,ve=e.FORCE_BODY||!1,Oe=!1!==e.SANITIZE_DOM,Ie=e.SANITIZE_NAMED_PROPS||!1,Ue=!1!==e.KEEP_CONTENT,Pe=e.IN_PLACE||!1,de=e.ALLOWED_URI_REGEXP||W,Ke=e.NAMESPACE||Xe,Ee=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&rt(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Ee.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&rt(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Ee.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(Ee.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),we&&(be=!1),ke&&(xe=!0),Fe&&(he=b({},[...C]),Te=[],!0===Fe.html&&(b(he,w),b(Te,O)),!0===Fe.svg&&(b(he,D),b(Te,I),b(Te,U)),!0===Fe.svgFilters&&(b(he,L),b(Te,I),b(Te,U)),!0===Fe.mathMl&&(b(he,x),b(Te,M),b(Te,U))),e.ADD_TAGS&&(he===ge&&(he=S(he)),b(he,e.ADD_TAGS,tt)),e.ADD_ATTR&&(Te===ye&&(Te=S(Te)),b(Te,e.ADD_ATTR,tt)),e.ADD_URI_SAFE_ATTR&&b(Ge,e.ADD_URI_SAFE_ATTR,tt),e.FORBID_CONTENTS&&(He===ze&&(He=S(He)),b(He,e.FORBID_CONTENTS,tt)),Ue&&(he["#text"]=!0),De&&b(he,["html","head","body"]),he.table&&(b(he,["tbody"]),delete Ae.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');Q=e.TRUSTED_TYPES_POLICY,ee=Q.createHTML("")}else void 0===Q&&(Q=K(G,c)),null!==Q&&"string"==typeof ee&&(ee=Q.createHTML(""));i&&i(e),nt=e}},at=b({},["mi","mo","mn","ms","mtext"]),lt=b({},["foreignobject","desc","title","annotation-xml"]),ct=b({},["title","style","font","a","script"]),st=b({},D);b(st,L),b(st,v);const ut=b({},x);b(ut,k);const mt=function(e){let t=J(e);t&&t.tagName||(t={namespaceURI:Ke,tagName:"template"});const n=p(e.tagName),o=p(t.tagName);return!!$e[e.namespaceURI]&&(e.namespaceURI===qe?t.namespaceURI===Xe?"svg"===n:t.namespaceURI===je?"svg"===n&&("annotation-xml"===o||at[o]):Boolean(st[n]):e.namespaceURI===je?t.namespaceURI===Xe?"math"===n:t.namespaceURI===qe?"math"===n&<[o]:Boolean(ut[n]):e.namespaceURI===Xe?!(t.namespaceURI===qe&&!lt[o])&&(!(t.namespaceURI===je&&!at[o])&&(!ut[n]&&(ct[n]||!st[n]))):!("application/xhtml+xml"!==Je||!$e[e.namespaceURI]))},ft=function(e){f(o.removed,{element:e});try{e.parentNode.removeChild(e)}catch(t){e.remove()}},pt=function(e,t){try{f(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){f(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e&&!Te[e])if(xe||ke)try{ft(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},dt=function(e){let t=null,n=null;if(ve)e=" "+e;else{const t=h(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===Je&&Ke===Xe&&(e=''+e+"");const o=Q?Q.createHTML(e):e;if(Ke===Xe)try{t=(new B).parseFromString(o,Je)}catch(e){}if(!t||!t.documentElement){t=te.createDocument(Ke,"template",null);try{t.documentElement.innerHTML=Ve?ee:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),Ke===Xe?re.call(t,De?"html":"body")[0]:De?t.documentElement:i},ht=function(e){return ne.call(e.ownerDocument||e,e,F.SHOW_ELEMENT|F.SHOW_COMMENT|F.SHOW_TEXT,null)},gt=function(e){return e instanceof z&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof H)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Tt=function(e){return"function"==typeof N&&e instanceof N},yt=function(e,t,n){ae[e]&&u(ae[e],(e=>{e.call(o,t,n,nt)}))},Et=function(e){let t=null;if(yt("beforeSanitizeElements",e,null),gt(e))return ft(e),!0;const n=tt(e.nodeName);if(yt("uponSanitizeElement",e,{tagName:n,allowedTags:he}),e.hasChildNodes()&&!Tt(e.firstElementChild)&&E(/<[/\w]/g,e.innerHTML)&&E(/<[/\w]/g,e.textContent))return ft(e),!0;if(!he[n]||Ae[n]){if(!Ae[n]&&_t(n)){if(Ee.tagNameCheck instanceof RegExp&&E(Ee.tagNameCheck,n))return!1;if(Ee.tagNameCheck instanceof Function&&Ee.tagNameCheck(n))return!1}if(Ue&&!He[n]){const t=J(e)||e.parentNode,n=Z(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o)t.insertBefore(V(n[o],!0),$(e))}}return ft(e),!0}return e instanceof P&&!mt(e)?(ft(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!E(/<\/no(script|embed|frames)/i,e.innerHTML)?(we&&3===e.nodeType&&(t=e.textContent,u([le,ce,se],(e=>{t=g(t,e," ")})),e.textContent!==t&&(f(o.removed,{element:e.cloneNode()}),e.textContent=t)),yt("afterSanitizeElements",e,null),!1):(ft(e),!0)},At=function(e,t,n){if(Oe&&("id"===t||"name"===t)&&(n in r||n in ot))return!1;if(be&&!_e[t]&&E(ue,t));else if(Ne&&E(me,t));else if(!Te[t]||_e[t]){if(!(_t(e)&&(Ee.tagNameCheck instanceof RegExp&&E(Ee.tagNameCheck,e)||Ee.tagNameCheck instanceof Function&&Ee.tagNameCheck(e))&&(Ee.attributeNameCheck instanceof RegExp&&E(Ee.attributeNameCheck,t)||Ee.attributeNameCheck instanceof Function&&Ee.attributeNameCheck(t))||"is"===t&&Ee.allowCustomizedBuiltInElements&&(Ee.tagNameCheck instanceof RegExp&&E(Ee.tagNameCheck,n)||Ee.tagNameCheck instanceof Function&&Ee.tagNameCheck(n))))return!1}else if(Ge[t]);else if(E(de,g(n,pe,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==T(n,"data:")||!Be[e]){if(Se&&!E(fe,g(n,pe,"")));else if(n)return!1}else;return!0},_t=function(e){return e.indexOf("-")>0},Nt=function(e){yt("beforeSanitizeAttributes",e,null);const{attributes:t}=e;if(!t)return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:Te};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=tt(a);let f="value"===a?c:y(c);if(n.attrName=s,n.attrValue=f,n.keepAttr=!0,n.forceKeepAttr=void 0,yt("uponSanitizeAttribute",e,n),f=n.attrValue,n.forceKeepAttr)continue;if(pt(a,e),!n.keepAttr)continue;if(!Re&&E(/\/>/i,f)){pt(a,e);continue}we&&u([le,ce,se],(e=>{f=g(f,e," ")}));const p=tt(e.nodeName);if(At(p,s,f)){if(!Ie||"id"!==s&&"name"!==s||(pt(a,e),f=Me+f),Q&&"object"==typeof G&&"function"==typeof G.getAttributeType)if(l);else switch(G.getAttributeType(p,s)){case"TrustedHTML":f=Q.createHTML(f);break;case"TrustedScriptURL":f=Q.createScriptURL(f)}try{l?e.setAttributeNS(l,a,f):e.setAttribute(a,f),m(o.removed)}catch(e){}}}yt("afterSanitizeAttributes",e,null)},bt=function e(t){let n=null;const o=ht(t);for(yt("beforeSanitizeShadowDOM",t,null);n=o.nextNode();)yt("uponSanitizeShadowNode",n,null),Et(n)||(n.content instanceof s&&e(n.content),Nt(n));yt("afterSanitizeShadowDOM",t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(Ve=!e,Ve&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Tt(e)){if("function"!=typeof e.toString)throw A("toString is not a function");if("string"!=typeof(e=e.toString()))throw A("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Le||it(t),o.removed=[],"string"==typeof e&&(Pe=!1),Pe){if(e.nodeName){const t=tt(e.nodeName);if(!he[t]||Ae[t])throw A("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof N)n=dt("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),1===r.nodeType&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!xe&&!we&&!De&&-1===e.indexOf("<"))return Q&&Ce?Q.createHTML(e):e;if(n=dt(e),!n)return xe?null:Ce?ee:""}n&&ve&&ft(n.firstChild);const c=ht(Pe?e:n);for(;i=c.nextNode();)Et(i)||(i.content instanceof s&&bt(i.content),Nt(i));if(Pe)return e;if(xe){if(ke)for(l=oe.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(Te.shadowroot||Te.shadowrootmode)&&(l=ie.call(a,l,!0)),l}let m=De?n.outerHTML:n.innerHTML;return De&&he["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&E(j,n.ownerDocument.doctype.name)&&(m="\n"+m),we&&u([le,ce,se],(e=>{m=g(m,e," ")})),Q&&Ce?Q.createHTML(m):m},o.setConfig=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};it(e),Le=!0},o.clearConfig=function(){nt=null,Le=!1},o.isValidAttribute=function(e,t,n){nt||it({});const o=tt(e),r=tt(t);return At(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&(ae[e]=ae[e]||[],f(ae[e],t))},o.removeHook=function(e){if(ae[e])return m(ae[e])},o.removeHooks=function(e){ae[e]&&(ae[e]=[])},o.removeAllHooks=function(){ae={}},o}();return V}));
diff --git a/src/main/resources/static/readme.html b/src/main/resources/static/readme.html
new file mode 100644
index 00000000..a675bb97
--- /dev/null
+++ b/src/main/resources/static/readme.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ Java HTTP Server - Team juv25d || Readme
+
+
+
+
+
+
+ 🔍 ReadMe 🔎
+ By Team JUV25D
+
+
+
+
+ Welcome to our Read Me page
+ Here you will find all the information you need about this project's structure and code
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/test/java/org/example/AppIT.java b/src/test/java/org/example/AppIT.java
deleted file mode 100644
index 9d1ca031..00000000
--- a/src/test/java/org/example/AppIT.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package org.example;
-
-import org.junit.jupiter.api.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-public class AppIT {
- @Test
- void itTest() {
- assertThat(false).isFalse();
- }
-}
diff --git a/src/test/java/org/juv25d/AppIT.java b/src/test/java/org/juv25d/AppIT.java
new file mode 100644
index 00000000..c7e2e871
--- /dev/null
+++ b/src/test/java/org/juv25d/AppIT.java
@@ -0,0 +1,53 @@
+package org.juv25d;
+
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.images.builder.ImageFromDockerfile;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Paths;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Testcontainers
+public class AppIT {
+
+ @Container
+ @SuppressWarnings("resource")
+ public static GenericContainer> server = new GenericContainer<>(
+ new ImageFromDockerfile("java-http-server-test")
+ .withFileFromPath(".", Paths.get("."))
+ ).withExposedPorts(8080)
+ .waitingFor(Wait.forHttp("/").forStatusCode(200));
+
+ private final HttpClient client = HttpClient.newHttpClient();
+
+ @Test
+ void shouldReturnIndexHtml() throws Exception {
+ HttpResponse response = get("/");
+
+ assertThat(response.statusCode()).isEqualTo(200);
+ assertThat(response.body()).contains("π Java HTTP Server");
+ assertThat(response.headers().firstValue("Content-Type")).get().asString().contains("text/html");
+ }
+
+ @Test
+ void shouldReturn404ForNonExistentPage() throws Exception {
+ HttpResponse response = get("/not-found.html");
+
+ assertThat(response.statusCode()).isEqualTo(404);
+ assertThat(response.body()).contains("404");
+ }
+
+ private HttpResponse get(String path) throws Exception {
+ String url = "http://" + server.getHost() + ":" + server.getMappedPort(8080) + path;
+ HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
+ return client.send(request, HttpResponse.BodyHandlers.ofString());
+ }
+}
diff --git a/src/test/java/org/example/AppTest.java b/src/test/java/org/juv25d/AppTest.java
similarity index 89%
rename from src/test/java/org/example/AppTest.java
rename to src/test/java/org/juv25d/AppTest.java
index d522a7e2..5e1d4d23 100644
--- a/src/test/java/org/example/AppTest.java
+++ b/src/test/java/org/juv25d/AppTest.java
@@ -1,4 +1,4 @@
-package org.example;
+package org.juv25d;
import org.junit.jupiter.api.Test;
diff --git a/src/test/java/org/juv25d/ArchitectureTest.java b/src/test/java/org/juv25d/ArchitectureTest.java
new file mode 100644
index 00000000..6923bece
--- /dev/null
+++ b/src/test/java/org/juv25d/ArchitectureTest.java
@@ -0,0 +1,172 @@
+package org.juv25d;
+
+import com.tngtech.archunit.core.domain.JavaClasses;
+import com.tngtech.archunit.core.importer.ClassFileImporter;
+import com.tngtech.archunit.core.importer.ImportOption;
+
+import com.tngtech.archunit.lang.syntax.ArchRuleDefinition;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+
+import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
+import static com.tngtech.archunit.core.domain.JavaClass.Predicates.simpleName;
+
+/**
+ * This system follows a specific lifecycle where an HTTP request is processed through specialized layers.
+ * To maintain a clean architecture, the core principle is that lower layers must never depend on higher layers.
+ * While the flow is not strictly linearβfor instance, filters must call the chain to proceedβthese rules ensure that
+ * dependencies only move in authorized directions and that components do not "skip" steps unnecessarily
+ *
+ * The request lifecycle is designed to follow this strict flow: (Runtime Flow)
+ *
+ * Client
+ * β
+ * ServerSocket
+ * β
+ * ConnectionHandler (Virtual Thread)
+ * β
+ * Pipeline
+ * β
+ * FilterChain
+ * β
+ * Router
+ * β
+ * Plugin
+ * β
+ * HttpResponseWriter
+ * β
+ * Client
+ *
+ * Note: This describes the runtime execution flow, not direct code dependencies.
+ * Dependencies are allowed for bootstrapping and controlled object creation,
+ * but must never violate the downward lifecycle direction.
+ */
+
+public class ArchitectureTest {
+
+ private static JavaClasses importedClasses;
+
+
+ @BeforeAll
+ static void setup() {
+ importedClasses = new ClassFileImporter()
+ .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
+ .importPackages("org.juv25d");
+ }
+ /**
+ * This rule ensures that only the Server and its associated factories can initiate a ConnectionHandler.
+ * This prevents other parts of the application from accidentally manipulating direct client connections.
+ */
+ @Test
+ void connectionHandlerAccessRule () {
+ ArchRuleDefinition.classes()
+ .that().haveSimpleName("ConnectionHandler")
+ .should().onlyBeAccessed().byClassesThat(
+ simpleName("Server")
+ .or(simpleName("ConnectionHandler"))
+ .or(simpleName("DefaultConnectionHandlerFactory"))
+ .or(simpleName("ConnectionHandlerFactory")))
+ .as("ConnectionHandler access rule")
+ .because("ConnectionHandler should only be accessed by server, connectionhandler or its factories")
+ .check(importedClasses);
+ }
+
+ /**
+ * Only the network layer (ConnectionHandler) or the application's startup class (App) may interact with the Pipeline.
+ * This guarantees that the execution chain remains intact and is not modified during an active request.
+ */
+ @Test
+ void pipelineAccessRule () {
+ ArchRuleDefinition.classes()
+ .that().haveSimpleName("Pipeline")
+ .should().onlyBeAccessed().byClassesThat(
+ simpleName("ConnectionHandler")
+ .or(simpleName("ConnectionHandlerFactory"))
+ .or(simpleName("DefaultConnectionHandlerFactory"))
+ .or(simpleName("Pipeline"))
+ .or(simpleName("App")) // App handles bootstrapping and wiring of the Pipeline during startup. This should stay.
+ .or(simpleName("Bootstrap")))
+ .as("Pipeline access rule")
+ .because("Pipeline should only be accessed by ConnectionHandler, App, Bootstrap during setup")
+ .check(importedClasses);
+ }
+
+
+ /**
+ * The FilterChain is created by the Pipeline and triggered by the ConnectionHandler.
+ * This rule also allows individual filters to access the chain.
+ */
+ @Test
+ void filterChainRule () {
+ ArchRuleDefinition.classes()
+ .that().haveSimpleName("FilterChain")
+ .should().onlyBeAccessed().byClassesThat(
+ simpleName("Pipeline")
+ .or(simpleName("FilterChain"))
+ .or(simpleName("FilterChainImpl"))
+ .or(resideInAPackage("..filter.."))
+ .or(simpleName("ConnectionHandler"))) //This needs to be accessed because ConnectionHandler creates doFilter()
+ .as("FilterChain access rule")
+ .because("FilterChain should only be accessed by Pipeline, ConnectionHandler")
+ .check(importedClasses);
+ }
+
+
+ /**
+ * The Router should only be accessed by the FilterChain to determine which plugin to execute,
+ * or by App and Pipeline during the system's bootstrapping phase.
+ */
+ @Test
+ void routerRule () {
+ ArchRuleDefinition.classes()
+ .that().haveSimpleName("Router")
+ .should().onlyBeAccessed().byClassesThat(
+ simpleName("FilterChain")
+ .or(simpleName("FilterChainImpl"))
+ .or(simpleName("Router"))
+ .or(simpleName("Pipeline")) //Pipeline injects router
+ .or(simpleName("App"))) //App Creates router
+ .as("Router access rule")
+ .because("Router should only be accessed by FilterChain, Pipeline, App")
+ .check(importedClasses);
+ }
+
+
+ /**
+ * Plugins must only be instantiated by App at startup and subsequently called by the router or
+ * the execution chain (FilterChainImpl).
+ */
+ @Test
+ void pluginRule () {
+ ArchRuleDefinition.classes()
+ .that().resideInAPackage("..plugin..")
+ .should().onlyBeAccessed().byClassesThat(
+ resideInAPackage("..router..")
+ .or(resideInAPackage("..plugin.."))
+ .or(simpleName("App")) //App creates plugin
+ .or(simpleName("FilterChainImpl"))) //FilterChainImpl calls the plugin after the router has decided which one to run.
+ .as("Plugin access rule")
+ .because("Plugins should only be managed by the Router, App, FilterChainImpl")
+ .check(importedClasses);
+ }
+
+ /**
+ * The HttpResponseWriter is the final step in the request lifecycle, responsible for delivering the response to the client.
+ * This rule ensures that only the ConnectionHandler accesses it, guaranteeing controlled delivery and architectural integrity.
+ */
+ @Test
+ void httpResponseWriterAccessRule() {
+ ArchRuleDefinition.classes()
+ .that().haveSimpleName("HttpResponseWriter")
+ .should().onlyBeAccessed().byClassesThat(
+ simpleName("ConnectionHandler")
+ .or(simpleName("HttpResponseWriter")))
+ .as("HttpResponseWriter access rule")
+ .because("HttpResponseWriter is the final step in the lifecycle and should" +
+ "only be used by ConnectionHandler to ensure controlled delivery")
+ .check(importedClasses);
+ }
+}
+
+
diff --git a/src/test/java/org/juv25d/PipelineTest.java b/src/test/java/org/juv25d/PipelineTest.java
new file mode 100644
index 00000000..2baee04a
--- /dev/null
+++ b/src/test/java/org/juv25d/PipelineTest.java
@@ -0,0 +1,4 @@
+import static org.junit.jupiter.api.Assertions.*;
+class PipelineTest {
+
+}
diff --git a/src/test/java/org/juv25d/di/ContainerTest.java b/src/test/java/org/juv25d/di/ContainerTest.java
new file mode 100644
index 00000000..aa6e34db
--- /dev/null
+++ b/src/test/java/org/juv25d/di/ContainerTest.java
@@ -0,0 +1,92 @@
+package org.juv25d.di;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ContainerTest {
+
+ static class A {}
+
+ static class B {
+ final A a;
+
+ public B(A a) {
+ this.a = a;
+ }
+ }
+
+ @Test
+ void shouldResolveSimpleDependency() {
+ Container container = new Container("org.juv25d");
+
+ B b = container.get(B.class);
+
+ assertNotNull(b);
+ assertNotNull(b.a);
+ }
+
+ @Test
+ void shouldReturnSameInstance() {
+ Container container = new Container("org.juv25d");
+
+ A a1 = container.get(A.class);
+ A a2 = container.get(A.class);
+
+ assertSame(a1, a2);
+ }
+
+ interface Service {}
+
+ static class ServiceImpl implements Service {}
+
+ @Test
+ void shouldResolveInterfaceBinding() {
+ Container container = new Container("org.juv25d");
+
+ container.bind(Service.class, ServiceImpl.class);
+
+ Service service = container.get(Service.class);
+
+ assertNotNull(service);
+ assertTrue(service instanceof ServiceImpl);
+ }
+
+ @Test
+ void shouldFailForUnboundInterface() {
+ Container container = new Container("org.juv25d");
+
+ assertThrows(RuntimeException.class, () ->
+ container.get(Service.class)
+ );
+ }
+
+ static class C1 {
+ public C1(C2 c2) {}
+ }
+
+ static class C2 {
+ public C2(C1 c1) {}
+ }
+
+ @Test
+ void shouldDetectCircularDependency() {
+ Container container = new Container("org.juv25d");
+
+ assertThrows(RuntimeException.class, () ->
+ container.get(C1.class)
+ );
+ }
+
+ @Test
+ void shouldUseRegisteredInstance() {
+ Container container = new Container("org.juv25d");
+
+ A custom = new A();
+ container.register(A.class, custom);
+
+ A resolved = container.get(A.class);
+
+ assertSame(custom, resolved);
+ }
+}
diff --git a/src/test/java/org/juv25d/filter/CorsFilterTest.java b/src/test/java/org/juv25d/filter/CorsFilterTest.java
new file mode 100644
index 00000000..c0a09782
--- /dev/null
+++ b/src/test/java/org/juv25d/filter/CorsFilterTest.java
@@ -0,0 +1,116 @@
+package org.juv25d.filter;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+public class CorsFilterTest {
+
+ private final CorsFilter filter = new CorsFilter();
+
+ @Test
+ void shouldAllowConfiguredOrigin_onGet() throws Exception {
+ HttpRequest req = request("GET", "/api/test", Map.of("Origin", "http://localhost:3000"));
+ HttpResponse res = response();
+ FilterChain chain = mock(FilterChain.class);
+
+ filter.doFilter(req, res, chain);
+
+ verify(chain).doFilter(req, res);
+ assertEquals("http://localhost:3000", res.getHeader("Access-Control-Allow-Origin"));
+ assertEquals("Origin", res.getHeader("Vary"));
+ }
+
+ @Test
+ void shouldNotAddCorsHeaders_whenNoOriginHeader() throws Exception {
+ HttpRequest req = request("GET", "/api/test", Map.of());
+ HttpResponse res = response();
+ FilterChain chain = mock(FilterChain.class);
+
+ filter.doFilter(req, res, chain);
+
+ verify(chain).doFilter(req, res);
+ assertNull(res.getHeader("Access-Control-Allow-Origin"));
+ }
+
+ @Test
+ void shouldHandlePreflightOptionsRequest() throws Exception {
+ HttpRequest req = request(
+ "OPTIONS",
+ "/api/test",
+ Map.of(
+ "Origin", "http://localhost:3000",
+ "Access-Control-Request-Method", "GET",
+ "Access-Control-Request-Headers", "Content-Type"
+ )
+ );
+ HttpResponse res = response();
+ FilterChain chain = mock(FilterChain.class);
+
+ filter.doFilter(req, res, chain);
+
+ verify(chain, never()).doFilter(any(), any());
+ assertEquals(204, res.statusCode());
+ assertEquals("No Content", res.statusText());
+ assertEquals("http://localhost:3000", res.getHeader("Access-Control-Allow-Origin"));
+ String methods = res.getHeader("Access-Control-Allow-Methods");
+ assertNotNull(methods);
+ assertTrue(methods.contains("GET"));
+ assertEquals("Content-Type", res.getHeader("Access-Control-Allow-Headers"));
+ assertArrayEquals(new byte[0], res.body());
+ }
+
+ @Test
+ void shouldNotAllowUnknownOrigin() throws Exception {
+ HttpRequest req = request("GET", "/api/test", Map.of("Origin", "http://evil.com"));
+ HttpResponse res = response();
+ FilterChain chain = mock(FilterChain.class);
+
+ filter.doFilter(req, res, chain);
+
+ verify(chain).doFilter(req, res);
+ assertNull(res.getHeader("Access-Control-Allow-Origin"));
+ }
+
+ @Test
+ void shouldFallbackToDefaultAllowHeaders_onPreflightWithoutRequestHeaders() throws Exception {
+ HttpRequest req = request(
+ "OPTIONS",
+ "/api/test",
+ Map.of(
+ "Origin", "http://localhost:3000",
+ "Access-Control-Request-Method", "GET"
+ )
+ );
+ HttpResponse res = response();
+ FilterChain chain = mock(FilterChain.class);
+
+ filter.doFilter(req, res, chain);
+
+ verify(chain, never()).doFilter(any(), any());
+ assertEquals(204, res.statusCode());
+ assertEquals("Content-Type", res.getHeader("Access-Control-Allow-Headers"));
+ }
+
+ private static HttpRequest request(String method, String path, Map headers) {
+ return new HttpRequest(
+ method,
+ path,
+ "",
+ "HTTP/1.1",
+ new HashMap<>(headers),
+ new byte[0],
+ "127.0.0.1"
+ );
+ }
+
+ private static HttpResponse response() {
+ return new HttpResponse(200, "OK", new HashMap<>(), new byte[0]);
+ }
+}
diff --git a/src/test/java/org/juv25d/filter/FilterChainImplTest.java b/src/test/java/org/juv25d/filter/FilterChainImplTest.java
new file mode 100644
index 00000000..7db325dd
--- /dev/null
+++ b/src/test/java/org/juv25d/filter/FilterChainImplTest.java
@@ -0,0 +1,130 @@
+package org.juv25d.filter;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+import org.juv25d.plugin.Plugin;
+import org.juv25d.router.SimpleRouter; // New import
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class FilterChainImplTest {
+
+ @Test
+ void filters_areCalledInOrderAndPluginLast() throws IOException {
+
+ List calls = new ArrayList<>();
+
+ Filter f1 = (req, res, chain) -> {
+ calls.add("f1-before");
+ chain.doFilter(req, res);
+ calls.add("f1-after");
+ };
+
+ Filter f2 = (req, res, chain) -> {
+ calls.add("f2-before");
+ chain.doFilter(req, res);
+ calls.add("f2-after");
+ };
+
+ Plugin plugin = (req, res) -> calls.add("plugin");
+ SimpleRouter router = new SimpleRouter();
+ router.registerPlugin("/", plugin); // Register the plugin with a path
+
+ FilterChainImpl chain = new FilterChainImpl(
+ List.of(f1, f2),
+ router // Pass the router
+ );
+
+ HttpRequest req = new HttpRequest(
+ "GET",
+ "/",
+ null, // queryString
+ "HTTP/1.1",
+ Map.of(),
+ new byte[0],
+ "UNKNOWN" // remoteIp
+ );
+
+ chain.doFilter(req, new HttpResponse(200, "OK", new HashMap<>(), new byte[0]));
+
+ assertEquals(
+ List.of(
+ "f1-before",
+ "f2-before",
+ "plugin",
+ "f2-after",
+ "f1-after"
+ ),
+ calls
+ );
+ }
+
+ @Test
+ void filter_canStopChainExecution() throws IOException {
+
+ List calls = new ArrayList<>();
+
+ Filter blockingFilter = (req, res, chain) -> {
+ calls.add("blocked");
+ };
+
+ Plugin plugin = (req, res) -> calls.add("plugin");
+ SimpleRouter router = new SimpleRouter();
+ router.registerPlugin("/", plugin); // Register the plugin with a path
+
+ FilterChainImpl chain = new FilterChainImpl(
+ List.of(blockingFilter),
+ router // Pass the router
+ );
+
+ HttpRequest req = new HttpRequest(
+ "GET",
+ "/",
+ null, // queryString
+ "HTTP/1.1",
+ Map.of(),
+ new byte[0],
+ "UNKNOWN" // remoteIp
+ );
+
+ chain.doFilter(req, new HttpResponse(200, "OK", new HashMap<>(), new byte[0]));
+
+ assertEquals(List.of("blocked"), calls);
+ }
+
+ @Test
+ void plugin_isCalledWhenNoFiltersExist() throws IOException {
+
+ List calls = new ArrayList<>();
+
+ Plugin plugin = (req, res) -> calls.add("plugin");
+ SimpleRouter router = new SimpleRouter();
+ router.registerPlugin("/", plugin); // Register the plugin with a path
+
+ FilterChainImpl chain = new FilterChainImpl(
+ List.of(),
+ router // Pass the router
+ );
+
+ HttpRequest req = new HttpRequest(
+ "GET",
+ "/",
+ null, // queryString
+ "HTTP/1.1",
+ Map.of(),
+ new byte[0],
+ "UNKNOWN" // remoteIp
+ );
+
+ chain.doFilter(req, new HttpResponse(200, "OK", new HashMap<>(), new byte[0]));
+
+ assertEquals(List.of("plugin"), calls);
+ }
+}
diff --git a/src/test/java/org/juv25d/filter/FilterRegistryTest.java b/src/test/java/org/juv25d/filter/FilterRegistryTest.java
new file mode 100644
index 00000000..e6041f0f
--- /dev/null
+++ b/src/test/java/org/juv25d/filter/FilterRegistryTest.java
@@ -0,0 +1,79 @@
+package org.juv25d.filter;
+
+import org.junit.jupiter.api.Test;
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class FilterRegistryTest {
+
+ static class TestFilter implements Filter {
+ int destroyCount = 0;
+
+ @Override
+ public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) {
+ }
+
+ @Override
+ public void destroy() {
+ destroyCount++;
+ }
+ }
+
+ @Test
+ void shouldRegisterGlobalFilter() {
+ FilterRegistry registry = new FilterRegistry();
+ TestFilter filter = new TestFilter();
+
+ registry.registerGlobal(filter, 1);
+
+ List globals = registry.getGlobalFilters();
+
+ assertEquals(1, globals.size());
+ assertSame(filter, globals.get(0).filter());
+ }
+
+ @Test
+ void shouldRegisterRouteFilter() {
+ FilterRegistry registry = new FilterRegistry();
+ TestFilter filter = new TestFilter();
+
+ registry.registerRoute(filter, 1, "/test");
+
+ Map> routes = registry.getRouteFilters();
+
+ assertTrue(routes.containsKey("/test"));
+ assertEquals(1, routes.get("/test").size());
+ assertSame(filter, routes.get("/test").get(0).filter());
+ }
+
+ @Test
+ void shouldReturnUnmodifiableCollections() {
+ FilterRegistry registry = new FilterRegistry();
+
+ assertThrows(UnsupportedOperationException.class, () ->
+ registry.getGlobalFilters().add(null)
+ );
+
+ assertThrows(UnsupportedOperationException.class, () ->
+ registry.getRouteFilters().put("x", List.of())
+ );
+ }
+
+ @Test
+ void shouldDestroyFiltersOnlyOnce() {
+ FilterRegistry registry = new FilterRegistry();
+ TestFilter filter = new TestFilter();
+
+ registry.registerGlobal(filter, 1);
+ registry.registerRoute(filter, 1, "/test");
+
+ registry.shutdown();
+
+ assertEquals(1, filter.destroyCount);
+ }
+}
diff --git a/src/test/java/org/juv25d/filter/ForwardedHeaderFilterTest.java b/src/test/java/org/juv25d/filter/ForwardedHeaderFilterTest.java
new file mode 100644
index 00000000..e44d6430
--- /dev/null
+++ b/src/test/java/org/juv25d/filter/ForwardedHeaderFilterTest.java
@@ -0,0 +1,155 @@
+package org.juv25d.filter;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+import org.juv25d.util.ConfigLoader;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Tests for the {@link ForwardedHeaderFilter} class.
+ */
+@ExtendWith(MockitoExtension.class)
+class ForwardedHeaderFilterTest {
+
+ @Mock
+ private HttpRequest req;
+ @Mock
+ private HttpResponse res;
+ @Mock
+ private FilterChain chain;
+
+ private final String expectedRemoteIp = "127.0.0.1";
+ private final String singleForwardedHeader = "127.0.0.1";
+ private final String multipleForwardedHeader = "127.0.0.1, 123.0.0.1, 83.2.0.12";
+ private final List trustedProxy = List.of("123.0.0.1", "83.2.0.12");
+
+ @Test
+ void shouldSetRemoteIp_fromForwardedHeader() throws IOException {
+ // Arrange
+ ForwardedHeaderFilter filter = new ForwardedHeaderFilter();
+ when(req.headers()).thenReturn(Map.of("X-Forwarded-For", singleForwardedHeader));
+ when(req.method()).thenReturn("GET");
+ when(req.path()).thenReturn("/test");
+ when(req.queryString()).thenReturn("");
+ when(req.httpVersion()).thenReturn("HTTP/1.1");
+ when(req.body()).thenReturn(new byte[0]);
+ when(req.creationTimeNanos()).thenReturn(1000L);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class);
+
+ // Act
+ filter.doFilter(req, res, chain);
+
+ // Assert
+ verify(chain).doFilter(captor.capture(), eq(res));
+ assertEquals(expectedRemoteIp, captor.getValue().remoteIp());
+ assertEquals("GET", captor.getValue().method());
+ assertEquals("/test", captor.getValue().path());
+ assertEquals("", captor.getValue().queryString());
+ assertEquals("HTTP/1.1", captor.getValue().httpVersion());
+ assertArrayEquals(new byte[0], captor.getValue().body());
+ assertEquals(1000L, captor.getValue().creationTimeNanos());
+ }
+
+ @Test
+ void shouldPassOnRequest_ifHeaderNotPresent() throws IOException {
+ // Arrange
+ ForwardedHeaderFilter filter = new ForwardedHeaderFilter();
+ when(req.remoteIp()).thenReturn(expectedRemoteIp);
+ when(req.headers()).thenReturn(Map.of());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class);
+
+ // Act
+ filter.doFilter(req, res, chain);
+
+ // Assert
+ verify(chain).doFilter(captor.capture(), eq(res));
+ assertEquals(expectedRemoteIp, captor.getValue().remoteIp());
+ }
+
+ @Test
+ void shouldPassOnRequest_ifHeaderIsBlank() throws IOException {
+ // Arrange
+ ForwardedHeaderFilter filter = new ForwardedHeaderFilter();
+ when(req.remoteIp()).thenReturn(expectedRemoteIp);
+ when(req.headers()).thenReturn(Map.of("X-Forwarded-For", ""));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class);
+
+ // Act
+ filter.doFilter(req, res, chain);
+
+ // Assert
+ verify(chain).doFilter(captor.capture(), eq(res));
+ assertEquals(expectedRemoteIp, captor.getValue().remoteIp());
+ }
+
+ @Test
+ void shouldReturnIp_toTheLeftOfTrustedProxy() throws IOException {
+ // Arrange
+ ConfigLoader configLoader = mock(ConfigLoader.class);
+ when(configLoader.getTrustedProxies()).thenReturn(trustedProxy);
+ when(req.headers()).thenReturn(Map.of("X-Forwarded-For", multipleForwardedHeader));
+
+ ForwardedHeaderFilter filter = new ForwardedHeaderFilter(configLoader);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class);
+
+ // Act
+ filter.doFilter(req, res, chain);
+
+ // Assert
+ verify(chain).doFilter(captor.capture(), eq(res));
+ assertEquals(expectedRemoteIp, captor.getValue().remoteIp());
+ }
+
+ @Test
+ void shouldReturnFirstIp_whenNoTrustedProxiesAreConfigured() throws IOException {
+ // Arrange
+ ConfigLoader configLoader = mock(ConfigLoader.class);
+ when(configLoader.getTrustedProxies()).thenReturn(List.of());
+ when(req.headers()).thenReturn(Map.of("X-Forwarded-For", multipleForwardedHeader));
+
+ ForwardedHeaderFilter filter = new ForwardedHeaderFilter(configLoader);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class);
+
+ // Act
+ filter.doFilter(req, res, chain);
+
+ // Assert
+ verify(chain).doFilter(captor.capture(), eq(res));
+ assertEquals(singleForwardedHeader, captor.getValue().remoteIp());
+ }
+
+ @Test
+ void shouldReturnFirstIp_whenAllIpsAreTrusted() throws IOException {
+ // Arrange
+ ConfigLoader configLoader = mock(ConfigLoader.class);
+ when(configLoader.getTrustedProxies()).thenReturn(List.of("127.0.0.1", "123.0.0.1", "83.2.0.12"));
+ when(req.headers()).thenReturn(Map.of("X-Forwarded-For", multipleForwardedHeader));
+
+ ForwardedHeaderFilter filter = new ForwardedHeaderFilter(configLoader);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class);
+
+ // Act
+ filter.doFilter(req, res, chain);
+
+ // Assert
+ verify(chain).doFilter(captor.capture(), eq(res));
+ assertEquals(expectedRemoteIp, captor.getValue().remoteIp());
+ }
+}
diff --git a/src/test/java/org/juv25d/filter/IpFilterTest.java b/src/test/java/org/juv25d/filter/IpFilterTest.java
new file mode 100644
index 00000000..bef44170
--- /dev/null
+++ b/src/test/java/org/juv25d/filter/IpFilterTest.java
@@ -0,0 +1,67 @@
+package org.juv25d.filter;
+import org.junit.jupiter.api.Test;
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+
+import java.io.IOException;
+import java.util.Set;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.*;
+
+class IpFilterTest {
+
+ HttpRequest req = mock(HttpRequest.class);
+ HttpResponse res = new HttpResponse();
+ FilterChain chain = mock(FilterChain.class);
+
+ @Test
+ void whitelist_allowsIp() throws IOException {
+ IpFilter filter = new IpFilter(Set.of("127.0.0.1"), null, false);
+
+ when(req.remoteIp()).thenReturn("127.0.0.1");
+
+ filter.doFilter(req, res, chain);
+
+ verify(chain).doFilter(req, res);
+ assertEquals(200, res.statusCode());
+ }
+
+ @Test
+ void blacklist_blocksIp() throws IOException {
+ IpFilter filter = new IpFilter(null, Set.of("127.0.0.1"), true);
+
+ when(req.remoteIp()).thenReturn("127.0.0.1");
+
+ filter.doFilter(req, res, chain);
+ verify(chain, never()).doFilter(req, res);
+
+ assertEquals(403, res.statusCode());
+ assertEquals("Forbidden", res.statusText());
+
+ }
+
+ @Test
+ void allowsIP_inBothList_defaultTrue() throws IOException {
+ IpFilter filter = new IpFilter(Set.of("127.0.0.1"), Set.of("127.0.0.1"), true);
+
+ when(req.remoteIp()).thenReturn("127.0.0.1");
+
+ filter.doFilter(req, res, chain);
+
+ verify(chain).doFilter(req, res);
+ assertEquals(200, res.statusCode());
+ }
+
+ @Test
+ void blocksIP_inNeitherList_defaultFalse() throws IOException {
+ IpFilter filter = new IpFilter(null, null, false);
+
+ when(req.remoteIp()).thenReturn("127.0.0.1");
+
+ filter.doFilter(req, res, chain);
+ verify(chain, never()).doFilter(req, res);
+
+ assertEquals(403, res.statusCode());
+ assertEquals("Forbidden", res.statusText());
+ }
+}
diff --git a/src/test/java/org/juv25d/filter/LoggingFilterTest.java b/src/test/java/org/juv25d/filter/LoggingFilterTest.java
new file mode 100644
index 00000000..07054d52
--- /dev/null
+++ b/src/test/java/org/juv25d/filter/LoggingFilterTest.java
@@ -0,0 +1,68 @@
+package org.juv25d.filter;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.*;
+
+class LoggingFilterTest {
+
+ @Test
+ void callsNextFilterInChain() throws IOException {
+
+ LoggingFilter filter = new LoggingFilter();
+ HttpRequest req = mock(HttpRequest.class);
+ HttpResponse res = mock(HttpResponse.class);
+ FilterChain chain = mock(FilterChain.class);
+
+ filter.doFilter(req, res, chain);
+
+ verify(chain).doFilter(req, res);
+ }
+
+ @Test
+ void logsHttpMethodAndPath() throws IOException {
+ LoggingFilter filter = new LoggingFilter();
+ HttpRequest req = mock(HttpRequest.class);
+ HttpResponse res = mock(HttpResponse.class);
+ FilterChain chain = mock(FilterChain.class);
+
+ when(req.method()).thenReturn("GET");
+ when(req.path()).thenReturn("/test");
+
+ java.util.logging.Logger logger = org.juv25d.logging.ServerLogging.getLogger();
+ java.util.logging.Level originalLevel = logger.getLevel();
+ logger.setLevel(java.util.logging.Level.INFO);
+
+ java.util.List records = new java.util.ArrayList<>();
+ java.util.logging.Handler handler = new java.util.logging.Handler() {
+ @Override
+ public void publish(java.util.logging.LogRecord record) {
+ records.add(record);
+ }
+
+ @Override
+ public void flush() {}
+
+ @Override
+ public void close() throws SecurityException {}
+ };
+ logger.addHandler(handler);
+
+ try {
+ filter.doFilter(req, res, chain);
+
+ java.util.logging.SimpleFormatter formatter = new java.util.logging.SimpleFormatter();
+ boolean found = records.stream()
+ .anyMatch(r -> formatter.formatMessage(r).contains("GET /test"));
+ assertTrue(found, "Logger should have captured the method and path");
+ } finally {
+ logger.setLevel(originalLevel);
+ logger.removeHandler(handler);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/org/juv25d/filter/RateLimitingFilterTest.java b/src/test/java/org/juv25d/filter/RateLimitingFilterTest.java
new file mode 100644
index 00000000..767b62a0
--- /dev/null
+++ b/src/test/java/org/juv25d/filter/RateLimitingFilterTest.java
@@ -0,0 +1,130 @@
+package org.juv25d.filter;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.IOException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.*;
+
+/**
+ * Tests for the {@link RateLimitingFilter} class.
+ */
+@ExtendWith(MockitoExtension.class)
+class RateLimitingFilterTest {
+
+ @Mock
+ private HttpRequest req;
+ @Mock
+ private HttpResponse res;
+ @Mock
+ private FilterChain chain;
+
+ /**
+ * Verifies that the filter allows requests when they are within the rate limit.
+ */
+ @Test
+ void shouldAllowRequest_whenWithinRateLimit() throws IOException {
+ // Arrange
+ RateLimitingFilter filter = new RateLimitingFilter(60, 5);
+ when(req.remoteIp()).thenReturn("127.0.0.1");
+
+ // Act
+ filter.doFilter(req, res, chain);
+
+ // Assert
+ verify(chain, times(1)).doFilter(req, res);
+ verifyNoMoreInteractions(chain);
+ verifyNoInteractions(res);
+ }
+
+ /**
+ * Verifies that the filter blocks requests when the rate limit is exceeded.
+ */
+ @Test
+ void shouldBlockRequest_whenExceedingRateLimit() throws IOException {
+ // Arrange
+ RateLimitingFilter filter = new RateLimitingFilter(60, 5);
+ when(req.remoteIp()).thenReturn("127.0.0.1");
+
+ // Act
+ for (int i = 0; i < 6; i++) {
+ filter.doFilter(req, res, chain);
+ }
+
+ // Assert
+ verify(chain, times(5)).doFilter(req, res);
+ verifyNoMoreInteractions(chain);
+ verify(res).setStatusCode(429);
+ verify(res).setStatusText("Too Many Requests");
+ verify(res).setHeader("Content-Type", "text/html; charset=utf-8");
+ verify(res).setHeader(eq("Content-Length"), any());
+ verify(res).setHeader("Retry-After", "60");
+ verify(res).setBody(any());
+ }
+
+ /**
+ * Verifies that rate limits are tracked independently for different client IPs.
+ */
+ @Test
+ void shouldAllowRequests_fromDifferentIpsIndependently() throws IOException {
+ // Arrange
+ RateLimitingFilter filter = new RateLimitingFilter(60, 5);
+ HttpRequest req2 = mock(HttpRequest.class);
+ HttpResponse res2 = mock(HttpResponse.class);
+ when(req.remoteIp()).thenReturn("127.0.0.1");
+ when(req2.remoteIp()).thenReturn("192.168.1.1");
+
+ // Act
+ for (int i = 0; i < 6; i++) { // Empty first bucket
+ filter.doFilter(req, res, chain);
+ }
+ for (int i = 0; i < 2; i++) {
+ filter.doFilter(req2, res2, chain);
+ }
+
+ // Assert
+ verify(chain, times(7)).doFilter(any(), any());
+ verify(res).setStatusCode(429);
+ verifyNoInteractions(res2);
+ }
+
+ /**
+ * Verifies that the internal bucket map is cleared when the filter is destroyed.
+ */
+ @Test
+ void shouldClearBuckets_onDestroy() throws IOException {
+ // Arrange
+ RateLimitingFilter filter = new RateLimitingFilter(60, 5);
+ when(req.remoteIp()).thenReturn("127.0.0.1");
+
+ filter.doFilter(req, res, chain);
+ assertThat(filter.getTrackedIpCount()).isEqualTo(1);
+
+ // Act
+ filter.destroy();
+
+ // Assert
+ assertThat(filter.getTrackedIpCount()).isZero();
+ }
+
+ /**
+ * Verifies that the constructor throws an exception for invalid configuration values.
+ */
+ @Test
+ void shouldThrowException_whenInvalidConfiguration() {
+ // Act & Assert
+ assertThatThrownBy(() -> new RateLimitingFilter(0, 5))
+ .isInstanceOf(IllegalArgumentException.class);
+
+ // Act & Assert
+ assertThatThrownBy(() -> new RateLimitingFilter(60, 0))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
diff --git a/src/test/java/org/juv25d/filter/RedirectFilterTest.java b/src/test/java/org/juv25d/filter/RedirectFilterTest.java
new file mode 100644
index 00000000..19288230
--- /dev/null
+++ b/src/test/java/org/juv25d/filter/RedirectFilterTest.java
@@ -0,0 +1,190 @@
+package org.juv25d.filter;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+class RedirectFilterTest {
+
+ private FilterChain mockChain;
+
+ @BeforeEach
+ void setUp() {
+ mockChain = Mockito.mock(FilterChain.class);
+ }
+
+ @Test
+ void shouldRedirect301ForMatchingPath() throws IOException {
+ // Given
+ List rules = List.of(
+ new RedirectRule("/old-page", "/new-page", 301)
+ );
+ RedirectFilter filter = new RedirectFilter(rules);
+
+ HttpRequest request = createRequest("/old-page");
+ HttpResponse response = new HttpResponse();
+
+ // When
+ filter.doFilter(request, response, mockChain);
+
+ // Then
+ assertThat(response.statusCode()).isEqualTo(301);
+ assertThat(response.statusText()).isEqualTo("Moved Permanently");
+ assertThat(response.headers().get("Location")).isEqualTo("/new-page");
+ assertThat(response.headers().get("Content-Length")).isEqualTo("0");
+ assertThat(response.body()).isEmpty();
+
+ // Pipeline should be stopped
+ verify(mockChain, never()).doFilter(any(), any());
+ }
+
+ @Test
+ void shouldRedirect302ForTemporaryRedirect() throws IOException {
+ // Given
+ List rules = List.of(
+ new RedirectRule("/temp", "https://example.com/temporary", 302)
+ );
+ RedirectFilter filter = new RedirectFilter(rules);
+
+ HttpRequest request = createRequest("/temp");
+ HttpResponse response = new HttpResponse();
+
+ // When
+ filter.doFilter(request, response, mockChain);
+
+ // Then
+ assertThat(response.statusCode()).isEqualTo(302);
+ assertThat(response.statusText()).isEqualTo("Found");
+ assertThat(response.headers().get("Location")).isEqualTo("https://example.com/temporary");
+
+ // Pipeline should be stopped
+ verify(mockChain, never()).doFilter(any(), any());
+ }
+
+ @Test
+ void shouldNotRedirectWhenNoMatchingRule() throws IOException {
+ // Given
+ List rules = List.of(
+ new RedirectRule("/old-page", "/new-page", 301)
+ );
+ RedirectFilter filter = new RedirectFilter(rules);
+
+ HttpRequest request = createRequest("/other-page");
+ HttpResponse response = new HttpResponse();
+
+ // When
+ filter.doFilter(request, response, mockChain);
+
+ // Then - pipeline should continue
+ verify(mockChain, times(1)).doFilter(request, response);
+
+ // No redirect headers set
+ assertThat(response.headers().get("Location")).isNull();
+ }
+
+ @Test
+ void shouldMatchWildcardPattern() throws IOException {
+ // Given
+ List rules = List.of(
+ new RedirectRule("/docs/*", "/documentation/", 301)
+ );
+ RedirectFilter filter = new RedirectFilter(rules);
+
+ HttpRequest request = createRequest("/docs/api");
+ HttpResponse response = new HttpResponse();
+
+ // When
+ filter.doFilter(request, response, mockChain);
+
+ // Then
+ assertThat(response.statusCode()).isEqualTo(301);
+ assertThat(response.headers().get("Location")).isEqualTo("/documentation/");
+
+ verify(mockChain, never()).doFilter(any(), any());
+ }
+
+ @Test
+ void shouldMatchMultiplePathsWithWildcard() throws IOException {
+ // Given
+ List rules = List.of(
+ new RedirectRule("/docs/*", "/documentation/", 301)
+ );
+ RedirectFilter filter = new RedirectFilter(rules);
+
+ // Test multiple paths
+ testWildcardMatch(filter, "/docs/api");
+ testWildcardMatch(filter, "/docs/guide");
+ testWildcardMatch(filter, "/docs/tutorial/advanced");
+ }
+
+ @Test
+ void shouldEvaluateRulesInOrder() throws IOException {
+ // Given - first matching rule should win
+ List rules = List.of(
+ new RedirectRule("/page", "/first-target", 301),
+ new RedirectRule("/page", "/second-target", 302) // This won't be used
+ );
+ RedirectFilter filter = new RedirectFilter(rules);
+
+ HttpRequest request = createRequest("/page");
+ HttpResponse response = new HttpResponse();
+
+ // When
+ filter.doFilter(request, response, mockChain);
+
+ // Then - first rule should be applied
+ assertThat(response.statusCode()).isEqualTo(301);
+ assertThat(response.headers().get("Location")).isEqualTo("/first-target");
+ }
+
+ @Test
+ void shouldHandleEmptyRulesList() throws IOException {
+ // Given
+ List rules = List.of(); // No rules
+ RedirectFilter filter = new RedirectFilter(rules);
+
+ HttpRequest request = createRequest("/any-page");
+ HttpResponse response = new HttpResponse();
+
+ // When
+ filter.doFilter(request, response, mockChain);
+
+ // Then - pipeline should continue
+ verify(mockChain, times(1)).doFilter(request, response);
+ }
+
+ // Helper methods
+
+ private void testWildcardMatch(RedirectFilter filter, String path) throws IOException {
+ HttpRequest request = createRequest(path);
+ HttpResponse response = new HttpResponse();
+ FilterChain chain = Mockito.mock(FilterChain.class);
+
+ filter.doFilter(request, response, chain);
+
+ assertThat(response.statusCode()).isEqualTo(301);
+ assertThat(response.headers().get("Location")).isEqualTo("/documentation/");
+ verify(chain, never()).doFilter(any(), any());
+ }
+
+ private HttpRequest createRequest(String path) {
+ return new HttpRequest(
+ "GET",
+ path,
+ "",
+ "HTTP/1.1",
+ Map.of(),
+ new byte[0],
+ "127.0.0.1"
+ );
+ }
+}
diff --git a/src/test/java/org/juv25d/handler/MimeTypeResolverTest.java b/src/test/java/org/juv25d/handler/MimeTypeResolverTest.java
new file mode 100644
index 00000000..ecbf64f0
--- /dev/null
+++ b/src/test/java/org/juv25d/handler/MimeTypeResolverTest.java
@@ -0,0 +1,86 @@
+package org.juv25d.handler;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MimeTypeResolverTest {
+
+ @Test
+ void shouldReturnTextHtmlForHtmlFiles() {
+ assertThat(MimeTypeResolver.getMimeType("index.html")).isEqualTo("text/html");
+ assertThat(MimeTypeResolver.getMimeType("page.htm")).isEqualTo("text/html");
+ }
+
+ @Test
+ void shouldReturnTextCssForCssFiles() {
+ assertThat(MimeTypeResolver.getMimeType("styles.css")).isEqualTo("text/css");
+ }
+
+ @Test
+ void shouldReturnApplicationJavascriptForJsFiles() {
+ assertThat(MimeTypeResolver.getMimeType("app.js")).isEqualTo("application/javascript");
+ }
+
+ @Test
+ void shouldReturnImagePngForPngFiles() {
+ assertThat(MimeTypeResolver.getMimeType("logo.png")).isEqualTo("image/png");
+ }
+
+ @Test
+ void shouldReturnImageJpegForJpgFiles() {
+ assertThat(MimeTypeResolver.getMimeType("photo.jpg")).isEqualTo("image/jpeg");
+ assertThat(MimeTypeResolver.getMimeType("image.jpeg")).isEqualTo("image/jpeg");
+ }
+
+ @Test
+ void shouldReturnApplicationJsonForJsonFiles() {
+ assertThat(MimeTypeResolver.getMimeType("data.json")).isEqualTo("application/json");
+ }
+
+ @Test
+ void shouldReturnDefaultMimeTypeForUnknownExtension() {
+ assertThat(MimeTypeResolver.getMimeType("file.unknown")).isEqualTo("application/octet-stream");
+ }
+
+ @Test
+ void shouldReturnDefaultMimeTypeForFileWithoutExtension() {
+ assertThat(MimeTypeResolver.getMimeType("README")).isEqualTo("application/octet-stream");
+ }
+
+ @Test
+ void shouldReturnDefaultMimeTypeForNullFilename() {
+ assertThat(MimeTypeResolver.getMimeType(null)).isEqualTo("application/octet-stream");
+ }
+
+ @Test
+ void shouldReturnDefaultMimeTypeForEmptyFilename() {
+ assertThat(MimeTypeResolver.getMimeType("")).isEqualTo("application/octet-stream");
+ }
+
+ @Test
+ void shouldHandleUppercaseExtensions() {
+ assertThat(MimeTypeResolver.getMimeType("file.HTML")).isEqualTo("text/html");
+ assertThat(MimeTypeResolver.getMimeType("file.CSS")).isEqualTo("text/css");
+ assertThat(MimeTypeResolver.getMimeType("file.JS")).isEqualTo("application/javascript");
+ }
+
+ @Test
+ void shouldHandleMixedCaseExtensions() {
+ assertThat(MimeTypeResolver.getMimeType("file.HtMl")).isEqualTo("text/html");
+ assertThat(MimeTypeResolver.getMimeType("photo.JpG")).isEqualTo("image/jpeg");
+ }
+
+ @Test
+ void shouldHandleFilesWithMultipleDots() {
+ assertThat(MimeTypeResolver.getMimeType("my.file.name.html")).isEqualTo("text/html");
+ assertThat(MimeTypeResolver.getMimeType("bundle.min.js")).isEqualTo("application/javascript");
+ }
+
+ @Test
+ void shouldHandlePathsWithDirectories() {
+ assertThat(MimeTypeResolver.getMimeType("/css/styles.css")).isEqualTo("text/css");
+ assertThat(MimeTypeResolver.getMimeType("/js/app.js")).isEqualTo("application/javascript");
+ assertThat(MimeTypeResolver.getMimeType("/images/logo.png")).isEqualTo("image/png");
+ }
+}
diff --git a/src/test/java/org/juv25d/handler/StaticFileHandlerTest.java b/src/test/java/org/juv25d/handler/StaticFileHandlerTest.java
new file mode 100644
index 00000000..abf53bbc
--- /dev/null
+++ b/src/test/java/org/juv25d/handler/StaticFileHandlerTest.java
@@ -0,0 +1,208 @@
+package org.juv25d.handler;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class StaticFileHandlerTest {
+
+ @Test
+ void shouldReturn200ForExistingFile() {
+ HttpRequest request = createRequest("GET", "/index.html");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.statusCode()).isEqualTo(200);
+ assertThat(response.statusText()).isEqualTo("OK");
+ }
+
+ @Test
+ void shouldReturnCorrectContentTypeForHtml() {
+ HttpRequest request = createRequest("GET", "/index.html");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.headers()).containsEntry("Content-Type", "text/html; charset=utf-8");
+ }
+
+ @Test
+ void shouldReturnCorrectContentTypeForCss() {
+ HttpRequest request = createRequest("GET", "/css/styles.css");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.headers()).containsEntry("Content-Type", "text/css; charset=utf-8");
+ }
+
+ @Test
+ void shouldReturnCorrectContentTypeForJs() {
+ HttpRequest request = createRequest("GET", "/js/app.js");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.headers()).containsEntry("Content-Type", "application/javascript; charset=utf-8");
+ }
+
+ @Test
+ void shouldIncludeEtagAndCacheControlForSuccessfulStaticResponse() {
+ HttpRequest request = createRequest("GET", "/index.html");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.statusCode()).isEqualTo(200);
+ assertThat(response.headers()).containsKey("ETag");
+ assertThat(response.headers()).containsEntry("Cache-Control", "public, max-age=5");
+ }
+
+ @Test
+ void shouldReturn304WhenIfNoneMatchMatchesEtag() {
+ HttpRequest first = createRequest("GET", "/index.html");
+ HttpResponse firstResponse = StaticFileHandler.handle(first);
+
+ assertThat(firstResponse.statusCode()).isEqualTo(200);
+ String etag = firstResponse.headers().get("ETag");
+ assertThat(etag).isNotBlank();
+
+ Map headers = new HashMap<>();
+ headers.put("If-None-Match", etag);
+
+ HttpRequest second = createRequest("GET", "/index.html", headers);
+ HttpResponse secondResponse = StaticFileHandler.handle(second);
+
+ assertThat(secondResponse.statusCode()).isEqualTo(304);
+ assertThat(secondResponse.statusText()).isEqualTo("Not Modified");
+ assertThat(secondResponse.body()).isEmpty();
+ assertThat(secondResponse.headers()).containsEntry("ETag", etag);
+ assertThat(secondResponse.headers()).containsEntry("Cache-Control", "public, max-age=5");
+ }
+
+ @Test
+ void shouldReturn404ForNonExistingFile() {
+ HttpRequest request = createRequest("GET", "/nonexistent.html");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.statusCode()).isEqualTo(404);
+ assertThat(response.statusText()).isEqualTo("Not Found");
+ }
+
+ @Test
+ void shouldReturn404ResponseWithHtmlContent() {
+ HttpRequest request = createRequest("GET", "/missing.html");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.body()).isNotEmpty();
+ assertThat(new String(response.body())).contains("404");
+ assertThat(new String(response.body())).contains("Not Found");
+ }
+
+ @Test
+ void shouldServeIndexHtmlForRootPath() {
+ HttpRequest request = createRequest("GET", "/");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.statusCode()).isEqualTo(200);
+ assertThat(response.headers()).containsEntry("Content-Type", "text/html; charset=utf-8");
+ assertThat(new String(response.body())).contains("");
+ }
+
+ @Test
+ void shouldReturn403ForPathTraversalAttempt() {
+ HttpRequest request = createRequest("GET", "/../../../etc/passwd");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.statusCode()).isEqualTo(403);
+ assertThat(response.statusText()).isEqualTo("Forbidden");
+ }
+
+ @Test
+ void shouldReturn403ForPathWithDoubleDots() {
+ HttpRequest request = createRequest("GET", "/css/../../secrets.txt");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.statusCode()).isEqualTo(403);
+ }
+
+ @Test
+ void shouldReturn403ForPathWithDoubleSlashes() {
+ HttpRequest request = createRequest("GET", "//etc/passwd");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.statusCode()).isEqualTo(403);
+ }
+
+ @Test
+ void shouldReturn403ForPathWithBackslashes() {
+ HttpRequest request = createRequest("GET", "/css\\..\\secrets.txt");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.statusCode()).isEqualTo(403);
+ }
+
+ @Test
+ void shouldReturn405ForNonGetRequest() {
+ HttpRequest request = createRequest("POST", "/index.html");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.statusCode()).isEqualTo(405);
+ assertThat(response.statusText()).isEqualTo("Method Not Allowed");
+ }
+
+ @Test
+ void shouldReturn405ForPutRequest() {
+ HttpRequest request = createRequest("PUT", "/index.html");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.statusCode()).isEqualTo(405);
+ }
+
+ @Test
+ void shouldReturn405ForDeleteRequest() {
+ HttpRequest request = createRequest("DELETE", "/index.html");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.statusCode()).isEqualTo(405);
+ }
+
+ @Test
+ void shouldHandleValidNestedPaths() {
+ HttpRequest request = createRequest("GET", "/css/styles.css");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ // Should either return 200 (if file exists) or 404 (if not), but NOT 403
+ assertThat(response.statusCode()).isIn(200, 404);
+ }
+
+ @Test
+ void shouldReturnNonEmptyBodyForSuccessfulRequest() {
+ HttpRequest request = createRequest("GET", "/index.html");
+ HttpResponse response = StaticFileHandler.handle(request);
+
+ assertThat(response.body()).isNotEmpty();
+ assertThat(response.body().length).isGreaterThan(0);
+ }
+
+ // Helper method to create HttpRequest objects
+ private HttpRequest createRequest(String method, String path) {
+ return new HttpRequest(
+ method,
+ path,
+ null,
+ "HTTP/1.1",
+ new HashMap<>(),
+ new byte[0],
+ "UNKNOWN"
+ );
+ }
+
+ private HttpRequest createRequest(String method, String path, Map headers) {
+ return new HttpRequest(
+ method,
+ path,
+ null,
+ "HTTP/1.1",
+ headers != null ? headers : new HashMap<>(),
+ new byte[0],
+ "UNKNOWN"
+ );
+ }
+}
diff --git a/src/test/java/org/juv25d/http/HttpParserTest.java b/src/test/java/org/juv25d/http/HttpParserTest.java
new file mode 100644
index 00000000..d9161071
--- /dev/null
+++ b/src/test/java/org/juv25d/http/HttpParserTest.java
@@ -0,0 +1,200 @@
+package org.juv25d.http;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link HttpParser}.
+ *
+ * Covers:
+ * - Valid GET and POST requests
+ * - Query string parsing
+ * - Header parsing and validation
+ * - Content-Length handling, including invalid and negative values
+ * - Error handling for empty and malformed requests
+ */
+@DisplayName("HttpParser - unit tests")
+class HttpParserTest {
+
+ private HttpParser parser;
+
+ @BeforeEach
+ void setUp() {
+ parser = new HttpParser();
+ }
+
+ /**
+ * Parses a well-formed GET request without a body and extracts method, path,
+ * HTTP version, and headers.
+ */
+ @DisplayName("Parses a valid GET request without body")
+ @Test
+ void parseValidGetRequest() throws IOException {
+ // Arrange
+ String request = "GET /index.html HTTP/1.1\r\n" +
+ "Host: localhost:8080\r\n" +
+ "Connection: close\r\n" +
+ "\r\n";
+
+ // Act
+ HttpRequest result = parser.parse(createInputStream(request));
+
+ // Assert
+ assertThat(result.method()).isEqualTo("GET");
+ assertThat(result.path()).isEqualTo("/index.html");
+ assertThat(result.httpVersion()).isEqualTo("HTTP/1.1");
+ assertThat(result.headers().get("Host")).isEqualTo("localhost:8080");
+ assertThat(result.headers().get("Connection")).isEqualTo("close");
+ assertThat(result.body()).isEmpty();
+ assertThat(result.queryString()).isNull();
+ }
+
+ /**
+ * Rejects empty requests (empty or only CRLF) with an informative IOException.
+ */
+ @DisplayName("Empty request β IOException with 'The request is empty'")
+ @Test
+ void parseEmptyRequest_throwsException() {
+ // Arrange
+ String emptyStringRequest = "";
+ String emptyRequest = "\r\n";
+
+ // Act + Assert
+ assertThatThrownBy(() -> parser.parse(createInputStream(emptyStringRequest)))
+ .isInstanceOf(IOException.class)
+ .hasMessage("The request is empty");
+ assertThatThrownBy(() -> parser.parse(createInputStream(emptyRequest)))
+ .isInstanceOf(IOException.class)
+ .hasMessage("The request is empty");
+ }
+
+ /**
+ * Fails when the request line is malformed (missing HTTP version or parts).
+ */
+ @DisplayName("Malformed request line β IOException")
+ @Test
+ void parseMalformedRequest_throwsException() {
+ // Arrange
+ String request = "GET /index.html\r\n";
+
+ // Act + Assert
+ assertThatThrownBy(() -> parser.parse(createInputStream(request)))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Malformed request line");
+ }
+
+ /**
+ * Fails when a header line is malformed (missing name/value separator).
+ */
+ @DisplayName("Malformed header line β IOException")
+ @Test
+ void parseMalformedHeader_throwsException() {
+ // Arrange
+ String request = "GET /index.html HTTP/1.1\r\n" +
+ ":Host localhost:8080\r\n" +
+ "Connection: close\r\n";
+
+ // Act + Assert
+ assertThatThrownBy(() -> parser.parse(createInputStream(request)))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Malformed header line");
+ }
+
+ /**
+ * Extracts the query string and normalized path from the request target.
+ */
+ @DisplayName("Parses query string and normalizes path")
+ @Test
+ void parseValidQueryString() throws IOException {
+ // Arrange
+ String request = "GET /search?q=java HTTP/1.1\r\n";
+
+ // Act
+ HttpRequest result = parser.parse(createInputStream(request));
+
+ // Assert
+ assertThat(result.path()).isEqualTo("/search");
+ assertThat(result.queryString()).isEqualTo("q=java");
+ }
+
+ /**
+ * Parses a well-formed POST request with Content-Length and body.
+ */
+ @DisplayName("Parses a valid POST request with headers and body")
+ @Test
+ void parseValidPostRequest() throws IOException {
+ // Arrange
+ String body = "body";
+ String request = "POST /users HTTP/1.1\r\n" +
+ "Host: localhost:8080\r\n" +
+ "Content-Type: text/html\r\n" +
+ "Content-Length: " + body.getBytes(StandardCharsets.UTF_8).length + "\r\n" +
+ "\r\n" +
+ body;
+
+ // Act
+ HttpRequest result = parser.parse(createInputStream(request));
+
+ // Assert
+ assertThat(result.method()).isEqualTo("POST");
+ assertThat(result.path()).isEqualTo("/users");
+ assertThat(result.httpVersion()).isEqualTo("HTTP/1.1");
+ assertThat(result.headers().get("Host")).isEqualTo("localhost:8080");
+ assertThat(result.headers().get("Content-Type")).isEqualTo("text/html");
+ assertThat(result.body()).isEqualTo(body.getBytes());
+ }
+
+ /**
+ * Rejects non-numeric Content-Length values.
+ */
+ @DisplayName("Invalid Content-Length (non-numeric) β IOException")
+ @Test
+ void parseInvalidContentLength_throwsException() {
+ // Arrange
+ String request = "POST /users HTTP/1.1\r\n" +
+ "Host: localhost:8080\r\n" +
+ "Content-Type: text/html\r\n" +
+ "Content-Length: abc\r\n" +
+ "\r\n";
+
+ // Act + Assert
+ assertThatThrownBy(() -> parser.parse(createInputStream(request)))
+ .isInstanceOf(IOException.class)
+ .hasMessage("Invalid Content-Length: abc");
+ }
+
+ /**
+ * Rejects negative Content-Length values.
+ */
+ @DisplayName("Negative Content-Length β IOException")
+ @Test
+ void parseNegativeContentLength_throwsException() {
+ // Arrange
+ String request = "POST /users HTTP/1.1\r\n" +
+ "Host: localhost:8080\r\n" +
+ "Content-Type: text/html\r\n" +
+ "Content-Length: -10\r\n" +
+ "\r\n";
+
+ // Act + Assert
+ assertThatThrownBy(() -> parser.parse(createInputStream(request)))
+ .isInstanceOf(IOException.class)
+ .hasMessage("Negative Content-Length: -10");
+ }
+
+ /**
+ * Utility to wrap a request string into an {@link InputStream}.
+ */
+ private InputStream createInputStream(String request) {
+ return new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/src/test/java/org/juv25d/http/HttpResponseTest.java b/src/test/java/org/juv25d/http/HttpResponseTest.java
new file mode 100644
index 00000000..d61c89d8
--- /dev/null
+++ b/src/test/java/org/juv25d/http/HttpResponseTest.java
@@ -0,0 +1,42 @@
+package org.juv25d.http;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class HttpResponseTest {
+
+ private HttpResponse response;
+
+ @BeforeEach
+ void setUp() {
+ response = new HttpResponse();
+ }
+
+ @Test
+ void shouldReturnDefaultStatusCode() {
+ assertEquals(200, response.statusCode());
+ }
+
+ @Test
+ void shouldReturnDefaultText() {
+ assertEquals("OK", response.statusText());
+ }
+
+ @Test
+ void shouldAllowEmptyStatusText() {
+ response.setStatusText("");
+ assertEquals("", response.statusText());
+ }
+
+ @Test
+ void shouldHaveEmptyHeaderByDefault() {
+ assertTrue(response.headers().isEmpty());
+ }
+
+ @Test
+ void shouldHaveEmptyBodyByDefault() {
+ assertArrayEquals(new byte[0], response.body());
+ }
+}
diff --git a/src/test/java/org/juv25d/http/HttpResponseWriterTest.java b/src/test/java/org/juv25d/http/HttpResponseWriterTest.java
new file mode 100644
index 00000000..35ebe3a7
--- /dev/null
+++ b/src/test/java/org/juv25d/http/HttpResponseWriterTest.java
@@ -0,0 +1,58 @@
+package org.juv25d.http;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+
+public class HttpResponseWriterTest {
+
+
+ @Test
+ @DisplayName("Should write a valid HTTP 200 OK response ")
+ void writesValidHttp200Response() throws Exception {
+ // Arrange
+ HttpResponse response = new HttpResponse(
+ 200,
+ "OK",
+ Map.of("Content-Type", "text/plain"),
+ "Hello World".getBytes(StandardCharsets.UTF_8)
+ );
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+ // Act
+ HttpResponseWriter.write(out, response);
+
+ // Assert
+ String result = out.toString(StandardCharsets.UTF_8);
+
+ assertThat(result).startsWith("HTTP/1.1 200 OK");
+ assertThat(result).contains("Content-Type: text/plain");
+ assertThat(result).contains("Content-Length: 11");
+ assertThat(result).endsWith("Hello World");
+ }
+
+ @Test
+ @DisplayName("Should write a valid HTTP 404 Not Found Response")
+ void writes404NotFoundResponse() throws Exception {
+ HttpResponse response = new HttpResponse(
+ 404,
+ "Not Found",
+ Map.of("Content-Type", "text/plain"),
+ "Not found".getBytes(StandardCharsets.UTF_8)
+ );
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+ HttpResponseWriter.write(out, response);
+
+ String result = out.toString(StandardCharsets.UTF_8);
+
+ assertThat(result).startsWith("HTTP/1.1 404 Not Found");
+ }
+}
diff --git a/src/test/java/org/juv25d/logging/ConnectionIdLoggingTest.java b/src/test/java/org/juv25d/logging/ConnectionIdLoggingTest.java
new file mode 100644
index 00000000..4dfe5bd5
--- /dev/null
+++ b/src/test/java/org/juv25d/logging/ConnectionIdLoggingTest.java
@@ -0,0 +1,49 @@
+package org.juv25d.logging;
+
+import org.junit.jupiter.api.Test;
+import java.util.logging.Handler;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ConnectionIdLoggingTest {
+
+ @Test
+ void logMessageShouldIncludeConnectionId() {
+ // Arrange
+ Logger logger = Logger.getLogger("test.connectionid");
+ logger.setUseParentHandlers(false);
+
+ List formattedMessages = new ArrayList<>();
+ ServerLogFormatter formatter = new ServerLogFormatter();
+
+ Handler handler = new Handler() {
+ @Override
+ public void publish(LogRecord record) {
+ formattedMessages.add(formatter.format(record));
+ }
+ @Override
+ public void flush() {}
+ @Override
+ public void close() throws SecurityException {}
+ };
+ logger.addHandler(handler);
+
+ try {
+ String testId = "test-123";
+ LogContext.setConnectionId(testId);
+
+ // Act
+ logger.info("This is a test message");
+
+ // Assert
+ assertTrue(formattedMessages.get(0).contains("[" + testId + "]"),
+ "Log message should contain the connection ID. Found: " + formattedMessages.get(0));
+ } finally {
+ LogContext.clear();
+ }
+ }
+}
diff --git a/src/test/java/org/juv25d/logging/ServerLoggingTest.java b/src/test/java/org/juv25d/logging/ServerLoggingTest.java
new file mode 100644
index 00000000..915a543b
--- /dev/null
+++ b/src/test/java/org/juv25d/logging/ServerLoggingTest.java
@@ -0,0 +1,142 @@
+package org.juv25d.logging;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.juv25d.util.ConfigLoader;
+
+import java.util.logging.ConsoleHandler;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ServerLoggingTest {
+
+ private Logger logger;
+
+ //Arrange
+ @BeforeEach
+ void setUp() {
+ logger = ServerLogging.getLogger();
+
+ // Clean handlers
+ for (Handler handler : logger.getHandlers()) {
+ logger.removeHandler(handler);
+ }
+ }
+
+ @Test
+ @DisplayName("Logger should return same instance")
+ void getLogger_shouldReturnSameInstance() {
+ Logger logger1 = ServerLogging.getLogger();
+ Logger logger2 = ServerLogging.getLogger();
+
+ assertSame(logger1, logger2);
+ }
+
+ @Test
+ @DisplayName("Logger should have console handler")
+ void logger_shouldHaveConsoleHandler() {
+ Logger logger = ServerLogging.getLogger();
+
+ // Reset logger
+ for (Handler handler : logger.getHandlers()) {
+ logger.removeHandler(handler);
+ }
+
+ // Explicit configure
+ ServerLogging.configure(logger);
+
+ boolean hasConsoleHandler = false;
+ for (Handler handler : logger.getHandlers()) {
+ if (handler instanceof ConsoleHandler) {
+ hasConsoleHandler = true;
+ break;
+ }
+ }
+
+ assertTrue(hasConsoleHandler, "Logger should have a Console Handler");
+ }
+
+ @Test
+ @DisplayName("Logger should not add duplicate handlers")
+ void logger_shouldNotAddDuplicateHandlers() {
+ Logger logger = ServerLogging.getLogger();
+
+ // Clean slate
+ for (Handler handler : logger.getHandlers()) {
+ logger.removeHandler(handler);
+ }
+
+ ServerLogging.configure(logger);
+ int handlerCountAfterFirst = logger.getHandlers().length;
+
+ ServerLogging.configure(logger);
+ int handlerCountAfterSecond = logger.getHandlers().length;
+
+ assertEquals(1, handlerCountAfterFirst);
+ assertEquals(handlerCountAfterFirst, handlerCountAfterSecond,
+ "configure() should not add duplicate handlers");
+ }
+
+ @Test
+ @DisplayName("Logger should have INFO level by default")
+ void logger_shouldHaveInfoLevelByDefault() {
+ Logger logger = ServerLogging.getLogger();
+
+ assertEquals(Level.INFO, logger.getLevel());
+ }
+
+ @Test
+ @DisplayName("Logger should not use parent handlers")
+ void logger_shouldNotUseParentHandlers() {
+ Logger logger = ServerLogging.getLogger();
+
+ assertFalse(logger.getUseParentHandlers());
+ }
+
+ @Test
+ @DisplayName("Logger should use log level from system property")
+ void logger_shouldUseLogLevelFromSystemProperty() {
+ String original = System.getProperty("log.level");
+
+ try {
+ System.setProperty("log.level", "WARNING");
+
+ Logger testLogger = Logger.getLogger("test.logger");
+ ServerLogging.configure(testLogger);
+
+ assertEquals(Level.WARNING, testLogger.getLevel());
+
+ } finally {
+ // Reset system state
+ if (original == null) {
+ System.clearProperty("log.level");
+ } else {
+ System.setProperty("log.level", original);
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("Logger should fall back to logging.level from application-properties.yml")
+ void logger_shouldUseLogLevelFromApplicationProperties() {
+ String configLogLevel = ConfigLoader.getInstance().getLogLevel();
+
+ try {
+ System.clearProperty("log.level");
+
+ Level expectedLevel = Level.parse(configLogLevel.toUpperCase());
+
+ Logger testLogger = Logger.getLogger("test.logger");
+ ServerLogging.configure(testLogger);
+
+ assertEquals(expectedLevel, testLogger.getLevel());
+
+ } finally {
+ System.clearProperty("log.level");
+ }
+ }
+}
diff --git a/src/test/java/org/juv25d/plugin/MetricPluginTest.java b/src/test/java/org/juv25d/plugin/MetricPluginTest.java
new file mode 100644
index 00000000..b7421a57
--- /dev/null
+++ b/src/test/java/org/juv25d/plugin/MetricPluginTest.java
@@ -0,0 +1,40 @@
+package org.juv25d.plugin;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class MetricPluginTest {
+
+ @Test
+ void sets200StatusAndJsonBody() throws IOException {
+ MetricPlugin plugin = new MetricPlugin();
+ HttpRequest req = new HttpRequest("GET", "/metric", null, "HTTP/1.1", Map.of(), new byte[0], "HEALTH");
+ HttpResponse res = new HttpResponse();
+
+ plugin.handle(req, res);
+
+ assertEquals(200, res.statusCode());
+
+ assertEquals("application/json", res.headers().get("Content-Type"));
+
+ String body = new String(res.body(), StandardCharsets.UTF_8);
+ assertTrue(body.contains("\"localTime\""), "Body should contain localTime");
+ assertTrue(body.contains("\"utcTime\""), "Body should contain utcTime");
+ assertTrue(body.contains("\"server\": \"juv25d-webserver\""), "Body should contain server info");
+ assertTrue(body.contains("\"buildVersion\""), "Body should contain buildVersion");
+ assertTrue(body.contains("\"gitCommit\""), "Body should contain gitCommit");
+ assertTrue(body.contains("\"responseTimeUs\""), "Body should contain responseTimeUs");
+ assertTrue(body.contains("\"memory\""), "Body should contain memory info");
+
+ String contentLength = res.headers().get("Content-Length");
+ assertNotNull(contentLength, "Content-Length should be set");
+ assertEquals(String.valueOf(res.body().length), contentLength);
+ }
+}
diff --git a/src/test/java/org/juv25d/plugin/NotFoundPluginTest.java b/src/test/java/org/juv25d/plugin/NotFoundPluginTest.java
new file mode 100644
index 00000000..f0d452fb
--- /dev/null
+++ b/src/test/java/org/juv25d/plugin/NotFoundPluginTest.java
@@ -0,0 +1,26 @@
+package org.juv25d.plugin;
+
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class NotFoundPluginTest {
+
+ @Test
+ void sets404StatusAndBody() throws IOException {
+ NotFoundPlugin plugin = new NotFoundPlugin();
+ HttpRequest req = new HttpRequest("GET", "/unknown", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN");
+ HttpResponse res = new HttpResponse();
+
+ plugin.handle(req, res);
+
+ assertEquals(404, res.statusCode());
+ assertEquals("Not Found", res.statusText());
+ assertArrayEquals("404 - Resource Not Found".getBytes(), res.body());
+ }
+}
diff --git a/src/test/java/org/juv25d/proxy/ProxyPluginTest.java b/src/test/java/org/juv25d/proxy/ProxyPluginTest.java
new file mode 100644
index 00000000..c4a42a5a
--- /dev/null
+++ b/src/test/java/org/juv25d/proxy/ProxyPluginTest.java
@@ -0,0 +1,262 @@
+package org.juv25d.proxy;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.juv25d.http.HttpRequest;
+import org.juv25d.http.HttpResponse;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.IOException;
+
+import java.net.ConnectException;
+import java.net.http.HttpClient;
+import java.net.http.HttpHeaders;
+
+import java.net.http.HttpTimeoutException;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class ProxyPluginTest {
+
+ @Nested
+ class IntegrationTests {
+ @org.jspecify.annotations.Nullable private ProxyRoute proxyRoute;
+ @org.jspecify.annotations.Nullable private ProxyPlugin proxyPlugin;
+
+ @DisplayName("should handle the request to an invalid upstream and return 502")
+ @Test
+ void handleInvalidDomain() throws IOException {
+ this.proxyRoute = new ProxyRoute("/api", "https://invalid-upstream-domain-ex-juv25d.info");
+ this.proxyPlugin = new ProxyPlugin(proxyRoute);
+
+ HttpRequest req = new HttpRequest(
+ "GET",
+ "/api/users",
+ null,
+ "HTTP/1.1",
+ Map.of("Content-Type", "application/json"),
+ new byte[0],
+ "127.0.0.1"
+ );
+ HttpResponse res = new HttpResponse();
+
+ proxyPlugin.handle(req, res);
+
+ // returns 502 Bad Gateway when connection fails
+ assertEquals(502, res.statusCode());
+ }
+
+ @DisplayName("proxies the request to valid upstream target server but non existing resource path and relay 404")
+ @Test
+ void upstreamResourceNotFound() throws IOException {
+ this.proxyRoute = new ProxyRoute("/api", "https://jsonplaceholder.typicode.com");
+ this.proxyPlugin = new ProxyPlugin(proxyRoute);
+
+ HttpRequest req = new HttpRequest(
+ "GET",
+ "/api/test-resource",
+ null,
+ "HTTP/1.1",
+ Map.of("Content-Type", "application/json"),
+ new byte[0],
+ "127.0.0.1"
+ );
+ HttpResponse res = new HttpResponse();
+
+ proxyPlugin.handle(req, res);
+
+ assertEquals(404, res.statusCode());
+ }
+
+ @DisplayName("returns 200 with response body")
+ @Test
+ void successfulResponse() throws IOException {
+ this.proxyRoute = new ProxyRoute("/api", "https://jsonplaceholder.typicode.com");
+ this.proxyPlugin = new ProxyPlugin(proxyRoute);
+
+ HttpRequest req = new HttpRequest(
+ "GET",
+ "/api/posts",
+ null,
+ "HTTP/1.1",
+ Map.of("Content-Type", "application/json"),
+ new byte[0],
+ "127.0.0.1"
+ );
+
+ HttpResponse res = new HttpResponse();
+
+ proxyPlugin.handle(req, res);
+
+ assertEquals(200, res.statusCode());
+ assertNotNull(res.body());
+ assertTrue(res.body().length > 0);
+ }
+ }
+
+ @Nested
+ @ExtendWith(MockitoExtension.class)
+ class UnitTests {
+
+ @Mock
+ private HttpClient httpClient;
+
+ @Mock
+ private java.net.http.HttpResponse upstreamResponse;
+
+ @Mock
+ private HttpHeaders httpHeaders;
+
+ private ProxyRoute proxyRoute;
+ private ProxyPlugin proxyPlugin;
+
+ @BeforeEach
+ void setUp() {
+ proxyRoute = new ProxyRoute("/api", "https://example.com");
+ proxyPlugin = new ProxyPlugin(proxyRoute, httpClient);
+ }
+
+ @DisplayName("strips base route from path when building upstream URL")
+ @Test
+ void stripsBaseRouteFromPath() throws Exception {
+ HttpRequest req = new HttpRequest(
+ "GET", "/api/users", null, "HTTP/1.1",
+ Map.of(), new byte[0], "127.0.0.1"
+ );
+ HttpResponse res = new HttpResponse();
+
+ when(upstreamResponse.statusCode()).thenReturn(200);
+ when(upstreamResponse.body()).thenReturn(new byte[0]);
+ when(upstreamResponse.headers()).thenReturn(httpHeaders);
+ when(httpHeaders.map()).thenReturn(Map.of());
+
+ java.net.http.HttpRequest[] capturedRequest = new java.net.http.HttpRequest[1];
+ when(httpClient.send(any(java.net.http.HttpRequest.class), any()))
+ .thenAnswer(invocation -> {
+ capturedRequest[0] = invocation.getArgument(0);
+ return upstreamResponse;
+ });
+
+ proxyPlugin.handle(req, res);
+
+ assertEquals(200, res.statusCode());
+ assertEquals("https://example.com/users", capturedRequest[0].uri().toString());
+ assertEquals("GET", capturedRequest[0].method());
+ }
+
+ @DisplayName("filters restricted headers from being proxied")
+ @Test
+ void filtersRestrictedHeaders() throws Exception {
+ Map headers = Map.of(
+ "Content-Type", "application/json",
+ "Connection", "keep-alive",
+ "Host", "example.com"
+ );
+ HttpRequest req = new HttpRequest("GET", "/api/test/users", null, "HTTP/1.1",
+ headers, new byte[0], "UNKNOWN");
+ HttpResponse res = new HttpResponse();
+
+ when(upstreamResponse.statusCode()).thenReturn(200);
+ when(upstreamResponse.body()).thenReturn(new byte[0]);
+ when(upstreamResponse.headers()).thenReturn(
+ HttpHeaders.of(Map.of(), (name, value) -> true));
+ doReturn(upstreamResponse).when(httpClient).send(
+ any(java.net.http.HttpRequest.class),
+ any(java.net.http.HttpResponse.BodyHandler.class)
+ );
+
+ proxyPlugin.handle(req, res);
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ }
+
+ @DisplayName("proxies request and received 200 response with JSON data")
+ @Test
+ void successfullyProxiesRequest() throws Exception {
+ HttpRequest req = new HttpRequest("GET", "/api/test/users", null, "HTTP/1.1",
+ Map.of(), new byte[0], "UNKNOWN");
+ HttpResponse res = new HttpResponse();
+
+ when(upstreamResponse.statusCode()).thenReturn(200);
+ when(upstreamResponse.body()).thenReturn("{\"id\":1}".getBytes());
+ when(upstreamResponse.headers()).thenReturn(
+ HttpHeaders.of(Map.of("Content-Type", List.of("application/json")),
+ (name, value) -> true));
+
+ doReturn(upstreamResponse).when(httpClient).send(
+ any(java.net.http.HttpRequest.class),
+ any(java.net.http.HttpResponse.BodyHandler.class)
+ );
+
+ proxyPlugin.handle(req, res);
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(new String(res.body())).isEqualTo("{\"id\":1}");
+ assertThat(res.headers()).containsKey("Content-Type");
+ }
+
+ @DisplayName("returns 502 response if ConnectException is caught due to failed connection to upstream server")
+ @Test
+ void handlesConnectionException() throws Exception {
+ HttpRequest req = new HttpRequest("GET", "/api/test/users", null, "HTTP/1.1",
+ Map.of(), new byte[0], "UNKNOWN");
+ HttpResponse res = new HttpResponse();
+
+ doThrow(new ConnectException("Connection refused")).when(httpClient).send(
+ any(java.net.http.HttpRequest.class),
+ any(java.net.http.HttpResponse.BodyHandler.class)
+ );
+
+ proxyPlugin.handle(req, res);
+
+ assertThat(res.statusCode()).isEqualTo(502);
+ assertThat(res.statusText()).isEqualTo("Bad Gateway");
+ }
+
+ @DisplayName("return 504 when request to upstream times out")
+ @Test
+ void handlesTimeoutException() throws Exception {
+ HttpRequest req = new HttpRequest("GET", "/api/test/users", null, "HTTP/1.1",
+ Map.of(), new byte[0], "UNKNOWN");
+ HttpResponse res = new HttpResponse();
+
+ doThrow(new HttpTimeoutException("Request timed out")).when(httpClient).send(
+ any(java.net.http.HttpRequest.class),
+ any(java.net.http.HttpResponse.BodyHandler.class)
+ );
+
+ proxyPlugin.handle(req, res);
+
+ assertThat(res.statusCode()).isEqualTo(504);
+ assertThat(res.statusText()).isEqualTo("Gateway Timeout");
+ }
+
+ @DisplayName("generic exception return 502 Bad Gateway as default")
+ @Test
+ void handlesGenericException() throws Exception {
+ HttpRequest req = new HttpRequest("GET", "/api/test/users", null, "HTTP/1.1",
+ Map.of(), new byte[0], "UNKNOWN");
+ HttpResponse res = new HttpResponse();
+
+ doThrow(new RuntimeException("Unexpected error")).when(httpClient).send(
+ any(java.net.http.HttpRequest.class),
+ any(java.net.http.HttpResponse.BodyHandler.class)
+ );
+
+ proxyPlugin.handle(req, res);
+
+ assertThat(res.statusCode()).isEqualTo(502);
+ assertThat(res.statusText()).isEqualTo("Bad Gateway");
+ }
+ }
+}
+
diff --git a/src/test/java/org/juv25d/router/SimpleRouterTest.java b/src/test/java/org/juv25d/router/SimpleRouterTest.java
new file mode 100644
index 00000000..9c9e29d5
--- /dev/null
+++ b/src/test/java/org/juv25d/router/SimpleRouterTest.java
@@ -0,0 +1,121 @@
+package org.juv25d.router;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.juv25d.http.HttpRequest;
+import org.juv25d.plugin.NotFoundPlugin;
+import org.juv25d.plugin.Plugin;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mock;
+
+class SimpleRouterTest {
+
+ private SimpleRouter router;
+ private Plugin mockPluginA;
+ private Plugin mockPluginB;
+ private Plugin notFoundPlugin;
+
+ @BeforeEach
+ void setUp() {
+ router = new SimpleRouter();
+ mockPluginA = mock(Plugin.class);
+ mockPluginB = mock(Plugin.class);
+ notFoundPlugin = new NotFoundPlugin(); // Assuming NotFoundPlugin is a concrete class
+ }
+
+ @Test
+ void resolve_returnsRegisteredPluginForExactPath() {
+ router.registerPlugin("/pathA", mockPluginA);
+ HttpRequest request = new HttpRequest("GET", "/pathA", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN");
+
+ Plugin resolvedPlugin = router.resolve(request);
+ assertEquals(mockPluginA, resolvedPlugin);
+ }
+
+ @Test
+ void resolve_returnsNotFoundPluginForUnregisteredPath() {
+ router.registerPlugin("/pathA", mockPluginA);
+ HttpRequest request = new HttpRequest("GET", "/nonExistentPath", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN");
+
+ Plugin resolvedPlugin = router.resolve(request);
+ // Assuming SimpleRouter's constructor initializes notFoundPlugin
+ // or there's a way to get it for assertion
+ assertTrue(resolvedPlugin instanceof NotFoundPlugin);
+ }
+
+ @Test
+ void resolve_returnsWildcardPluginForMatchingPath() {
+ router.registerPlugin("/api/*", mockPluginA);
+ HttpRequest request = new HttpRequest("GET", "/api/users", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN");
+
+ Plugin resolvedPlugin = router.resolve(request);
+ assertEquals(mockPluginA, resolvedPlugin);
+ }
+
+ @Test
+ void resolve_returnsNotFoundPluginIfNoWildcardMatch() {
+ router.registerPlugin("/admin/*", mockPluginA);
+ HttpRequest request = new HttpRequest("GET", "/api/users", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN");
+
+ Plugin resolvedPlugin = router.resolve(request);
+ assertTrue(resolvedPlugin instanceof NotFoundPlugin);
+ }
+
+ @Test
+ void resolve_prefersExactMatchOverWildcard() {
+ router.registerPlugin("/api/users", mockPluginA);
+ router.registerPlugin("/api/*", mockPluginB);
+ HttpRequest request = new HttpRequest("GET", "/api/users", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN");
+
+ Plugin resolvedPlugin = router.resolve(request);
+ assertEquals(mockPluginA, resolvedPlugin);
+ }
+
+ @Test
+ void resolve_handlesRootPath() {
+ router.registerPlugin("/", mockPluginA);
+ HttpRequest request = new HttpRequest("GET", "/", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN");
+
+ Plugin resolvedPlugin = router.resolve(request);
+ assertEquals(mockPluginA, resolvedPlugin);
+ }
+
+ @Test
+ void resolve_handlesRootWildcardPath() {
+ router.registerPlugin("/*", mockPluginA);
+ HttpRequest request = new HttpRequest("GET", "/any/path/here", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN");
+
+ Plugin resolvedPlugin = router.resolve(request);
+ assertEquals(mockPluginA, resolvedPlugin);
+ }
+
+ @Test
+ void resolve_returnsNotFoundPluginForEmptyRouter() {
+ HttpRequest request = new HttpRequest("GET", "/anypath", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN");
+ Plugin resolvedPlugin = router.resolve(request);
+ assertTrue(resolvedPlugin instanceof NotFoundPlugin);
+ }
+
+ @Test
+ void resolve_prefersMoreSpecificWildcardOverLessSpecific() {
+ router.registerPlugin("/api/*", mockPluginA);
+ router.registerPlugin("/api/users/*", mockPluginB);
+
+ HttpRequest request = new HttpRequest(
+ "GET",
+ "/api/users/123",
+ null,
+ "HTTP/1.1",
+ Map.of(),
+ new byte[0],
+ "UNKNOWN"
+ );
+
+ Plugin resolvedPlugin = router.resolve(request);
+ assertEquals(mockPluginB, resolvedPlugin);
+ }
+
+}
diff --git a/src/test/java/org/juv25d/util/ConfigLoaderTest.java b/src/test/java/org/juv25d/util/ConfigLoaderTest.java
new file mode 100644
index 00000000..cf7598c6
--- /dev/null
+++ b/src/test/java/org/juv25d/util/ConfigLoaderTest.java
@@ -0,0 +1,97 @@
+package org.juv25d.util;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ConfigLoaderTest {
+
+ /**
+ * Verifies that ConfigLoader correctly reads and assigns configuration values when provided
+ * with a valid YAML input stream. Ensures that all expected fieldsβserver port, root directory,
+ * and logging levelβare populated with the values defined in the YAML content.
+ */
+
+ @Test
+ void loadsValuesFromYaml() {
+ String yaml = """
+ server:
+ port: 9090
+ root-dir: "public"
+ trusted-proxies:
+ - 1.1.1.1
+ logging:
+ level: "DEBUG"
+ rate-limiting:
+ enabled: true
+ requests-per-minute: 20
+ burst-capacity: 10
+ """;
+
+ ConfigLoader loader = new ConfigLoader(
+ new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8))
+ );
+
+ assertEquals(9090, loader.getPort());
+ assertEquals("public", loader.getRootDirectory());
+ assertEquals("1.1.1.1", loader.getTrustedProxies().getFirst());
+ assertEquals("DEBUG", loader.getLogLevel());
+ assertTrue(loader.isRateLimitingEnabled());
+ assertEquals(20, loader.getRequestsPerMinute());
+ assertEquals(10, loader.getBurstCapacity());
+ }
+
+ /**
+ * Ensures that ConfigLoader falls back to its documented default values when the YAML input
+ * omits server configuration keys. This test confirms that missing fields do not cause errors
+ * and that default port and root directory values are applied as intended.
+ */
+
+ @Test
+ void usesDefaultsWhenServerKeysMissing() {
+ String yaml = """
+ server: {}
+ logging: {}
+ rate-limiting: {}
+ """;
+
+ ConfigLoader loader = new ConfigLoader(
+ new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8))
+ );
+
+ assertEquals(8080, loader.getPort());
+ assertEquals("static", loader.getRootDirectory());
+ assertEquals("INFO", loader.getLogLevel());
+ assertFalse(loader.isRateLimitingEnabled());
+ assertEquals(List.of(), loader.getTrustedProxies());
+ }
+
+ /**
+ * Confirms that ConfigLoader fails predictably when no YAML configuration is provided.
+ * Passing a null InputStream should trigger a RuntimeException, indicating that the loader
+ * cannot operate without configuration data.
+ */
+
+ @Test
+ @SuppressWarnings("NullAway")
+ void throwsWhenYamlMissing() {
+ assertThrows(RuntimeException.class, () ->
+ new ConfigLoader((java.io.InputStream) null) ); }
+
+ @Test
+ void shouldThrowWhenYamlIsMalformed() {
+ String yaml = "server:\nBadYaml";
+ InputStream input = new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8));
+
+ RuntimeException ex = assertThrows(RuntimeException.class,
+ () -> new ConfigLoader(input));
+
+ String msg = ex.getMessage();
+ assertTrue(msg != null && msg.contains("Failed to load application config"));
+ }
+}