Changed Web Routing and added WebHash
This commit is contained in:
4
pom.xml
4
pom.xml
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<groupId>org.openautonomousconnection</groupId>
|
<groupId>org.openautonomousconnection</groupId>
|
||||||
<artifactId>WebServer</artifactId>
|
<artifactId>WebServer</artifactId>
|
||||||
<version>1.0.0-BETA.1.4</version>
|
<version>1.0.0-BETA.1.5</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>
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.openautonomousconnection</groupId>
|
<groupId>org.openautonomousconnection</groupId>
|
||||||
<artifactId>Protocol</artifactId>
|
<artifactId>Protocol</artifactId>
|
||||||
<version>1.0.0-BETA.6.0</version>
|
<version>1.0.0-BETA.6.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package org.openautonomousconnection.webserver;
|
package org.openautonomousconnection.webserver;
|
||||||
|
|
||||||
import dev.unlegitdqrk.unlegitlibrary.network.system.utils.Transport;
|
import dev.unlegitdqrk.unlegitlibrary.network.system.utils.Transport;
|
||||||
|
import lombok.Getter;
|
||||||
import org.openautonomousconnection.protocol.annotations.ProtocolInfo;
|
import org.openautonomousconnection.protocol.annotations.ProtocolInfo;
|
||||||
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.*;
|
|
||||||
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebRequestPacket;
|
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebRequestPacket;
|
||||||
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket;
|
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket;
|
||||||
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.stream.WebStreamChunkPacket;
|
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.stream.WebStreamChunkPacket;
|
||||||
@@ -11,10 +11,10 @@ import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.stream.WebS
|
|||||||
import org.openautonomousconnection.protocol.side.web.ConnectedWebClient;
|
import org.openautonomousconnection.protocol.side.web.ConnectedWebClient;
|
||||||
import org.openautonomousconnection.protocol.side.web.ProtocolWebServer;
|
import org.openautonomousconnection.protocol.side.web.ProtocolWebServer;
|
||||||
import org.openautonomousconnection.protocol.side.web.managers.RuleManager;
|
import org.openautonomousconnection.protocol.side.web.managers.RuleManager;
|
||||||
import org.openautonomousconnection.protocol.side.web.managers.SessionManager;
|
|
||||||
import org.openautonomousconnection.protocol.versions.ProtocolVersion;
|
import org.openautonomousconnection.protocol.versions.ProtocolVersion;
|
||||||
import org.openautonomousconnection.webserver.api.SessionContext;
|
import org.openautonomousconnection.webserver.api.SessionContext;
|
||||||
import org.openautonomousconnection.webserver.runtime.JavaPageDispatcher;
|
import org.openautonomousconnection.webserver.runtime.JavaPageDispatcher;
|
||||||
|
import org.openautonomousconnection.webserver.utils.WebHasher;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@@ -26,9 +26,26 @@ public final class WebServer extends ProtocolWebServer {
|
|||||||
private static final int STREAM_CHUNK_SIZE = 64 * 1024;
|
private static final int STREAM_CHUNK_SIZE = 64 * 1024;
|
||||||
private static final long STREAM_THRESHOLD = 2L * 1024 * 1024;
|
private static final long STREAM_THRESHOLD = 2L * 1024 * 1024;
|
||||||
|
|
||||||
public WebServer(File authFile, File rulesFile, int tcpPort, int udpPort,
|
@Getter
|
||||||
int sessionExpire, int maxUpload) throws Exception {
|
private final WebHasher hasher;
|
||||||
|
|
||||||
|
public WebServer(
|
||||||
|
File authFile,
|
||||||
|
File rulesFile,
|
||||||
|
int tcpPort,
|
||||||
|
int udpPort,
|
||||||
|
int sessionExpire,
|
||||||
|
int maxUpload
|
||||||
|
) throws Exception {
|
||||||
super(authFile, rulesFile, tcpPort, udpPort, sessionExpire, maxUpload);
|
super(authFile, rulesFile, tcpPort, udpPort, sessionExpire, maxUpload);
|
||||||
|
|
||||||
|
// NOTE: Values chosen as safe defaults.
|
||||||
|
// move them to Main and pass them in here (no hidden assumptions).
|
||||||
|
this.hasher = new WebHasher(
|
||||||
|
120_000, // PBKDF2 iterations
|
||||||
|
16, // salt bytes
|
||||||
|
32 // key bytes
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -36,11 +53,15 @@ public final class WebServer extends ProtocolWebServer {
|
|||||||
try {
|
try {
|
||||||
String path = request.getPath() == null ? "/" : request.getPath();
|
String path = request.getPath() == null ? "/" : request.getPath();
|
||||||
|
|
||||||
if (RuleManager.isDenied(path)) return new WebResponsePacket(403, "text/plain", Map.of(), "Forbidden".getBytes());
|
if (RuleManager.isDenied(path)) {
|
||||||
|
return new WebResponsePacket(403, "text/plain; charset=utf-8", Map.of(), "Forbidden".getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
if (RuleManager.requiresAuth(path)) {
|
if (RuleManager.requiresAuth(path)) {
|
||||||
SessionContext ctx = SessionContext.from(client, this, request.getHeaders());
|
SessionContext ctx = SessionContext.from(client, this, request.getHeaders());
|
||||||
if (!ctx.isValid()) return new WebResponsePacket(401, "text/plain", Map.of(), "Authentication required".getBytes());
|
if (!ctx.isValid()) {
|
||||||
|
return new WebResponsePacket(401, "text/plain; charset=utf-8", Map.of(), "Authentication required".getBytes());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
WebResponsePacket javaResp = JavaPageDispatcher.dispatch(client, this, request);
|
WebResponsePacket javaResp = JavaPageDispatcher.dispatch(client, this, request);
|
||||||
@@ -49,7 +70,8 @@ public final class WebServer extends ProtocolWebServer {
|
|||||||
return serveFile(client, path);
|
return serveFile(client, path);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return new WebResponsePacket(500, "text/plain", Map.of(), ("Internal Error: " + e.getMessage()).getBytes());
|
return new WebResponsePacket(500, "text/plain; charset=utf-8", Map.of(),
|
||||||
|
("Internal Error: " + e.getMessage()).getBytes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +82,8 @@ 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())) return new WebResponsePacket(403, "text/plain", Map.of(), "Forbidden".getBytes());
|
if (!file.getPath().startsWith(root.getPath())) return new WebResponsePacket(403, "text/plain; charset=utf-8", Map.of(), "Forbidden".getBytes());
|
||||||
if (!file.exists() || !file.isFile()) return new WebResponsePacket(404, "text/plain", Map.of(), "Not found".getBytes());
|
if (!file.exists() || !file.isFile()) return new WebResponsePacket(404, "text/plain; charset=utf-8", Map.of(), "Not found".getBytes());
|
||||||
|
|
||||||
String contentType = ContentTypeResolver.resolve(file.getName());
|
String contentType = ContentTypeResolver.resolve(file.getName());
|
||||||
long size = file.length();
|
long size = file.length();
|
||||||
@@ -86,8 +108,10 @@ public final class WebServer extends ProtocolWebServer {
|
|||||||
int r;
|
int r;
|
||||||
while ((r = in.read(buf)) != -1) {
|
while ((r = in.read(buf)) != -1) {
|
||||||
byte[] chunk = (r == buf.length) ? buf : Arrays.copyOf(buf, r);
|
byte[] chunk = (r == buf.length) ? buf : Arrays.copyOf(buf, r);
|
||||||
client.getConnection().sendPacket(new WebStreamChunkPacket(seq++, chunk),
|
client.getConnection().sendPacket(
|
||||||
ContentTypeResolver.isVideoFile(file.getName()) ? Transport.UDP : Transport.TCP);
|
new WebStreamChunkPacket(seq++, chunk),
|
||||||
|
ContentTypeResolver.isVideoFile(file.getName()) ? Transport.UDP : Transport.TCP
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package org.openautonomousconnection.webserver.api;
|
||||||
|
|
||||||
|
import java.lang.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares a route path for a server-side Java WebPage.
|
||||||
|
*/
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
public @interface Route {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The absolute route path (must start with '/').
|
||||||
|
*
|
||||||
|
* @return route path
|
||||||
|
*/
|
||||||
|
String path();
|
||||||
|
}
|
||||||
@@ -3,19 +3,31 @@ package org.openautonomousconnection.webserver.api;
|
|||||||
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebRequestPacket;
|
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebRequestPacket;
|
||||||
import org.openautonomousconnection.protocol.side.web.ConnectedWebClient;
|
import org.openautonomousconnection.protocol.side.web.ConnectedWebClient;
|
||||||
import org.openautonomousconnection.protocol.side.web.ProtocolWebServer;
|
import org.openautonomousconnection.protocol.side.web.ProtocolWebServer;
|
||||||
|
import org.openautonomousconnection.webserver.utils.WebHasher;
|
||||||
|
import org.openautonomousconnection.webserver.utils.RequestParams;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context passed to Java WebPages (client, request, session).
|
* Context passed to Java WebPages (client, request, session, params, hasher).
|
||||||
*/
|
*/
|
||||||
public final class WebPageContext {
|
public final class WebPageContext {
|
||||||
|
|
||||||
public final ConnectedWebClient client;
|
public final ConnectedWebClient client;
|
||||||
public final WebRequestPacket request;
|
public final WebRequestPacket request;
|
||||||
public final SessionContext session;
|
public final SessionContext session;
|
||||||
|
public final RequestParams params;
|
||||||
|
public final WebHasher hasher;
|
||||||
|
|
||||||
public WebPageContext(ConnectedWebClient client, ProtocolWebServer server, WebRequestPacket request) throws Exception {
|
public WebPageContext(
|
||||||
|
ConnectedWebClient client,
|
||||||
|
ProtocolWebServer server,
|
||||||
|
WebRequestPacket request,
|
||||||
|
RequestParams params,
|
||||||
|
WebHasher hasher
|
||||||
|
) throws Exception {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.request = request;
|
this.request = request;
|
||||||
this.session = SessionContext.from(client, server, request.getHeaders());
|
this.session = SessionContext.from(client, server, request.getHeaders());
|
||||||
|
this.params = params;
|
||||||
|
this.hasher = hasher;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import javax.tools.*;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.net.URLClassLoader;
|
import java.net.URLClassLoader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compiles and loads Java web pages at runtime.
|
* Compiles and loads Java pages at runtime.
|
||||||
*
|
*
|
||||||
* NOTE: Requires running with a JDK (ToolProvider.getSystemJavaCompiler != null).
|
* <p>Supports packages by deriving the fully qualified class name from source.
|
||||||
|
* <p>NOTE: Requires a JDK (ToolProvider.getSystemJavaCompiler != null).
|
||||||
*/
|
*/
|
||||||
public final class JavaPageCompiler {
|
public final class JavaPageCompiler {
|
||||||
|
|
||||||
@@ -20,10 +23,8 @@ public final class JavaPageCompiler {
|
|||||||
if (compiler == null) throw new IllegalStateException("JDK required (JavaCompiler not available)");
|
if (compiler == null) throw new IllegalStateException("JDK required (JavaCompiler not available)");
|
||||||
|
|
||||||
StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
|
StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
|
||||||
|
|
||||||
Iterable<? extends JavaFileObject> units = fileManager.getJavaFileObjects(javaFile);
|
Iterable<? extends JavaFileObject> units = fileManager.getJavaFileObjects(javaFile);
|
||||||
|
|
||||||
// Compile in-place (class next to the .java file)
|
|
||||||
List<String> options = List.of("-classpath", System.getProperty("java.class.path"));
|
List<String> options = List.of("-classpath", System.getProperty("java.class.path"));
|
||||||
|
|
||||||
boolean success = compiler.getTask(null, fileManager, null, options, null, units).call();
|
boolean success = compiler.getTask(null, fileManager, null, options, null, units).call();
|
||||||
@@ -31,9 +32,32 @@ public final class JavaPageCompiler {
|
|||||||
|
|
||||||
if (!success) throw new RuntimeException("Compilation failed: " + javaFile.getName());
|
if (!success) throw new RuntimeException("Compilation failed: " + javaFile.getName());
|
||||||
|
|
||||||
|
String fqcn = deriveFqcn(javaFile);
|
||||||
|
|
||||||
|
// Root must be the folder containing the package root (javaFile parent is fine if classes output there).
|
||||||
URLClassLoader cl = new URLClassLoader(new URL[]{ javaFile.getParentFile().toURI().toURL() });
|
URLClassLoader cl = new URLClassLoader(new URL[]{ javaFile.getParentFile().toURI().toURL() });
|
||||||
|
|
||||||
String className = javaFile.getName().replace(".java", "");
|
return cl.loadClass(fqcn);
|
||||||
return cl.loadClass(className);
|
}
|
||||||
|
|
||||||
|
private static String deriveFqcn(File javaFile) throws Exception {
|
||||||
|
String src = Files.readString(javaFile.toPath(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
String pkg = null;
|
||||||
|
for (String line : src.split("\\R")) {
|
||||||
|
String t = line.trim();
|
||||||
|
if (t.startsWith("package ") && t.endsWith(";")) {
|
||||||
|
pkg = t.substring("package ".length(), t.length() - 1).trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Stop scanning early if class appears before package (invalid anyway)
|
||||||
|
if (t.startsWith("public class") || t.startsWith("class ") || t.startsWith("public final class")) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String simple = javaFile.getName().replace(".java", "");
|
||||||
|
if (pkg == null || pkg.isBlank()) return simple;
|
||||||
|
return pkg + "." + simple;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,22 @@ import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebRequestP
|
|||||||
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket;
|
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket;
|
||||||
import org.openautonomousconnection.protocol.side.web.ConnectedWebClient;
|
import org.openautonomousconnection.protocol.side.web.ConnectedWebClient;
|
||||||
import org.openautonomousconnection.protocol.side.web.ProtocolWebServer;
|
import org.openautonomousconnection.protocol.side.web.ProtocolWebServer;
|
||||||
|
import org.openautonomousconnection.webserver.WebServer;
|
||||||
import org.openautonomousconnection.webserver.api.WebPage;
|
import org.openautonomousconnection.webserver.api.WebPage;
|
||||||
import org.openautonomousconnection.webserver.api.WebPageContext;
|
import org.openautonomousconnection.webserver.api.WebPageContext;
|
||||||
|
import org.openautonomousconnection.webserver.utils.WebHasher;
|
||||||
|
import org.openautonomousconnection.webserver.utils.RequestParams;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches Java WebPages using @Route annotation.
|
||||||
|
*/
|
||||||
public final class JavaPageDispatcher {
|
public final class JavaPageDispatcher {
|
||||||
|
|
||||||
private static final JavaPageCache CACHE = new JavaPageCache();
|
private static final JavaRouteRegistry ROUTES = new JavaRouteRegistry();
|
||||||
|
|
||||||
private JavaPageDispatcher() {}
|
private JavaPageDispatcher() {}
|
||||||
|
|
||||||
@@ -23,18 +31,53 @@ public final class JavaPageDispatcher {
|
|||||||
|
|
||||||
if (request.getPath() == null) return null;
|
if (request.getPath() == null) return null;
|
||||||
|
|
||||||
String p = request.getPath().startsWith("/") ? request.getPath().substring(1) : request.getPath();
|
String route = request.getPath();
|
||||||
File javaFile = new File(server.getContentFolder(), p + ".java");
|
if (!route.startsWith("/")) route = "/" + route;
|
||||||
|
|
||||||
if (!javaFile.exists() || !javaFile.isFile()) return null;
|
File contentRoot = server.getContentFolder();
|
||||||
|
ROUTES.refreshIfNeeded(contentRoot);
|
||||||
|
|
||||||
Class<?> clazz = CACHE.getOrCompile(javaFile);
|
JavaRouteRegistry.RouteLookupResult found = ROUTES.find(route);
|
||||||
|
|
||||||
|
// If no @Route match, still detect "requested a .java-backed path by filename"
|
||||||
|
// (legacy behavior): if a matching *.java exists, compile/load but return error.
|
||||||
|
if (found == null) {
|
||||||
|
String p = route.startsWith("/") ? route.substring(1) : route;
|
||||||
|
File javaFile = new File(contentRoot, p + ".java");
|
||||||
|
if (javaFile.exists() && javaFile.isFile()) {
|
||||||
|
// Compile/load but do not serve.
|
||||||
|
new JavaPageCache().getOrCompile(javaFile);
|
||||||
|
return error(501, "Java class exists but has no @Route: " + route);
|
||||||
|
}
|
||||||
|
return null; // not a Java route -> let file server handle it
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it has @Route but is not a WebPage, compile/load but return error.
|
||||||
|
if (!found.routable()) {
|
||||||
|
return error(500, "Class has @Route but is not a WebPage: " + found.fqcn());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and execute
|
||||||
|
Class<?> clazz = Class.forName(found.fqcn());
|
||||||
Object instance = clazz.getDeclaredConstructor().newInstance();
|
Object instance = clazz.getDeclaredConstructor().newInstance();
|
||||||
|
|
||||||
if (!(instance instanceof WebPage page))
|
if (!(instance instanceof WebPage page)) {
|
||||||
throw new IllegalStateException("Java page must implement WebPage");
|
return error(500, "Routed class is not a WebPage: " + found.fqcn());
|
||||||
|
}
|
||||||
|
|
||||||
WebPageContext ctx = new WebPageContext(client, server, request);
|
WebHasher hasher = (server instanceof WebServer ws) ? ws.getHasher() : null;
|
||||||
|
if (hasher == null) return error(500, "WebHasher missing on server instance.");
|
||||||
|
|
||||||
|
WebPageContext ctx = new WebPageContext(client, server, request, new RequestParams(request), hasher);
|
||||||
return page.handle(ctx);
|
return page.handle(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static WebResponsePacket error(int code, String msg) {
|
||||||
|
return new WebResponsePacket(
|
||||||
|
code,
|
||||||
|
"text/plain; charset=utf-8",
|
||||||
|
Map.of(),
|
||||||
|
msg.getBytes(StandardCharsets.UTF_8)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package org.openautonomousconnection.webserver.runtime;
|
||||||
|
|
||||||
|
import org.openautonomousconnection.webserver.api.Route;
|
||||||
|
import org.openautonomousconnection.webserver.api.WebPage;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans the content folder for .java files, compiles them and maps @Route paths to classes/files.
|
||||||
|
*
|
||||||
|
* <p>Behavior:
|
||||||
|
* <ul>
|
||||||
|
* <li>If class has @Route AND implements WebPage => routable</li>
|
||||||
|
* <li>If class is compiled but not routable => still cached as loaded, but never served</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class JavaRouteRegistry {
|
||||||
|
|
||||||
|
private static final class RouteEntry {
|
||||||
|
final File sourceFile;
|
||||||
|
final long lastModified;
|
||||||
|
final String fqcn;
|
||||||
|
final boolean routable;
|
||||||
|
|
||||||
|
RouteEntry(File sourceFile, long lastModified, String fqcn, boolean routable) {
|
||||||
|
this.sourceFile = sourceFile;
|
||||||
|
this.lastModified = lastModified;
|
||||||
|
this.fqcn = fqcn;
|
||||||
|
this.routable = routable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final JavaPageCache cache = new JavaPageCache();
|
||||||
|
private final ConcurrentHashMap<String, RouteEntry> routes = new ConcurrentHashMap<>();
|
||||||
|
private final ConcurrentHashMap<String, Long> scanState = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 routable class for a route.
|
||||||
|
*
|
||||||
|
* @param route route path
|
||||||
|
* @return entry or null
|
||||||
|
*/
|
||||||
|
public RouteLookupResult find(String route) {
|
||||||
|
RouteEntry e = routes.get(route);
|
||||||
|
if (e == null) return null;
|
||||||
|
return new RouteLookupResult(e.sourceFile, e.fqcn, e.routable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rebuild(File contentRoot) {
|
||||||
|
routes.clear();
|
||||||
|
|
||||||
|
List<File> javaFiles = listJavaFiles(contentRoot);
|
||||||
|
for (File f : javaFiles) {
|
||||||
|
try {
|
||||||
|
long lm = f.lastModified();
|
||||||
|
Class<?> clazz = cache.getOrCompile(f);
|
||||||
|
|
||||||
|
Route r = clazz.getAnnotation(Route.class);
|
||||||
|
boolean isWebPage = WebPage.class.isAssignableFrom(clazz);
|
||||||
|
boolean routable = (r != null && isWebPage);
|
||||||
|
|
||||||
|
if (r != null) {
|
||||||
|
String path = normalizeRoute(r.path());
|
||||||
|
routes.put(path, new RouteEntry(f, lm, clazz.getName(), routable));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Compilation errors are handled later when requested or during next refresh.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeRoute(String value) {
|
||||||
|
if (value == null) return "/";
|
||||||
|
String v = value.trim();
|
||||||
|
if (v.isEmpty()) return "/";
|
||||||
|
if (!v.startsWith("/")) v = "/" + v;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<File> listJavaFiles(File root) {
|
||||||
|
ArrayList<File> out = new ArrayList<>();
|
||||||
|
Deque<File> stack = new ArrayDeque<>();
|
||||||
|
stack.push(root);
|
||||||
|
|
||||||
|
while (!stack.isEmpty()) {
|
||||||
|
File cur = stack.pop();
|
||||||
|
File[] children = cur.listFiles();
|
||||||
|
if (children == null) continue;
|
||||||
|
|
||||||
|
for (File c : children) {
|
||||||
|
if (c.isDirectory()) {
|
||||||
|
stack.push(c);
|
||||||
|
} else if (c.isFile() && c.getName().endsWith(".java")) {
|
||||||
|
out.add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long folderLastModified(File folder) {
|
||||||
|
long lm = folder.lastModified();
|
||||||
|
File[] children = folder.listFiles();
|
||||||
|
if (children == null) return lm;
|
||||||
|
for (File c : children) {
|
||||||
|
lm = Math.max(lm, c.isDirectory() ? folderLastModified(c) : c.lastModified());
|
||||||
|
}
|
||||||
|
return lm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup result for a route.
|
||||||
|
*
|
||||||
|
* @param sourceFile Java source file
|
||||||
|
* @param fqcn fully qualified class name
|
||||||
|
* @param routable true if @Route + implements WebPage
|
||||||
|
*/
|
||||||
|
public record RouteLookupResult(File sourceFile, String fqcn, boolean routable) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package org.openautonomousconnection.webserver.utils;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small HTML helpers for server-side rendering.
|
||||||
|
*/
|
||||||
|
public final class Html {
|
||||||
|
|
||||||
|
private Html() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes text for HTML.
|
||||||
|
*
|
||||||
|
* @param s input
|
||||||
|
* @return escaped
|
||||||
|
*/
|
||||||
|
public static String esc(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes to UTF-8 bytes.
|
||||||
|
*
|
||||||
|
* @param html html
|
||||||
|
* @return bytes
|
||||||
|
*/
|
||||||
|
public static byte[] utf8(String html) {
|
||||||
|
return (html == null ? "" : html).getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple page wrapper.
|
||||||
|
*
|
||||||
|
* @param title title
|
||||||
|
* @param body body html
|
||||||
|
* @return full html
|
||||||
|
*/
|
||||||
|
public static String page(String title, String body) {
|
||||||
|
return """
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>%s</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,Segoe UI,Arial,sans-serif;background:#0f111a;color:#e5e7eb;margin:0;padding:24px;}
|
||||||
|
a{color:#60a5fa;text-decoration:none} a:hover{text-decoration:underline}
|
||||||
|
.card{background:#111827;border:1px solid #1f2937;border-radius:12px;padding:16px;max-width:900px}
|
||||||
|
input,select{background:#0b1220;color:#e5e7eb;border:1px solid #243042;border-radius:8px;padding:10px;width:100%%;box-sizing:border-box}
|
||||||
|
button{background:#2563eb;color:white;border:none;border-radius:8px;padding:10px 14px;cursor:pointer}
|
||||||
|
button:hover{filter:brightness(1.05)}
|
||||||
|
.row{display:flex;gap:12px;flex-wrap:wrap}
|
||||||
|
.col{flex:1;min-width:220px}
|
||||||
|
.muted{color:#94a3b8}
|
||||||
|
.err{color:#fca5a5}
|
||||||
|
.ok{color:#86efac}
|
||||||
|
code{background:#0b1220;padding:2px 6px;border-radius:6px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
%s
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".formatted(esc(title), body == null ? "" : body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.openautonomousconnection.webserver.api;
|
package org.openautonomousconnection.webserver.utils;
|
||||||
|
|
||||||
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket;
|
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket;
|
||||||
import org.openautonomousconnection.webserver.api.WebPageContext;
|
import org.openautonomousconnection.webserver.api.WebPageContext;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package org.openautonomousconnection.webserver.utils;
|
||||||
|
|
||||||
|
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebRequestPacket;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads parameters from WebRequestPacket headers (case-insensitive).
|
||||||
|
*
|
||||||
|
* <p>Additionally provides hashing helpers via a supplied {@link WebHasher}.
|
||||||
|
*/
|
||||||
|
public final class RequestParams {
|
||||||
|
|
||||||
|
private final Map<String, String> headers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a param reader.
|
||||||
|
*
|
||||||
|
* @param request request
|
||||||
|
*/
|
||||||
|
public RequestParams(WebRequestPacket request) {
|
||||||
|
Map<String, String> h = request != null ? request.getHeaders() : null;
|
||||||
|
this.headers = (h == null) ? Collections.emptyMap() : h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a header (case-insensitive).
|
||||||
|
*
|
||||||
|
* @param key key
|
||||||
|
* @return value or null
|
||||||
|
*/
|
||||||
|
public String get(String key) {
|
||||||
|
if (key == null) return null;
|
||||||
|
String needle = key.toLowerCase(Locale.ROOT);
|
||||||
|
for (Map.Entry<String, String> e : headers.entrySet()) {
|
||||||
|
if (e.getKey() != null && e.getKey().toLowerCase(Locale.ROOT).equals(needle)) {
|
||||||
|
return e.getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a header or default.
|
||||||
|
*
|
||||||
|
* @param key key
|
||||||
|
* @param def default
|
||||||
|
* @return value or default
|
||||||
|
*/
|
||||||
|
public String getOr(String key, String def) {
|
||||||
|
String v = get(key);
|
||||||
|
return (v == null) ? def : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an int header.
|
||||||
|
*
|
||||||
|
* @param key key
|
||||||
|
* @param def default
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
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 a trimmed header value.
|
||||||
|
*
|
||||||
|
* @param key key
|
||||||
|
* @return trimmed value or null
|
||||||
|
*/
|
||||||
|
public String getTrimmed(String key) {
|
||||||
|
String v = get(key);
|
||||||
|
if (v == null) return null;
|
||||||
|
String t = v.trim();
|
||||||
|
return t.isEmpty() ? null : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns SHA-256 hex of a header value.
|
||||||
|
*
|
||||||
|
* @param hasher hasher
|
||||||
|
* @param key header key
|
||||||
|
* @return sha256 hex (or null if missing)
|
||||||
|
*/
|
||||||
|
public String getSha256Hex(WebHasher hasher, String key) {
|
||||||
|
String v = getTrimmed(key);
|
||||||
|
if (v == null) return null;
|
||||||
|
return hasher.sha256Hex(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package org.openautonomousconnection.webserver.utils;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKeyFactory;
|
||||||
|
import javax.crypto.spec.PBEKeySpec;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides hashing utilities for the registrar frontend.
|
||||||
|
*
|
||||||
|
* <p>Username: SHA-256 hex
|
||||||
|
* <p>Password: PBKDF2WithHmacSHA256 storage format:
|
||||||
|
* PBKDF2$sha256$<iterations>$<saltB64>$<hashB64>
|
||||||
|
*/
|
||||||
|
public final class WebHasher {
|
||||||
|
|
||||||
|
private final SecureRandom rng = new SecureRandom();
|
||||||
|
private final int pbkdf2Iterations;
|
||||||
|
private final int pbkdf2SaltBytes;
|
||||||
|
private final int pbkdf2KeyBytes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a hasher.
|
||||||
|
*
|
||||||
|
* @param pbkdf2Iterations iterations (recommended >= 100k)
|
||||||
|
* @param pbkdf2SaltBytes salt bytes
|
||||||
|
* @param pbkdf2KeyBytes derived key bytes
|
||||||
|
*/
|
||||||
|
public WebHasher(int pbkdf2Iterations, int pbkdf2SaltBytes, int pbkdf2KeyBytes) {
|
||||||
|
if (pbkdf2Iterations < 10_000) throw new IllegalArgumentException("pbkdf2Iterations too low");
|
||||||
|
if (pbkdf2SaltBytes < 8) throw new IllegalArgumentException("pbkdf2SaltBytes too low");
|
||||||
|
if (pbkdf2KeyBytes < 16) throw new IllegalArgumentException("pbkdf2KeyBytes too low");
|
||||||
|
this.pbkdf2Iterations = pbkdf2Iterations;
|
||||||
|
this.pbkdf2SaltBytes = pbkdf2SaltBytes;
|
||||||
|
this.pbkdf2KeyBytes = pbkdf2KeyBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA-256 hashes text (lowercase hex).
|
||||||
|
*
|
||||||
|
* @param text input
|
||||||
|
* @return sha256 hex
|
||||||
|
*/
|
||||||
|
public String sha256Hex(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PBKDF2-hashes a raw password into storage format.
|
||||||
|
*
|
||||||
|
* @param password raw password
|
||||||
|
* @return encoded storage string
|
||||||
|
*/
|
||||||
|
public String pbkdf2Hash(String password) {
|
||||||
|
Objects.requireNonNull(password, "password");
|
||||||
|
byte[] salt = new byte[pbkdf2SaltBytes];
|
||||||
|
rng.nextBytes(salt);
|
||||||
|
|
||||||
|
byte[] dk = derive(password.toCharArray(), salt, pbkdf2Iterations, pbkdf2KeyBytes);
|
||||||
|
|
||||||
|
return "PBKDF2$sha256$" + pbkdf2Iterations + "$" +
|
||||||
|
Base64.getEncoder().encodeToString(salt) + "$" +
|
||||||
|
Base64.getEncoder().encodeToString(dk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a raw password against a stored PBKDF2 string.
|
||||||
|
*
|
||||||
|
* @param password raw password
|
||||||
|
* @param stored stored format
|
||||||
|
* @return true if valid
|
||||||
|
*/
|
||||||
|
public boolean pbkdf2Verify(String password, String stored) {
|
||||||
|
if (password == null || stored == null) return false;
|
||||||
|
|
||||||
|
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;
|
||||||
|
try {
|
||||||
|
it = Integer.parseInt(parts[2]);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] salt;
|
||||||
|
byte[] expected;
|
||||||
|
try {
|
||||||
|
salt = Base64.getDecoder().decode(parts[3]);
|
||||||
|
expected = Base64.getDecoder().decode(parts[4]);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] actual = derive(password.toCharArray(), salt, it, expected.length);
|
||||||
|
return constantTimeEquals(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] derive(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("PBKDF2 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toHexLower(byte[] data) {
|
||||||
|
final char[] hex = "0123456789abcdef".toCharArray();
|
||||||
|
char[] out = new char[data.length * 2];
|
||||||
|
int i = 0;
|
||||||
|
for (byte b : data) {
|
||||||
|
out[i++] = hex[(b >>> 4) & 0x0F];
|
||||||
|
out[i++] = hex[b & 0x0F];
|
||||||
|
}
|
||||||
|
return new String(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user