Finished
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user