HTTPD performance improvements

This commit is contained in:
2026-05-17 23:38:54 -04:00
parent 94cb2a98c4
commit 823ee61a07
20 changed files with 367 additions and 188 deletions
+13 -1
View File
@@ -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));
@@ -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 <a class=\"text-primary underline\" href=\"/oauth2/login\">sign in</a> 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 <a class=\"text-primary underline\" href=\"" + href + "\">sign in</a> as staff " + action + ".";
}
public static String readFile(InputStream filename)
@@ -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();
@@ -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
+ "<p>" + escape(e.getMessage()) + "</p>"
+ "<p><a href=\"/oauth2/login\">Try again</a></p>";
}
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 "";
@@ -77,7 +77,7 @@ public class CommandsEndpoint extends AbstractServlet
<svg class="size-4 text-muted-foreground transition-transform group-open:rotate-90" aria-hidden="true"><use href="#i-arrow-right"/></svg>
%s
</span>
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">
<span class="text-sm text-muted-foreground">
%d %s
</span>
</summary>
@@ -113,11 +113,11 @@ public class CommandsEndpoint extends AbstractServlet
%s
</header>
%s
<dl class="mt-3 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 border-t border-border/60 pt-3 font-mono text-[11px]">
<dt class="text-muted-foreground uppercase tracking-wider">usage</dt>
<dd class="text-foreground/80 break-all">%s</dd>
<dt class="text-muted-foreground uppercase tracking-wider">perm</dt>
<dd class="text-foreground/80 break-all">%s</dd>
<dl class="mt-3 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 border-t border-border/60 pt-3 text-xs">
<dt class="text-muted-foreground">usage</dt>
<dd class="font-mono text-foreground/80 break-all">%s</dd>
<dt class="text-muted-foreground">perm</dt>
<dd class="font-mono text-foreground/80 break-all">%s</dd>
</dl>
</article>
""".formatted(searchBlob, name, aliasMarkup, descMarkup, usage, permission);
@@ -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");
@@ -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<IndefiniteBan> 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 """
<article class="ring-card rounded-2xl bg-card p-5">
<header class="flex flex-wrap items-baseline justify-between gap-3">
<p class="text-sm">%s</p>
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">%d %s</span>
<span class="text-xs text-muted-foreground">%d %s</span>
</header>
<dl class="mt-4 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 border-t border-border/60 pt-3 font-mono text-[11px]">
<dl class="mt-4 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 border-t border-border/60 pt-3 text-xs">
%s
</dl>
</article>
@@ -98,7 +98,7 @@ public class IndefBansUIEndpoint extends AbstractServlet
items.append("<span>").append(value).append("</span>");
}
return """
<dt class="text-muted-foreground uppercase tracking-wider">%s</dt>
<dt class="text-muted-foreground">%s</dt>
<dd class="flex flex-wrap gap-x-3 gap-y-1 %s">%s</dd>
""".formatted(label, valueClasses, items);
}
@@ -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<PlayerSnapshot> 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<PlayerSnapshot> 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<? extends Player> players = Bukkit.getOnlinePlayers();
List<PlayerSnapshot> 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<? extends Player> players)
private static String renderPlayerCards(List<PlayerSnapshot> 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()
? "<span class=\"inline-flex h-5 items-center rounded-full bg-primary/12 px-2 font-mono text-[10px] uppercase tracking-wider text-primary\">op</span>"
String pingColor = p.ping < 80 ? "text-success" : p.ping < 200 ? "text-warning" : "text-destructive";
String opChip = p.op
? "<span class=\"inline-flex h-5 items-center rounded-full bg-primary/12 px-2 text-xs text-primary\">op</span>"
: "";
String location = p.world.isEmpty() ? "" : "In " + p.world;
String separator = location.isEmpty() ? "" : "<span class=\"text-foreground/30\">·</span>";
return """
<article class="ring-card group rounded-2xl bg-card p-4 transition-colors hover:bg-secondary/50" data-name="%s">
<div class="flex items-center gap-3">
<img class="size-12 rounded-xl bg-muted [image-rendering:pixelated]"
src="https://vzge.me/face/512/%s.png"
alt="" loading="lazy" width="48" height="48">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate font-medium">%s</span>
%s
</div>
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[11px] text-muted-foreground">
<span class="inline-flex h-5 items-center rounded-full bg-muted px-2">%s</span>
<span class="text-foreground/30">·</span>
<span>%s</span>
</div>
<a href="/punishments/%s"
class="ring-card group flex items-center gap-3 rounded-2xl bg-card p-3 transition-colors hover:bg-secondary/50"
data-name="%s"
title="View punishments for %s">
<img class="size-10 rounded-lg bg-muted [image-rendering:pixelated]"
src="https://vzge.me/face/512/%s.png"
alt="" loading="lazy" width="40" height="40">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium">%s</span>
%s
</div>
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
<span>%s</span>
%s
<span class="tabular %s">%dms</span>
</div>
</div>
<div class="mt-3 flex items-center justify-between border-t border-border/60 pt-3 font-mono text-[11px]">
<span class="text-muted-foreground">ping</span>
<span class="tabular %s">%dms</span>
</div>
</article>
""".formatted(name, uuid, name, opChip, gamemode, world, pingColor, ping);
</a>
""".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(">", "&gt;")
.replace("\"", "&quot;");
}
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);
}
}
}
@@ -97,12 +97,12 @@ public class PunishmentsUIEndpoint extends AbstractServlet
if (p.isActive())
{
status = "active";
statusChip = "<span class=\"inline-flex h-5 items-center rounded-full bg-destructive/10 px-2 font-mono text-[10px] uppercase tracking-wider text-destructive\">active</span>";
statusChip = "<span class=\"inline-flex h-5 items-center rounded-full bg-destructive/10 px-2 text-xs text-destructive\">Active</span>";
}
else
{
status = "expired";
statusChip = "<span class=\"inline-flex h-5 items-center rounded-full bg-muted px-2 font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">expired</span>";
statusChip = "<span class=\"inline-flex h-5 items-center rounded-full bg-muted px-2 text-xs text-muted-foreground\">Expired</span>";
}
}
@@ -112,29 +112,30 @@ public class PunishmentsUIEndpoint extends AbstractServlet
{
ipBlob = p.getIp();
ipRow = """
<dt class="text-muted-foreground uppercase tracking-wider">IP</dt>
<dd class="text-foreground/80 break-all">%s</dd>
<dt class="text-muted-foreground">IP</dt>
<dd class="font-mono text-foreground/80 break-all">%s</dd>
""".formatted(escapeHtml(p.getIp()));
}
String searchBlob = escapeHtml((typeName + " " + rawReason + " " + punisher + " " + status + " " + ipBlob).toLowerCase());
String typeLabel = titleCase(typeName);
return """
<article class="ring-card rounded-2xl bg-card p-5" data-search="%s" data-type="%s" data-status="%s">
<header class="flex flex-wrap items-center gap-2">
<span class="inline-flex h-6 items-center rounded-full bg-%s/10 px-2.5 font-mono text-xs font-medium uppercase tracking-wider text-%s">%s</span>
<span class="inline-flex h-6 items-center rounded-full bg-%s/10 px-2.5 text-xs font-medium text-%s">%s</span>
%s
</header>
<p class="mt-3 text-sm">%s</p>
<dl class="mt-4 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 border-t border-border/60 pt-3 font-mono text-[11px]">
<dt class="text-muted-foreground uppercase tracking-wider">Punisher</dt>
<dl class="mt-4 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 border-t border-border/60 pt-3 text-xs">
<dt class="text-muted-foreground">Punisher</dt>
<dd class="text-foreground/80">%s</dd>
<dt class="text-muted-foreground uppercase tracking-wider">Expires</dt>
<dt class="text-muted-foreground">Expires</dt>
<dd class="text-foreground/80">%s</dd>
%s
</dl>
</article>
""".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(">", "&gt;")
.replace("\"", "&quot;");
}
private static String titleCase(String s)
{
if (s == null || s.isEmpty()) return s;
return Character.toUpperCase(s.charAt(0)) + s.substring(1).toLowerCase();
}
}
@@ -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"));
}