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

@@ -6,15 +6,21 @@ Feel free to join our Discord.
## License Notice ## 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:** **Third-party components:**
- *UnlegitLibrary* is authored by the same copyright holder and is used here under a special agreement: - *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**. it is additionally licensed under OAPL **exclusively for the OAC project**.
Therefore, within OAC, the OAPL terms apply to UnlegitLibrary as well. Therefore, within OAC, the OAPL terms apply to UnlegitLibrary as well.
# Bugs/Problems # Bugs/Problems
# In progress # In progress
# TODO # TODO
everything everything

View File

@@ -3,7 +3,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>org.openautonomousconnection</groupId> <groupId>org.openautonomousconnection</groupId>
<artifactId>WebServer</artifactId> <artifactId>WebServer</artifactId>
<version>1.0.0-BETA.1.10</version> <version>1.0.0-BETA.1.0</version>
<description>The default DNS-Server</description> <description>The default DNS-Server</description>
<url>https://open-autonomous-connection.org/</url> <url>https://open-autonomous-connection.org/</url>
<issueManagement> <issueManagement>
@@ -40,7 +40,8 @@
<license> <license>
<name>GNU General Public License v3.0</name> <name>GNU General Public License v3.0</name>
<url>https://www.gnu.org/licenses/gpl-3.0.html</url> <url>https://www.gnu.org/licenses/gpl-3.0.html</url>
<comments>Default license: Applies to all users and projects unless an explicit alternative license has been granted.</comments> <comments>Default license: Applies to all users and projects unless an explicit alternative license has been
granted.</comments>
</license> </license>
<license> <license>
<name>LPGL 3</name> <name>LPGL 3</name>

17
pom.xml
View File

@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" <project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>org.openautonomousconnection</groupId> <groupId>org.openautonomousconnection</groupId>
<artifactId>WebServer</artifactId> <artifactId>WebServer</artifactId>
<version>1.0.0-BETA.1.10</version> <version>1.0.0-BETA.1.0</version>
<organization> <organization>
<name>Open Autonomous Connection</name> <name>Open Autonomous Connection</name>
<url>https://open-autonomous-connection.org/</url> <url>https://open-autonomous-connection.org/</url>
@@ -65,7 +65,8 @@
<name>GNU General Public License v3.0</name> <name>GNU General Public License v3.0</name>
<url>https://www.gnu.org/licenses/gpl-3.0.html</url> <url>https://www.gnu.org/licenses/gpl-3.0.html</url>
<comments> <comments>
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.
</comments> </comments>
</license> </license>
<license> <license>
@@ -98,7 +99,8 @@
</license> </license>
<license> <license>
<name>mariadb</name> <name>mariadb</name>
<url>https://mariadb.com/docs/general-resources/community/community/faq/licensing-questions/licensing-faq</url> <url>https://mariadb.com/docs/general-resources/community/community/faq/licensing-questions/licensing-faq
</url>
</license> </license>
</licenses> </licenses>
@@ -116,7 +118,7 @@
<dependency> <dependency>
<groupId>org.openautonomousconnection</groupId> <groupId>org.openautonomousconnection</groupId>
<artifactId>Protocol</artifactId> <artifactId>Protocol</artifactId>
<version>1.0.0-BETA.7.7</version> <version>1.0.0-BETA.1.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
@@ -145,7 +147,8 @@
</goals> </goals>
<configuration> <configuration>
<transformers> <transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openautonomousconnection.webserver.Main</mainClass> <mainClass>org.openautonomousconnection.webserver.Main</mainClass>
</transformer> </transformer>
</transformers> </transformers>

View File

