From a92be6c681792c367f4e5cb309c028d0fc039c28 Mon Sep 17 00:00:00 2001 From: Telesphoreo Date: Sun, 17 May 2026 18:34:49 -0400 Subject: [PATCH] Redesign the HTTPD --- src/main/java/dev/plex/HTTPDModule.java | 5 + .../dev/plex/request/AbstractServlet.java | 25 +- .../dev/plex/request/impl/AssetsEndpoint.java | 46 ++ .../plex/request/impl/CommandsEndpoint.java | 197 +++++---- .../request/impl/IndefBansUIEndpoint.java | 145 +++++++ .../dev/plex/request/impl/IndexEndpoint.java | 17 +- .../plex/request/impl/PlayersEndpoint.java | 106 +++++ .../request/impl/PunishmentsUIEndpoint.java | 212 ++++++++++ .../impl/SchematicDownloadEndpoint.java | 24 +- .../dev/plex/request/impl/StatsEndpoint.java | 140 +++++++ src/main/resources/httpd/assets/dashboard.js | 178 ++++++++ src/main/resources/httpd/assets/plexlogo.webp | Bin 0 -> 19136 bytes src/main/resources/httpd/commands.html | 72 +++- src/main/resources/httpd/indefbans.html | 12 +- src/main/resources/httpd/indefbans_list.html | 41 ++ src/main/resources/httpd/index.html | 151 ++++++- src/main/resources/httpd/players.html | 43 ++ src/main/resources/httpd/punishments.html | 43 +- .../resources/httpd/punishments_error.html | 18 +- .../resources/httpd/punishments_good.html | 18 +- .../resources/httpd/punishments_results.html | 104 +++++ .../resources/httpd/schematic_download.html | 69 +-- .../resources/httpd/schematic_upload.html | 43 +- .../resources/httpd/schematic_upload_bad.html | 18 +- .../httpd/schematic_upload_good.html | 23 +- src/main/resources/httpd/template.html | 394 +++++++++++++++--- 26 files changed, 1909 insertions(+), 235 deletions(-) create mode 100644 src/main/java/dev/plex/request/impl/AssetsEndpoint.java create mode 100644 src/main/java/dev/plex/request/impl/IndefBansUIEndpoint.java create mode 100644 src/main/java/dev/plex/request/impl/PlayersEndpoint.java create mode 100644 src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java create mode 100644 src/main/java/dev/plex/request/impl/StatsEndpoint.java create mode 100644 src/main/resources/httpd/assets/dashboard.js create mode 100644 src/main/resources/httpd/assets/plexlogo.webp create mode 100644 src/main/resources/httpd/indefbans_list.html create mode 100644 src/main/resources/httpd/players.html create mode 100644 src/main/resources/httpd/punishments_results.html 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 0000000000000000000000000000000000000000..cfe7fff6c1c620d9c2a481e37ea3115722f0079a GIT binary patch literal 19136 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 - summary { - font-size: 24px; - padding: 16px; - } - -

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 +