diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..94b6761 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "additionalDirectories": [ + "C:\\Users\\telesphoreo\\IdeaProjects\\Plex" + ] + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 92b7b7a..2e6be01 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "dev.plex" -version = "1.6" +version = "1.7" description = "Module-HTTPD" repositories { @@ -27,7 +27,7 @@ dependencies { implementation("org.projectlombok:lombok:1.18.46") annotationProcessor("org.projectlombok:lombok:1.18.46") compileOnly("io.papermc.paper:paper-api:26.1.2.build.+") - implementation("dev.plex:server:1.6") + implementation("dev.plex:server:1.7-SNAPSHOT") implementation("org.json:json:20251224") implementation("org.reflections:reflections:0.10.2") plexLibrary("org.eclipse.jetty:jetty-server:12.1.9") diff --git a/src/main/java/dev/plex/HTTPDModule.java b/src/main/java/dev/plex/HTTPDModule.java index 70cd19a..c93f2bf 100644 --- a/src/main/java/dev/plex/HTTPDModule.java +++ b/src/main/java/dev/plex/HTTPDModule.java @@ -7,7 +7,11 @@ import dev.plex.logging.Log; import dev.plex.module.PlexModule; import dev.plex.ratelimit.RateLimitFilter; import dev.plex.request.AbstractServlet; +import dev.plex.request.PlayerActionServlet; +import dev.plex.request.PlayersStreamServlet; import dev.plex.request.SchematicUploadServlet; +import dev.plex.request.StaffPlayersStreamServlet; +import dev.plex.request.StatsStreamServlet; import dev.plex.request.impl.*; import dev.plex.util.PlexLog; import jakarta.servlet.DispatcherType; @@ -93,6 +97,9 @@ public class HTTPDModule extends PlexModule context.addFilter(new FilterHolder(new RateLimitFilter()), "/*", EnumSet.of(DispatcherType.REQUEST)); + StatsBroadcaster.get().start(); + PlayersBroadcaster.get().start(); + new IndefBansEndpoint(); new IndexEndpoint(); new ListEndpoint(); @@ -100,13 +107,18 @@ public class HTTPDModule extends PlexModule new CommandsEndpoint(); new SchematicDownloadEndpoint(); new SchematicUploadEndpoint(); - new StatsEndpoint(); new PlayersEndpoint(); + new PlayerAdminEndpoint(); new AssetsEndpoint(); new PunishmentsUIEndpoint(); new IndefBansUIEndpoint(); new AuthenticationEndpoint(); + HTTPDModule.context.addServlet(StatsStreamServlet.class, "/api/stats/stream"); + HTTPDModule.context.addServlet(PlayersStreamServlet.class, "/api/players/stream"); + HTTPDModule.context.addServlet(StaffPlayersStreamServlet.class, "/api/players/stream/staff"); + HTTPDModule.context.addServlet(PlayerActionServlet.class, "/api/admin/action"); + ServletHolder uploadHolder = HTTPDModule.context.addServlet(SchematicUploadServlet.class, "/api/schematics/uploading"); File uploadLoc = new File(System.getProperty("java.io.tmpdir"), "schematic-temp-dir"); @@ -139,6 +151,22 @@ public class HTTPDModule extends PlexModule { PlexLog.debug("Stopping Jetty server"); try + { + StatsBroadcaster.get().shutdown(); + } + catch (Throwable t) + { + t.printStackTrace(); + } + try + { + PlayersBroadcaster.get().shutdown(); + } + catch (Throwable t) + { + t.printStackTrace(); + } + try { atomicServer.get().stop(); atomicServer.get().destroy(); diff --git a/src/main/java/dev/plex/authentication/AuthenticatedUser.java b/src/main/java/dev/plex/authentication/AuthenticatedUser.java index 433e67e..b7f0907 100644 --- a/src/main/java/dev/plex/authentication/AuthenticatedUser.java +++ b/src/main/java/dev/plex/authentication/AuthenticatedUser.java @@ -1,19 +1,10 @@ package dev.plex.authentication; -import lombok.Data; import lombok.experimental.Accessors; import java.time.Instant; -@Data @Accessors(fluent = true) -public class AuthenticatedUser -{ - private final int userId; - private final String username; - private final boolean staff; - private final UserType userType; - private final String accessToken; - private final Instant accessTokenExpiresAt; - private final Instant authenticatedAt; +public record AuthenticatedUser(int userId, String username, boolean staff, UserType userType, String accessToken, + Instant accessTokenExpiresAt, Instant authenticatedAt) { } diff --git a/src/main/java/dev/plex/request/PlayerActionServlet.java b/src/main/java/dev/plex/request/PlayerActionServlet.java new file mode 100644 index 0000000..16627a3 --- /dev/null +++ b/src/main/java/dev/plex/request/PlayerActionServlet.java @@ -0,0 +1,164 @@ +package dev.plex.request; + +import dev.plex.Plex; +import dev.plex.authentication.AuthenticatedUser; +import dev.plex.cache.DataUtils; +import dev.plex.logging.Log; +import dev.plex.player.PlexPlayer; +import dev.plex.punishment.Punishment; +import dev.plex.punishment.PunishmentType; +import dev.plex.util.BungeeUtil; +import dev.plex.util.TimeUtils; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import java.io.IOException; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.UUID; + +public class PlayerActionServlet extends HttpServlet +{ + private static final long FAR_FUTURE_DAYS = 365L * 50L; + private static final List PERMANENT_ACTIONS = List.of("ban", "mute"); + private static final List TEMP_ACTIONS = List.of("tempban", "tempmute", "freeze"); + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException + { + AuthenticatedUser staff = AbstractServlet.currentStaff(request); + if (staff == null) + { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.getWriter().write("Not authorized."); + return; + } + + String uuidStr = request.getParameter("uuid"); + String action = request.getParameter("action"); + String reason = request.getParameter("reason"); + String durationStr = request.getParameter("duration"); + + if (uuidStr == null || action == null) + { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("Missing parameters."); + return; + } + if (!PERMANENT_ACTIONS.contains(action) && !TEMP_ACTIONS.contains(action)) + { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("Unknown action."); + return; + } + + UUID uuid; + try + { + uuid = UUID.fromString(uuidStr); + } + catch (IllegalArgumentException e) + { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("Bad UUID."); + return; + } + + PlexPlayer target = DataUtils.getPlayer(uuid); + if (target == null) + { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + response.getWriter().write("Player not found."); + return; + } + + String safeReason = (reason == null || reason.isBlank()) ? "No reason provided" : reason.trim(); + if (safeReason.length() > 500) safeReason = safeReason.substring(0, 500); + + PunishmentType type = mapType(action); + ZonedDateTime now = ZonedDateTime.now(ZoneId.of(TimeUtils.TIMEZONE)); + ZonedDateTime endDate = TEMP_ACTIONS.contains(action) + ? now.plusSeconds(parseDurationSeconds(durationStr)) + : now.plusDays(FAR_FUTURE_DAYS); + + Punishment punishment = new Punishment(uuid, null); + punishment.setType(type); + punishment.setReason(safeReason); + punishment.setPunishedUsername(target.getName()); + punishment.setPunisherName("xf:" + staff.username()); + punishment.setEndDate(endDate); + punishment.setCustomTime(TEMP_ACTIONS.contains(action)); + punishment.setActive(true); + List ips = target.getIps(); + if (ips != null && !ips.isEmpty()) punishment.setIp(ips.getLast()); + + String ipAddress = request.getRemoteAddr(); + if ("127.0.0.1".equals(ipAddress)) + { + String forwarded = request.getHeader("X-FORWARDED-FOR"); + if (forwarded != null) ipAddress = forwarded; + } + Log.log(ipAddress + " (xf:" + staff.username() + ") issued " + action + " on " + target.getName() + " (" + uuid + ")"); + + final boolean kick = action.equals("ban") || action.equals("tempban"); + final Punishment toApply = punishment; + Bukkit.getScheduler().runTask(Plex.get(), () -> + { + try + { + Plex.get().getPunishmentManager().punish(target, toApply); + } + catch (Throwable t) + { + t.printStackTrace(); + return; + } + if (kick) + { + Player online = Bukkit.getPlayer(uuid); + if (online != null) + { + try { BungeeUtil.kickPlayer(online, Punishment.generateBanMessage(toApply)); } + catch (Throwable t) { t.printStackTrace(); } + } + } + }); + + response.sendRedirect("/player/" + uuid); + } + + private static PunishmentType mapType(String action) + { + return switch (action) + { + case "ban" -> PunishmentType.BAN; + case "tempban" -> PunishmentType.TEMPBAN; + case "mute", "tempmute" -> PunishmentType.MUTE; + case "freeze" -> PunishmentType.FREEZE; + default -> throw new IllegalArgumentException("unknown action: " + action); + }; + } + + private static long parseDurationSeconds(String s) + { + if (s == null || s.length() < 2) return 24L * 3600L; + char unit = s.charAt(s.length() - 1); + long n; + try { n = Long.parseLong(s.substring(0, s.length() - 1)); } + catch (NumberFormatException e) { return 24L * 3600L; } + if (n <= 0) return 24L * 3600L; + return switch (unit) + { + case 'm' -> Math.min(n, 60L * 24L * 365L) * 60L; + case 'h' -> Math.min(n, 24L * 365L) * 3600L; + case 'd' -> Math.min(n, 365L * 50L) * 86400L; + default -> 24L * 3600L; + }; + } +} diff --git a/src/main/java/dev/plex/request/PlayersStreamServlet.java b/src/main/java/dev/plex/request/PlayersStreamServlet.java new file mode 100644 index 0000000..d2cb3d1 --- /dev/null +++ b/src/main/java/dev/plex/request/PlayersStreamServlet.java @@ -0,0 +1,92 @@ +package dev.plex.request; + +import dev.plex.logging.Log; +import dev.plex.request.impl.PlayersBroadcaster; +import jakarta.servlet.AsyncContext; +import jakarta.servlet.AsyncEvent; +import jakarta.servlet.AsyncListener; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.io.PrintWriter; + +public class PlayersStreamServlet extends HttpServlet +{ + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException + { + String ipAddress = request.getRemoteAddr(); + if ("127.0.0.1".equals(ipAddress)) + { + String forwarded = request.getHeader("X-FORWARDED-FOR"); + if (forwarded != null) ipAddress = forwarded; + } + Log.log(ipAddress + " opened SSE stream /api/players/stream"); + + PlayersBroadcaster broadcaster = PlayersBroadcaster.get(); + if (broadcaster.atCapacity()) + { + response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + response.setHeader("Retry-After", "30"); + return; + } + + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("text/event-stream"); + response.setCharacterEncoding("UTF-8"); + response.setHeader("Cache-Control", "no-cache, no-transform"); + response.setHeader("Connection", "keep-alive"); + response.setHeader("X-Accel-Buffering", "no"); + + final AsyncContext ctx = request.startAsync(); + ctx.setTimeout(0L); + ctx.addListener(new AsyncListener() + { + @Override public void onComplete(AsyncEvent event) { broadcaster.removeSubscriber(ctx); } + @Override public void onTimeout(AsyncEvent event) { broadcaster.removeSubscriber(ctx); } + @Override public void onError(AsyncEvent event) { broadcaster.removeSubscriber(ctx); } + @Override public void onStartAsync(AsyncEvent event) {} + }); + + PrintWriter writer; + try + { + writer = response.getWriter(); + } + catch (IOException e) + { + ctx.complete(); + return; + } + + if (!broadcaster.addSubscriber(ctx, writer, false)) + { + response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + ctx.complete(); + return; + } + + try + { + writer.write("retry: 5000\n\n"); + writer.write("data: "); + writer.write(broadcaster.currentPayload(false)); + writer.write("\n\n"); + writer.flush(); + if (writer.checkError()) + { + broadcaster.removeSubscriber(ctx); + ctx.complete(); + } + } + catch (Throwable t) + { + broadcaster.removeSubscriber(ctx); + try { ctx.complete(); } catch (Throwable ignored) {} + } + } +} diff --git a/src/main/java/dev/plex/request/StaffPlayersStreamServlet.java b/src/main/java/dev/plex/request/StaffPlayersStreamServlet.java new file mode 100644 index 0000000..f0ad190 --- /dev/null +++ b/src/main/java/dev/plex/request/StaffPlayersStreamServlet.java @@ -0,0 +1,98 @@ +package dev.plex.request; + +import dev.plex.logging.Log; +import dev.plex.request.impl.PlayersBroadcaster; +import jakarta.servlet.AsyncContext; +import jakarta.servlet.AsyncEvent; +import jakarta.servlet.AsyncListener; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.io.PrintWriter; + +public class StaffPlayersStreamServlet extends HttpServlet +{ + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException + { + if (AbstractServlet.currentStaff(request) == null) + { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return; + } + + String ipAddress = request.getRemoteAddr(); + if ("127.0.0.1".equals(ipAddress)) + { + String forwarded = request.getHeader("X-FORWARDED-FOR"); + if (forwarded != null) ipAddress = forwarded; + } + Log.log(ipAddress + " opened SSE stream /api/players/stream/staff"); + + PlayersBroadcaster broadcaster = PlayersBroadcaster.get(); + if (broadcaster.atCapacity()) + { + response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + response.setHeader("Retry-After", "30"); + return; + } + + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("text/event-stream"); + response.setCharacterEncoding("UTF-8"); + response.setHeader("Cache-Control", "no-cache, no-transform"); + response.setHeader("Connection", "keep-alive"); + response.setHeader("X-Accel-Buffering", "no"); + + final AsyncContext ctx = request.startAsync(); + ctx.setTimeout(0L); + ctx.addListener(new AsyncListener() + { + @Override public void onComplete(AsyncEvent event) { broadcaster.removeSubscriber(ctx); } + @Override public void onTimeout(AsyncEvent event) { broadcaster.removeSubscriber(ctx); } + @Override public void onError(AsyncEvent event) { broadcaster.removeSubscriber(ctx); } + @Override public void onStartAsync(AsyncEvent event) {} + }); + + PrintWriter writer; + try + { + writer = response.getWriter(); + } + catch (IOException e) + { + ctx.complete(); + return; + } + + if (!broadcaster.addSubscriber(ctx, writer, true)) + { + response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + ctx.complete(); + return; + } + + try + { + writer.write("retry: 5000\n\n"); + writer.write("data: "); + writer.write(broadcaster.currentPayload(true)); + writer.write("\n\n"); + writer.flush(); + if (writer.checkError()) + { + broadcaster.removeSubscriber(ctx); + ctx.complete(); + } + } + catch (Throwable t) + { + broadcaster.removeSubscriber(ctx); + try { ctx.complete(); } catch (Throwable ignored) {} + } + } +} diff --git a/src/main/java/dev/plex/request/StatsStreamServlet.java b/src/main/java/dev/plex/request/StatsStreamServlet.java new file mode 100644 index 0000000..81bd232 --- /dev/null +++ b/src/main/java/dev/plex/request/StatsStreamServlet.java @@ -0,0 +1,95 @@ +package dev.plex.request; + +import dev.plex.logging.Log; +import dev.plex.request.impl.StatsBroadcaster; +import jakarta.servlet.AsyncContext; +import jakarta.servlet.AsyncEvent; +import jakarta.servlet.AsyncListener; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.io.PrintWriter; + +public class StatsStreamServlet extends HttpServlet +{ + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException + { + String ipAddress = request.getRemoteAddr(); + if ("127.0.0.1".equals(ipAddress)) + { + String forwarded = request.getHeader("X-FORWARDED-FOR"); + if (forwarded != null) ipAddress = forwarded; + } + Log.log(ipAddress + " opened SSE stream /api/stats/stream"); + + StatsBroadcaster broadcaster = StatsBroadcaster.get(); + if (broadcaster.atCapacity()) + { + response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + response.setHeader("Retry-After", "30"); + return; + } + + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("text/event-stream"); + response.setCharacterEncoding("UTF-8"); + response.setHeader("Cache-Control", "no-cache, no-transform"); + response.setHeader("Connection", "keep-alive"); + // Disable proxy buffering (nginx and friends) so frames reach the client promptly. + response.setHeader("X-Accel-Buffering", "no"); + + final AsyncContext ctx = request.startAsync(); + ctx.setTimeout(0L); + ctx.addListener(new AsyncListener() + { + @Override public void onComplete(AsyncEvent event) { broadcaster.removeSubscriber(ctx); } + @Override public void onTimeout(AsyncEvent event) { broadcaster.removeSubscriber(ctx); } + @Override public void onError(AsyncEvent event) { broadcaster.removeSubscriber(ctx); } + @Override public void onStartAsync(AsyncEvent event) {} + }); + + PrintWriter writer; + try + { + writer = response.getWriter(); + } + catch (IOException e) + { + ctx.complete(); + return; + } + + if (!broadcaster.addSubscriber(ctx, writer)) + { + // Lost the capacity race that the atCapacity check above tried to avoid. + response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + ctx.complete(); + return; + } + + try + { + // Hint to the browser: if the connection drops, wait this long before reconnecting. + writer.write("retry: 5000\n\n"); + writer.write("data: "); + writer.write(broadcaster.currentPayload()); + writer.write("\n\n"); + writer.flush(); + if (writer.checkError()) + { + broadcaster.removeSubscriber(ctx); + ctx.complete(); + } + } + catch (Throwable t) + { + broadcaster.removeSubscriber(ctx); + try { ctx.complete(); } catch (Throwable ignored) {} + } + } +} diff --git a/src/main/java/dev/plex/request/impl/AssetsEndpoint.java b/src/main/java/dev/plex/request/impl/AssetsEndpoint.java index 479eb86..a85f46f 100644 --- a/src/main/java/dev/plex/request/impl/AssetsEndpoint.java +++ b/src/main/java/dev/plex/request/impl/AssetsEndpoint.java @@ -19,6 +19,20 @@ public class AssetsEndpoint extends AbstractServlet return readFileReal(this.getClass().getResourceAsStream("/httpd/assets/dashboard.js")); } + @GetMapping(endpoint = "/assets/players.js") + @MappingHeaders(headers = {"content-type;application/javascript; charset=utf-8", "cache-control;public, max-age=300"}) + public String playersJs(HttpServletRequest request, HttpServletResponse response) + { + return readFileReal(this.getClass().getResourceAsStream("/httpd/assets/players.js")); + } + + @GetMapping(endpoint = "/assets/player.js") + @MappingHeaders(headers = {"content-type;application/javascript; charset=utf-8", "cache-control;public, max-age=300"}) + public String playerJs(HttpServletRequest request, HttpServletResponse response) + { + return readFileReal(this.getClass().getResourceAsStream("/httpd/assets/player.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) diff --git a/src/main/java/dev/plex/request/impl/PlayerAdminEndpoint.java b/src/main/java/dev/plex/request/impl/PlayerAdminEndpoint.java new file mode 100644 index 0000000..3275bb6 --- /dev/null +++ b/src/main/java/dev/plex/request/impl/PlayerAdminEndpoint.java @@ -0,0 +1,115 @@ +package dev.plex.request.impl; + +import dev.plex.authentication.AuthenticatedUser; +import dev.plex.cache.DataUtils; +import dev.plex.player.PlexPlayer; +import dev.plex.request.AbstractServlet; +import dev.plex.request.GetMapping; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; + +import org.bukkit.Bukkit; + +public class PlayerAdminEndpoint extends AbstractServlet +{ + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z"); + + @GetMapping(endpoint = "/player/") + public String getPlayer(HttpServletRequest request, HttpServletResponse response) + { + AuthenticatedUser staff = currentStaff(request); + if (staff == null) + { + return errorPage(signInPrompt(request, "to access player admin tools")); + } + + String path = request.getPathInfo(); + String query = path == null ? "" : path.replace("/", "").trim(); + if (query.isEmpty()) + { + return errorPage("No player specified."); + } + + PlexPlayer player = lookupPlayer(query); + if (player == null) + { + return errorPage("No player found matching " + escapeHtml(query) + "."); + } + + String file = readFile(this.getClass().getResourceAsStream("/httpd/player.html")); + file = file.replace("${player_uuid}", player.getUuid().toString()); + file = file.replace("${player_name}", escapeHtml(player.getName())); + file = file.replace("${player_ip}", lastIp(player)); + file = file.replace("${player_first_played}", firstPlayed(player.getUuid())); + file = file.replace("${player_namemc}", "https://namemc.com/profile/" + player.getUuid()); + return file; + } + + private static PlexPlayer lookupPlayer(String query) + { + try + { + return DataUtils.getPlayer(UUID.fromString(query)); + } + catch (IllegalArgumentException ignored) + { + return DataUtils.getPlayer(query); + } + } + + private static String lastIp(PlexPlayer player) + { + List ips = player.getIps(); + if (ips == null || ips.isEmpty()) return ""; + return escapeHtml(ips.getLast()); + } + + private static String firstPlayed(UUID uuid) + { + try + { + long ms = Bukkit.getOfflinePlayer(uuid).getFirstPlayed(); + if (ms <= 0) return "Never"; + ZonedDateTime when = ZonedDateTime.ofInstant(Instant.ofEpochMilli(ms), ZoneId.systemDefault()); + return escapeHtml(DATE_FMT.format(when)); + } + catch (Throwable t) + { + return ""; + } + } + + private String errorPage(String message) + { + String content = """ + Player + PLAYERS +
+

Player

+
+
+

%s

+ ← Back to players +
+ """.formatted(message); + return readFile(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))); + } + + 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/PlayersBroadcaster.java b/src/main/java/dev/plex/request/impl/PlayersBroadcaster.java new file mode 100644 index 0000000..b692ccd --- /dev/null +++ b/src/main/java/dev/plex/request/impl/PlayersBroadcaster.java @@ -0,0 +1,300 @@ +package dev.plex.request.impl; + +import com.google.gson.GsonBuilder; +import dev.plex.HTTPDModule; +import dev.plex.Plex; +import dev.plex.util.PlexLog; +import jakarta.servlet.AsyncContext; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.scheduler.BukkitTask; + +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Pushes the online-player list to SSE subscribers on join/quit/world-change, + * plus a 5-second periodic refresh so ping values stay fresh (Player#getPing + * returns 0 until the first keepalive packet round-trip after join). Two + * payload variants are produced each refresh — a minimal one for anonymous + * viewers and a richer one for staff — so the public endpoint can't leak + * staff-only fields. + */ +public final class PlayersBroadcaster +{ + private static final PlayersBroadcaster INSTANCE = new PlayersBroadcaster(); + private static final long REFRESH_TICKS = 100L; // 5 seconds at 20 TPS + + public static PlayersBroadcaster get() + { + return INSTANCE; + } + + private final Set subscribers = ConcurrentHashMap.newKeySet(); + private final AtomicInteger subscriberCount = new AtomicInteger(); + private final AtomicBoolean refreshScheduled = new AtomicBoolean(false); + + private volatile String cachedPublicFrame = "{\"players\":[],\"max\":0}"; + private volatile String cachedStaffFrame = "{\"players\":[],\"max\":0}"; + + private ScheduledExecutorService executor; + private BukkitTask refreshTask; + private Listener listener; + private int maxConnections = 32; + + private PlayersBroadcaster() {} + + public synchronized void start() + { + if (executor != null) return; + + maxConnections = HTTPDModule.moduleConfig.getInt("server.sse.max-connections", 32); + int threads = Math.max(1, HTTPDModule.moduleConfig.getInt("server.sse.threads", 2)); + + executor = Executors.newScheduledThreadPool(threads, r -> + { + Thread t = new Thread(r, "Plex-HTTPD-Players-SSE"); + t.setDaemon(true); + return t; + }); + + listener = new PlayersListener(); + try + { + Bukkit.getPluginManager().registerEvents(listener, Plex.get()); + } + catch (Throwable t) + { + PlexLog.debug("PlayersBroadcaster: could not register Bukkit listener: " + t.getMessage()); + } + + try + { + refreshTask = Bukkit.getScheduler().runTaskTimer( + Plex.get(), this::refreshAndBroadcast, 0L, REFRESH_TICKS); + } + catch (Throwable t) + { + PlexLog.debug("PlayersBroadcaster: could not register refresh task: " + t.getMessage()); + } + } + + public synchronized void shutdown() + { + if (listener != null) + { + try { HandlerList.unregisterAll(listener); } catch (Throwable ignored) {} + listener = null; + } + if (refreshTask != null) + { + try { refreshTask.cancel(); } catch (Throwable ignored) {} + refreshTask = null; + } + if (executor != null) + { + executor.shutdownNow(); + executor = null; + } + for (Subscriber sub : subscribers) + { + try { sub.ctx.complete(); } catch (Throwable ignored) {} + } + subscribers.clear(); + subscriberCount.set(0); + } + + public boolean atCapacity() + { + return subscriberCount.get() >= maxConnections; + } + + public boolean addSubscriber(AsyncContext ctx, PrintWriter writer, boolean staff) + { + if (subscriberCount.get() >= maxConnections) return false; + Subscriber sub = new Subscriber(ctx, writer, staff); + if (subscribers.add(sub)) + { + subscriberCount.incrementAndGet(); + return true; + } + return false; + } + + public void removeSubscriber(AsyncContext ctx) + { + Subscriber match = null; + for (Subscriber sub : subscribers) + { + if (sub.ctx == ctx) { match = sub; break; } + } + if (match != null && subscribers.remove(match)) + { + subscriberCount.decrementAndGet(); + } + } + + public String currentPayload(boolean staff) + { + return staff ? cachedStaffFrame : cachedPublicFrame; + } + + private void refreshAndBroadcast() + { + try + { + List online = new ArrayList<>(Bukkit.getOnlinePlayers()); + int max = Bukkit.getMaxPlayers(); + String publicJson = buildPublicPayload(online, max); + String staffJson = buildStaffPayload(online, max); + cachedPublicFrame = publicJson; + cachedStaffFrame = staffJson; + + ScheduledExecutorService exec = executor; + if (exec == null || subscribers.isEmpty()) return; + + final String publicFrame = "data: " + publicJson + "\n\n"; + final String staffFrame = "data: " + staffJson + "\n\n"; + for (Subscriber sub : subscribers) + { + final String frame = sub.staff ? staffFrame : publicFrame; + try + { + exec.execute(() -> writeFrame(sub, frame)); + } + catch (Throwable t) + { + dropSubscriber(sub); + } + } + } + catch (Throwable ignored) {} + } + + private void writeFrame(Subscriber sub, String frame) + { + try + { + sub.writer.write(frame); + sub.writer.flush(); + if (sub.writer.checkError()) dropSubscriber(sub); + } + catch (Throwable t) + { + dropSubscriber(sub); + } + } + + private void dropSubscriber(Subscriber sub) + { + if (subscribers.remove(sub)) subscriberCount.decrementAndGet(); + try { sub.ctx.complete(); } catch (Throwable ignored) {} + } + + /** + * Defers refresh by one tick so PlayerQuitEvent (which fires BEFORE the + * player leaves the online list) samples the correct post-state, and so + * concurrent events collapse into a single broadcast. + */ + private void scheduleRefresh() + { + if (!refreshScheduled.compareAndSet(false, true)) return; + try + { + Bukkit.getScheduler().runTaskLater(Plex.get(), () -> + { + refreshScheduled.set(false); + refreshAndBroadcast(); + }, 1L); + } + catch (Throwable t) + { + refreshScheduled.set(false); + } + } + + private String buildPublicPayload(List online, int max) + { + List> players = new ArrayList<>(); + for (Player p : online) + { + Map m = new LinkedHashMap<>(); + m.put("uuid", p.getUniqueId().toString()); + m.put("name", p.getName()); + try { m.put("world", p.getWorld() != null ? p.getWorld().getName() : ""); } + catch (Throwable ignored) { m.put("world", ""); } + int ping = 0; + try { ping = p.getPing(); } catch (Throwable ignored) {} + m.put("ping", ping); + players.add(m); + } + Map root = new LinkedHashMap<>(); + root.put("players", players); + root.put("max", max); + return new GsonBuilder().serializeNulls().create().toJson(root); + } + + private String buildStaffPayload(List online, int max) + { + List> players = new ArrayList<>(); + for (Player p : online) + { + Map m = new LinkedHashMap<>(); + m.put("uuid", p.getUniqueId().toString()); + m.put("name", p.getName()); + try { m.put("world", p.getWorld() != null ? p.getWorld().getName() : ""); } + catch (Throwable ignored) { m.put("world", ""); } + try { m.put("op", p.isOp()); } catch (Throwable ignored) { m.put("op", false); } + try { m.put("gamemode", p.getGameMode().name()); } + catch (Throwable ignored) { m.put("gamemode", ""); } + int ping = 0; + try { ping = p.getPing(); } catch (Throwable ignored) {} + m.put("ping", ping); + players.add(m); + } + Map root = new LinkedHashMap<>(); + root.put("players", players); + root.put("max", max); + return new GsonBuilder().serializeNulls().create().toJson(root); + } + + private final class PlayersListener implements Listener + { + @EventHandler + public void onJoin(PlayerJoinEvent e) { scheduleRefresh(); } + + @EventHandler + public void onQuit(PlayerQuitEvent e) { scheduleRefresh(); } + + @EventHandler + public void onWorldChange(PlayerChangedWorldEvent e) { scheduleRefresh(); } + } + + private static final class Subscriber + { + final AsyncContext ctx; + final PrintWriter writer; + final boolean staff; + Subscriber(AsyncContext ctx, PrintWriter writer, boolean staff) + { + this.ctx = ctx; + this.writer = writer; + this.staff = staff; + } + } +} diff --git a/src/main/java/dev/plex/request/impl/PlayersEndpoint.java b/src/main/java/dev/plex/request/impl/PlayersEndpoint.java index f65af40..e3365e6 100644 --- a/src/main/java/dev/plex/request/impl/PlayersEndpoint.java +++ b/src/main/java/dev/plex/request/impl/PlayersEndpoint.java @@ -1,141 +1,17 @@ package dev.plex.request.impl; -import dev.plex.Plex; import dev.plex.request.AbstractServlet; import dev.plex.request.GetMapping; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; - public class PlayersEndpoint extends AbstractServlet { - private static volatile List snapshot = Collections.emptyList(); - private static volatile int maxPlayers = 0; - private static volatile boolean schedulerStarted = false; - - public PlayersEndpoint() - { - super(); - startSnapshotTask(); - } - - private static synchronized void startSnapshotTask() - { - if (schedulerStarted) return; - try - { - Bukkit.getScheduler().runTaskTimer(Plex.get(), PlayersEndpoint::refreshSnapshot, 0L, 20L); - schedulerStarted = true; - } - catch (Throwable ignored) - { - } - } - - private static void refreshSnapshot() - { - List next = new ArrayList<>(); - for (Player p : Bukkit.getOnlinePlayers()) - { - next.add(PlayerSnapshot.of(p)); - } - snapshot = List.copyOf(next); - maxPlayers = Bukkit.getMaxPlayers(); - } - @GetMapping(endpoint = "/players/") public String getPlayers(HttpServletRequest request, HttpServletResponse response) { - List players = snapshot; - String cards = players.isEmpty() ? emptyState() : renderPlayerCards(players); - + boolean isStaff = currentStaff(request) != null; 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(maxPlayers)); - file = file.replace("${player_cards}", cards); - return file; - } - - private static String renderPlayerCards(List players) - { - StringBuilder sb = new StringBuilder(); - for (PlayerSnapshot p : players) - { - sb.append(renderCard(p)); - } - return sb.toString(); - } - - private static String renderCard(PlayerSnapshot p) - { - String pingColor = p.ping < 80 ? "text-success" : p.ping < 200 ? "text-warning" : "text-destructive"; - String opChip = p.op - ? "op" - : ""; - String location = p.world.isEmpty() ? "" : "In " + p.world; - String separator = location.isEmpty() ? "" : "·"; - - return """ - - -
-
- %s - %s -
-
- %s - %s - %dms -
-
-
- """.formatted(p.uuid, p.name, p.name, p.uuid, p.name, opChip, location, separator, pingColor, p.ping); - } - - private static String emptyState() - { - return """ -
- -

No players online right now.

-
- """; - } - - private static String escapeHtml(String s) - { - if (s == null) return ""; - return s.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """); - } - - private record PlayerSnapshot(UUID uuid, String name, String world, boolean op, int ping) - { - static PlayerSnapshot of(Player p) - { - int ping; - try { ping = p.getPing(); } catch (Throwable t) { ping = 0; } - return new PlayerSnapshot( - p.getUniqueId(), - escapeHtml(p.getName()), - escapeHtml(p.getWorld() != null ? p.getWorld().getName() : ""), - p.isOp(), - ping); - } + return file.replace("${IS_STAFF}", String.valueOf(isStaff)); } } diff --git a/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java b/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java index b6f81ac..f489a5f 100644 --- a/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java +++ b/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java @@ -1,6 +1,5 @@ package dev.plex.request.impl; -import dev.plex.Plex; import dev.plex.cache.DataUtils; import dev.plex.player.PlexPlayer; import dev.plex.punishment.Punishment; @@ -86,7 +85,7 @@ public class PunishmentsUIEndpoint extends AbstractServlet 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 punisher = resolvePunisher(p); String endDate = p.getEndDate() == null ? "permanent" : escapeHtml(formatDate(p.getEndDate())); boolean isBan = type == PunishmentType.BAN || type == PunishmentType.TEMPBAN; @@ -149,18 +148,18 @@ public class PunishmentsUIEndpoint extends AbstractServlet }; } - private static String resolveName(UUID uuid) + private static String resolvePunisher(Punishment p) { - if (uuid == null) return "CONSOLE"; try { - String name = Plex.get().getSqlPlayerData().getNameByUUID(uuid); + String name = Punishment.punisherDisplayName(p); if (name != null && !name.isBlank()) return name; } catch (Throwable ignored) { } - return uuid.toString(); + UUID uuid = p.getPunisher(); + return uuid == null ? "CONSOLE" : uuid.toString(); } private static String formatDate(ZonedDateTime date) diff --git a/src/main/java/dev/plex/request/impl/StatsBroadcaster.java b/src/main/java/dev/plex/request/impl/StatsBroadcaster.java new file mode 100644 index 0000000..03ba9ac --- /dev/null +++ b/src/main/java/dev/plex/request/impl/StatsBroadcaster.java @@ -0,0 +1,302 @@ +package dev.plex.request.impl; + +import com.google.gson.GsonBuilder; +import dev.plex.HTTPDModule; +import dev.plex.Plex; +import dev.plex.util.PlexLog; +import jakarta.servlet.AsyncContext; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.scheduler.BukkitTask; + +import java.io.PrintWriter; +import java.lang.management.ManagementFactory; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Samples Bukkit/JMX/Runtime stats off the request thread and fans the + * resulting JSON out to every connected SSE subscriber. One sampler tick on + * the Minecraft main thread; assembly and writes happen on a dedicated + * executor so slow clients can't stall the tick loop. + */ +public final class StatsBroadcaster +{ + private static final StatsBroadcaster INSTANCE = new StatsBroadcaster(); + + public static StatsBroadcaster get() + { + return INSTANCE; + } + + private final Set subscribers = ConcurrentHashMap.newKeySet(); + private final AtomicInteger subscriberCount = new AtomicInteger(); + + // Sampled on the Bukkit main thread. + private volatile int cachedChunks; + private volatile int cachedEntities; + private volatile int cachedWorlds; + private volatile int cachedOnlinePlayers; + private volatile int cachedMaxPlayers; + private volatile int cachedPlugins; + private volatile double[] cachedTps = new double[]{20d, 20d, 20d}; + private volatile String cachedVersion = "unknown"; + + // Epoch ms when the JVM started — derived once, used by the client to tick uptime locally. + private final long serverStartTime = + System.currentTimeMillis() - ManagementFactory.getRuntimeMXBean().getUptime(); + + private ScheduledExecutorService executor; + private BukkitTask bukkitTask; + private ScheduledFuture broadcastTask; + + private int maxConnections = 32; + private long broadcastIntervalMs = 2000L; + + private StatsBroadcaster() {} + + public synchronized void start() + { + if (executor != null) return; + + maxConnections = HTTPDModule.moduleConfig.getInt("server.sse.max-connections", 32); + broadcastIntervalMs = HTTPDModule.moduleConfig.getLong("server.sse.broadcast-interval-ms", 2000L); + int threads = Math.max(1, HTTPDModule.moduleConfig.getInt("server.sse.threads", 2)); + + executor = Executors.newScheduledThreadPool(threads, r -> + { + Thread t = new Thread(r, "Plex-HTTPD-SSE"); + t.setDaemon(true); + return t; + }); + + try + { + bukkitTask = Bukkit.getScheduler().runTaskTimer(Plex.get(), this::sampleBukkit, 0L, 40L); + } + catch (Throwable t) + { + PlexLog.debug("StatsBroadcaster: could not register Bukkit sampling task: " + t.getMessage()); + } + + broadcastTask = executor.scheduleAtFixedRate( + this::tick, broadcastIntervalMs, broadcastIntervalMs, TimeUnit.MILLISECONDS); + } + + public synchronized void shutdown() + { + if (bukkitTask != null) + { + try { bukkitTask.cancel(); } catch (Throwable ignored) {} + bukkitTask = null; + } + if (broadcastTask != null) + { + broadcastTask.cancel(false); + broadcastTask = null; + } + if (executor != null) + { + executor.shutdownNow(); + executor = null; + } + for (Subscriber sub : subscribers) + { + try { sub.ctx.complete(); } catch (Throwable ignored) {} + } + subscribers.clear(); + subscriberCount.set(0); + } + + public boolean atCapacity() + { + return subscriberCount.get() >= maxConnections; + } + + public boolean addSubscriber(AsyncContext ctx, PrintWriter writer) + { + if (subscriberCount.get() >= maxConnections) return false; + Subscriber sub = new Subscriber(ctx, writer); + if (subscribers.add(sub)) + { + subscriberCount.incrementAndGet(); + return true; + } + return false; + } + + public void removeSubscriber(AsyncContext ctx) + { + Subscriber match = null; + for (Subscriber sub : subscribers) + { + if (sub.ctx == ctx) + { + match = sub; + break; + } + } + if (match != null && subscribers.remove(match)) + { + subscriberCount.decrementAndGet(); + } + } + + public String currentPayload() + { + return buildPayload(); + } + + private void sampleBukkit() + { + try + { + int chunks = 0; + int entities = 0; + for (World w : Bukkit.getWorlds()) + { + try + { + chunks += w.getLoadedChunks().length; + entities += w.getEntities().size(); + } + catch (Throwable ignored) {} + } + cachedChunks = chunks; + cachedEntities = entities; + cachedWorlds = Bukkit.getWorlds().size(); + cachedOnlinePlayers = Bukkit.getOnlinePlayers().size(); + cachedMaxPlayers = Bukkit.getMaxPlayers(); + cachedPlugins = Bukkit.getPluginManager().getPlugins().length; + try { cachedTps = Bukkit.getTPS(); } catch (Throwable ignored) {} + try + { + cachedVersion = Bukkit.getMinecraftVersion(); + } + catch (Throwable ignored) + { + try { cachedVersion = Bukkit.getBukkitVersion(); } catch (Throwable ignored2) {} + } + } + catch (Throwable ignored) {} + } + + private void tick() + { + if (subscribers.isEmpty()) return; + final String frame = "data: " + buildPayload() + "\n\n"; + ScheduledExecutorService exec = executor; + if (exec == null) return; + for (Subscriber sub : subscribers) + { + try + { + exec.execute(() -> writeFrame(sub, frame)); + } + catch (Throwable t) + { + dropSubscriber(sub); + } + } + } + + private void writeFrame(Subscriber sub, String frame) + { + try + { + sub.writer.write(frame); + sub.writer.flush(); + if (sub.writer.checkError()) + { + dropSubscriber(sub); + } + } + catch (Throwable t) + { + dropSubscriber(sub); + } + } + + private void dropSubscriber(Subscriber sub) + { + if (subscribers.remove(sub)) + { + subscriberCount.decrementAndGet(); + } + try { sub.ctx.complete(); } catch (Throwable ignored) {} + } + + private String buildPayload() + { + Map root = new LinkedHashMap<>(); + + Map server = new LinkedHashMap<>(); + server.put("version", cachedVersion); + server.put("startTime", serverStartTime); + server.put("tps", cachedTps); + 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", cachedOnlinePlayers); + players.put("max", cachedMaxPlayers); + root.put("players", players); + + Map world = new LinkedHashMap<>(); + world.put("loadedChunks", cachedChunks); + world.put("entities", cachedEntities); + world.put("worlds", cachedWorlds); + root.put("world", world); + + Map plugins = new LinkedHashMap<>(); + plugins.put("active", cachedPlugins); + 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 final class Subscriber + { + final AsyncContext ctx; + final PrintWriter writer; + + Subscriber(AsyncContext ctx, PrintWriter writer) + { + this.ctx = ctx; + this.writer = writer; + } + } +} diff --git a/src/main/java/dev/plex/request/impl/StatsEndpoint.java b/src/main/java/dev/plex/request/impl/StatsEndpoint.java deleted file mode 100644 index ff6f9b9..0000000 --- a/src/main/java/dev/plex/request/impl/StatsEndpoint.java +++ /dev/null @@ -1,140 +0,0 @@ -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 index c6a9810..35f16d6 100644 --- a/src/main/resources/httpd/assets/dashboard.js +++ b/src/main/resources/httpd/assets/dashboard.js @@ -1,7 +1,7 @@ (function () { - const POLL_MS = 3000; const SPARK_MAX = 60; const tpsHistory = []; + let serverStartTime = null; const fmt = { pct(n) { @@ -112,18 +112,6 @@ } } - 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'; @@ -134,6 +122,11 @@ }); } + function tickUptime() { + if (serverStartTime == null) return; + setText('[data-stat="uptime"]', fmt.duration(Date.now() - serverStartTime)); + } + function paint(s) { setText('[data-stat="players-online"]', String(s.players.online)); setText('[data-stat="players-max"]', String(s.players.max)); @@ -164,7 +157,10 @@ if (tpsHistory.length > SPARK_MAX) tpsHistory.shift(); renderSparkline(tpsHistory); - setText('[data-stat="uptime"]', fmt.duration(s.server.uptime)); + if (typeof s.server.startTime === 'number' && serverStartTime !== s.server.startTime) { + serverStartTime = s.server.startTime; + tickUptime(); + } setText('[data-stat="version"]', s.server.version); setText('[data-stat="chunks"]', fmt.int(s.world.loadedChunks)); @@ -173,6 +169,21 @@ setText('[data-stat="plugins"]', fmt.int(s.plugins.active)); } - refresh(); - setInterval(refresh, POLL_MS); + function connect() { + const es = new EventSource('/api/stats/stream'); + es.addEventListener('open', () => setStatus(true)); + es.addEventListener('message', (evt) => { + try { + paint(JSON.parse(evt.data)); + setStatus(true); + } catch (e) { + // ignore malformed frame; next tick will overwrite + } + }); + es.addEventListener('error', () => setStatus(false)); + return es; + } + + setInterval(tickUptime, 1000); + connect(); })(); diff --git a/src/main/resources/httpd/assets/player.js b/src/main/resources/httpd/assets/player.js new file mode 100644 index 0000000..c72c321 --- /dev/null +++ b/src/main/resources/httpd/assets/player.js @@ -0,0 +1,84 @@ +(function () { + const pingEl = document.querySelector('[data-player-ping]'); + const statusEl = document.querySelector('[data-player-status]'); + const worldEl = document.querySelector('[data-player-world]'); + const gamemodeEl = document.querySelector('[data-player-gamemode]'); + if (!pingEl) return; + const uuid = pingEl.getAttribute('data-uuid'); + if (!uuid) return; + + function pingColor(ping) { + if (ping < 80) return 'text-success'; + if (ping < 200) return 'text-warning'; + return 'text-destructive'; + } + + function setOffline() { + pingEl.textContent = '—'; + pingEl.classList.remove('text-success', 'text-warning', 'text-destructive'); + if (statusEl) { + statusEl.textContent = 'offline'; + statusEl.classList.remove('text-success'); + statusEl.classList.add('text-muted-foreground'); + } + if (worldEl) worldEl.textContent = '—'; + if (gamemodeEl) gamemodeEl.textContent = '—'; + } + + function setOnline(p) { + pingEl.textContent = (p.ping | 0) + 'ms'; + pingEl.classList.remove('text-success', 'text-warning', 'text-destructive'); + pingEl.classList.add(pingColor(p.ping)); + if (statusEl) { + statusEl.textContent = 'online'; + statusEl.classList.remove('text-muted-foreground'); + statusEl.classList.add('text-success'); + } + if (worldEl) worldEl.textContent = p.world || '—'; + if (gamemodeEl) gamemodeEl.textContent = p.gamemode ? p.gamemode.toLowerCase() : '—'; + } + + function handle(state) { + const players = Array.isArray(state.players) ? state.players : []; + const match = players.find(p => p.uuid === uuid); + if (match) setOnline(match); + else setOffline(); + } + + const es = new EventSource('/api/players/stream/staff'); + es.addEventListener('message', (evt) => { + try { handle(JSON.parse(evt.data)); } + catch (e) {} + }); + + // Action dialog wiring. + const dialog = document.getElementById('action-dialog'); + const form = document.getElementById('action-form'); + if (!dialog || !form) return; + const actionInput = form.querySelector('[data-action-input]'); + const actionLabel = form.querySelector('[data-action-label]'); + const durationField = form.querySelector('[data-duration-field]'); + const reasonInput = form.querySelector('input[name="reason"]'); + + document.querySelectorAll('[data-admin-action]').forEach(btn => { + btn.addEventListener('click', () => { + const action = btn.getAttribute('data-admin-action'); + const isTemp = btn.getAttribute('data-admin-temp') === 'true'; + actionInput.value = action; + actionLabel.textContent = action; + durationField.hidden = !isTemp; + durationField.querySelector('select').disabled = !isTemp; + if (reasonInput) reasonInput.value = ''; + if (typeof dialog.showModal === 'function') { + dialog.showModal(); + } else { + dialog.setAttribute('open', ''); + } + if (reasonInput) setTimeout(() => reasonInput.focus(), 0); + }); + }); + + form.querySelectorAll('[data-dialog-cancel]').forEach(btn => { + btn.addEventListener('click', () => dialog.close()); + }); +})(); diff --git a/src/main/resources/httpd/assets/players.js b/src/main/resources/httpd/assets/players.js new file mode 100644 index 0000000..47060f7 --- /dev/null +++ b/src/main/resources/httpd/assets/players.js @@ -0,0 +1,116 @@ +(function () { + const grid = document.getElementById('players-grid'); + const filterInput = document.getElementById('player-filter'); + if (!grid) return; + + const isStaff = grid.dataset.staff === 'true'; + let filter = ''; + + function escapeHtml(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function pingColor(ping) { + if (ping < 80) return 'text-success'; + if (ping < 200) return 'text-warning'; + return 'text-destructive'; + } + + function renderCard(p) { + const safeName = escapeHtml(p.name); + const safeUuid = encodeURIComponent(p.uuid); + const opChip = p.op + ? 'op' + : ''; + const worldLabel = p.world ? 'In ' + escapeHtml(p.world) : ''; + const separator = worldLabel ? '·' : ''; + const body = ` + +
+
+ ${safeName} + ${opChip} +
+
+ ${worldLabel} + ${separator} + ${p.ping | 0}ms +
+
+ `; + if (isStaff) { + return ` + ${body} + `; + } + return ` +
${body}
+ `; + } + + function renderEmpty() { + return ` +
+ +

No players online right now.

+
+ `; + } + + function applyFilter() { + const q = filter; + const cards = grid.querySelectorAll('[data-name]'); + cards.forEach(c => { + const n = c.getAttribute('data-name') || ''; + c.style.display = (!q || n.includes(q)) ? '' : 'none'; + }); + } + + function paint(state) { + const players = Array.isArray(state.players) ? state.players : []; + document.querySelectorAll('[data-stat="players-online"]').forEach(el => { + el.textContent = String(players.length); + }); + document.querySelectorAll('[data-stat="players-max"]').forEach(el => { + el.textContent = String(state.max ?? 0); + }); + grid.innerHTML = players.length === 0 + ? renderEmpty() + : players.map(renderCard).join(''); + applyFilter(); + } + + if (filterInput) { + filterInput.addEventListener('input', () => { + filter = filterInput.value.toLowerCase().trim(); + applyFilter(); + }); + } + + function connect() { + const endpoint = isStaff ? '/api/players/stream/staff' : '/api/players/stream'; + const es = new EventSource(endpoint); + es.addEventListener('message', (evt) => { + try { + paint(JSON.parse(evt.data)); + } catch (e) { + // ignore malformed frame + } + }); + return es; + } + + connect(); +})(); diff --git a/src/main/resources/httpd/commands.html b/src/main/resources/httpd/commands.html index 44e5f0c..0f8081d 100644 --- a/src/main/resources/httpd/commands.html +++ b/src/main/resources/httpd/commands.html @@ -53,11 +53,17 @@ ${commands} } if (toggle) { + const syncToggleButton = () => { + const allClosed = sections.every(s => !s.open); + toggle.dataset.state = allClosed ? 'closed' : 'open'; + toggle.textContent = allClosed ? 'Expand all' : 'Collapse all'; + }; + + sections.forEach(s => s.addEventListener('toggle', syncToggleButton)); + toggle.addEventListener('click', () => { const willOpen = toggle.dataset.state !== 'open'; sections.forEach(s => { s.open = willOpen; }); - toggle.dataset.state = willOpen ? 'open' : 'closed'; - toggle.textContent = willOpen ? 'Collapse all' : 'Expand all'; }); } })(); diff --git a/src/main/resources/httpd/config.yml b/src/main/resources/httpd/config.yml index bd0d984..86b5ec6 100644 --- a/src/main/resources/httpd/config.yml +++ b/src/main/resources/httpd/config.yml @@ -20,6 +20,15 @@ server: accept-queue: 32 # OS-level backlog of pending TCP connects request-header-bytes: 8192 # cap header size; oversized requests get 431 + # Server-Sent Events stream for the live dashboard. One connection per + # open dashboard tab; the broadcaster pushes a stats frame on a fixed cadence + # so the page never has to poll. Bounded so a flood of dashboards can't + # exhaust Jetty's thread pool. + sse: + max-connections: 32 # reject further connects with 503 once reached + broadcast-interval-ms: 2000 # keep below limits.idle-timeout-ms + threads: 2 # dedicated executor; isolates from Minecraft tick thread + # Token-bucket rate limiting. Defaults are sized for a small sized server. # capacity = burst, per-second = sustained rate. Disable globally with enabled: false. rate-limit: diff --git a/src/main/resources/httpd/player.html b/src/main/resources/httpd/player.html new file mode 100644 index 0000000..d1352f0 --- /dev/null +++ b/src/main/resources/httpd/player.html @@ -0,0 +1,140 @@ +Player +PLAYERS +
+
+ +
+

${player_name}

+

${player_uuid}

+
+
+ + ← Back to players + +
+ +
+ +
+

Info

+
+
Status
+
offline
+ +
Ping
+
+ +
World
+
+ +
Gamemode
+
+ +
IP
+
${player_ip}
+ +
First played
+
${player_first_played}
+ +
Punishments
+
+ + View history + + +
+ +
NameMC
+
+ + View profile + + +
+
+
+ +
+

Actions

+

+ Issued punishments are attributed to your XenForo username. +

+
+ + + + + +
+
+ +
+ + +
+ + + +
+

+ Confirm action +

+

+ Target: ${player_name} +

+
+ + + + + + + + + +
+
+ + diff --git a/src/main/resources/httpd/players.html b/src/main/resources/httpd/players.html index 817ff5a..2f90f86 100644 --- a/src/main/resources/httpd/players.html +++ b/src/main/resources/httpd/players.html @@ -3,7 +3,7 @@ PLAYERS

Players

- ${player_count} / ${player_max} online + / online
@@ -16,28 +16,13 @@ PLAYERS class="h-full flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" autocomplete="off"> - -
-${player_cards} +
+
+

Loading players…

+
- + diff --git a/src/main/resources/module.yml b/src/main/resources/module.yml index f6e0330..745fce7 100644 --- a/src/main/resources/module.yml +++ b/src/main/resources/module.yml @@ -1,4 +1,4 @@ name: Module-HTTPD -version: 1.6 +version: 1.7 description: HTTPD server for Plex main: dev.plex.HTTPDModule \ No newline at end of file