@@ -3,26 +3,18 @@ package org.openautonomousconnection.webserver;
import dev.unlegitdqrk.unlegitlibrary.command.CommandExecutor; import dev.unlegitdqrk.unlegitlibrary.command.CommandExecutor;
import dev.unlegitdqrk.unlegitlibrary.command.CommandManager; import dev.unlegitdqrk.unlegitlibrary.command.CommandManager;
import dev.unlegitdqrk.unlegitlibrary.command.CommandPermission; import dev.unlegitdqrk.unlegitlibrary.command.CommandPermission;
import dev.unlegitdqrk.unlegitlibrary.event.EventListener;
import dev.unlegitdqrk.unlegitlibrary.event.EventManager; import dev.unlegitdqrk.unlegitlibrary.event.EventManager;
import dev.unlegitdqrk.unlegitlibrary.event.Listener;
import dev.unlegitdqrk.unlegitlibrary.file.ConfigurationManager; 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.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 dev.unlegitdqrk.unlegitlibrary.network.system.utils.ClientAuthMode;
import lombok.Getter; import lombok.Getter;
import org.openautonomousconnection.protocol.ProtocolBridge; import org.openautonomousconnection.protocol.ProtocolBridge;
import org.openautonomousconnection.protocol.ProtocolValues; import org.openautonomousconnection.protocol.ProtocolValues;
import org.openautonomousconnection.protocol.side.server.events.S_CustomClientConnectedEvent;
import org.openautonomousconnection.protocol.versions.ProtocolVersion; import org.openautonomousconnection.protocol.versions.ProtocolVersion;
import org.openautonomousconnection.webserver.commands.StopCommand; import org.openautonomousconnection.webserver.commands.StopCommand;
import java.io.File; import java.io.File;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Scanner; import java.util.Scanner;
public class Main { public class Main {
@@ -44,7 +36,8 @@ public class Main {
values.packetHandler = new PacketHandler(); values.packetHandler = new PacketHandler();
values.eventManager = new EventManager(); 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")); ConfigurationManager config = new ConfigurationManager(new File("config.properties"));
config.loadProperties(); config.loadProperties();

View File

@@ -1,8 +1,5 @@
package org.openautonomousconnection.webserver; 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 dev.unlegitdqrk.unlegitlibrary.network.system.utils.TransportProtocol;
import lombok.Getter; import lombok.Getter;
import org.openautonomousconnection.protocol.annotations.ProtocolInfo; 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 { private WebResponsePacket serveFile(CustomConnectedClient client, String path) throws Exception {
if (path.startsWith("/")) path = path.substring(1); if (path.startsWith("/")) path = path.substring(1);
if (path.isEmpty()) path = "index.html"; if (path.isEmpty()) path = "index.html";
@@ -115,11 +98,11 @@ public final class WebServer extends ProtocolWebServer {
File root = getContentFolder().getCanonicalFile(); File root = getContentFolder().getCanonicalFile();
File file = new File(root, path).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()); return new WebResponsePacket(403, "text/plain; charset=utf-8", HeaderMaps.mutable(), "Forbidden".getBytes());
} }
if (!file.exists() || !file.isFile()) { if (RuleManager.isDenied(path)) {
return new WebResponsePacket(404, "text/plain; charset=utf-8", HeaderMaps.mutable(), "Not found".getBytes()); return new WebResponsePacket(403, "text/plain; charset=utf-8", HeaderMaps.mutable(), "Forbidden".getBytes());
} }
String contentType = ContentTypeResolver.resolve(file.getName()); String contentType = ContentTypeResolver.resolve(file.getName());

View File

@@ -5,14 +5,17 @@ import org.openautonomousconnection.protocol.side.web.ProtocolWebServer;
import org.openautonomousconnection.protocol.side.web.managers.SessionManager; import org.openautonomousconnection.protocol.side.web.managers.SessionManager;
import java.io.IOException; import java.io.IOException;
import java.util.Locale;
import java.util.Map; import java.util.Map;
/** /**
* Provides session-related information for Java WebPages. * 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 { public final class SessionContext {
private static final String COOKIE_NAME = "session";
private final String sessionId; private final String sessionId;
private final String user; private final String user;
private final boolean valid; private final boolean valid;
@@ -23,16 +26,30 @@ public final class SessionContext {
this.valid = valid; 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<String, String> headers) throws IOException { public static SessionContext from(CustomConnectedClient client, ProtocolWebServer server, Map<String, String> 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"); String sessionId = extractSessionId(headers);
if (sessionId == null) return new SessionContext(null, null, false); if (sessionId == null || sessionId.isBlank()) return new SessionContext(null, null, false);
String ip = (client.getConnection().getTcpSocket() != null && client.getConnection().getTcpSocket().getInetAddress() != null) String ip = (client != null
? client.getConnection().getTcpSocket().getInetAddress().getHostAddress() : ""; && 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); boolean valid = SessionManager.isValid(sessionId, ip, userAgent, server);
if (!valid) return new SessionContext(sessionId, null, false); if (!valid) return new SessionContext(sessionId, null, false);
@@ -41,15 +58,65 @@ public final class SessionContext {
return new SessionContext(sessionId, user, true); return new SessionContext(sessionId, user, true);
} }
private static String extractSessionId(Map<String, String> 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<String, String> headers, String key) {
if (key == null) return null;
String needle = key.trim().toLowerCase(Locale.ROOT);
for (Map.Entry<String, String> 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() { public boolean isValid() {
return valid; return valid;
} }
/**
* @return session id
*/
public String getSessionId() { public String getSessionId() {
return sessionId; return sessionId;
} }
/**
* @return user id stored in session (string)
*/
public String getUser() { public String getUser() {
return user; return user;
} }
} }

View File

@@ -1,10 +1,6 @@
package org.openautonomousconnection.webserver.runtime; package org.openautonomousconnection.webserver.runtime;
import java.io.File; 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.Objects;
import java.util.concurrent.ConcurrentHashMap; 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. * Compiles the content tree if needed and returns a loaded class using a build-dir ClassLoader.
* *
* @param contentRoot content root directory * @param contentRoot content root directory
* @param javaFile requested Java source file * @param javaFile requested Java source file
* @param contentLastModified aggregate last modified of the whole content tree * @param contentLastModified aggregate last modified of the whole content tree
* @return loaded class * @return loaded class
* @throws Exception on compilation or classloading errors * @throws Exception on compilation or classloading errors
@@ -43,13 +39,15 @@ public final class JavaPageCache {
return loaded; 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. * Result wrapper holding both the ClassLoader and the loaded class.
* *
* @param classLoader loader used to load the 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) {
}
} }

