mirror of
https://github.com/plexusorg/Module-HTTPD.git
synced 2026-06-05 01:26:54 +00:00
Redesign the HTTPD
This commit is contained in:
@@ -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("<", "<").replaceAll(">", ">");
|
||||
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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -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("<", "<").replaceAll(">", ">");
|
||||
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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user