diff --git a/src/main/java/dev/plex/HTTPDModule.java b/src/main/java/dev/plex/HTTPDModule.java index 8707d01..d9755da 100644 --- a/src/main/java/dev/plex/HTTPDModule.java +++ b/src/main/java/dev/plex/HTTPDModule.java @@ -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"); diff --git a/src/main/java/dev/plex/request/AbstractServlet.java b/src/main/java/dev/plex/request/AbstractServlet.java index f0ec05b..900fb75 100644 --- a/src/main/java/dev/plex/request/AbstractServlet.java +++ b/src/main/java/dev/plex/request/AbstractServlet.java @@ -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) { diff --git a/src/main/java/dev/plex/request/impl/AssetsEndpoint.java b/src/main/java/dev/plex/request/impl/AssetsEndpoint.java new file mode 100644 index 0000000..479eb86 --- /dev/null +++ b/src/main/java/dev/plex/request/impl/AssetsEndpoint.java @@ -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) + { + } + } +} diff --git a/src/main/java/dev/plex/request/impl/CommandsEndpoint.java b/src/main/java/dev/plex/request/impl/CommandsEndpoint.java index b4125f2..e9490ec 100644 --- a/src/main/java/dev/plex/request/impl/CommandsEndpoint.java +++ b/src/main/java/dev/plex/request/impl/CommandsEndpoint.java @@ -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> 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 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 "
" + pluginName + "\n" - + "\n" - + " \n \n \n " - + "\n " - + "\n " - + "\n \n\n" - + "\n " + commandRows + "\n\n
Name (Aliases)DescriptionUsagePermission
\n
"; - } - - private String createRow(String name, List aliases, String description, String usage, String permission) - { - return " \n " + name - + (aliases.isEmpty() || aliases.toString().equals("[]") ? "" : " (" + String.join(", ", aliases) + ")") + "\n" - + " " + description + "\n" - + " " + cleanUsage(usage) + "\n" - + " " + (permission != null ? permission.replaceAll(";", "
") : "N/A") + "\n "; - } - - private String cleanUsage(String usage) - { - usage = usage.replaceAll("<", "<").replaceAll(">", ">"); - if (usage.isBlank()) + final SortedMap> 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 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 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 commands) + { + StringBuilder cards = new StringBuilder(); + for (Command c : commands) + { + cards.append(renderCard(c)); + } + String name = escapeHtml(plugin); + return """ +
+ + + + %s + + + %d %s + + +
+ %s +
+
+ """.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() + ? "" + : "/ " + escapeHtml(aliases) + ""; + + String descMarkup = description.isEmpty() + ? "

No description provided.

" + : "

" + description + "

"; + + String searchBlob = (name + " " + aliases + " " + description + " " + permission).toLowerCase(); + + return """ +
+
+ /%s + %s +
+ %s +
+
usage
+
%s
+
perm
+
%s
+
+
+ """.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(";", "
"); + } + + 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("\"", """); } } diff --git a/src/main/java/dev/plex/request/impl/IndefBansUIEndpoint.java b/src/main/java/dev/plex/request/impl/IndefBansUIEndpoint.java new file mode 100644 index 0000000..d731cf2 --- /dev/null +++ b/src/main/java/dev/plex/request/impl/IndefBansUIEndpoint.java @@ -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 plex.httpd.indefbans.access."); + } + + List bans = Plex.get().getPunishmentManager().getIndefiniteBans(); + return listHTML(bans); + } + + private String listHTML(List 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(""" +
+ +

No indefinite bans configured.

+
+ """); + } + 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()) + ? "No reason provided" + : escapeHtml(ban.getReason()); + + int total = ban.getUsernames().size() + ban.getUuids().size() + ban.getIps().size(); + return """ +
+
+

%s

+ %d %s +
+
+ %s +
+
+ """.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 """ + + %s + %s + + """.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("\"", """); + } +} diff --git a/src/main/java/dev/plex/request/impl/IndexEndpoint.java b/src/main/java/dev/plex/request/impl/IndexEndpoint.java index b3f02a3..c5a312f 100644 --- a/src/main/java/dev/plex/request/impl/IndexEndpoint.java +++ b/src/main/java/dev/plex/request/impl/IndexEndpoint.java @@ -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")); } } diff --git a/src/main/java/dev/plex/request/impl/PlayersEndpoint.java b/src/main/java/dev/plex/request/impl/PlayersEndpoint.java new file mode 100644 index 0000000..a08634b --- /dev/null +++ b/src/main/java/dev/plex/request/impl/PlayersEndpoint.java @@ -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 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 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() + ? "op" + : ""; + + return """ +
+
+ +
+
+ %s + %s +
+
+ %s + · + %s +
+
+
+
+ ping + %dms +
+
+ """.formatted(name, uuid, name, opChip, gamemode, world, pingColor, ping); + } + + private static String emptyState() + { + return """ +
+ +

