From 823ee61a07a3ab5a697b2724ebc368db2cba73f2 Mon Sep 17 00:00:00 2001 From: Telesphoreo Date: Sun, 17 May 2026 23:38:54 -0400 Subject: [PATCH] HTTPD performance improvements --- src/main/java/dev/plex/HTTPDModule.java | 14 +- .../dev/plex/request/AbstractServlet.java | 16 +- .../plex/request/SchematicUploadServlet.java | 2 +- .../request/impl/AuthenticationEndpoint.java | 60 ++++++- .../plex/request/impl/CommandsEndpoint.java | 12 +- .../plex/request/impl/IndefBansEndpoint.java | 2 +- .../request/impl/IndefBansUIEndpoint.java | 14 +- .../plex/request/impl/PlayersEndpoint.java | 129 ++++++++------ .../request/impl/PunishmentsUIEndpoint.java | 25 ++- .../request/impl/SchematicUploadEndpoint.java | 2 +- src/main/resources/httpd/commands.html | 4 +- src/main/resources/httpd/config.yml | 13 ++ src/main/resources/httpd/indefbans_list.html | 2 +- src/main/resources/httpd/index.html | 62 +++---- src/main/resources/httpd/players.html | 4 +- src/main/resources/httpd/punishments.html | 6 +- .../resources/httpd/punishments_results.html | 24 +-- .../resources/httpd/schematic_download.html | 4 +- .../resources/httpd/schematic_upload.html | 2 +- src/main/resources/httpd/template.html | 158 +++++++++++------- 20 files changed, 367 insertions(+), 188 deletions(-) diff --git a/src/main/java/dev/plex/HTTPDModule.java b/src/main/java/dev/plex/HTTPDModule.java index 215bb57..70cd19a 100644 --- a/src/main/java/dev/plex/HTTPDModule.java +++ b/src/main/java/dev/plex/HTTPDModule.java @@ -19,6 +19,7 @@ import org.eclipse.jetty.ee10.servlet.ServletContextHandler; import org.eclipse.jetty.ee10.servlet.ServletHandler; import org.eclipse.jetty.ee10.servlet.ServletHolder; import org.eclipse.jetty.server.*; +import org.eclipse.jetty.util.thread.QueuedThreadPool; import java.io.File; import java.util.EnumSet; @@ -66,7 +67,14 @@ public class HTTPDModule extends PlexModule serverThread = new Thread(() -> { - Server server = new Server(); + int maxThreads = moduleConfig.getInt("server.threads.max", 16); + int minThreads = Math.min(moduleConfig.getInt("server.threads.min", 2), maxThreads); + int idleTimeout = moduleConfig.getInt("server.threads.idle-timeout-ms", 30_000); + QueuedThreadPool pool = new QueuedThreadPool(maxThreads, minThreads, idleTimeout); + pool.setName("Plex-HTTPD"); + pool.setDaemon(true); + + Server server = new Server(pool); ServletHandler servletHandler = new ServletHandler(); context = new ServletContextHandler(ServletContextHandler.SESSIONS); @@ -74,10 +82,14 @@ public class HTTPDModule extends PlexModule context.setContextPath("/"); HttpConfiguration configuration = new HttpConfiguration(); configuration.addCustomizer(new ForwardedRequestCustomizer()); + configuration.setRequestHeaderSize(moduleConfig.getInt("server.limits.request-header-bytes", 8 * 1024)); + configuration.setSendServerVersion(false); HttpConnectionFactory factory = new HttpConnectionFactory(configuration); ServerConnector connector = new ServerConnector(server, factory); connector.setHost(moduleConfig.getString("server.bind-address")); connector.setPort(moduleConfig.getInt("server.port")); + connector.setIdleTimeout(moduleConfig.getLong("server.limits.idle-timeout-ms", 15_000L)); + connector.setAcceptQueueSize(moduleConfig.getInt("server.limits.accept-queue", 32)); context.addFilter(new FilterHolder(new RateLimitFilter()), "/*", EnumSet.of(DispatcherType.REQUEST)); diff --git a/src/main/java/dev/plex/request/AbstractServlet.java b/src/main/java/dev/plex/request/AbstractServlet.java index 508446c..4eb8643 100644 --- a/src/main/java/dev/plex/request/AbstractServlet.java +++ b/src/main/java/dev/plex/request/AbstractServlet.java @@ -16,6 +16,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.text.CharacterIterator; import java.text.StringCharacterIterator; @@ -153,7 +154,20 @@ public class AbstractServlet extends HttpServlet public static String signInPrompt(String action) { - return "You must sign in as staff " + action + "."; + return signInPrompt(null, action); + } + + public static String signInPrompt(HttpServletRequest request, String action) + { + String href = "/oauth2/login"; + if (request != null) + { + String path = getRequestPath(request); + String query = request.getQueryString(); + String returnTo = query == null || query.isEmpty() ? path : path + "?" + query; + href = href + "?return_to=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8); + } + return "You must sign in as staff " + action + "."; } public static String readFile(InputStream filename) diff --git a/src/main/java/dev/plex/request/SchematicUploadServlet.java b/src/main/java/dev/plex/request/SchematicUploadServlet.java index 34a1366..71ab021 100644 --- a/src/main/java/dev/plex/request/SchematicUploadServlet.java +++ b/src/main/java/dev/plex/request/SchematicUploadServlet.java @@ -30,7 +30,7 @@ public class SchematicUploadServlet extends HttpServlet AuthenticatedUser user = AbstractServlet.currentStaff(request); if (user == null) { - response.getWriter().println(schematicUploadBadHTML(AbstractServlet.signInPrompt("to upload schematics"))); + response.getWriter().println(schematicUploadBadHTML(AbstractServlet.signInPrompt(request, "to upload schematics"))); return; } File worldeditFolder = HTTPDModule.getWorldeditFolder(); diff --git a/src/main/java/dev/plex/request/impl/AuthenticationEndpoint.java b/src/main/java/dev/plex/request/impl/AuthenticationEndpoint.java index 11ff236..495efd8 100644 --- a/src/main/java/dev/plex/request/impl/AuthenticationEndpoint.java +++ b/src/main/java/dev/plex/request/impl/AuthenticationEndpoint.java @@ -14,9 +14,14 @@ import jakarta.servlet.http.HttpServletResponse; import org.json.JSONObject; import java.io.IOException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; public class AuthenticationEndpoint extends AbstractServlet { + private static final String RETURN_TO_COOKIE = "plex_return_to"; + @GetMapping(endpoint = "/oauth2/login") public String login(HttpServletRequest request, HttpServletResponse response) throws IOException { @@ -26,6 +31,20 @@ public class AuthenticationEndpoint extends AbstractServlet response.sendError(HttpServletResponse.SC_NOT_FOUND, "Authentication is not enabled."); return null; } + + String returnTo = sanitizeReturnTo(request.getParameter("return_to")); + String cookieValue = returnTo == null ? "" : URLEncoder.encode(returnTo, StandardCharsets.UTF_8); + Cookie returnCookie = new Cookie(RETURN_TO_COOKIE, cookieValue); + returnCookie.setHttpOnly(true); + returnCookie.setPath("/"); + returnCookie.setMaxAge(returnTo == null ? 0 : 600); + returnCookie.setAttribute("SameSite", "Lax"); + if (request.isSecure() || "https".equalsIgnoreCase(request.getHeader("X-Forwarded-Proto"))) + { + returnCookie.setSecure(true); + } + response.addCookie(returnCookie); + response.sendRedirect(provider.buildAuthorizeUrl(request)); return null; } @@ -54,7 +73,12 @@ public class AuthenticationEndpoint extends AbstractServlet + "

" + escape(e.getMessage()) + "

" + "

Try again

"; } - response.sendRedirect("/"); + + String raw = readCookie(request, RETURN_TO_COOKIE); + String decoded = raw == null || raw.isEmpty() ? null : URLDecoder.decode(raw, StandardCharsets.UTF_8); + String target = sanitizeReturnTo(decoded); + clearReturnToCookie(request, response); + response.sendRedirect(target == null ? "/" : target); return null; } @@ -103,12 +127,17 @@ public class AuthenticationEndpoint extends AbstractServlet } private static String readSessionCookie(HttpServletRequest request) + { + return readCookie(request, OAuth2Provider.SESSION_COOKIE); + } + + private static String readCookie(HttpServletRequest request, String name) { Cookie[] cookies = request.getCookies(); if (cookies == null) return null; for (Cookie cookie : cookies) { - if (OAuth2Provider.SESSION_COOKIE.equals(cookie.getName())) + if (name.equals(cookie.getName())) { return cookie.getValue(); } @@ -116,6 +145,33 @@ public class AuthenticationEndpoint extends AbstractServlet return null; } + private void clearReturnToCookie(HttpServletRequest request, HttpServletResponse response) + { + Cookie clear = new Cookie(RETURN_TO_COOKIE, ""); + clear.setHttpOnly(true); + clear.setPath("/"); + clear.setMaxAge(0); + clear.setAttribute("SameSite", "Lax"); + if (request.isSecure() || "https".equalsIgnoreCase(request.getHeader("X-Forwarded-Proto"))) + { + clear.setSecure(true); + } + response.addCookie(clear); + } + + private static String sanitizeReturnTo(String value) + { + if (value == null || value.isEmpty()) return null; + if (!value.startsWith("/")) return null; + if (value.startsWith("//") || value.startsWith("/\\")) return null; + for (int i = 0; i < value.length(); i++) + { + char c = value.charAt(i); + if (c == '\n' || c == '\r' || c == '\\') return null; + } + return value; + } + private static String escape(String s) { if (s == null) return ""; diff --git a/src/main/java/dev/plex/request/impl/CommandsEndpoint.java b/src/main/java/dev/plex/request/impl/CommandsEndpoint.java index e9490ec..a229f08 100644 --- a/src/main/java/dev/plex/request/impl/CommandsEndpoint.java +++ b/src/main/java/dev/plex/request/impl/CommandsEndpoint.java @@ -77,7 +77,7 @@ public class CommandsEndpoint extends AbstractServlet %s - + %d %s @@ -113,11 +113,11 @@ public class CommandsEndpoint extends AbstractServlet %s %s -
-
usage
-
%s
-
perm
-
%s
+
+
usage
+
%s
+
perm
+
%s
""".formatted(searchBlob, name, aliasMarkup, descMarkup, usage, permission); diff --git a/src/main/java/dev/plex/request/impl/IndefBansEndpoint.java b/src/main/java/dev/plex/request/impl/IndefBansEndpoint.java index 609bd8a..15e62a0 100644 --- a/src/main/java/dev/plex/request/impl/IndefBansEndpoint.java +++ b/src/main/java/dev/plex/request/impl/IndefBansEndpoint.java @@ -16,7 +16,7 @@ public class IndefBansEndpoint extends AbstractServlet AuthenticatedUser user = currentStaff(request); if (user == null) { - return indefbansHTML(signInPrompt("to view this page")); + return indefbansHTML(signInPrompt(request, "to view this page")); } response.setHeader("content-type", "application/json"); diff --git a/src/main/java/dev/plex/request/impl/IndefBansUIEndpoint.java b/src/main/java/dev/plex/request/impl/IndefBansUIEndpoint.java index e5a933c..3e56a5b 100644 --- a/src/main/java/dev/plex/request/impl/IndefBansUIEndpoint.java +++ b/src/main/java/dev/plex/request/impl/IndefBansUIEndpoint.java @@ -19,7 +19,7 @@ public class IndefBansUIEndpoint extends AbstractServlet AuthenticatedUser viewer = currentStaff(request); if (viewer == null) { - return errorHTML(signInPrompt("to view this page")); + return errorHTML(signInPrompt(request, "to view this page")); } List bans = Plex.get().getPunishmentManager().getIndefiniteBans(); @@ -66,24 +66,24 @@ public class IndefBansUIEndpoint extends AbstractServlet StringBuilder rows = new StringBuilder(); if (!ban.getUsernames().isEmpty()) { - rows.append(renderRow("users", "text-foreground/90 break-all", ban.getUsernames().stream().map(IndefBansUIEndpoint::escapeHtml).toList())); + rows.append(renderRow("Users", "text-foreground/90 break-all", ban.getUsernames().stream().map(IndefBansUIEndpoint::escapeHtml).toList())); } if (!ban.getUuids().isEmpty()) { - rows.append(renderRow("uuids", "text-foreground/55 break-all", ban.getUuids().stream().map(UUID::toString).toList())); + rows.append(renderRow("UUIDs", "font-mono text-foreground/55 break-all", ban.getUuids().stream().map(UUID::toString).toList())); } if (!ban.getIps().isEmpty()) { - rows.append(renderRow("ips", "text-warning break-all", ban.getIps().stream().map(IndefBansUIEndpoint::escapeHtml).toList())); + rows.append(renderRow("IPs", "font-mono text-warning break-all", ban.getIps().stream().map(IndefBansUIEndpoint::escapeHtml).toList())); } return """

%s

- %d %s + %d %s
-
+
%s
@@ -98,7 +98,7 @@ public class IndefBansUIEndpoint extends AbstractServlet items.append("").append(value).append(""); } return """ -
%s
+
%s
%s
""".formatted(label, valueClasses, items); } diff --git a/src/main/java/dev/plex/request/impl/PlayersEndpoint.java b/src/main/java/dev/plex/request/impl/PlayersEndpoint.java index a08634b..f65af40 100644 --- a/src/main/java/dev/plex/request/impl/PlayersEndpoint.java +++ b/src/main/java/dev/plex/request/impl/PlayersEndpoint.java @@ -1,76 +1,108 @@ package dev.plex.request.impl; +import dev.plex.Plex; import dev.plex.request.AbstractServlet; import dev.plex.request.GetMapping; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import java.util.Collection; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; import org.bukkit.Bukkit; import org.bukkit.entity.Player; public class PlayersEndpoint extends AbstractServlet { + private static volatile List snapshot = Collections.emptyList(); + private static volatile int maxPlayers = 0; + private static volatile boolean schedulerStarted = false; + + public PlayersEndpoint() + { + super(); + startSnapshotTask(); + } + + private static synchronized void startSnapshotTask() + { + if (schedulerStarted) return; + try + { + Bukkit.getScheduler().runTaskTimer(Plex.get(), PlayersEndpoint::refreshSnapshot, 0L, 20L); + schedulerStarted = true; + } + catch (Throwable ignored) + { + } + } + + private static void refreshSnapshot() + { + List next = new ArrayList<>(); + for (Player p : Bukkit.getOnlinePlayers()) + { + next.add(PlayerSnapshot.of(p)); + } + snapshot = List.copyOf(next); + maxPlayers = Bukkit.getMaxPlayers(); + } + @GetMapping(endpoint = "/players/") public String getPlayers(HttpServletRequest request, HttpServletResponse response) { - Collection players = Bukkit.getOnlinePlayers(); + List players = snapshot; String cards = players.isEmpty() ? emptyState() : renderPlayerCards(players); String file = readFile(this.getClass().getResourceAsStream("/httpd/players.html")); file = file.replace("${player_count}", String.valueOf(players.size())); - file = file.replace("${player_max}", String.valueOf(Bukkit.getMaxPlayers())); + file = file.replace("${player_max}", String.valueOf(maxPlayers)); file = file.replace("${player_cards}", cards); return file; } - private static String renderPlayerCards(Collection players) + private static String renderPlayerCards(List players) { StringBuilder sb = new StringBuilder(); - for (Player p : players) + for (PlayerSnapshot p : players) { sb.append(renderCard(p)); } return sb.toString(); } - private static String renderCard(Player p) + private static String renderCard(PlayerSnapshot p) { - String uuid = p.getUniqueId().toString(); - String name = escapeHtml(p.getName()); - String gamemode = p.getGameMode().name().toLowerCase(); - String world = escapeHtml(p.getWorld().getName()); - int ping = safePing(p); - String pingColor = ping < 80 ? "text-success" : ping < 200 ? "text-warning" : "text-destructive"; - String opChip = p.isOp() - ? "op" + String pingColor = p.ping < 80 ? "text-success" : p.ping < 200 ? "text-warning" : "text-destructive"; + String opChip = p.op + ? "op" : ""; + String location = p.world.isEmpty() ? "" : "In " + p.world; + String separator = location.isEmpty() ? "" : "·"; return """ - - """.formatted(name, uuid, name, opChip, gamemode, world, pingColor, ping); + + """.formatted(p.uuid, p.name, p.name, p.uuid, p.name, opChip, location, separator, pingColor, p.ping); } private static String emptyState() @@ -83,18 +115,6 @@ public class PlayersEndpoint extends AbstractServlet """; } - private static int safePing(Player p) - { - try - { - return p.getPing(); - } - catch (Throwable t) - { - return 0; - } - } - private static String escapeHtml(String s) { if (s == null) return ""; @@ -103,4 +123,19 @@ public class PlayersEndpoint extends AbstractServlet .replace(">", ">") .replace("\"", """); } + + private record PlayerSnapshot(UUID uuid, String name, String world, boolean op, int ping) + { + static PlayerSnapshot of(Player p) + { + int ping; + try { ping = p.getPing(); } catch (Throwable t) { ping = 0; } + return new PlayerSnapshot( + p.getUniqueId(), + escapeHtml(p.getName()), + escapeHtml(p.getWorld() != null ? p.getWorld().getName() : ""), + p.isOp(), + ping); + } + } } diff --git a/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java b/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java index 638b4d7..b6f81ac 100644 --- a/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java +++ b/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java @@ -97,12 +97,12 @@ public class PunishmentsUIEndpoint extends AbstractServlet if (p.isActive()) { status = "active"; - statusChip = "active"; + statusChip = "Active"; } else { status = "expired"; - statusChip = "expired"; + statusChip = "Expired"; } } @@ -112,29 +112,30 @@ public class PunishmentsUIEndpoint extends AbstractServlet { ipBlob = p.getIp(); ipRow = """ -
IP
-
%s
+
IP
+
%s
""".formatted(escapeHtml(p.getIp())); } String searchBlob = escapeHtml((typeName + " " + rawReason + " " + punisher + " " + status + " " + ipBlob).toLowerCase()); + String typeLabel = titleCase(typeName); return """
- %s + %s %s

%s

-
-
Punisher
+
+
Punisher
%s
-
Expires
+
Expires
%s
%s
- """.formatted(searchBlob, typeName, status, accent, accent, typeName, statusChip, reason, escapeHtml(punisher), endDate, ipRow); + """.formatted(searchBlob, typeName, status, accent, accent, typeLabel, statusChip, reason, escapeHtml(punisher), endDate, ipRow); } private static String accentFor(PunishmentType type) @@ -196,4 +197,10 @@ public class PunishmentsUIEndpoint extends AbstractServlet .replace(">", ">") .replace("\"", """); } + + private static String titleCase(String s) + { + if (s == null || s.isEmpty()) return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1).toLowerCase(); + } } diff --git a/src/main/java/dev/plex/request/impl/SchematicUploadEndpoint.java b/src/main/java/dev/plex/request/impl/SchematicUploadEndpoint.java index 1340f8a..210fd91 100644 --- a/src/main/java/dev/plex/request/impl/SchematicUploadEndpoint.java +++ b/src/main/java/dev/plex/request/impl/SchematicUploadEndpoint.java @@ -14,7 +14,7 @@ public class SchematicUploadEndpoint extends AbstractServlet AuthenticatedUser user = currentStaff(request); if (user == null) { - return schematicsHTML(signInPrompt("to upload schematics")); + return schematicsHTML(signInPrompt(request, "to upload schematics")); } return readFile(this.getClass().getResourceAsStream("/httpd/schematic_upload.html")); } diff --git a/src/main/resources/httpd/commands.html b/src/main/resources/httpd/commands.html index fc52795..44e5f0c 100644 --- a/src/main/resources/httpd/commands.html +++ b/src/main/resources/httpd/commands.html @@ -5,7 +5,7 @@ COMMANDS
-
- +
${commands} diff --git a/src/main/resources/httpd/config.yml b/src/main/resources/httpd/config.yml index 6b59bbf..bd0d984 100644 --- a/src/main/resources/httpd/config.yml +++ b/src/main/resources/httpd/config.yml @@ -7,6 +7,19 @@ server: file-path: "httpd.log" # relative to the module's data folder console: false # also mirror to the Bukkit console + # Jetty thread pool. Bounded so a flood of HTTP requests can't starve the + # Minecraft tick thread or consume unbounded memory. + threads: + max: 16 + min: 2 + idle-timeout-ms: 30000 + + # Per-connection limits that close slow/abusive clients quickly. + limits: + idle-timeout-ms: 15000 # drop conns with no progress for this long + accept-queue: 32 # OS-level backlog of pending TCP connects + request-header-bytes: 8192 # cap header size; oversized requests get 431 + # Token-bucket rate limiting. Defaults are sized for a small sized server. # capacity = burst, per-second = sustained rate. Disable globally with enabled: false. rate-limit: diff --git a/src/main/resources/httpd/indefbans_list.html b/src/main/resources/httpd/indefbans_list.html index d65ded8..bdf46e3 100644 --- a/src/main/resources/httpd/indefbans_list.html +++ b/src/main/resources/httpd/indefbans_list.html @@ -2,7 +2,7 @@ Indefinite Bans INDEFBANS

Indefinite bans

-
+
${group_count} groups ${total_users} users ${total_uuids} uuids diff --git a/src/main/resources/httpd/index.html b/src/main/resources/httpd/index.html index b34291a..ddd23c1 100644 --- a/src/main/resources/httpd/index.html +++ b/src/main/resources/httpd/index.html @@ -2,26 +2,26 @@ Overview HOME

Overview

- - Minecraft version + + Minecraft version
-
+ -
+
- CPU + CPU
@@ -40,38 +40,38 @@ HOME
-
+
process · cores sys
-
+
- Memory + Memory
- +
-
+
heap · max
-
+
- Ticks per second + Ticks per second
- / 20.00 + / 20.00
@@ -83,7 +83,7 @@ HOME -
+
5m 15m
@@ -93,51 +93,51 @@ HOME
-
+
- Uptime + Uptime
-
+
-
+
- World + World
-
+
-
Worlds
+
Worlds
-
Chunks
+
Chunks
-
Entities
+
Entities
-
+
- Plugins + Plugins
active
-
- + diff --git a/src/main/resources/httpd/players.html b/src/main/resources/httpd/players.html index 2344be1..817ff5a 100644 --- a/src/main/resources/httpd/players.html +++ b/src/main/resources/httpd/players.html @@ -2,13 +2,13 @@ Players PLAYERS

Players

- + ${player_count} / ${player_max} online
-
-
-
-
- + ${punishment_count} ${punishment_label}
-