Skip to content
48 changes: 35 additions & 13 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
Expand All @@ -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"
Expand All @@ -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 {
Expand All @@ -78,13 +75,38 @@ java {
targetCompatibility = JavaVersion.VERSION_21
}



jar {
from("LICENSE") {
rename { "${it}_${project.base.archivesName.get()}"}
}

}

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 {
Expand Down
3 changes: 1 addition & 2 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
240 changes: 123 additions & 117 deletions src/client/java/dev/creesch/WebInterface.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebSocket> connections = new HashSet<>();

ModConfig config = ModConfig.HANDLER.instance();
// Server related things
@Getter
private final Javalin server;
private final Set<WsContext> 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<String, String> 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);
}
});
}
}
Loading