Redesign the HTTPD

This commit is contained in:
2026-05-17 18:34:49 -04:00
parent 4fff172232
commit a92be6c681
26 changed files with 1909 additions and 235 deletions
+5
View File
@@ -88,6 +88,11 @@ public class HTTPDModule extends PlexModule
new CommandsEndpoint();
new SchematicDownloadEndpoint();
new SchematicUploadEndpoint();
new StatsEndpoint();
new PlayersEndpoint();
new AssetsEndpoint();
new PunishmentsUIEndpoint();
new IndefBansUIEndpoint();
ServletHolder uploadHolder = HTTPDModule.context.addServlet(SchematicUploadServlet.class, "/api/schematics/uploading");
@@ -13,6 +13,7 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.text.CharacterIterator;
import java.text.StringCharacterIterator;
import java.util.List;
@@ -39,7 +40,9 @@ public class AbstractServlet extends HttpServlet
}
GET_MAPPINGS.add(mapping);
ServletHolder holder = new ServletHolder(this);
HTTPDModule.context.addServlet(holder, getMapping.endpoint() + "*");
String endpoint = getMapping.endpoint();
String pattern = endpoint.endsWith("/") ? endpoint + "*" : endpoint;
HTTPDModule.context.addServlet(holder, pattern);
}
}
}
@@ -63,15 +66,22 @@ public class AbstractServlet extends HttpServlet
}*/
GET_MAPPINGS.stream().filter(mapping -> endpointMatchesRequest(mapping.getMapping().endpoint(), requestPath)).forEach(mapping ->
{
resp.setCharacterEncoding("UTF-8");
if (mapping.headers != null)
{
for (String headers : mapping.headers.headers())
{
String header = headers.split(";")[0];
String value = headers.split(";")[1];
resp.addHeader(header, value);
String[] parts = headers.split(";", 2);
if (parts.length == 2)
{
resp.addHeader(parts[0], parts[1]);
}
}
}
if (resp.getContentType() == null)
{
resp.setContentType("text/html; charset=UTF-8");
}
resp.setStatus(HttpServletResponse.SC_OK);
try
{
@@ -129,11 +139,10 @@ public class AbstractServlet extends HttpServlet
String page = readFileReal(filename);
String[] info = page.split("\n", 3);
base = base.replace("${TITLE}", info[0]);
base = base.replace("${ACTIVE_" + info[1] + "}", "active\" aria-current=\"page");
base = base.replace("${ACTIVE_" + info[1] + "}", "active");
base = base.replace("${ACTIVE_HOME}", "");
base = base.replace("${ACTIVE_ADMINS}", "");
base = base.replace("${ACTIVE_PLAYERS}", "");
base = base.replace("${ACTIVE_INDEFBANS}", "");
base = base.replace("${ACTIVE_LIST}", "");
base = base.replace("${ACTIVE_COMMANDS}", "");
base = base.replace("${ACTIVE_PUNISHMENTS}", "");
base = base.replace("${ACTIVE_SCHEMATICS}", "");
@@ -146,7 +155,7 @@ public class AbstractServlet extends HttpServlet
StringBuilder contentBuilder = new StringBuilder();
try
{
BufferedReader in = new BufferedReader(new InputStreamReader(Objects.requireNonNull(filename)));
BufferedReader in = new BufferedReader(new InputStreamReader(Objects.requireNonNull(filename), StandardCharsets.UTF_8));
String str;
while ((str = in.readLine()) != null)
{
@@ -0,0 +1,46 @@
package dev.plex.request.impl;
import dev.plex.request.AbstractServlet;
import dev.plex.request.GetMapping;
import dev.plex.request.MappingHeaders;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
public class AssetsEndpoint extends AbstractServlet
{
@GetMapping(endpoint = "/assets/dashboard.js")
@MappingHeaders(headers = {"content-type;application/javascript; charset=utf-8", "cache-control;public, max-age=300"})
public String dashboardJs(HttpServletRequest request, HttpServletResponse response)
{
return readFileReal(this.getClass().getResourceAsStream("/httpd/assets/dashboard.js"));
}
@GetMapping(endpoint = "/assets/plexlogo.webp")
@MappingHeaders(headers = {"content-type;image/webp", "cache-control;public, max-age=86400"})
public String plexLogo(HttpServletRequest request, HttpServletResponse response)
{
serveResource("/httpd/assets/plexlogo.webp", response);
return null;
}
private static void serveResource(String classpathPath, HttpServletResponse response)
{
try (InputStream in = AssetsEndpoint.class.getResourceAsStream(classpathPath);
OutputStream out = response.getOutputStream())
{
if (in == null)
{
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
in.transferTo(out);
}
catch (IOException ignored)
{
}
}
}
@@ -6,11 +6,13 @@ import dev.plex.request.AbstractServlet;
import dev.plex.request.GetMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.SortedMap;
import java.util.TreeMap;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandMap;
@@ -18,94 +20,137 @@ import org.bukkit.command.PluginIdentifiableCommand;
public class CommandsEndpoint extends AbstractServlet
{
private final StringBuilder list = new StringBuilder();
private boolean loadedCommands = false;
private String cachedHtml;
@GetMapping(endpoint = "/api/commands/")
public String getCommands(HttpServletRequest request, HttpServletResponse response)
{
if (!loadedCommands)
if (cachedHtml == null)
{
final SortedMap<String, List<Command>> commandMap = new TreeMap<>();
final CommandMap map = Bukkit.getCommandMap();
for (Command command : map.getKnownCommands().values())
{
String plugin = "Bukkit";
if (command instanceof PluginIdentifiableCommand)
{
plugin = ((PluginIdentifiableCommand) command).getPlugin().getName();
}
List<Command> pluginCommands = commandMap.computeIfAbsent(plugin, k -> new ArrayList<>());
if (!pluginCommands.contains(command))
{
pluginCommands.add(command);
}
}
for (String key : commandMap.keySet())
{
commandMap.get(key).sort(Comparator.comparing(Command::getName));
StringBuilder rows = new StringBuilder();
for (Command command : commandMap.get(key))
{
String permission = command.getPermission();
if (command instanceof PlexCommand plexCmd)
{
CommandPermissions perms = plexCmd.getClass().getAnnotation(CommandPermissions.class);
if (perms != null)
{
permission = (perms.permission().isBlank() ? "N/A" : perms.permission());
}
}
rows.append(createRow(command.getName(), command.getAliases(), command.getDescription(), command.getUsage(), permission));
}
list.append(createTable(key, rows.toString())).append("\n");
}
loadedCommands = true;
cachedHtml = buildSections();
}
return commandsHTML(list.toString());
}
private String commandsHTML(String commandsList)
{
String file = readFile(this.getClass().getResourceAsStream("/httpd/commands.html"));
file = file.replace("${commands}", commandsList);
file = file.replace("${commands}", cachedHtml);
return file;
}
private String createTable(String pluginName, String commandRows)
private static String buildSections()
{
return "<details id=\"" + pluginName + "\"><summary>" + pluginName + "</summary>\n"
+ "<table id=\"" + pluginName + "Table\" class=\"table table-striped table-bordered\">\n"
+ " <thead>\n <tr>\n <th scope=\"col\">Name (Aliases)</th>\n "
+ "<th scope=\"col\">Description</th>\n "
+ "<th scope=\"col\">Usage</th>\n "
+ "<th scope=\"col\">Permission</th>\n </tr>\n</thead>\n"
+ "<tbody>\n " + commandRows + "\n</tbody>\n</table>\n</details>";
}
private String createRow(String name, List<String> aliases, String description, String usage, String permission)
{
return " <tr>\n <th scope=\"row\">" + name
+ (aliases.isEmpty() || aliases.toString().equals("[]") ? "" : " (" + String.join(", ", aliases) + ")") + "</th>\n"
+ " <th scope=\"row\">" + description + "</th>\n"
+ " <th scope=\"row\"><code>" + cleanUsage(usage) + "</code></th>\n"
+ " <th scope=\"row\">" + (permission != null ? permission.replaceAll(";", "<br>") : "N/A") + "</th>\n </tr>";
}
private String cleanUsage(String usage)
{
usage = usage.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
if (usage.isBlank())
final SortedMap<String, List<Command>> commandMap = new TreeMap<>();
final CommandMap map = Bukkit.getCommandMap();
for (Command command : map.getKnownCommands().values())
{
usage = "Not Provided";
String plugin = "Bukkit";
if (command instanceof PluginIdentifiableCommand pic)
{
plugin = pic.getPlugin().getName();
}
List<Command> pluginCommands = commandMap.computeIfAbsent(plugin, k -> new ArrayList<>());
if (!pluginCommands.contains(command))
{
pluginCommands.add(command);
}
}
return usage.startsWith("/") || usage.equals("Not Provided") ? usage : "/" + usage;
StringBuilder sb = new StringBuilder();
for (String key : commandMap.keySet())
{
List<Command> commands = commandMap.get(key);
commands.sort(Comparator.comparing(Command::getName));
sb.append(renderSection(key, commands));
}
return sb.toString();
}
private static String renderSection(String plugin, List<Command> commands)
{
StringBuilder cards = new StringBuilder();
for (Command c : commands)
{
cards.append(renderCard(c));
}
String name = escapeHtml(plugin);
return """
<details class="command-section group mt-3 first:mt-0" data-plugin="%s" open>
<summary class="group flex cursor-pointer list-none items-center justify-between gap-3 rounded-2xl px-2 py-3 transition-colors hover:bg-muted/40 [&::-webkit-details-marker]:hidden">
<span class="flex items-center gap-2.5 text-lg font-medium tracking-tight">
<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">
%d %s
</span>
</summary>
<div class="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
%s
</div>
</details>
""".formatted(name, name, commands.size(), commands.size() == 1 ? "command" : "commands", cards);
}
private static String renderCard(Command c)
{
String name = escapeHtml(c.getName());
String aliases = c.getAliases() == null || c.getAliases().isEmpty() ? "" : String.join(", ", c.getAliases());
String description = c.getDescription() == null || c.getDescription().isBlank() ? "" : escapeHtml(c.getDescription());
String usage = cleanUsage(c.getUsage());
String permission = resolvePermission(c);
String aliasMarkup = aliases.isEmpty()
? ""
: "<span class=\"font-mono text-xs text-muted-foreground\">/ " + escapeHtml(aliases) + "</span>";
String descMarkup = description.isEmpty()
? "<p class=\"mt-2 text-sm text-muted-foreground/70 italic\">No description provided.</p>"
: "<p class=\"mt-2 text-sm text-muted-foreground\">" + description + "</p>";
String searchBlob = (name + " " + aliases + " " + description + " " + permission).toLowerCase();
return """
<article class="ring-card group flex flex-col rounded-2xl bg-card p-4 transition-colors hover:bg-secondary/50" data-search="%s">
<header class="flex flex-wrap items-baseline gap-2">
<code class="rounded-md bg-muted px-2 py-0.5 font-mono text-sm font-medium text-foreground">/%s</code>
%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>
</article>
""".formatted(searchBlob, name, aliasMarkup, descMarkup, usage, permission);
}
private static String resolvePermission(Command c)
{
String permission = c.getPermission();
if (c instanceof PlexCommand plexCmd)
{
CommandPermissions perms = plexCmd.getClass().getAnnotation(CommandPermissions.class);
if (perms != null)
{
permission = perms.permission().isBlank() ? "N/A" : perms.permission();
}
}
if (permission == null || permission.isBlank()) return "N/A";
return escapeHtml(permission).replace(";", "<br>");
}
private static String cleanUsage(String usage)
{
if (usage == null || usage.isBlank()) return "Not provided";
String escaped = escapeHtml(usage);
return escaped.startsWith("/") ? escaped : "/" + escaped;
}
private static String escapeHtml(String s)
{
if (s == null) return "";
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -0,0 +1,145 @@
package dev.plex.request.impl;
import dev.plex.HTTPDModule;
import dev.plex.Plex;
import dev.plex.cache.DataUtils;
import dev.plex.player.PlexPlayer;
import dev.plex.punishment.PunishmentManager.IndefiniteBan;
import dev.plex.request.AbstractServlet;
import dev.plex.request.GetMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
public class IndefBansUIEndpoint extends AbstractServlet
{
@GetMapping(endpoint = "/indefbans/")
public String getBans(HttpServletRequest request, HttpServletResponse response)
{
String ipAddress = request.getRemoteAddr();
if (ipAddress == null)
{
return errorHTML("Cannot detect an IP address on this request.");
}
PlexPlayer viewer = DataUtils.getPlayerByIP(ipAddress);
if (viewer == null)
{
return errorHTML("This IP (" + escapeHtml(ipAddress) + ") is not linked to a known player.");
}
OfflinePlayer offline = Bukkit.getOfflinePlayer(viewer.getUuid());
if (!HTTPDModule.getPermissions().playerHas(null, offline, "plex.httpd.indefbans.access"))
{
return errorHTML("Your account does not have <span class=\"font-mono text-foreground\">plex.httpd.indefbans.access</span>.");
}
List<IndefiniteBan> bans = Plex.get().getPunishmentManager().getIndefiniteBans();
return listHTML(bans);
}
private String listHTML(List<IndefiniteBan> bans)
{
StringBuilder cards = new StringBuilder();
int totalUsers = 0, totalUuids = 0, totalIps = 0;
for (IndefiniteBan ban : bans)
{
totalUsers += ban.getUsernames().size();
totalUuids += ban.getUuids().size();
totalIps += ban.getIps().size();
cards.append(renderCard(ban));
}
if (cards.length() == 0)
{
cards.append("""
<div class="ring-card rounded-2xl bg-card p-10 text-center">
<svg class="mx-auto size-8 text-muted-foreground/60" aria-hidden="true"><use href="#i-check"/></svg>
<p class="mt-3 text-sm text-muted-foreground">No indefinite bans configured.</p>
</div>
""");
}
String file = readFile(this.getClass().getResourceAsStream("/httpd/indefbans_list.html"));
file = file.replace("${group_count}", String.valueOf(bans.size()));
file = file.replace("${total_users}", String.valueOf(totalUsers));
file = file.replace("${total_uuids}", String.valueOf(totalUuids));
file = file.replace("${total_ips}", String.valueOf(totalIps));
file = file.replace("${bans}", cards.toString());
return file;
}
private static String renderCard(IndefiniteBan ban)
{
StringBuilder chips = new StringBuilder();
for (String name : ban.getUsernames())
{
chips.append(chip("user", escapeHtml(name)));
}
for (UUID id : ban.getUuids())
{
chips.append(chip("uuid", id.toString()));
}
for (String ip : ban.getIps())
{
chips.append(chip("ip", escapeHtml(ip)));
}
String reason = (ban.getReason() == null || ban.getReason().isBlank())
? "<span class=\"italic text-muted-foreground/70\">No reason provided</span>"
: escapeHtml(ban.getReason());
int total = ban.getUsernames().size() + ban.getUuids().size() + ban.getIps().size();
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>
</header>
<div class="mt-4 flex flex-wrap gap-1.5">
%s
</div>
</article>
""".formatted(reason, total, total == 1 ? "entry" : "entries", chips);
}
private static String chip(String kind, String value)
{
String color = switch (kind)
{
case "user" -> "bg-muted text-foreground";
case "uuid" -> "bg-primary/10 text-primary";
case "ip" -> "bg-warning/10 text-warning";
default -> "bg-muted text-foreground";
};
String label = switch (kind)
{
case "user" -> "user";
case "uuid" -> "uuid";
case "ip" -> "ip";
default -> "";
};
return """
<span class="inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 font-mono text-xs %s">
<span class="text-[9px] uppercase tracking-wider opacity-60">%s</span>
<span>%s</span>
</span>
""".formatted(color, label, value);
}
private String errorHTML(String message)
{
String file = readFile(this.getClass().getResourceAsStream("/httpd/indefbans.html"));
file = file.replace("${MESSAGE}", message);
return file;
}
private static String escapeHtml(String s)
{
if (s == null) return "";
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -4,31 +4,18 @@ import dev.plex.request.AbstractServlet;
import dev.plex.request.GetMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.bukkit.Bukkit;
public class IndexEndpoint extends AbstractServlet
{
@GetMapping(endpoint = "//")
public String getIndex(HttpServletRequest request, HttpServletResponse response)
{
return indexHTML();
return readFile(this.getClass().getResourceAsStream("/httpd/index.html"));
}
@GetMapping(endpoint = "/api/")
public String getAPI(HttpServletRequest request, HttpServletResponse response)
{
return indexHTML();
}
private String indexHTML()
{
String file = readFile(this.getClass().getResourceAsStream("/httpd/index.html"));
String isAre = Bukkit.getOnlinePlayers().size() == 1 ? " is " : " are ";
String pluralOnline = Bukkit.getOnlinePlayers().size() == 1 ? " player " : " players ";
String pluralMax = Bukkit.getMaxPlayers() == 1 ? " player " : " players ";
file = file.replace("${is_are}", isAre);
file = file.replace("${server_online_players}", Bukkit.getOnlinePlayers().size() + pluralOnline);
file = file.replace("${server_total_players}", Bukkit.getMaxPlayers() + pluralMax);
return file;
return readFile(this.getClass().getResourceAsStream("/httpd/index.html"));
}
}
@@ -0,0 +1,106 @@
package dev.plex.request.impl;
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 org.bukkit.Bukkit;
import org.bukkit.entity.Player;
public class PlayersEndpoint extends AbstractServlet
{
@GetMapping(endpoint = "/players/")
public String getPlayers(HttpServletRequest request, HttpServletResponse response)
{
Collection<? extends Player> players = Bukkit.getOnlinePlayers();
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_cards}", cards);
return file;
}
private static String renderPlayerCards(Collection<? extends Player> players)
{
StringBuilder sb = new StringBuilder();
for (Player p : players)
{
sb.append(renderCard(p));
}
return sb.toString();
}
private static String renderCard(Player 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>"
: "";
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>
</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);
}
private static String emptyState()
{
return """
<div class="ring-card col-span-full rounded-2xl bg-card p-10 text-center">
<svg class="mx-auto size-8 text-muted-foreground/60" aria-hidden="true"><use href="#i-users"/></svg>
<p class="mt-3 text-sm text-muted-foreground">No players online right now.</p>
</div>
""";
}
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 "";
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -0,0 +1,212 @@
package dev.plex.request.impl;
import dev.plex.HTTPDModule;
import dev.plex.Plex;
import dev.plex.cache.DataUtils;
import dev.plex.player.PlexPlayer;
import dev.plex.punishment.Punishment;
import dev.plex.punishment.PunishmentType;
import dev.plex.request.AbstractServlet;
import dev.plex.request.GetMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
public class PunishmentsUIEndpoint extends AbstractServlet
{
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z");
@GetMapping(endpoint = "/punishments/")
public String getPunishments(HttpServletRequest request, HttpServletResponse response)
{
String path = request.getPathInfo();
if (path == null || path.equals("/"))
{
return readFile(this.getClass().getResourceAsStream("/httpd/punishments.html"));
}
String query = path.replace("/", "").trim();
if (query.isEmpty())
{
return readFile(this.getClass().getResourceAsStream("/httpd/punishments.html"));
}
PlexPlayer punished = lookupPlayer(query);
if (punished == null)
{
return errorHTML("No player found matching <span class=\"font-mono text-foreground\">" + escapeHtml(query) + "</span>.");
}
List<Punishment> punishments = punished.getPunishments();
if (punishments == null || punishments.isEmpty())
{
return goodHTML(escapeHtml(punished.getName()) + " has no punishments on record.");
}
boolean showIps = canViewIps(request.getRemoteAddr());
return resultsHTML(punished, punishments, showIps);
}
private static PlexPlayer lookupPlayer(String query)
{
try
{
return DataUtils.getPlayer(UUID.fromString(query));
}
catch (IllegalArgumentException ignored)
{
return DataUtils.getPlayer(query);
}
}
private static boolean canViewIps(String requesterIp)
{
if (requesterIp == null) return false;
PlexPlayer viewer = DataUtils.getPlayerByIP(requesterIp);
if (viewer == null) return false;
OfflinePlayer offline = Bukkit.getOfflinePlayer(viewer.getUuid());
return HTTPDModule.getPermissions().playerHas(null, offline, "plex.httpd.punishments.access");
}
private String resultsHTML(PlexPlayer player, List<Punishment> punishments, boolean showIps)
{
StringBuilder cards = new StringBuilder();
for (Punishment p : punishments)
{
cards.append(renderCard(p, showIps));
}
String file = readFile(this.getClass().getResourceAsStream("/httpd/punishments_results.html"));
file = file.replace("${player_name}", escapeHtml(player.getName()));
file = file.replace("${player_uuid}", player.getUuid().toString());
file = file.replace("${punishment_count}", String.valueOf(punishments.size()));
file = file.replace("${punishment_label}", punishments.size() == 1 ? "punishment" : "punishments");
file = file.replace("${punishments}", cards.toString());
return file;
}
private static String renderCard(Punishment p, boolean showIps)
{
PunishmentType type = p.getType();
String typeName = type == null ? "UNKNOWN" : type.name();
String accent = accentFor(type);
String rawReason = (p.getReason() == null || p.getReason().isBlank()) ? "" : p.getReason();
String reason = rawReason.isEmpty() ? "<span class=\"italic text-muted-foreground/70\">No reason provided</span>" : escapeHtml(rawReason);
String punisher = resolveName(p.getPunisher());
String endDate = p.getEndDate() == null ? "permanent" : escapeHtml(formatDate(p.getEndDate()));
boolean isBan = type == PunishmentType.BAN || type == PunishmentType.TEMPBAN;
String status = "";
String statusChip = "";
if (isBan)
{
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>";
}
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>";
}
}
String ipRow = "";
String ipBlob = "";
if (showIps && p.getIp() != null && !p.getIp().isBlank())
{
ipBlob = p.getIp();
ipRow = """
<dt class="text-muted-foreground uppercase tracking-wider">IP</dt>
<dd class="text-foreground/80 break-all">%s</dd>
""".formatted(escapeHtml(p.getIp()));
}
String searchBlob = escapeHtml((typeName + " " + rawReason + " " + punisher + " " + status + " " + ipBlob).toLowerCase());
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>
%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>
<dd class="text-foreground/80">%s</dd>
<dt class="text-muted-foreground uppercase tracking-wider">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);
}
private static String accentFor(PunishmentType type)
{
if (type == null) return "muted-foreground";
return switch (type)
{
case BAN, SMITE -> "destructive";
case TEMPBAN, MUTE -> "warning";
case KICK, FREEZE -> "primary";
};
}
private static String resolveName(UUID uuid)
{
if (uuid == null) return "CONSOLE";
try
{
String name = Plex.get().getSqlPlayerData().getNameByUUID(uuid);
if (name != null && !name.isBlank()) return name;
}
catch (Throwable ignored)
{
}
return uuid.toString();
}
private static String formatDate(ZonedDateTime date)
{
try
{
return DATE_FMT.format(date);
}
catch (Throwable t)
{
return date.toString();
}
}
private String errorHTML(String message)
{
String file = readFile(this.getClass().getResourceAsStream("/httpd/punishments_error.html"));
file = file.replace("${MESSAGE}", message);
return file;
}
private String goodHTML(String message)
{
String file = readFile(this.getClass().getResourceAsStream("/httpd/punishments_good.html"));
file = file.replace("${MESSAGE}", message);
return file;
}
private static String escapeHtml(String s)
{
if (s == null) return "";
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -77,15 +77,25 @@ public class SchematicDownloadEndpoint extends AbstractServlet
{
return null;
}
List<File> entries = listFilesForFolder(worldeditFolder);
StringBuilder sb = new StringBuilder();
for (File worldeditFile : listFilesForFolder(worldeditFolder))
if (entries.isEmpty())
{
String fixedPath = worldeditFile.getPath().replace("plugins/FastAsyncWorldEdit/schematics/", "");
fixedPath = fixedPath.replace("plugins/WorldEdit/schematics/", "");
String sanitizedName = fixedPath.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
sb.append(" <tr>\n" + " <th scope=\"row\">\n <a href=\"").append(fixedPath).append("\" download>")
.append(sanitizedName).append("</a>\n </th>\n").append(" <td>\n ")
.append(formattedSize(worldeditFile.length())).append("\n </td>\n").append(" </tr>\n");
sb.append("<tr><td colspan=\"3\" class=\"px-4 py-8 text-center text-sm text-muted-foreground\">No schematics yet.</td></tr>");
}
for (File worldeditFile : entries)
{
String fixedPath = worldeditFile.getPath()
.replace("plugins/FastAsyncWorldEdit/schematics/", "")
.replace("plugins/WorldEdit/schematics/", "");
String sanitizedName = fixedPath.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
String size = formattedSize(worldeditFile.length());
sb.append("<tr data-name=\"").append(sanitizedName).append("\" class=\"transition-colors hover:bg-muted/40\">\n")
.append(" <td class=\"px-4 py-2.5\"><a class=\"font-mono text-foreground hover:text-primary\" href=\"")
.append(sanitizedName).append("\" download>").append(sanitizedName).append("</a></td>\n")
.append(" <td class=\"px-4 py-2.5 text-right font-mono text-xs text-muted-foreground tabular\">").append(size).append("</td>\n")
.append(" <td class=\"pr-3\"><a href=\"").append(sanitizedName).append("\" download class=\"inline-flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\" aria-label=\"Download\"><svg class=\"size-3.5\" aria-hidden=\"true\"><use href=\"#i-download\"/></svg></a></td>\n")
.append("</tr>\n");
}
file = file.replace("${schematics}", sb.toString());
files.clear();
@@ -0,0 +1,140 @@
package dev.plex.request.impl;
import com.google.gson.GsonBuilder;
import dev.plex.Plex;
import dev.plex.request.AbstractServlet;
import dev.plex.request.GetMapping;
import dev.plex.request.MappingHeaders;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.lang.management.ManagementFactory;
import java.util.LinkedHashMap;
import java.util.Map;
import org.bukkit.Bukkit;
import org.bukkit.World;
public class StatsEndpoint extends AbstractServlet
{
private static volatile int cachedChunks = 0;
private static volatile int cachedEntities = 0;
private static volatile boolean schedulerStarted = false;
public StatsEndpoint()
{
super();
startWorldSnapshotTask();
}
private static synchronized void startWorldSnapshotTask()
{
if (schedulerStarted) return;
try
{
Bukkit.getScheduler().runTaskTimer(Plex.get(), () ->
{
int chunks = 0;
int entities = 0;
for (World world : Bukkit.getWorlds())
{
try
{
chunks += world.getLoadedChunks().length;
entities += world.getEntities().size();
}
catch (Throwable ignored)
{
}
}
cachedChunks = chunks;
cachedEntities = entities;
}, 0L, 40L);
schedulerStarted = true;
}
catch (Throwable ignored)
{
}
}
@GetMapping(endpoint = "/api/stats/")
@MappingHeaders(headers = {"content-type;application/json; charset=utf-8", "cache-control;no-store"})
public String getStats(HttpServletRequest request, HttpServletResponse response)
{
Map<String, Object> root = new LinkedHashMap<>();
Map<String, Object> server = new LinkedHashMap<>();
server.put("version", safeServerVersion());
server.put("uptime", ManagementFactory.getRuntimeMXBean().getUptime());
server.put("tps", safeTps());
root.put("server", server);
com.sun.management.OperatingSystemMXBean os =
(com.sun.management.OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
Map<String, Object> cpu = new LinkedHashMap<>();
cpu.put("process", clamp01(os.getProcessCpuLoad()));
cpu.put("system", clamp01(os.getCpuLoad()));
cpu.put("cores", os.getAvailableProcessors());
cpu.put("loadAverage", os.getSystemLoadAverage());
root.put("cpu", cpu);
Runtime rt = Runtime.getRuntime();
long max = rt.maxMemory();
long total = rt.totalMemory();
long free = rt.freeMemory();
long used = total - free;
Map<String, Object> memory = new LinkedHashMap<>();
memory.put("used", used);
memory.put("total", total);
memory.put("max", max);
root.put("memory", memory);
Map<String, Object> players = new LinkedHashMap<>();
players.put("online", Bukkit.getOnlinePlayers().size());
players.put("max", Bukkit.getMaxPlayers());
root.put("players", players);
Map<String, Object> world = new LinkedHashMap<>();
world.put("loadedChunks", cachedChunks);
world.put("entities", cachedEntities);
world.put("worlds", Bukkit.getWorlds().size());
root.put("world", world);
Map<String, Object> plugins = new LinkedHashMap<>();
plugins.put("active", Bukkit.getPluginManager().getPlugins().length);
root.put("plugins", plugins);
return new GsonBuilder().serializeNulls().create().toJson(root);
}
private static double clamp01(double v)
{
if (Double.isNaN(v) || v < 0) return 0d;
if (v > 1) return 1d;
return v;
}
private static double[] safeTps()
{
try
{
return Bukkit.getTPS();
}
catch (Throwable t)
{
return new double[]{20d, 20d, 20d};
}
}
private static String safeServerVersion()
{
try
{
return Bukkit.getMinecraftVersion();
}
catch (Throwable t)
{
return Bukkit.getBukkitVersion();
}
}
}