No players online right now.

+
+ """; + } + + 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("\"", """); + } +} diff --git a/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java b/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java new file mode 100644 index 0000000..8a0b2a6 --- /dev/null +++ b/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java @@ -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 " + escapeHtml(query) + "."); + } + + List 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 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() ? "No reason provided" : 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 = "active"; + } + else + { + status = "expired"; + statusChip = "expired"; + } + } + + String ipRow = ""; + String ipBlob = ""; + if (showIps && p.getIp() != null && !p.getIp().isBlank()) + { + ipBlob = p.getIp(); + ipRow = """ +
IP
+
%s
+ """.formatted(escapeHtml(p.getIp())); + } + + String searchBlob = escapeHtml((typeName + " " + rawReason + " " + punisher + " " + status + " " + ipBlob).toLowerCase()); + + return """ +
+
+ %s + %s +
+

%s

+
+
Punisher
+
%s
+
Expires
+
%s
+ %s +
+
+ """.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("\"", """); + } +} diff --git a/src/main/java/dev/plex/request/impl/SchematicDownloadEndpoint.java b/src/main/java/dev/plex/request/impl/SchematicDownloadEndpoint.java index 9ce5b4d..7661969 100644 --- a/src/main/java/dev/plex/request/impl/SchematicDownloadEndpoint.java +++ b/src/main/java/dev/plex/request/impl/SchematicDownloadEndpoint.java @@ -77,15 +77,25 @@ public class SchematicDownloadEndpoint extends AbstractServlet { return null; } + List 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(" \n" + " \n ") - .append(sanitizedName).append("\n \n").append(" \n ") - .append(formattedSize(worldeditFile.length())).append("\n \n").append(" \n"); + sb.append("No schematics yet."); + } + 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("\n") + .append(" ").append(sanitizedName).append("\n") + .append(" ").append(size).append("\n") + .append(" \n") + .append("\n"); } file = file.replace("${schematics}", sb.toString()); files.clear(); diff --git a/src/main/java/dev/plex/request/impl/StatsEndpoint.java b/src/main/java/dev/plex/request/impl/StatsEndpoint.java new file mode 100644 index 0000000..ff6f9b9 --- /dev/null +++ b/src/main/java/dev/plex/request/impl/StatsEndpoint.java @@ -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 root = new LinkedHashMap<>(); + + Map 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 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 memory = new LinkedHashMap<>(); + memory.put("used", used); + memory.put("total", total); + memory.put("max", max); + root.put("memory", memory); + + Map players = new LinkedHashMap<>(); + players.put("online", Bukkit.getOnlinePlayers().size()); + players.put("max", Bukkit.getMaxPlayers()); + root.put("players", players); + + Map world = new LinkedHashMap<>(); + world.put("loadedChunks", cachedChunks); + world.put("entities", cachedEntities); + world.put("worlds", Bukkit.getWorlds().size()); + root.put("world", world); + + Map 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(); + } + } +} diff --git a/src/main/resources/httpd/assets/dashboard.js b/src/main/resources/httpd/assets/dashboard.js new file mode 100644 index 0000000..c6a9810 --- /dev/null +++ b/src/main/resources/httpd/assets/dashboard.js @@ -0,0 +1,178 @@ +(function () { + const POLL_MS = 3000; + const SPARK_MAX = 60; + const tpsHistory = []; + + const fmt = { + pct(n) { + if (!isFinite(n) || n === null) return '—'; + return (n * 100).toFixed(1) + '%'; + }, + bytes(b) { + if (b == null || !isFinite(b)) return '—'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let n = b, i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + units[i]; + }, + bytesValue(b) { + if (b == null || !isFinite(b)) return '—'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let n = b, i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return i === 0 ? n.toFixed(0) : n.toFixed(1); + }, + bytesUnit(b) { + if (b == null || !isFinite(b)) return ''; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let n = b, i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return units[i]; + }, + tps(n) { + if (!isFinite(n)) return '—'; + return Math.min(n, 20).toFixed(2); + }, + int(n) { + if (n == null || !isFinite(n)) return '—'; + return Math.round(n).toLocaleString(); + }, + duration(ms) { + if (!ms || !isFinite(ms)) return '—'; + const s = Math.floor(ms / 1000); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m ${sec}s`; + if (m > 0) return `${m}m ${sec}s`; + return `${sec}s`; + } + }; + + function setText(selector, value) { + document.querySelectorAll(selector).forEach(el => { + if (el.textContent !== value) { + el.textContent = value; + el.classList.remove('tick'); + void el.offsetWidth; + el.classList.add('tick'); + } + }); + } + + function setWidth(selector, percent) { + document.querySelectorAll(selector).forEach(el => { + el.style.width = Math.max(0, Math.min(100, percent)).toFixed(1) + '%'; + }); + } + + function setBarColor(selector, percent) { + document.querySelectorAll(selector).forEach(el => { + el.classList.remove('bg-primary', 'bg-warning', 'bg-destructive'); + const cls = percent < 70 ? 'bg-primary' : (percent < 90 ? 'bg-warning' : 'bg-destructive'); + el.classList.add(cls); + }); + } + + function setTpsBadge(tps) { + document.querySelectorAll('[data-tps-state]').forEach(el => { + el.classList.remove('text-success', 'text-warning', 'text-destructive'); + el.classList.add(tps >= 19.5 ? 'text-success' : tps >= 18 ? 'text-warning' : 'text-destructive'); + }); + } + + function renderSparkline(history) { + const svg = document.querySelector('[data-spark="tps"]'); + if (!svg) return; + const w = svg.viewBox.baseVal.width || 600; + const h = svg.viewBox.baseVal.height || 80; + const pad = 4; + const max = 20; + const min = 15; + if (history.length < 2) return; + const stepX = (w - pad * 2) / (SPARK_MAX - 1); + const xs = history.slice(-SPARK_MAX); + const offset = SPARK_MAX - xs.length; + const points = xs.map((v, i) => { + const x = pad + (i + offset) * stepX; + const cv = Math.max(min, Math.min(max, v)); + const y = pad + (h - pad * 2) * (1 - (cv - min) / (max - min)); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + const line = svg.querySelector('[data-spark-line]'); + if (line) line.setAttribute('points', points.join(' ')); + const area = svg.querySelector('[data-spark-area]'); + if (area) { + const first = points[0].split(','); + const last = points[points.length - 1].split(','); + area.setAttribute('points', + `${first[0]},${h - pad} ${points.join(' ')} ${last[0]},${h - pad}`); + } + } + + async function refresh() { + try { + const r = await fetch('/api/stats/', {cache: 'no-store'}); + if (!r.ok) throw new Error('HTTP ' + r.status); + const s = await r.json(); + paint(s); + setStatus(true); + } catch (e) { + setStatus(false); + } + } + + function setStatus(ok) { + document.querySelectorAll('[data-status="text"]').forEach(el => { + el.textContent = ok ? 'online' : 'offline'; + }); + document.querySelectorAll('.status-dot').forEach(el => { + el.classList.remove('bg-success', 'bg-destructive'); + el.classList.add(ok ? 'bg-success' : 'bg-destructive'); + }); + } + + function paint(s) { + setText('[data-stat="players-online"]', String(s.players.online)); + setText('[data-stat="players-max"]', String(s.players.max)); + const pPercent = s.players.max > 0 ? (s.players.online / s.players.max) * 100 : 0; + setWidth('[data-stat="players-bar"]', pPercent); + + setText('[data-stat="cpu-process-value"]', fmt.pct(s.cpu.process)); + setText('[data-stat="cpu-system-value"]', fmt.pct(s.cpu.system)); + setText('[data-stat="cpu-cores"]', String(s.cpu.cores)); + const cpuPercent = (s.cpu.process || 0) * 100; + setWidth('[data-stat="cpu-bar"]', cpuPercent); + setBarColor('[data-stat="cpu-bar"]', cpuPercent); + + setText('[data-stat="mem-value"]', fmt.bytesValue(s.memory.used)); + setText('[data-stat="mem-unit"]', fmt.bytesUnit(s.memory.used)); + setText('[data-stat="mem-max"]', fmt.bytes(s.memory.max)); + const memPercent = (s.memory.used / s.memory.max) * 100; + setText('[data-stat="mem-percent"]', memPercent.toFixed(1) + '%'); + setWidth('[data-stat="mem-bar"]', memPercent); + setBarColor('[data-stat="mem-bar"]', memPercent); + + const tps1 = s.server.tps[0]; + setText('[data-stat="tps-1m"]', fmt.tps(tps1)); + setText('[data-stat="tps-5m"]', fmt.tps(s.server.tps[1])); + setText('[data-stat="tps-15m"]', fmt.tps(s.server.tps[2])); + setTpsBadge(tps1); + tpsHistory.push(tps1); + if (tpsHistory.length > SPARK_MAX) tpsHistory.shift(); + renderSparkline(tpsHistory); + + setText('[data-stat="uptime"]', fmt.duration(s.server.uptime)); + setText('[data-stat="version"]', s.server.version); + + setText('[data-stat="chunks"]', fmt.int(s.world.loadedChunks)); + setText('[data-stat="entities"]', fmt.int(s.world.entities)); + setText('[data-stat="worlds"]', fmt.int(s.world.worlds)); + setText('[data-stat="plugins"]', fmt.int(s.plugins.active)); + } + + refresh(); + setInterval(refresh, POLL_MS); +})(); diff --git a/src/main/resources/httpd/assets/plexlogo.webp b/src/main/resources/httpd/assets/plexlogo.webp new file mode 100644 index 0000000..cfe7fff Binary files /dev/null and b/src/main/resources/httpd/assets/plexlogo.webp differ diff --git a/src/main/resources/httpd/commands.html b/src/main/resources/httpd/commands.html index f052b87..fc52795 100644 --- a/src/main/resources/httpd/commands.html +++ b/src/main/resources/httpd/commands.html @@ -1,12 +1,64 @@ Commands COMMANDS - -

Commands List

-
A list of commands is below.
-

-${commands} \ No newline at end of file +
+

Commands

+
+ +
+ + +
+ + +
+${commands} +
+ + diff --git a/src/main/resources/httpd/indefbans.html b/src/main/resources/httpd/indefbans.html index 32439cc..71daf1c 100644 --- a/src/main/resources/httpd/indefbans.html +++ b/src/main/resources/httpd/indefbans.html @@ -1,4 +1,12 @@ Indefinite Bans INDEFBANS -

Indefinite Bans

- \ No newline at end of file +
+

Indefinite bans

+
+ +
+ +
+

${MESSAGE}

+
+
diff --git a/src/main/resources/httpd/indefbans_list.html b/src/main/resources/httpd/indefbans_list.html new file mode 100644 index 0000000..d65ded8 --- /dev/null +++ b/src/main/resources/httpd/indefbans_list.html @@ -0,0 +1,41 @@ +Indefinite Bans +INDEFBANS +
+

Indefinite bans

+
+ ${group_count} groups + ${total_users} users + ${total_uuids} uuids + ${total_ips} ips +
+
+ +
+ +
+ +
+${bans} +
+ + diff --git a/src/main/resources/httpd/index.html b/src/main/resources/httpd/index.html index 705d2b2..b34291a 100644 --- a/src/main/resources/httpd/index.html +++ b/src/main/resources/httpd/index.html @@ -1,5 +1,148 @@ -Home +Overview HOME -

Welcome to the Plex HTTPD!

-

Use the sidebar to navigate the available pages.

-


There ${is_are} currently ${server_online_players} online out of ${server_total_players} total.

\ No newline at end of file +
+

Overview

+ + Minecraft version + +
+ +
+ + + +
+
+ CPU + +
+
+ +
+
+
+
+
+ process · cores + sys +
+
+ +
+
+ Memory + +
+
+ + +
+
+
+
+
+ heap · + max +
+
+ +
+
+ Ticks per second + +
+
+ + / 20.00 +
+ + + + + + + + + + +
+ 5m + 15m +
+
+ +
+ +
+ +
+
+ Uptime + +
+
+ +
+
+ +
+
+ World + +
+
+
+
Worlds
+
+
+
+
Chunks
+
+
+
+
Entities
+
+
+
+
+ + + +
+ + diff --git a/src/main/resources/httpd/players.html b/src/main/resources/httpd/players.html new file mode 100644 index 0000000..2344be1 --- /dev/null +++ b/src/main/resources/httpd/players.html @@ -0,0 +1,43 @@ +Players +PLAYERS +
+

Players

+ + ${player_count} / ${player_max} online + +
+ +
+ + +
+ +
+${player_cards} +
+ + diff --git a/src/main/resources/httpd/punishments.html b/src/main/resources/httpd/punishments.html index 105236c..263ac80 100644 --- a/src/main/resources/httpd/punishments.html +++ b/src/main/resources/httpd/punishments.html @@ -1,23 +1,32 @@ Punishments PUNISHMENTS -

Punishment Search

- -
- - -
+
+

Punishments

+
+ +
+
+ + +
+
+ - \ No newline at end of file diff --git a/src/main/resources/httpd/punishments_error.html b/src/main/resources/httpd/punishments_error.html index 8c6207c..22a8f2d 100644 --- a/src/main/resources/httpd/punishments_error.html +++ b/src/main/resources/httpd/punishments_error.html @@ -1,4 +1,18 @@ Punishments PUNISHMENTS -

Punishment Search

- \ No newline at end of file +
+

Punishments

+
+ +
+ +

${MESSAGE}

+
+ + diff --git a/src/main/resources/httpd/punishments_good.html b/src/main/resources/httpd/punishments_good.html index 6e35768..9d8df84 100644 --- a/src/main/resources/httpd/punishments_good.html +++ b/src/main/resources/httpd/punishments_good.html @@ -1,4 +1,18 @@ Punishments PUNISHMENTS -

Punishment Search

- \ No newline at end of file +
+

Punishments

+
+ +
+ +

${MESSAGE}

+
+ + diff --git a/src/main/resources/httpd/punishments_results.html b/src/main/resources/httpd/punishments_results.html new file mode 100644 index 0000000..81c8551 --- /dev/null +++ b/src/main/resources/httpd/punishments_results.html @@ -0,0 +1,104 @@ +Punishments +PUNISHMENTS +
+
+ +
+

${player_name}

+

${player_uuid}

+
+
+ + ${punishment_count} ${punishment_label} + +
+ +
+ + + + New search + +
+ +
+ +
+${punishments} +
+ + + + diff --git a/src/main/resources/httpd/schematic_download.html b/src/main/resources/httpd/schematic_download.html index 814556c..038bed3 100644 --- a/src/main/resources/httpd/schematic_download.html +++ b/src/main/resources/httpd/schematic_download.html @@ -1,35 +1,48 @@ Schematics SCHEMATICS -

Schematic Download

- -
- -
- - - - - - - - -${schematics} - -
NameSize
- \ No newline at end of file + diff --git a/src/main/resources/httpd/schematic_upload.html b/src/main/resources/httpd/schematic_upload.html index 18fede8..84000b4 100644 --- a/src/main/resources/httpd/schematic_upload.html +++ b/src/main/resources/httpd/schematic_upload.html @@ -1,13 +1,36 @@ Schematics SCHEMATICS -

Schematic Upload

-
- -
-
- - -
+
+

Upload schematic

+
+ +
+ + + -
\ No newline at end of file +

+ + + diff --git a/src/main/resources/httpd/schematic_upload_bad.html b/src/main/resources/httpd/schematic_upload_bad.html index 7afcab6..2d22299 100644 --- a/src/main/resources/httpd/schematic_upload_bad.html +++ b/src/main/resources/httpd/schematic_upload_bad.html @@ -1,4 +1,18 @@ Schematics SCHEMATICS -

Schematic Upload

- \ No newline at end of file +
+

Upload schematic

+
+ +
+ +

${MESSAGE}

+
+ + diff --git a/src/main/resources/httpd/schematic_upload_good.html b/src/main/resources/httpd/schematic_upload_good.html index adc4b62..7b6034b 100644 --- a/src/main/resources/httpd/schematic_upload_good.html +++ b/src/main/resources/httpd/schematic_upload_good.html @@ -1,4 +1,23 @@ Schematics SCHEMATICS -

Schematic Upload

- \ No newline at end of file +
+

Upload schematic

+
+ +
+ +

${MESSAGE}

+
+ + diff --git a/src/main/resources/httpd/template.html b/src/main/resources/httpd/template.html index 4746e63..a10e5d5 100644 --- a/src/main/resources/httpd/template.html +++ b/src/main/resources/httpd/template.html @@ -1,59 +1,353 @@ - + + - - - + ${TITLE} · Plex HTTPD + + + - - ${TITLE} - Plex HTTPD + + + + + + + + + - - -
- ${CONTENT} + + +
+ ${CONTENT} +
+
+ + + - \ No newline at end of file +