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: * * *

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<>(); // Query string String query = extractQuery(rawTarget); if (query != null && !query.isBlank()) { mergeInto(merged, parseUrlEncoded(query, StandardCharsets.UTF_8), false); } // 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(Collections.unmodifiableMap(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)) { // Always keep ArrayList in target to allow appends safely. target.get(k).addAll(vals); continue; } // Insert/override with a mutable list to preserve later merge behavior. 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; } } if (c <= 0xFF) baos.write((byte) c); else baos.writeBytes(String.valueOf(c).getBytes(charset)); } 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); } }