diff --git a/README.MD b/README.MD index e0301dd..440d3fc 100644 --- a/README.MD +++ b/README.MD @@ -6,15 +6,21 @@ Feel free to join our Discord. ## License Notice -This project (OAC) is licensed under the [Open Autonomous Public License (OAPL)](https://open-autonomous-connection.org/license.html). +This project (OAC) is licensed under +the [Open Autonomous Public License (OAPL)](https://open-autonomous-connection.org/license.html). **Third-party components:** + - *UnlegitLibrary* is authored by the same copyright holder and is used here under a special agreement: - While [UnlegitLibrary](https://repo.unlegitdqrk.dev/UnlegitDqrk/unlegitlibrary/) is generally distributed under the [GNU GPLv3](https://repo.unlegitdqrk.dev/UnlegitDqrk/unlegitlibrary/src/branch/master/LICENSE), + While [UnlegitLibrary](https://repo.unlegitdqrk.dev/UnlegitDqrk/unlegitlibrary/) is generally distributed under + the [GNU GPLv3](https://repo.unlegitdqrk.dev/UnlegitDqrk/unlegitlibrary/src/branch/master/LICENSE), it is additionally licensed under OAPL **exclusively for the OAC project**. Therefore, within OAC, the OAPL terms apply to UnlegitLibrary as well. # Bugs/Problems + # In progress + # TODO + everything \ No newline at end of file diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml index 09355f4..13a3e03 100644 --- a/dependency-reduced-pom.xml +++ b/dependency-reduced-pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.openautonomousconnection WebServer - 1.0.0-BETA.1.10 + 1.0.0-BETA.1.0 The default DNS-Server https://open-autonomous-connection.org/ @@ -40,7 +40,8 @@ GNU General Public License v3.0 https://www.gnu.org/licenses/gpl-3.0.html - Default license: Applies to all users and projects unless an explicit alternative license has been granted. + Default license: Applies to all users and projects unless an explicit alternative license has been + granted. LPGL 3 diff --git a/pom.xml b/pom.xml index f2cab12..6176e36 100644 --- a/pom.xml +++ b/pom.xml @@ -1,12 +1,12 @@ - 4.0.0 org.openautonomousconnection WebServer - 1.0.0-BETA.1.10 + 1.0.0-BETA.1.0 Open Autonomous Connection https://open-autonomous-connection.org/ @@ -65,7 +65,8 @@ GNU General Public License v3.0 https://www.gnu.org/licenses/gpl-3.0.html - Default license: Applies to all users and projects unless an explicit alternative license has been granted. + Default license: Applies to all users and projects unless an explicit alternative license has been + granted. @@ -98,7 +99,8 @@ mariadb - https://mariadb.com/docs/general-resources/community/community/faq/licensing-questions/licensing-faq + https://mariadb.com/docs/general-resources/community/community/faq/licensing-questions/licensing-faq + @@ -116,7 +118,7 @@ org.openautonomousconnection Protocol - 1.0.0-BETA.7.7 + 1.0.0-BETA.1.0 org.projectlombok @@ -145,7 +147,8 @@ - + org.openautonomousconnection.webserver.Main diff --git a/src/main/java/org/openautonomousconnection/webserver/Main.java b/src/main/java/org/openautonomousconnection/webserver/Main.java index 0b3d52a..5a4c8eb 100644 --- a/src/main/java/org/openautonomousconnection/webserver/Main.java +++ b/src/main/java/org/openautonomousconnection/webserver/Main.java @@ -3,26 +3,18 @@ package org.openautonomousconnection.webserver; import dev.unlegitdqrk.unlegitlibrary.command.CommandExecutor; import dev.unlegitdqrk.unlegitlibrary.command.CommandManager; import dev.unlegitdqrk.unlegitlibrary.command.CommandPermission; -import dev.unlegitdqrk.unlegitlibrary.event.EventListener; import dev.unlegitdqrk.unlegitlibrary.event.EventManager; -import dev.unlegitdqrk.unlegitlibrary.event.Listener; import dev.unlegitdqrk.unlegitlibrary.file.ConfigurationManager; -import dev.unlegitdqrk.unlegitlibrary.network.system.client.events.state.ClientConnectedEvent; import dev.unlegitdqrk.unlegitlibrary.network.system.packets.PacketHandler; -import dev.unlegitdqrk.unlegitlibrary.network.system.server.NetworkServer; -import dev.unlegitdqrk.unlegitlibrary.network.system.server.events.packets.S_PacketSendEvent; -import dev.unlegitdqrk.unlegitlibrary.network.system.server.events.state.ServerStoppedEvent; import dev.unlegitdqrk.unlegitlibrary.network.system.utils.ClientAuthMode; import lombok.Getter; import org.openautonomousconnection.protocol.ProtocolBridge; import org.openautonomousconnection.protocol.ProtocolValues; -import org.openautonomousconnection.protocol.side.server.events.S_CustomClientConnectedEvent; import org.openautonomousconnection.protocol.versions.ProtocolVersion; import org.openautonomousconnection.webserver.commands.StopCommand; import java.io.File; import java.nio.file.Files; -import java.nio.file.Path; import java.util.Scanner; public class Main { @@ -44,7 +36,8 @@ public class Main { values.packetHandler = new PacketHandler(); values.eventManager = new EventManager(); - if (!Files.exists(new File("config.properties").toPath())) Files.createFile(new File("config.properties").toPath()); + if (!Files.exists(new File("config.properties").toPath())) + Files.createFile(new File("config.properties").toPath()); ConfigurationManager config = new ConfigurationManager(new File("config.properties")); config.loadProperties(); diff --git a/src/main/java/org/openautonomousconnection/webserver/WebServer.java b/src/main/java/org/openautonomousconnection/webserver/WebServer.java index 5457712..7907b99 100644 --- a/src/main/java/org/openautonomousconnection/webserver/WebServer.java +++ b/src/main/java/org/openautonomousconnection/webserver/WebServer.java @@ -1,8 +1,5 @@ package org.openautonomousconnection.webserver; -import dev.unlegitdqrk.unlegitlibrary.event.Listener; -import dev.unlegitdqrk.unlegitlibrary.network.system.server.events.packets.S_PacketReadEvent; -import dev.unlegitdqrk.unlegitlibrary.network.system.server.events.packets.S_PacketSendEvent; import dev.unlegitdqrk.unlegitlibrary.network.system.utils.TransportProtocol; import lombok.Getter; import org.openautonomousconnection.protocol.annotations.ProtocolInfo; @@ -94,20 +91,6 @@ public final class WebServer extends ProtocolWebServer { } } - // TODO: Temporary solution until its fixed in Protocol - @Listener - public void onRequest(S_PacketReadEvent event) { - if (event.getPacket() instanceof WebRequestPacket) { - try { - event.getClient().sendPacket( - onWebRequest(getClientByID(event.getClient().getUniqueID()), (WebRequestPacket) event.getPacket()), - TransportProtocol.TCP); - } catch (IOException e) { - getProtocolBridge().getLogger().exception("Failed to send web response", e); - } - } - } - private WebResponsePacket serveFile(CustomConnectedClient client, String path) throws Exception { if (path.startsWith("/")) path = path.substring(1); if (path.isEmpty()) path = "index.html"; @@ -115,11 +98,11 @@ public final class WebServer extends ProtocolWebServer { File root = getContentFolder().getCanonicalFile(); File file = new File(root, path).getCanonicalFile(); - if (!file.getPath().startsWith(root.getPath())) { + if (!RuleManager.isAllowed(path)) { return new WebResponsePacket(403, "text/plain; charset=utf-8", HeaderMaps.mutable(), "Forbidden".getBytes()); } - if (!file.exists() || !file.isFile()) { - return new WebResponsePacket(404, "text/plain; charset=utf-8", HeaderMaps.mutable(), "Not found".getBytes()); + if (RuleManager.isDenied(path)) { + return new WebResponsePacket(403, "text/plain; charset=utf-8", HeaderMaps.mutable(), "Forbidden".getBytes()); } String contentType = ContentTypeResolver.resolve(file.getName()); diff --git a/src/main/java/org/openautonomousconnection/webserver/api/SessionContext.java b/src/main/java/org/openautonomousconnection/webserver/api/SessionContext.java index 9ea4df1..f7a4bf5 100644 --- a/src/main/java/org/openautonomousconnection/webserver/api/SessionContext.java +++ b/src/main/java/org/openautonomousconnection/webserver/api/SessionContext.java @@ -5,14 +5,17 @@ import org.openautonomousconnection.protocol.side.web.ProtocolWebServer; import org.openautonomousconnection.protocol.side.web.managers.SessionManager; import java.io.IOException; +import java.util.Locale; import java.util.Map; /** * Provides session-related information for Java WebPages. - * Thin layer on top of SessionManager. + * Reads session id primarily from Cookie header ("session=..."). */ public final class SessionContext { + private static final String COOKIE_NAME = "session"; + private final String sessionId; private final String user; private final boolean valid; @@ -23,16 +26,30 @@ public final class SessionContext { this.valid = valid; } + /** + * Creates a SessionContext from request headers (case-insensitive). + * + * @param client connected client + * @param server web server + * @param headers request headers + * @return session context + * @throws IOException on errors + */ public static SessionContext from(CustomConnectedClient client, ProtocolWebServer server, Map headers) throws IOException { - if (headers == null) return new SessionContext(null, null, false); + if (headers == null || headers.isEmpty()) return new SessionContext(null, null, false); - String sessionId = headers.get("session"); - if (sessionId == null) return new SessionContext(null, null, false); + String sessionId = extractSessionId(headers); + if (sessionId == null || sessionId.isBlank()) return new SessionContext(null, null, false); - String ip = (client.getConnection().getTcpSocket() != null && client.getConnection().getTcpSocket().getInetAddress() != null) - ? client.getConnection().getTcpSocket().getInetAddress().getHostAddress() : ""; + String ip = (client != null + && client.getConnection() != null + && client.getConnection().getTcpSocket() != null + && client.getConnection().getTcpSocket().getInetAddress() != null) + ? client.getConnection().getTcpSocket().getInetAddress().getHostAddress() + : ""; - String userAgent = headers.getOrDefault("user-agent", ""); + String userAgent = getHeaderIgnoreCase(headers, "user-agent"); + if (userAgent == null) userAgent = ""; boolean valid = SessionManager.isValid(sessionId, ip, userAgent, server); if (!valid) return new SessionContext(sessionId, null, false); @@ -41,15 +58,65 @@ public final class SessionContext { return new SessionContext(sessionId, user, true); } + private static String extractSessionId(Map headers) { + // 1) Cookie header preferred + String cookie = getHeaderIgnoreCase(headers, "cookie"); + String fromCookie = parseCookie(cookie, COOKIE_NAME); + if (fromCookie != null && !fromCookie.isBlank()) return fromCookie; + + // 2) Backward-compatible fallback: old custom header + String legacy = getHeaderIgnoreCase(headers, "session"); + return (legacy == null || legacy.isBlank()) ? null : legacy.trim(); + } + + private static String parseCookie(String cookieHeader, String name) { + if (cookieHeader == null || cookieHeader.isBlank() || name == null || name.isBlank()) return null; + + String[] parts = cookieHeader.split(";"); + for (String p : parts) { + String t = p.trim(); + int eq = t.indexOf('='); + if (eq <= 0) continue; + + String k = t.substring(0, eq).trim(); + if (!k.equalsIgnoreCase(name)) continue; + + String v = t.substring(eq + 1).trim(); + return v.isEmpty() ? null : v; + } + return null; + } + + private static String getHeaderIgnoreCase(Map headers, String key) { + if (key == null) return null; + String needle = key.trim().toLowerCase(Locale.ROOT); + for (Map.Entry e : headers.entrySet()) { + if (e.getKey() == null) continue; + if (e.getKey().trim().toLowerCase(Locale.ROOT).equals(needle)) { + return e.getValue(); + } + } + return null; + } + + /** + * @return whether session is valid + */ public boolean isValid() { return valid; } + /** + * @return session id + */ public String getSessionId() { return sessionId; } + /** + * @return user id stored in session (string) + */ public String getUser() { return user; } -} +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageCache.java b/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageCache.java index d43ed16..3600baf 100644 --- a/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageCache.java +++ b/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageCache.java @@ -1,10 +1,6 @@ package org.openautonomousconnection.webserver.runtime; import java.io.File; -import java.net.URL; -import java.net.URLClassLoader; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; @@ -21,8 +17,8 @@ public final class JavaPageCache { /** * Compiles the content tree if needed and returns a loaded class using a build-dir ClassLoader. * - * @param contentRoot content root directory - * @param javaFile requested Java source file + * @param contentRoot content root directory + * @param javaFile requested Java source file * @param contentLastModified aggregate last modified of the whole content tree * @return loaded class * @throws Exception on compilation or classloading errors @@ -43,13 +39,15 @@ public final class JavaPageCache { return loaded; } - private record Entry(long contentLastModified, LoadedClass loadedClass) { } + private record Entry(long contentLastModified, LoadedClass loadedClass) { + } /** * Result wrapper holding both the ClassLoader and the loaded class. * * @param classLoader loader used to load the class - * @param clazz loaded class + * @param clazz loaded class */ - public record LoadedClass(ClassLoader classLoader, Class clazz) { } + public record LoadedClass(ClassLoader classLoader, Class clazz) { + } } \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageCompiler.java b/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageCompiler.java index 284d0c9..8bff2ac 100644 --- a/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageCompiler.java +++ b/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageCompiler.java @@ -25,7 +25,7 @@ public final class JavaPageCompiler { * Compiles all Java sources under {@code contentRoot} into {@code .oac-build} * and loads the class corresponding to {@code requestedJavaFile}. * - * @param contentRoot content root + * @param contentRoot content root * @param requestedJavaFile requested .java file * @return loaded class with its loader * @throws Exception on compilation/load error @@ -38,7 +38,8 @@ public final class JavaPageCompiler { Files.createDirectories(buildDir); List sources = listJavaFiles(contentRoot); - if (sources.isEmpty()) throw new IllegalStateException("No .java files found under content root: " + contentRoot); + if (sources.isEmpty()) + throw new IllegalStateException("No .java files found under content root: " + contentRoot); try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8)) { Iterable units = fileManager.getJavaFileObjectsFromFiles(sources); diff --git a/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageDispatcher.java b/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageDispatcher.java index 8dc406e..4b3aeda 100644 --- a/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageDispatcher.java +++ b/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageDispatcher.java @@ -13,7 +13,6 @@ import org.openautonomousconnection.webserver.utils.WebHasher; import java.io.File; import java.nio.charset.StandardCharsets; -import java.util.Map; /** * Dispatches Java WebPages using {@code @Route} annotation. diff --git a/src/main/java/org/openautonomousconnection/webserver/runtime/JavaRouteRegistry.java b/src/main/java/org/openautonomousconnection/webserver/runtime/JavaRouteRegistry.java index 13179d6..7a6d3e8 100644 --- a/src/main/java/org/openautonomousconnection/webserver/runtime/JavaRouteRegistry.java +++ b/src/main/java/org/openautonomousconnection/webserver/runtime/JavaRouteRegistry.java @@ -1,7 +1,5 @@ package org.openautonomousconnection.webserver.runtime; -import org.openautonomousconnection.webserver.api.Route; - import java.io.File; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -20,68 +18,10 @@ import java.util.regex.Pattern; */ public final class JavaRouteRegistry { - private final ConcurrentHashMap routes = new ConcurrentHashMap<>(); - private final ConcurrentHashMap scanState = new ConcurrentHashMap<>(); - private static final Pattern PACKAGE_PATTERN = Pattern.compile("^\\s*package\\s+([a-zA-Z0-9_\\.]+)\\s*;\\s*$"); private static final Pattern ROUTE_PATTERN = Pattern.compile("@Route\\s*\\(\\s*path\\s*=\\s*\"([^\"]+)\"\\s*\\)"); - - /** - * 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 final ConcurrentHashMap routes = new ConcurrentHashMap<>(); + private final ConcurrentHashMap 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