This commit is contained in:
UnlegitDqrk
2026-02-11 23:20:06 +01:00
parent 87a28b3749
commit 7f0c30a358
16 changed files with 870 additions and 128 deletions

View File

@@ -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:
* <ul>
* <li>URL query string (GET params)</li>
* <li>POST body (application/x-www-form-urlencoded or multipart/form-data text fields)</li>
* </ul>
*
* <p>Precedence: POST body overrides query string on key collision.</p>
*/
public final class MergedRequestParams {
private final Map<String, List<String>> params;
private MergedRequestParams(Map<String, List<String>> 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<String, String> headers, byte[] body) {
Map<String, List<String>> 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<String, List<String>> 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<String, String> headers, String keyLower) {
if (headers == null || headers.isEmpty()) return null;
for (Map.Entry<String, String> e : headers.entrySet()) {
if (e.getKey() == null) continue;
if (e.getKey().trim().equalsIgnoreCase(keyLower)) {
return e.getValue();
}
}
return null;
}
private static void mergeInto(Map<String, List<String>> target, Map<String, List<String>> src, boolean override) {
if (src == null || src.isEmpty()) return;
for (Map.Entry<String, List<String>> e : src.entrySet()) {
if (e.getKey() == null) continue;
String k = e.getKey();
List<String> 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<String, List<String>> 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<String, List<String>> parseUrlEncoded(String s, Charset charset) {
Map<String, List<String>> 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.
* <p>Ignores file uploads and binary content.</p>
*/
private static Map<String, List<String>> parseMultipartTextFields(byte[] body, String boundary, Charset charset) {
Map<String, List<String>> 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<String> 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<String> getAll(String key) {
List<String> 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<String, List<String>> asMap() {
Map<String, List<String>> out = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> e : params.entrySet()) {
out.put(e.getKey(), List.copyOf(e.getValue()));
}
return Collections.unmodifiableMap(out);
}
}