diff --git a/build.gradle b/build.gradle index cf3833a..51608d6 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ plugins { id 'fabric-loom' version '1.9-SNAPSHOT' id 'maven-publish' + id 'com.gradleup.shadow' version '8.3.5' } version = project.mod_version @@ -11,11 +12,6 @@ base { } repositories { - // Add repositories to retrieve artifacts from in here. - // You should only use this when depending on other mods because - // Loom adds the essential maven repositories to download Minecraft and libraries from automatically. - // See https://docs.gradle.org/current/userguide/declaring_repositories.html - // for more information about repositories. maven { url "https://maven.terraformersmc.com/releases/" } // Mod Menu maven { url "https://maven.isxander.dev/releases" } // YACL } @@ -32,6 +28,11 @@ loom { } +configurations { + shadow // Define a shadow configuration + implementation.extendsFrom shadow // Extend implementation to include shadow dependencies +} + dependencies { minecraft "com.mojang:minecraft:${project.minecraft_version}" mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" @@ -46,14 +47,10 @@ dependencies { annotationProcessor "org.projectlombok:lombok:${project.lombok_version}" compileOnly "org.projectlombok:lombok:${project.lombok_version}" - // Included - implementation "org.java-websocket:Java-WebSocket:${project.java_websocket_version}" - include("org.java-websocket:Java-WebSocket:${project.java_websocket_version}") - implementation "org.nanohttpd:nanohttpd:${nanohttpd_version}" - include("org.nanohttpd:nanohttpd:${nanohttpd_version}") - implementation "com.google.code.gson:gson:${project.gson_version}" - include("com.google.code.gson:gson:${project.gson_version}") - + // Implementation and included in shadow + implementation "io.javalin:javalin:${project.javalin_version}" + shadow "io.javalin:javalin:${project.javalin_version}" + } processResources { @@ -78,6 +75,8 @@ java { targetCompatibility = JavaVersion.VERSION_21 } + + jar { from("LICENSE") { rename { "${it}_${project.base.archivesName.get()}"} @@ -85,6 +84,29 @@ jar { } +shadowJar { + // Make sure we are actually including relevant references + from sourceSets.main.output + from sourceSets.client.output + configurations = [project.configurations.shadow] // Include shadow dependencies + archiveClassifier.set("") // Make sure we end up with just one unified jar + + // Relocate dependencies to avoid conflicts + relocate "io.javalin", "dev.creesch.shadow.io.javalin" + relocate "org.eclipse.jetty", "dev.creesch.shadow.jetty" + + minimize() + +} + +remapJar { + dependsOn shadowJar + inputFile.set(shadowJar.archiveFile) + archiveClassifier.set("") // Produce the final remapped JAR without classifier +} + +tasks.assemble.dependsOn remapJar + // configure the maven publication publishing { publications { diff --git a/gradle.properties b/gradle.properties index 757e84c..1af3a94 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,7 +19,6 @@ yacl_version=3.6.1+1.21-fabric mod_menu_version=11.0.3 # Generic java dependencies -java_websocket_version=1.5.7 -nanohttpd_version=2.3.1 +javalin_version=6.3.0 lombok_version=1.18.36 gson_version=2.11.0 \ No newline at end of file diff --git a/src/client/java/dev/creesch/WebInterface.java b/src/client/java/dev/creesch/WebInterface.java index 2cb349a..0ca66b3 100644 --- a/src/client/java/dev/creesch/WebInterface.java +++ b/src/client/java/dev/creesch/WebInterface.java @@ -2,155 +2,161 @@ import dev.creesch.config.ModConfig; import dev.creesch.util.NamedLogger; -import fi.iki.elonen.NanoHTTPD; +import io.javalin.Javalin; +import io.javalin.http.staticfiles.Location; +import io.javalin.websocket.WsContext; +import lombok.Getter; import net.minecraft.client.MinecraftClient; import net.minecraft.client.network.ClientPlayerEntity; -import org.java_websocket.server.WebSocketServer; -import org.java_websocket.handshake.ClientHandshake; -import org.java_websocket.WebSocket; - -import java.io.IOException; -import java.io.InputStream; -import java.net.InetSocketAddress; -import java.util.HashSet; -import java.util.Map; + +import java.util.Collections; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; public class WebInterface { - private final WebServer webServer; - private final ChatWebSocketServer websocketServer; - private final Set connections = new HashSet<>(); - - ModConfig config = ModConfig.HANDLER.instance(); + // Server related things + @Getter + private final Javalin server; + private final Set connections = Collections.newSetFromMap(new ConcurrentHashMap<>()); private static final NamedLogger LOGGER = new NamedLogger("web-chat"); + ModConfig config = ModConfig.HANDLER.instance(); + private static final Pattern ILLEGAL_CHARACTERS = Pattern.compile("[\\n\\r§\u00A7\\u0000-\\u001F\\u200B-\\u200F\\u2028-\\u202F]"); public WebInterface() { - webServer = new WebServer(config.httpPortNumber); - websocketServer = new ChatWebSocketServer(config.httpPortNumber + 1); - - try { - webServer.start(); - websocketServer.start(); - } catch (IOException e) { - LOGGER.error("Error with webserver connection", e); - } - } + server = createServer(); + setupWebSocket(); - // HTTP server for static files - private class WebServer extends NanoHTTPD { - public WebServer(int port) { - super(port); - } - - private static final Map MIME_TYPES = Map.of( - ".html", "text/html", - ".css", "text/css", - ".js", "application/javascript", - ".png", "image/png", - ".webmanifest", "application/manifest+json" - ); - - @Override - public Response serve(IHTTPSession session) { - String uri = session.getUri(); - LOGGER.info("Attempting to serve request: {}", uri); - // Serve index.html as default - if (uri.equals("/")) { - uri = "/index.html"; - } + server.start(config.httpPortNumber); + LOGGER.info("Web interface started on port {}", config.httpPortNumber); + } - // If we still have a / at the end someone is trying to get the contents of subdirectory. We are not doing that. - if (uri.endsWith("/")) { - return newFixedLengthResponse(Response.Status.UNAUTHORIZED, MIME_PLAINTEXT, "Unauthorized access"); + private Javalin createServer() { + return Javalin.create(config -> { + config.staticFiles.add("/web", Location.CLASSPATH); + config.http.defaultContentType = "text/plain"; + }).before(ctx -> { + // Note, most things that are set here are overkill as users are _supposed_ to only uses this on their local machine through localhost. + // Or if we are being generous through a device on their own network. + // But, as we can't be sure that someone doesn't (accidentally) opens this up to the internet we take the better safe than sorry route. + String uri = ctx.path(); + + // Allow access to the root directory ("/") but block subdirectories ending with "/" + if (!uri.equals("/") && uri.endsWith("/")) { + LOGGER.warn("Unauthorized attempt to access subdirectory: " + uri); + ctx.status(401).result("Unauthorized access"); + return; } - // Not really a factor in this context, but just in case anyone exposes this to the wider world we want to be a little bit safe. + // Reject requests containing `..` (path traversal attack) + // Javelin also does this, this is just to be extra secure if (uri.contains("..")) { - return newFixedLengthResponse(Response.Status.BAD_REQUEST, MIME_PLAINTEXT, "Invalid path"); + LOGGER.warn("Invalid path detected: " + uri); + ctx.status(400).result("Invalid path"); + return; } - try { - InputStream inputStream = WebchatClient.class.getResourceAsStream("/web" + uri); - if (inputStream == null) { - return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "File not found"); + // Security headers + ctx.header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self';"); + ctx.header("X-Frame-Options", "DENY"); // Prevent clickjacking + ctx.header("X-Content-Type-Options", "nosniff"); // Prevent MIME type sniffin + }); + } + + private void setupWebSocket() { + server.ws("/chat", ws -> { + ws.onConnect(ctx -> { + // For localhost connections pinging likely isn't needed. + // But if someone wants to use the mod on their phone or something it might be useful to include it. + ctx.enableAutomaticPings(15, TimeUnit.SECONDS); + LOGGER.info("New WebSocket connection from {}", ctx.session.getRemoteAddress() != null ? ctx.session.getRemoteAddress() : "unknown remote address"); + connections.add(ctx); + }); + + ws.onClose(ctx -> { + LOGGER.info("WebSocket connection closed: {} with status {} and reason: {}", ctx.session.getRemoteAddress(), ctx.status(), ctx.reason()); + connections.remove(ctx); + }); + + ws.onMessage(ctx -> { + String message = ctx.message(); + + if (message.trim().isEmpty()) { + LOGGER.warn("Received an empty message from {}", ctx.session.getRemoteAddress()); + return; } + LOGGER.info("Received WebSocket message: {}", message); - String extension = uri.substring(uri.lastIndexOf('.')); - String mimeType = MIME_TYPES.getOrDefault(extension, "application/octet-stream"); + // Sanitize the message + message = sanitizeMessage(message); - return newChunkedResponse(Response.Status.OK, mimeType, inputStream); + // Send the sanitized message to Minecraft chat + sendMinecraftMessage(message); + }); + + ws.onError(ctx -> { + LOGGER.error("WebSocket error: ", ctx.error()); + connections.remove(ctx); + }); + }); + } + + public void shutdown() { + // Try to avoid log spam from connections that are not gracefully closed. + connections.forEach(ctx -> { + try { + ctx.session.close(); // Close the WebSocket session } catch (Exception e) { - return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Error loading file"); + LOGGER.warn("Failed to close WebSocket connection: {}", ctx.session.getRemoteAddress(), e); } + }); + connections.clear(); + if (server != null) { + LOGGER.info("Shutting down web interface"); + server.stop(); } } - // WebSocket server for chat - private class ChatWebSocketServer extends WebSocketServer { - public ChatWebSocketServer(int port) { - super(new InetSocketAddress(port)); - LOGGER.info("Starting WebSocket server on port " + port); - } - - @Override - public void onOpen(WebSocket conn, ClientHandshake handshake) { - LOGGER.info("New connection from " + conn.getRemoteSocketAddress()); - connections.add(conn); - } + private String sanitizeMessage(String message) { + // Replace known illegal characters like linebreaks, control characters, zero width characters, etc + return ILLEGAL_CHARACTERS.matcher(message).replaceAll(""); + } - @Override - public void onClose(WebSocket conn, int code, String reason, boolean remote) { - LOGGER.info("Closed connection to " + conn.getRemoteSocketAddress() + " with code " + code + " for reason: " + reason); - connections.remove(conn); + private void sendMinecraftMessage(String message) { + MinecraftClient client = MinecraftClient.getInstance(); + // Probably an edge case, if even possible but client can potentially be null + if (client == null) { + LOGGER.warn("MinecraftClient instance is null. Cannot send message."); + return; } - - private void sendMinecraftMessage(String message) { - MinecraftClient client = MinecraftClient.getInstance(); - // Need to schedule on the main thread since we're coming from websocket thread - client.execute(() -> { - ClientPlayerEntity player = client.player; - if (player != null) { + client.execute(() -> { + ClientPlayerEntity player = client.player; + if (player != null) { + // Break long messages into smaller chunks + int maxLength = 256; + if (message.length() > maxLength) { + for (int i = 0; i < message.length(); i += maxLength) { + int end = Math.min(i + maxLength, message.length()); + player.networkHandler.sendChatMessage(message.substring(i, end)); + } + } else { player.networkHandler.sendChatMessage(message); } - }); - } - - @Override - public void onMessage(WebSocket conn, String message) { - LOGGER.info("Received message from " + conn.getRemoteSocketAddress() + ": " + message); - // Replace known illegal characters like linebreaks, control characters, zero width characters, etc - message = message.replaceAll("[\\n\\r§\u00A7\\u0000-\\u001F\\u200B-\\u200F\\u2028-\\u202F]", ""); - int maxLength = 256; - if (message.length() > maxLength) { - for (int i = 0; i < message.length(); i += maxLength) { - int end = Math.min(i + maxLength, message.length()); - sendMinecraftMessage(message.substring(i, end)); - } } else { - sendMinecraftMessage(message); - } - } - - @Override - public void onError(WebSocket conn, Exception ex) { - LOGGER.error("WebSocket error: ", ex); - if (conn != null) { - connections.remove(conn); + LOGGER.warn("Player value is null. Cannot send message."); } - } - - @Override - public void onStart() { - LOGGER.info("WebSocket server started successfully"); - setConnectionLostTimeout(30); - } + }); } - // Method to broadcast messages to all web clients public void broadcastMessage(String message) { - for (WebSocket conn : connections) { - conn.send(message); - } + connections.forEach(ctx -> { + try { + ctx.send(message); + } catch (Exception e) { + LOGGER.warn("Failed to send message to connection: {}", ctx.session.getRemoteAddress(), e); + } + }); } } \ No newline at end of file diff --git a/src/client/java/dev/creesch/WebchatClient.java b/src/client/java/dev/creesch/WebchatClient.java index 2b03e7a..6c43e89 100644 --- a/src/client/java/dev/creesch/WebchatClient.java +++ b/src/client/java/dev/creesch/WebchatClient.java @@ -4,6 +4,7 @@ import dev.creesch.config.ModConfig; import dev.creesch.util.NamedLogger; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.minecraft.client.MinecraftClient; @@ -62,6 +63,11 @@ public void onInitializeClient() { }); }); - + // Properly handle minecraft shutting down + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> { + if (webInterface != null) { + webInterface.shutdown(); + } + }); } } \ No newline at end of file diff --git a/src/client/resources/web/js/chat.mjs b/src/client/resources/web/js/chat.mjs index cdb0a54..dd4b0b6 100644 --- a/src/client/resources/web/js/chat.mjs +++ b/src/client/resources/web/js/chat.mjs @@ -7,7 +7,6 @@ import { parseMinecraftText, initializeObfuscation } from './message_parsing.mjs /** @type {WebSocket | null} */ let ws = null; -const wsPort = parseInt(location.port, 10) + 1; let reconnectAttempts = 0; const maxReconnectAttempts = 300; // TODO: add a reconnect button after automatic retries are done. @@ -83,7 +82,7 @@ function addMessage(json, store = true) { } function connect() { - ws = new WebSocket(`ws://localhost:${wsPort}`); + ws = new WebSocket(`ws://${location.host}/chat`); ws.onopen = function () { console.log('Connected to server'); @@ -166,4 +165,4 @@ if (input) { sendMessage(); } }); -} +} \ No newline at end of file diff --git a/src/main/java/dev/creesch/Webchat.java b/src/main/java/dev/creesch/Webchat.java index 28f0ec0..4f7a9d8 100644 --- a/src/main/java/dev/creesch/Webchat.java +++ b/src/main/java/dev/creesch/Webchat.java @@ -2,9 +2,16 @@ import net.fabricmc.api.ModInitializer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + public class Webchat implements ModInitializer { + public static final String MOD_ID = "web-chat"; + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + @Override public void onInitialize() { // Nothing to do here, since this is a client only mod everything can be found `src/client/java` + LOGGER.info(MOD_ID); } } \ No newline at end of file