View File

@@ -25,7 +25,7 @@ public final class JavaPageCompiler {
* Compiles all Java sources under {@code contentRoot} into {@code .oac-build} * Compiles all Java sources under {@code contentRoot} into {@code .oac-build}
* and loads the class corresponding to {@code requestedJavaFile}. * and loads the class corresponding to {@code requestedJavaFile}.
* *
* @param contentRoot content root * @param contentRoot content root
* @param requestedJavaFile requested .java file * @param requestedJavaFile requested .java file
* @return loaded class with its loader * @return loaded class with its loader
* @throws Exception on compilation/load error * @throws Exception on compilation/load error
@@ -38,7 +38,8 @@ public final class JavaPageCompiler {
Files.createDirectories(buildDir); Files.createDirectories(buildDir);
List<File> sources = listJavaFiles(contentRoot); List<File> 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)) { try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8)) {
Iterable<? extends JavaFileObject> units = fileManager.getJavaFileObjectsFromFiles(sources); Iterable<? extends JavaFileObject> units = fileManager.getJavaFileObjectsFromFiles(sources);

View File

@@ -13,7 +13,6 @@ import org.openautonomousconnection.webserver.utils.WebHasher;
import java.io.File; import java.io.File;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Map;
/** /**
* Dispatches Java WebPages using {@code @Route} annotation. * Dispatches Java WebPages using {@code @Route} annotation.

View File

@@ -1,7 +1,5 @@
package org.openautonomousconnection.webserver.runtime; package org.openautonomousconnection.webserver.runtime;
import org.openautonomousconnection.webserver.api.Route;
import java.io.File; import java.io.File;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
@@ -20,68 +18,10 @@ import java.util.regex.Pattern;
*/ */
public final class JavaRouteRegistry { public final class JavaRouteRegistry {
private final ConcurrentHashMap<String, RouteEntry> routes = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Long> scanState = new ConcurrentHashMap<>();
private static final Pattern PACKAGE_PATTERN = Pattern.compile("^\\s*package\\s+([a-zA-Z0-9_\\.]+)\\s*;\\s*$"); 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*\\)"); private static final Pattern ROUTE_PATTERN = Pattern.compile("@Route\\s*\\(\\s*path\\s*=\\s*\"([^\"]+)\"\\s*\\)");
private final ConcurrentHashMap<String, RouteEntry> routes = new ConcurrentHashMap<>();
/** private final ConcurrentHashMap<String, Long> scanState = new ConcurrentHashMap<>();
* 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 static String normalizeRoute(String value) { private static String normalizeRoute(String value) {
if (value == null) return "/"; if (value == null) return "/";
@@ -151,15 +91,75 @@ public final class JavaRouteRegistry {
return new RouteMeta(routePath, fqcn); 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. * Lookup result for a route.
* *
* @param sourceFile Java source file * @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) {
}
} }

View File

@@ -8,8 +8,6 @@ import java.io.ByteArrayOutputStream;
import java.io.InputStream; import java.io.InputStream;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/** /**
* Simple HTTPS -> OAC proxy helper. * Simple HTTPS -> OAC proxy helper.

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);
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View File

@@ -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.
*
* <p>This avoids hard-coding a method name you did not confirm.</p>
*
* @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<String, String> parseQuery(String rawTarget) {
Map<String, String> 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<String, String> map;
/**
* Creates a wrapper.
*
* @param map parsed query map
*/
public Q(Map<String, String> 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);
}
}
}

View File

@@ -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);
}
}