scanState = new ConcurrentHashMap<>();
private static String normalizeRoute(String value) {
if (value == null) return "/";
@@ -151,15 +91,75 @@ public final class JavaRouteRegistry {
return new RouteMeta(routePath, fqcn);
}
- private record RouteEntry(File sourceFile, String fqcn) { }
+ /**
+ * Returns the aggregate last modified timestamp of the content tree.
+ *
+ * @param contentRoot content root
+ * @return last modified max value
+ */
+ public long currentContentLastModified(File contentRoot) {
+ return folderLastModified(contentRoot);
+ }
+
+ /**
+ * Refreshes registry when content folder changed.
+ *
+ * @param contentRoot content root
+ */
+ public void refreshIfNeeded(File contentRoot) {
+ if (contentRoot == null) return;
+
+ long lm = folderLastModified(contentRoot);
+ Long prev = scanState.get("lm");
+
+ if (prev != null && prev == lm && !routes.isEmpty()) {
+ return;
+ }
+
+ scanState.put("lm", lm);
+ rebuild(contentRoot);
+ }
+
+ /**
+ * Finds a route mapping.
+ *
+ * @param route route path
+ * @return result or null
+ */
+ public RouteLookupResult find(String route) {
+ RouteEntry e = routes.get(route);
+ if (e == null) return null;
+ return new RouteLookupResult(e.sourceFile, e.fqcn);
+ }
+
+ private void rebuild(File contentRoot) {
+ routes.clear();
+
+ for (File f : listJavaFiles(contentRoot)) {
+ try {
+ RouteMeta meta = parseRouteMeta(f);
+ if (meta == null) continue;
+
+ String route = normalizeRoute(meta.routePath());
+ routes.put(route, new RouteEntry(f, meta.fqcn()));
+ } catch (Exception ignored) {
+ // Ignore invalid sources; compilation will surface errors later when requested.
+ }
+ }
+ }
+
+ private record RouteEntry(File sourceFile, String fqcn) {
+ }
/**
* Lookup result for a route.
*
* @param sourceFile Java source file
- * @param fqcn fully qualified class name
+ * @param fqcn fully qualified class name
*/
- public record RouteLookupResult(File sourceFile, String fqcn) { }
+ public record RouteLookupResult(File sourceFile, String fqcn) {
+ }
- private record RouteMeta(String routePath, String fqcn) { }
+ private record RouteMeta(String routePath, String fqcn) {
+ }
}
\ No newline at end of file
diff --git a/src/main/java/org/openautonomousconnection/webserver/utils/HttpsProxy.java b/src/main/java/org/openautonomousconnection/webserver/utils/HttpsProxy.java
index b203c64..0c140a1 100644
--- a/src/main/java/org/openautonomousconnection/webserver/utils/HttpsProxy.java
+++ b/src/main/java/org/openautonomousconnection/webserver/utils/HttpsProxy.java
@@ -8,8 +8,6 @@ import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
-import java.util.HashMap;
-import java.util.Map;
/**
* Simple HTTPS -> OAC proxy helper.
diff --git a/src/main/java/org/openautonomousconnection/webserver/utils/MergedRequestParams.java b/src/main/java/org/openautonomousconnection/webserver/utils/MergedRequestParams.java
new file mode 100644
index 0000000..a4c75f2
--- /dev/null
+++ b/src/main/java/org/openautonomousconnection/webserver/utils/MergedRequestParams.java
@@ -0,0 +1,393 @@
+package org.openautonomousconnection.webserver.utils;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+
+/**
+ * Parses and merges request parameters from:
+ *
+ * - URL query string (GET params)
+ * - POST body (application/x-www-form-urlencoded or multipart/form-data text fields)
+ *
+ *
+ * Precedence: POST body overrides query string on key collision.
+ */
+public final class MergedRequestParams {
+
+ private final Map> params;
+
+ private MergedRequestParams(Map> params) {
+ this.params = params;
+ }
+
+ /**
+ * Creates a {@link MergedRequestParams} instance by parsing query string and POST body.
+ *
+ * @param rawTarget the raw request target (path + optional query), e.g. "/dashboard.html?action=..."
+ * @param headers request headers (may be null)
+ * @param body request body bytes (may be null/empty)
+ * @return merged parameters
+ */
+ public static MergedRequestParams from(String rawTarget, Map headers, byte[] body) {
+ Map> merged = new LinkedHashMap<>();
+
+ // 1) Query string
+ String query = extractQuery(rawTarget);
+ if (query != null && !query.isBlank()) {
+ mergeInto(merged, parseUrlEncoded(query, StandardCharsets.UTF_8), false);
+ }
+
+ // 2) Body
+ if (body != null && body.length > 0) {
+ String contentType = header(headers, "content-type");
+ Map> bodyParams = parseBody(contentType, body);
+ mergeInto(merged, bodyParams, true);
+ }
+
+ return new MergedRequestParams(merged);
+ }
+
+ private static String extractQuery(String rawTarget) {
+ if (rawTarget == null) return null;
+ int q = rawTarget.indexOf('?');
+ if (q < 0) return null;
+ if (q == rawTarget.length() - 1) return "";
+ return rawTarget.substring(q + 1);
+ }
+
+ private static String header(Map headers, String keyLower) {
+ if (headers == null || headers.isEmpty()) return null;
+ for (Map.Entry e : headers.entrySet()) {
+ if (e.getKey() == null) continue;
+ if (e.getKey().trim().equalsIgnoreCase(keyLower)) {
+ return e.getValue();
+ }
+ }
+ return null;
+ }
+
+ private static void mergeInto(Map> target, Map> src, boolean override) {
+ if (src == null || src.isEmpty()) return;
+ for (Map.Entry> e : src.entrySet()) {
+ if (e.getKey() == null) continue;
+ String k = e.getKey();
+ List vals = e.getValue() == null ? List.of() : e.getValue();
+ if (!override && target.containsKey(k)) {
+ // append
+ target.get(k).addAll(vals);
+ continue;
+ }
+ // override or insert
+ target.put(k, new ArrayList<>(vals));
+ }
+ }
+
+ private static Map> parseBody(String contentType, byte[] body) {
+ if (contentType != null) {
+ String ct = contentType.toLowerCase(Locale.ROOT);
+ if (ct.startsWith("application/x-www-form-urlencoded")) {
+ Charset cs = charsetFromContentType(contentType, StandardCharsets.UTF_8);
+ return parseUrlEncoded(new String(body, cs), cs);
+ }
+ if (ct.startsWith("multipart/form-data")) {
+ String boundary = boundaryFromContentType(contentType);
+ if (boundary != null && !boundary.isBlank()) {
+ return parseMultipartTextFields(body, boundary, StandardCharsets.UTF_8);
+ }
+ }
+ }
+
+ // Fallback: try urlencoded safely (common when content-type is missing in custom stacks)
+ return parseUrlEncoded(new String(body, StandardCharsets.UTF_8), StandardCharsets.UTF_8);
+ }
+
+ private static Charset charsetFromContentType(String contentType, Charset def) {
+ if (contentType == null) return def;
+ String[] parts = contentType.split(";");
+ for (String p : parts) {
+ String s = p.trim().toLowerCase(Locale.ROOT);
+ if (s.startsWith("charset=")) {
+ String name = s.substring("charset=".length()).trim();
+ try {
+ return Charset.forName(name);
+ } catch (Exception ignored) {
+ return def;
+ }
+ }
+ }
+ return def;
+ }
+
+ private static String boundaryFromContentType(String contentType) {
+ if (contentType == null) return null;
+ String[] parts = contentType.split(";");
+ for (String p : parts) {
+ String s = p.trim();
+ if (s.toLowerCase(Locale.ROOT).startsWith("boundary=")) {
+ String b = s.substring("boundary=".length()).trim();
+ if (b.startsWith("\"") && b.endsWith("\"") && b.length() >= 2) {
+ b = b.substring(1, b.length() - 1);
+ }
+ return b;
+ }
+ }
+ return null;
+ }
+
+ // ----------------------------- Internals -----------------------------
+
+ /**
+ * Parses "application/x-www-form-urlencoded".
+ */
+ private static Map> parseUrlEncoded(String s, Charset charset) {
+ Map> out = new LinkedHashMap<>();
+ if (s == null || s.isBlank()) return out;
+
+ String[] pairs = s.split("&");
+ for (String pair : pairs) {
+ if (pair.isEmpty()) continue;
+ int eq = pair.indexOf('=');
+ String k;
+ String v;
+ if (eq < 0) {
+ k = urlDecode(pair, charset);
+ v = "";
+ } else {
+ k = urlDecode(pair.substring(0, eq), charset);
+ v = urlDecode(pair.substring(eq + 1), charset);
+ }
+ if (k == null || k.isBlank()) continue;
+ out.computeIfAbsent(k, __ -> new ArrayList<>()).add(v == null ? "" : v);
+ }
+ return out;
+ }
+
+ /**
+ * Minimal multipart parser for text fields only.
+ * Ignores file uploads and binary content.
+ */
+ private static Map> parseMultipartTextFields(byte[] body, String boundary, Charset charset) {
+ Map> out = new LinkedHashMap<>();
+ if (body == null || body.length == 0) return out;
+
+ byte[] boundaryBytes = ("--" + boundary).getBytes(StandardCharsets.ISO_8859_1);
+ byte[] endBoundaryBytes = ("--" + boundary + "--").getBytes(StandardCharsets.ISO_8859_1);
+
+ int i = 0;
+ while (i < body.length) {
+ int bStart = indexOf(body, boundaryBytes, i);
+ if (bStart < 0) break;
+ int bLineEnd = indexOfCrlf(body, bStart);
+ if (bLineEnd < 0) break;
+
+ // Check end boundary
+ if (startsWithAt(body, endBoundaryBytes, bStart)) {
+ break;
+ }
+
+ int partStart = bLineEnd + 2; // skip CRLF after boundary line
+
+ int headersEnd = indexOfDoubleCrlf(body, partStart);
+ if (headersEnd < 0) break;
+
+ String headersStr = new String(body, partStart, headersEnd - partStart, StandardCharsets.ISO_8859_1);
+ String name = extractMultipartName(headersStr);
+ boolean isFile = isMultipartFile(headersStr);
+
+ int dataStart = headersEnd + 4; // skip CRLFCRLF
+
+ int nextBoundary = indexOf(body, boundaryBytes, dataStart);
+ if (nextBoundary < 0) break;
+
+ int dataEnd = nextBoundary - 2; // strip trailing CRLF before boundary
+ if (dataEnd < dataStart) dataEnd = dataStart;
+
+ if (name != null && !name.isBlank() && !isFile) {
+ String value = new String(body, dataStart, dataEnd - dataStart, charset);
+ out.computeIfAbsent(name, __ -> new ArrayList<>()).add(value);
+ }
+
+ i = nextBoundary;
+ }
+
+ return out;
+ }
+
+ private static String extractMultipartName(String headers) {
+ // Content-Disposition: form-data; name="action"
+ String[] lines = headers.split("\r\n");
+ for (String line : lines) {
+ String lower = line.toLowerCase(Locale.ROOT);
+ if (!lower.startsWith("content-disposition:")) continue;
+ int nameIdx = lower.indexOf("name=");
+ if (nameIdx < 0) continue;
+ String after = line.substring(nameIdx + "name=".length()).trim();
+ if (after.startsWith("\"")) {
+ int end = after.indexOf('"', 1);
+ if (end > 1) return after.substring(1, end);
+ }
+ int semi = after.indexOf(';');
+ if (semi >= 0) after = after.substring(0, semi).trim();
+ return after;
+ }
+ return null;
+ }
+
+ private static boolean isMultipartFile(String headers) {
+ String lower = headers.toLowerCase(Locale.ROOT);
+ return lower.contains("filename=");
+ }
+
+ private static int indexOfDoubleCrlf(byte[] haystack, int from) {
+ for (int i = from; i + 3 < haystack.length; i++) {
+ if (haystack[i] == '\r' && haystack[i + 1] == '\n' && haystack[i + 2] == '\r' && haystack[i + 3] == '\n') {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private static int indexOfCrlf(byte[] haystack, int from) {
+ for (int i = from; i + 1 < haystack.length; i++) {
+ if (haystack[i] == '\r' && haystack[i + 1] == '\n') return i;
+ }
+ return -1;
+ }
+
+ private static boolean startsWithAt(byte[] haystack, byte[] needle, int pos) {
+ if (pos < 0) return false;
+ if (pos + needle.length > haystack.length) return false;
+ for (int i = 0; i < needle.length; i++) {
+ if (haystack[pos + i] != needle[i]) return false;
+ }
+ return true;
+ }
+
+ private static int indexOf(byte[] haystack, byte[] needle, int from) {
+ if (needle.length == 0) return from;
+ outer:
+ for (int i = Math.max(0, from); i <= haystack.length - needle.length; i++) {
+ for (int j = 0; j < needle.length; j++) {
+ if (haystack[i + j] != needle[j]) continue outer;
+ }
+ return i;
+ }
+ return -1;
+ }
+
+ private static String urlDecode(String s, Charset charset) {
+ if (s == null) return null;
+ // Replace '+' with space
+ String in = s.replace('+', ' ');
+ ByteArrayOutputStream baos = new ByteArrayOutputStream(in.length());
+ for (int i = 0; i < in.length(); i++) {
+ char c = in.charAt(i);
+ if (c == '%' && i + 2 < in.length()) {
+ int hi = hex(in.charAt(i + 1));
+ int lo = hex(in.charAt(i + 2));
+ if (hi >= 0 && lo >= 0) {
+ baos.write((hi << 4) | lo);
+ i += 2;
+ continue;
+ }
+ }
+ // ISO-8859-1 safe char -> byte
+ if (c <= 0xFF) baos.write((byte) c);
+ else {
+ // for non-latin chars, fall back to UTF-8 bytes of that char
+ byte[] b = String.valueOf(c).getBytes(charset);
+ baos.writeBytes(b);
+ }
+ }
+ return baos.toString(charset);
+ }
+
+ private static int hex(char c) {
+ if (c >= '0' && c <= '9') return c - '0';
+ if (c >= 'a' && c <= 'f') return 10 + (c - 'a');
+ if (c >= 'A' && c <= 'F') return 10 + (c - 'A');
+ return -1;
+ }
+
+ /**
+ * Returns the first value for a key or {@code null} if absent.
+ *
+ * @param key parameter key
+ * @return first value or null
+ */
+ public String get(String key) {
+ List v = params.get(key);
+ if (v == null || v.isEmpty()) return null;
+ return v.get(0);
+ }
+
+ /**
+ * Returns the first value for a key, or a default if absent.
+ *
+ * @param key parameter key
+ * @param def default
+ * @return value or default
+ */
+ public String getOr(String key, String def) {
+ String v = get(key);
+ return v == null ? def : v;
+ }
+
+ /**
+ * Returns an int parameter or fallback if missing/invalid.
+ *
+ * @param key parameter key
+ * @param def default
+ * @return parsed int or default
+ */
+ public int getInt(String key, int def) {
+ String v = get(key);
+ if (v == null) return def;
+ try {
+ return Integer.parseInt(v.trim());
+ } catch (Exception ignored) {
+ return def;
+ }
+ }
+
+ /**
+ * Returns {@code true} if parameter is "1", "true", "yes", "on" (case-insensitive).
+ * Missing parameter returns {@code false}.
+ *
+ * @param key parameter key
+ * @return boolean value
+ */
+ public boolean getBool(String key) {
+ String v = get(key);
+ if (v == null) return false;
+ String s = v.trim().toLowerCase(Locale.ROOT);
+ return "1".equals(s) || "true".equals(s) || "yes".equals(s) || "on".equals(s);
+ }
+
+ /**
+ * Returns all values for a key (never null).
+ *
+ * @param key key
+ * @return list (immutable)
+ */
+ public List getAll(String key) {
+ List v = params.get(key);
+ if (v == null) return List.of();
+ return Collections.unmodifiableList(v);
+ }
+
+ /**
+ * Returns an immutable view of all parameters.
+ *
+ * @return map
+ */
+ public Map> asMap() {
+ Map> out = new LinkedHashMap<>();
+ for (Map.Entry> e : params.entrySet()) {
+ out.put(e.getKey(), List.copyOf(e.getValue()));
+ }
+ return Collections.unmodifiableMap(out);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/openautonomousconnection/webserver/utils/PasswordHasher.java b/src/main/java/org/openautonomousconnection/webserver/utils/PasswordHasher.java
new file mode 100644
index 0000000..fe4b296
--- /dev/null
+++ b/src/main/java/org/openautonomousconnection/webserver/utils/PasswordHasher.java
@@ -0,0 +1,24 @@
+package org.openautonomousconnection.webserver.utils;
+
+/**
+ * Password hashing interface.
+ */
+public interface PasswordHasher {
+
+ /**
+ * Hashes a raw password into a stored representation.
+ *
+ * @param rawPassword raw password
+ * @return stored string
+ */
+ String hash(String rawPassword);
+
+ /**
+ * Verifies a raw password against a stored representation.
+ *
+ * @param rawPassword raw password
+ * @param stored stored string
+ * @return true if valid
+ */
+ boolean verify(String rawPassword, String stored);
+}
\ No newline at end of file
diff --git a/src/main/java/org/openautonomousconnection/webserver/utils/Pbkdf2Sha256Hasher.java b/src/main/java/org/openautonomousconnection/webserver/utils/Pbkdf2Sha256Hasher.java
new file mode 100644
index 0000000..2518c0f
--- /dev/null
+++ b/src/main/java/org/openautonomousconnection/webserver/utils/Pbkdf2Sha256Hasher.java
@@ -0,0 +1,84 @@
+package org.openautonomousconnection.webserver.utils;
+
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
+import java.security.SecureRandom;
+import java.util.Base64;
+
+/**
+ * PBKDF2 password hasher: PBKDF2$sha256$iterations$saltB64$hashB64
+ */
+public final class Pbkdf2Sha256Hasher implements PasswordHasher {
+
+ private final SecureRandom rng = new SecureRandom();
+ private final int iterations;
+ private final int saltBytes;
+ private final int keyBytes;
+
+ /**
+ * Creates a PBKDF2 hasher.
+ *
+ * @param iterations iterations (recommend >= 100k)
+ * @param saltBytes salt size
+ * @param keyBytes derived key size
+ */
+ public Pbkdf2Sha256Hasher(int iterations, int saltBytes, int keyBytes) {
+ if (iterations < 10_000) throw new IllegalArgumentException("iterations too low");
+ if (saltBytes < 16) throw new IllegalArgumentException("saltBytes too low");
+ if (keyBytes < 32) throw new IllegalArgumentException("keyBytes too low");
+ this.iterations = iterations;
+ this.saltBytes = saltBytes;
+ this.keyBytes = keyBytes;
+ }
+
+ private static byte[] pbkdf2(char[] password, byte[] salt, int iterations, int keyBytes) {
+ try {
+ PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, keyBytes * 8);
+ SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
+ return skf.generateSecret(spec).getEncoded();
+ } catch (Exception e) {
+ throw new IllegalStateException("PBKDF2WithHmacSHA256 not available", e);
+ }
+ }
+
+ private static boolean constantTimeEquals(byte[] a, byte[] b) {
+ if (a == null || b == null) return false;
+ if (a.length != b.length) return false;
+ int r = 0;
+ for (int i = 0; i < a.length; i++) r |= (a[i] ^ b[i]);
+ return r == 0;
+ }
+
+ @Override
+ public String hash(String rawPassword) {
+ if (rawPassword == null) rawPassword = "";
+ byte[] salt = new byte[saltBytes];
+ rng.nextBytes(salt);
+ byte[] dk = pbkdf2(rawPassword.toCharArray(), salt, iterations, keyBytes);
+ return "PBKDF2$sha256$" + iterations + "$" +
+ Base64.getEncoder().encodeToString(salt) + "$" +
+ Base64.getEncoder().encodeToString(dk);
+ }
+
+ @Override
+ public boolean verify(String rawPassword, String stored) {
+ if (rawPassword == null) rawPassword = "";
+ if (stored == null || stored.isBlank()) return false;
+
+ try {
+ String[] parts = stored.split("\\$");
+ if (parts.length != 5) return false;
+ if (!"PBKDF2".equals(parts[0])) return false;
+ if (!"sha256".equalsIgnoreCase(parts[1])) return false;
+
+ int it = Integer.parseInt(parts[2]);
+ byte[] salt = Base64.getDecoder().decode(parts[3]);
+ byte[] want = Base64.getDecoder().decode(parts[4]);
+
+ byte[] got = pbkdf2(rawPassword.toCharArray(), salt, it, want.length);
+ return constantTimeEquals(want, got);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/openautonomousconnection/webserver/utils/QuerySupport.java b/src/main/java/org/openautonomousconnection/webserver/utils/QuerySupport.java
new file mode 100644
index 0000000..6bf3fda
--- /dev/null
+++ b/src/main/java/org/openautonomousconnection/webserver/utils/QuerySupport.java
@@ -0,0 +1,150 @@
+package org.openautonomousconnection.webserver.utils;
+
+import java.lang.reflect.Method;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * GET-only support: extracts request target (path + query) and parses query parameters.
+ */
+public final class QuerySupport {
+
+ private QuerySupport() {
+ // Utility class
+ }
+
+ /**
+ * Extracts the raw request target (path + optional query) from a request object via reflection.
+ *
+ * This avoids hard-coding a method name you did not confirm.
+ *
+ * @param request request object (e.g. WebRequestPacket)
+ * @return raw target string, never null
+ */
+ public static String extractRawTarget(Object request) {
+ if (request == null) return "";
+
+ String[] candidates = {
+ "getTarget",
+ "getRequestTarget",
+ "getPath",
+ "getUrl",
+ "getURI",
+ "getUri"
+ };
+
+ for (String name : candidates) {
+ String v = tryInvokeStringGetter(request, name);
+ if (v != null && !v.isBlank()) return v;
+ }
+
+ return "";
+ }
+
+ private static String tryInvokeStringGetter(Object obj, String methodName) {
+ try {
+ Method m = obj.getClass().getMethod(methodName);
+ if (!m.getReturnType().equals(String.class)) return null;
+ return (String) m.invoke(obj);
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+
+ /**
+ * Parses query parameters from a raw target string.
+ *
+ * @param rawTarget e.g. "/ins/dashboard?action=x&k=v"
+ * @return map of params (decoded), never null
+ */
+ public static Map parseQuery(String rawTarget) {
+ Map out = new HashMap<>();
+ if (rawTarget == null || rawTarget.isBlank()) return out;
+
+ int q = rawTarget.indexOf('?');
+ if (q < 0 || q == rawTarget.length() - 1) return out;
+
+ String query = rawTarget.substring(q + 1);
+ for (String pair : query.split("&")) {
+ if (pair.isEmpty()) continue;
+ int eq = pair.indexOf('=');
+ String k = (eq >= 0) ? pair.substring(0, eq) : pair;
+ String v = (eq >= 0) ? pair.substring(eq + 1) : "";
+ out.put(urlDecode(k), urlDecode(v));
+ }
+ return out;
+ }
+
+ private static String urlDecode(String s) {
+ try {
+ return URLDecoder.decode(s, StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ return s;
+ }
+ }
+
+ /**
+ * Convenience wrapper for query maps.
+ */
+ public static final class Q {
+
+ private final Map map;
+
+ /**
+ * Creates a wrapper.
+ *
+ * @param map parsed query map
+ */
+ public Q(Map map) {
+ this.map = (map == null) ? Map.of() : map;
+ }
+
+ /**
+ * @param key key
+ * @return value or null
+ */
+ public String get(String key) {
+ return map.get(key);
+ }
+
+ /**
+ * @param key key
+ * @param def default
+ * @return value or default
+ */
+ public String getOr(String key, String def) {
+ String v = map.get(key);
+ return (v == null) ? def : v;
+ }
+
+ /**
+ * @param key key
+ * @param def default
+ * @return int or default
+ */
+ public int getInt(String key, int def) {
+ String v = map.get(key);
+ if (v == null) return def;
+ try {
+ return Integer.parseInt(v.trim());
+ } catch (Exception ignored) {
+ return def;
+ }
+ }
+
+ /**
+ * Accepts "1"/"0", "true"/"false", "yes"/"no".
+ *
+ * @param key key
+ * @return boolean
+ */
+ public boolean getBool(String key) {
+ String v = map.get(key);
+ if (v == null) return false;
+ String t = v.trim();
+ return "1".equals(t) || "true".equalsIgnoreCase(t) || "yes".equalsIgnoreCase(t);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/openautonomousconnection/webserver/utils/Sha256.java b/src/main/java/org/openautonomousconnection/webserver/utils/Sha256.java
new file mode 100644
index 0000000..2dca2ae
--- /dev/null
+++ b/src/main/java/org/openautonomousconnection/webserver/utils/Sha256.java
@@ -0,0 +1,42 @@
+package org.openautonomousconnection.webserver.utils;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+
+/**
+ * SHA-256 helper.
+ */
+public final class Sha256 {
+
+ private Sha256() {
+ // Utility class
+ }
+
+ /**
+ * Computes SHA-256 hex (lowercase) of a string using UTF-8.
+ *
+ * @param text input
+ * @return sha256 hex
+ */
+ public static String hex(String text) {
+ if (text == null) text = "";
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(text.getBytes(StandardCharsets.UTF_8));
+ return toHexLower(digest);
+ } catch (Exception e) {
+ throw new IllegalStateException("SHA-256 not available", e);
+ }
+ }
+
+ private static String toHexLower(byte[] bytes) {
+ char[] out = new char[bytes.length * 2];
+ final char[] hex = "0123456789abcdef".toCharArray();
+ int i = 0;
+ for (byte b : bytes) {
+ out[i++] = hex[(b >>> 4) & 0x0F];
+ out[i++] = hex[b & 0x0F];
+ }
+ return new String(out);
+ }
+}
\ No newline at end of file