first commit

This commit is contained in:
Patrick
2026-05-01 18:46:17 +02:00
commit 61ae38701e
104 changed files with 20058 additions and 0 deletions
@@ -0,0 +1,212 @@
package de.winniepat.minePanel;
import de.winniepat.minePanel.auth.*;
import de.winniepat.minePanel.config.WebPanelConfig;
import de.winniepat.minePanel.extensions.*;
import de.winniepat.minePanel.integrations.*;
import de.winniepat.minePanel.lifecycle.PluginLifecycleSupport;
import de.winniepat.minePanel.logs.*;
import de.winniepat.minePanel.persistence.*;
import de.winniepat.minePanel.util.ServerSchedulerBridge;
import de.winniepat.minePanel.web.*;
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.plugin.java.JavaPlugin;
import java.nio.file.Path;
import java.time.Instant;
public final class MinePanel extends JavaPlugin {
private Database database;
private WebPanelServer webPanelServer;
private DiscordWebhookService discordWebhookService;
private LogRepository logRepository;
private PanelLogger panelLogger;
private PlayerActivityRepository playerActivityRepository;
private JoinLeaveEventRepository joinLeaveEventRepository;
private ExtensionManager extensionManager;
private ExtensionCommandRegistry extensionCommandRegistry;
private WebAssetService webAssetService;
private ExtensionSettingsRepository extensionSettingsRepository;
private ServerSchedulerBridge schedulerBridge;
MiniMessage mm = MiniMessage.miniMessage();
ComponentLogger componentLogger = this.getComponentLogger();
private record StartupContext(
UserRepository userRepository,
KnownPlayerRepository knownPlayerRepository,
SessionService sessionService,
PasswordHasher passwordHasher,
ServerLogService serverLogService,
BootstrapService bootstrapService,
JoinLeaveEventRepository joinLeaveEventRepository,
OAuthAccountRepository oAuthAccountRepository,
OAuthStateRepository oAuthStateRepository,
ExtensionManager extensionManager,
WebAssetService webAssetService,
ExtensionSettingsRepository extensionSettingsRepository
) {}
@Override
public void onEnable() {
saveDefaultConfig();
PluginLifecycleSupport.configureThirdPartyStartupLogging();
this.schedulerBridge = new ServerSchedulerBridge(this);
WebPanelConfig panelConfig = WebPanelConfig.fromConfig(getConfig());
StartupContext startupContext = initializeStartupContext(panelConfig);
PluginLifecycleSupport.announceMinePanelBanner(this, componentLogger, mm);
PluginLifecycleSupport.announceBootstrapToken(startupContext.bootstrapService(), componentLogger, mm);
PluginLifecycleSupport.registerPluginListeners(
this,
panelLogger,
startupContext.knownPlayerRepository(),
playerActivityRepository,
joinLeaveEventRepository
);
PluginLifecycleSupport.synchronizeKnownPlayers(getServer(), startupContext.knownPlayerRepository(), playerActivityRepository);
startWebPanel(panelConfig, startupContext);
componentLogger.info("{}{}:{}", mm.deserialize("<aqua>MinePanel available at: </aqua>"), "http://" + panelConfig.host(), panelConfig.port());
panelLogger.log("SYSTEM", "PLUGIN", "MinePanel plugin started");
}
private StartupContext initializeStartupContext(WebPanelConfig panelConfig) {
this.database = new Database(getDataFolder().toPath().resolve("panel.db"));
this.database.initialize();
UserRepository userRepository = new UserRepository(database);
int normalizedOwners = userRepository.demoteExtraOwnersToAdmin();
if (normalizedOwners > 0) {
getLogger().warning("Detected multiple owner accounts. Demoted " + normalizedOwners + " extra owner account(s) to ADMIN.");
}
this.logRepository = new LogRepository(database);
KnownPlayerRepository knownPlayerRepository = new KnownPlayerRepository(database);
this.playerActivityRepository = new PlayerActivityRepository(database);
this.joinLeaveEventRepository = new JoinLeaveEventRepository(database);
DiscordWebhookRepository discordWebhookRepository = new DiscordWebhookRepository(database);
this.discordWebhookService = new DiscordWebhookService(getLogger(), discordWebhookRepository);
this.panelLogger = new PanelLogger(logRepository, discordWebhookService);
this.logRepository.clearLogs();
SessionService sessionService = new SessionService(database, panelConfig.sessionTtlMinutes());
PasswordHasher passwordHasher = new PasswordHasher();
ServerLogService serverLogService = new ServerLogService(getDataFolder().toPath());
BootstrapService bootstrapService = new BootstrapService(userRepository, panelConfig.bootstrapTokenLength());
OAuthAccountRepository oAuthAccountRepository = new OAuthAccountRepository(database);
OAuthStateRepository oAuthStateRepository = new OAuthStateRepository(database);
this.extensionSettingsRepository = new ExtensionSettingsRepository(database);
this.webAssetService = initializeWebAssets();
this.extensionManager = initializeExtensions(knownPlayerRepository);
return new StartupContext(
userRepository,
knownPlayerRepository,
sessionService,
passwordHasher,
serverLogService,
bootstrapService,
joinLeaveEventRepository,
oAuthAccountRepository,
oAuthStateRepository,
extensionManager,
webAssetService,
extensionSettingsRepository
);
}
private WebAssetService initializeWebAssets() {
WebAssetService service = new WebAssetService(this, getDataFolder().toPath().resolve("web"));
service.ensureSeeded();
return service;
}
private ExtensionManager initializeExtensions(KnownPlayerRepository knownPlayerRepository) {
this.extensionCommandRegistry = new BukkitExtensionCommandRegistry(this);
ExtensionContext context = new ExtensionContext(
this,
database,
panelLogger,
knownPlayerRepository,
playerActivityRepository,
extensionCommandRegistry,
schedulerBridge
);
ExtensionManager manager = new ExtensionManager(this, context);
Path extensionDirectory = getDataFolder().toPath().resolve("extensions");
manager.loadFromDirectory(extensionDirectory);
manager.enableAll();
return manager;
}
public ServerSchedulerBridge schedulerBridge() {
return schedulerBridge;
}
private void startWebPanel(WebPanelConfig panelConfig, StartupContext startupContext) {
this.webPanelServer = new WebPanelServer(
this,
panelConfig,
startupContext.userRepository(),
startupContext.sessionService(),
startupContext.passwordHasher(),
this.logRepository,
startupContext.knownPlayerRepository(),
playerActivityRepository,
discordWebhookService,
panelLogger,
startupContext.serverLogService(),
startupContext.bootstrapService(),
startupContext.joinLeaveEventRepository(),
startupContext.oAuthAccountRepository(),
startupContext.oAuthStateRepository(),
startupContext.extensionManager(),
startupContext.webAssetService(),
startupContext.extensionSettingsRepository()
);
this.webPanelServer.start();
}
@Override
public void onDisable() {
if (webPanelServer != null) {
webPanelServer.stop();
}
if (extensionManager != null) {
extensionManager.disableAll();
}
if (playerActivityRepository != null) {
long now = Instant.now().toEpochMilli();
getServer().getOnlinePlayers().forEach(player ->
playerActivityRepository.onQuit(player.getUniqueId(), now)
);
}
if (panelLogger != null) {
panelLogger.log("SYSTEM", "PLUGIN", "MinePanel plugin stopping");
}
if (logRepository != null) {
PluginLifecycleSupport.exportPanelLogsToServerLogsDirectory(getDataFolder().toPath(), logRepository, getLogger());
}
if (discordWebhookService != null) {
discordWebhookService.shutdown();
}
if (database != null) {
database.close();
}
}
}
@@ -0,0 +1,15 @@
package de.winniepat.minePanel.auth;
import org.mindrot.jbcrypt.BCrypt;
public final class PasswordHasher {
public String hash(String rawPassword) {
return BCrypt.hashpw(rawPassword, BCrypt.gensalt(12));
}
public boolean verify(String rawPassword, String hashedPassword) {
return BCrypt.checkpw(rawPassword, hashedPassword);
}
}
@@ -0,0 +1,108 @@
package de.winniepat.minePanel.auth;
import de.winniepat.minePanel.persistence.Database;
import java.security.SecureRandom;
import java.sql.*;
import java.time.Instant;
import java.util.*;
public final class SessionService {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final Database database;
private final int sessionTtlMinutes;
public SessionService(Database database, int sessionTtlMinutes) {
this.database = database;
this.sessionTtlMinutes = sessionTtlMinutes;
}
public String createSession(long userId) {
cleanupExpiredSessions();
String token = generateToken();
long now = Instant.now().toEpochMilli();
long expiresAt = now + (sessionTtlMinutes * 60_000L);
String sql = "INSERT INTO sessions(token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, token);
statement.setLong(2, userId);
statement.setLong(3, now);
statement.setLong(4, expiresAt);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not create session", exception);
}
return token;
}
public Optional<Long> resolveUserId(String token) {
if (token == null || token.isBlank()) {
return Optional.empty();
}
long now = Instant.now().toEpochMilli();
String sql = "SELECT user_id FROM sessions WHERE token = ? AND expires_at > ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, token);
statement.setLong(2, now);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(resultSet.getLong("user_id"));
}
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not resolve session", exception);
}
return Optional.empty();
}
public void deleteSession(String token) {
if (token == null || token.isBlank()) {
return;
}
String sql = "DELETE FROM sessions WHERE token = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, token);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not delete session", exception);
}
}
public void deleteSessionsByUserId(long userId) {
String sql = "DELETE FROM sessions WHERE user_id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not delete sessions for user", exception);
}
}
public void cleanupExpiredSessions() {
String sql = "DELETE FROM sessions WHERE expires_at <= ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, Instant.now().toEpochMilli());
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not clean expired sessions", exception);
}
}
private String generateToken() {
byte[] bytes = new byte[32];
SECURE_RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
@@ -0,0 +1,73 @@
package de.winniepat.minePanel.config;
import org.bukkit.configuration.file.FileConfiguration;
public record WebPanelConfig(
String host,
int port,
int sessionTtlMinutes,
int bootstrapTokenLength,
int oauthStateTtlMinutes,
OAuthProviderConfig googleOAuth,
OAuthProviderConfig discordOAuth
) {
public static WebPanelConfig fromConfig(FileConfiguration config) {
String host = config.getString("web.host", "127.0.0.1");
int port = config.getInt("web.port", 8080);
int sessionTtlMinutes = Math.max(5, config.getInt("web.sessionTtlMinutes", 120));
int bootstrapTokenLength = Math.max(16, config.getInt("security.bootstrapTokenLength", 32));
int oauthStateTtlMinutes = Math.max(2, config.getInt("integrations.oauth.stateTtlMinutes", 10));
OAuthProviderConfig googleOAuth = parseProvider(config, "google", "MINEPANEL_OAUTH_GOOGLE");
OAuthProviderConfig discordOAuth = parseProvider(config, "discord", "MINEPANEL_OAUTH_DISCORD");
return new WebPanelConfig(host, port, sessionTtlMinutes, bootstrapTokenLength, oauthStateTtlMinutes, googleOAuth, discordOAuth);
}
private static OAuthProviderConfig parseProvider(FileConfiguration config, String key, String envPrefix) {
String section = "integrations.oauth." + key + ".";
boolean enabled = config.getBoolean(section + "enabled", false);
String clientId = envOrDefault(envPrefix + "_CLIENT_ID", config.getString(section + "clientId", ""));
String clientSecret = envOrDefault(envPrefix + "_CLIENT_SECRET", config.getString(section + "clientSecret", ""));
String redirectUri = envOrDefault(envPrefix + "_REDIRECT_URI", config.getString(section + "redirectUri", ""));
return new OAuthProviderConfig(enabled, sanitize(clientId), sanitize(clientSecret), sanitize(redirectUri));
}
private static String envOrDefault(String key, String fallback) {
String value = System.getenv(key);
if (value != null && !value.isBlank()) {
return value;
}
return fallback;
}
private static String sanitize(String value) {
return value == null ? "" : value.trim();
}
public OAuthProviderConfig oauthProvider(String provider) {
if (provider == null) {
return OAuthProviderConfig.disabled();
}
String normalized = provider.trim().toLowerCase();
if ("google".equals(normalized)) {
return googleOAuth;
}
if ("discord".equals(normalized)) {
return discordOAuth;
}
return OAuthProviderConfig.disabled();
}
public record OAuthProviderConfig(boolean enabled, String clientId, String clientSecret, String redirectUri) {
public static OAuthProviderConfig disabled() {
return new OAuthProviderConfig(false, "", "", "");
}
public boolean configured() {
return enabled && !clientId.isBlank() && !clientSecret.isBlank() && !redirectUri.isBlank();
}
}
}
@@ -0,0 +1,200 @@
package de.winniepat.minePanel.extensions;
import de.winniepat.minePanel.MinePanel;
import org.bukkit.command.*;
import org.bukkit.plugin.Plugin;
import org.bukkit.command.PluginIdentifiableCommand;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.*;
public final class BukkitExtensionCommandRegistry implements ExtensionCommandRegistry {
private final MinePanel plugin;
private final Map<String, List<RuntimeExtensionCommand>> commandsByExtensionId = new HashMap<>();
public BukkitExtensionCommandRegistry(MinePanel plugin) {
this.plugin = plugin;
}
@Override
public synchronized boolean register(
String extensionId,
String name,
String description,
String usage,
String permission,
List<String> aliases,
CommandExecutor executor,
TabCompleter tabCompleter
) {
if (executor == null || isBlank(extensionId) || isBlank(name)) {
return false;
}
String normalizedExtensionId = extensionId.trim().toLowerCase(Locale.ROOT);
String normalizedName = name.trim().toLowerCase(Locale.ROOT);
CommandMap commandMap = getCommandMap();
if (commandMap == null) {
plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command map unavailable");
return false;
}
if (commandMap.getCommand(normalizedName) != null) {
plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command already exists");
return false;
}
RuntimeExtensionCommand command = new RuntimeExtensionCommand(
plugin,
normalizedName,
defaultString(description, "Extension command"),
defaultString(usage, "/" + normalizedName),
aliases == null ? List.of() : aliases,
executor,
tabCompleter
);
if (!isBlank(permission)) {
command.setPermission(permission.trim());
}
boolean registered = commandMap.register("minepanelext", command);
if (!registered) {
plugin.getLogger().warning("Could not register extension command '/" + normalizedName + "': command map rejected registration");
return false;
}
commandsByExtensionId.computeIfAbsent(normalizedExtensionId, ignored -> new ArrayList<>()).add(command);
return true;
}
@Override
public synchronized void unregisterForExtension(String extensionId) {
if (isBlank(extensionId)) {
return;
}
String normalizedExtensionId = extensionId.trim().toLowerCase(Locale.ROOT);
List<RuntimeExtensionCommand> commands = commandsByExtensionId.remove(normalizedExtensionId);
if (commands == null || commands.isEmpty()) {
return;
}
CommandMap commandMap = getCommandMap();
if (commandMap == null) {
return;
}
Map<String, Command> knownCommands = getKnownCommands(commandMap);
for (RuntimeExtensionCommand command : commands) {
command.unregister(commandMap);
removeCommandEntries(knownCommands, command);
}
}
@Override
public synchronized void unregisterAll() {
List<String> extensionIds = new ArrayList<>(commandsByExtensionId.keySet());
for (String extensionId : extensionIds) {
unregisterForExtension(extensionId);
}
commandsByExtensionId.clear();
}
private void removeCommandEntries(Map<String, Command> knownCommands, RuntimeExtensionCommand command) {
if (knownCommands == null || knownCommands.isEmpty()) {
return;
}
Iterator<Map.Entry<String, Command>> iterator = knownCommands.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Command> entry = iterator.next();
if (entry.getValue() == command) {
iterator.remove();
}
}
}
@SuppressWarnings("unchecked")
private Map<String, Command> getKnownCommands(CommandMap commandMap) {
try {
Field knownCommandsField = commandMap.getClass().getDeclaredField("knownCommands");
knownCommandsField.setAccessible(true);
Object value = knownCommandsField.get(commandMap);
if (value instanceof Map<?, ?> map) {
return (Map<String, Command>) map;
}
} catch (Exception ignored) {
// Best effort cleanup.
}
return Collections.emptyMap();
}
private CommandMap getCommandMap() {
try {
Method getCommandMapMethod = plugin.getServer().getClass().getMethod("getCommandMap");
Object value = getCommandMapMethod.invoke(plugin.getServer());
if (value instanceof CommandMap map) {
return map;
}
} catch (Exception ignored) {
// Supported on CraftServer-based implementations.
}
return null;
}
private String defaultString(String value, String fallback) {
return isBlank(value) ? fallback : value.trim();
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private static final class RuntimeExtensionCommand extends Command implements PluginIdentifiableCommand {
private final Plugin plugin;
private final CommandExecutor executor;
private final TabCompleter tabCompleter;
private RuntimeExtensionCommand(
Plugin plugin,
String name,
String description,
String usage,
List<String> aliases,
CommandExecutor executor,
TabCompleter tabCompleter
) {
super(name, description, usage, aliases == null ? List.of() : aliases);
this.plugin = plugin;
this.executor = executor;
this.tabCompleter = tabCompleter;
}
@Override
public boolean execute(CommandSender sender, String commandLabel, String[] args) {
if (testPermission(sender)) {
return executor.onCommand(sender, this, commandLabel, args);
}
return true;
}
@Override
public List<String> tabComplete(CommandSender sender, String alias, String[] args) {
if (tabCompleter == null) {
return List.of();
}
List<String> completions = tabCompleter.onTabComplete(sender, this, alias, args);
return completions == null ? List.of() : completions;
}
@Override
public Plugin getPlugin() {
return plugin;
}
}
}
@@ -0,0 +1,37 @@
package de.winniepat.minePanel.extensions;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.TabCompleter;
import java.util.List;
public interface ExtensionCommandRegistry {
boolean register(
String extensionId,
String name,
String description,
String usage,
String permission,
List<String> aliases,
CommandExecutor executor,
TabCompleter tabCompleter
);
default boolean register(
String extensionId,
String name,
String description,
String usage,
String permission,
List<String> aliases,
CommandExecutor executor
) {
return register(extensionId, name, description, usage, permission, aliases, executor, null);
}
void unregisterForExtension(String extensionId);
void unregisterAll();
}
@@ -0,0 +1,15 @@
package de.winniepat.minePanel.extensions;
/**
* Optional extension contract for runtime settings updates from the panel.
*/
public interface ExtensionConfigurable {
/**
* Applies updated settings JSON immediately at runtime.
*
* @param settingsJson extension settings JSON payload
*/
void onSettingsUpdated(String settingsJson);
}
@@ -0,0 +1,20 @@
package de.winniepat.minePanel.extensions;
import de.winniepat.minePanel.MinePanel;
import de.winniepat.minePanel.logs.PanelLogger;
import de.winniepat.minePanel.persistence.Database;
import de.winniepat.minePanel.persistence.KnownPlayerRepository;
import de.winniepat.minePanel.persistence.PlayerActivityRepository;
import de.winniepat.minePanel.util.ServerSchedulerBridge;
public record ExtensionContext(
MinePanel plugin,
Database database,
PanelLogger panelLogger,
KnownPlayerRepository knownPlayerRepository,
PlayerActivityRepository playerActivityRepository,
ExtensionCommandRegistry commandRegistry,
ServerSchedulerBridge schedulerBridge
) {
}
@@ -0,0 +1,566 @@
package de.winniepat.minePanel.extensions;
import de.winniepat.minePanel.MinePanel;
import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;
public final class ExtensionManager {
private final MinePanel plugin;
private final ExtensionContext context;
private final List<MinePanelExtension> loadedExtensions = new ArrayList<>();
private final List<URLClassLoader> classLoaders = new ArrayList<>();
private final Set<String> loadedIds = new HashSet<>();
private final Set<String> loadedArtifacts = new HashSet<>();
private final Map<String, String> extensionSourceById = new HashMap<>();
private Path extensionsDirectory;
private ExtensionWebRegistry activeWebRegistry;
public ExtensionManager(MinePanel plugin, ExtensionContext context) {
this.plugin = plugin;
this.context = context;
}
public void registerBuiltIn(MinePanelExtension extension) {
registerLoadedExtension(extension, "built-in");
}
public synchronized void loadFromDirectory(Path extensionsDirectory) {
this.extensionsDirectory = extensionsDirectory;
try {
Files.createDirectories(extensionsDirectory);
} catch (IOException exception) {
plugin.getLogger().warning("Could not create extensions directory: " + exception.getMessage());
return;
}
cleanupDuplicateArtifactsOnStartup(extensionsDirectory);
try (Stream<Path> files = Files.list(extensionsDirectory)) {
files
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar"))
.sorted(this::compareJarsNewestFirst)
.forEach(path -> loadJarExtensions(path, null));
} catch (IOException exception) {
plugin.getLogger().warning("Could not scan extensions directory: " + exception.getMessage());
}
}
public synchronized ReloadResult reloadNewFromDirectory(ExtensionWebRegistry webRegistry) {
if (webRegistry != null) {
this.activeWebRegistry = webRegistry;
}
if (extensionsDirectory == null) {
return new ReloadResult(0, List.of(), List.of("extensions_directory_unavailable"));
}
List<MinePanelExtension> newlyLoaded = new ArrayList<>();
List<String> warnings = new ArrayList<>();
int scanned = 0;
try (Stream<Path> files = Files.list(extensionsDirectory)) {
List<Path> jars = files
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar"))
.sorted(this::compareJarsNewestFirst)
.toList();
for (Path jarFile : jars) {
scanned++;
String artifactName = jarFile.getFileName().toString();
if (loadedArtifacts.contains(artifactName)) {
continue;
}
loadJarExtensions(jarFile, newlyLoaded);
}
} catch (IOException exception) {
warnings.add("could_not_scan_extensions_directory");
plugin.getLogger().warning("Could not scan extensions directory: " + exception.getMessage());
}
for (MinePanelExtension extension : newlyLoaded) {
try {
extension.onEnable();
plugin.getLogger().info("Enabled extension " + extension.id() + " (" + extension.displayName() + ")");
} catch (Exception exception) {
warnings.add("enable_failed:" + extension.id());
context.commandRegistry().unregisterForExtension(extension.id());
plugin.getLogger().warning("Could not enable extension " + extension.id() + ": " + exception.getMessage());
}
if (activeWebRegistry != null) {
try {
extension.registerWebRoutes(activeWebRegistry);
} catch (Exception exception) {
warnings.add("route_registration_failed:" + extension.id());
plugin.getLogger().warning("Could not register web routes for extension " + extension.id() + ": " + exception.getMessage());
}
}
}
List<String> loadedExtensionIds = newlyLoaded.stream().map(MinePanelExtension::id).toList();
return new ReloadResult(scanned, loadedExtensionIds, warnings);
}
public synchronized List<Map<String, Object>> installedExtensions() {
List<Map<String, Object>> installed = new ArrayList<>();
for (MinePanelExtension extension : loadedExtensions) {
String id = extension.id() == null ? "" : extension.id();
String source = extensionSourceById.getOrDefault(id.toLowerCase(Locale.ROOT), "unknown");
installed.add(Map.of(
"id", id,
"displayName", extension.displayName() == null ? id : extension.displayName(),
"source", source
));
}
installed.sort(Comparator.comparing(item -> String.valueOf(item.get("id")), String.CASE_INSENSITIVE_ORDER));
return installed;
}
public synchronized List<Map<String, Object>> availableArtifacts() {
List<String> artifacts = scanArtifactFileNames();
Set<String> loadedArtifacts = new HashSet<>();
for (String source : extensionSourceById.values()) {
if (source != null && source.toLowerCase(Locale.ROOT).endsWith(".jar")) {
loadedArtifacts.add(source);
}
}
List<Map<String, Object>> available = new ArrayList<>();
for (String artifact : artifacts) {
available.add(Map.of(
"fileName", artifact,
"loaded", loadedArtifacts.contains(artifact)
));
}
return available;
}
public void enableAll() {
for (MinePanelExtension extension : loadedExtensions) {
try {
extension.onEnable();
plugin.getLogger().info("Enabled extension " + extension.id() + " (" + extension.displayName() + ")");
} catch (Exception exception) {
context.commandRegistry().unregisterForExtension(extension.id());
plugin.getLogger().warning("Could not enable extension " + extension.id() + ": " + exception.getMessage());
}
}
}
public synchronized void registerWebRoutes(ExtensionWebRegistry webRegistry) {
this.activeWebRegistry = webRegistry;
for (MinePanelExtension extension : loadedExtensions) {
try {
extension.registerWebRoutes(webRegistry);
} catch (Exception exception) {
plugin.getLogger().warning("Could not register web routes for extension " + extension.id() + ": " + exception.getMessage());
}
}
}
public List<ExtensionNavigationTab> navigationTabs() {
List<ExtensionNavigationTab> tabs = new ArrayList<>();
for (MinePanelExtension extension : loadedExtensions) {
try {
tabs.addAll(extension.navigationTabs());
} catch (Exception exception) {
plugin.getLogger().warning("Could not read tabs for extension " + extension.id() + ": " + exception.getMessage());
}
}
return tabs;
}
public synchronized boolean supportsSettings(String extensionId) {
String normalizedId = normalizeExtensionId(extensionId);
if (normalizedId.isBlank()) {
return false;
}
for (MinePanelExtension extension : loadedExtensions) {
if (!normalizeExtensionId(extension.id()).equals(normalizedId)) {
continue;
}
return extension instanceof ExtensionConfigurable;
}
return false;
}
public synchronized boolean applySettings(String extensionId, String settingsJson) {
String normalizedId = normalizeExtensionId(extensionId);
if (normalizedId.isBlank()) {
return false;
}
for (MinePanelExtension extension : loadedExtensions) {
if (!normalizeExtensionId(extension.id()).equals(normalizedId)) {
continue;
}
if (!(extension instanceof ExtensionConfigurable configurable)) {
return false;
}
try {
configurable.onSettingsUpdated(settingsJson == null ? "{}" : settingsJson);
return true;
} catch (Exception exception) {
plugin.getLogger().warning("Could not apply live settings for extension " + extension.id() + ": " + exception.getMessage());
return false;
}
}
return false;
}
public synchronized void disableAll() {
ListIterator<MinePanelExtension> iterator = loadedExtensions.listIterator(loadedExtensions.size());
while (iterator.hasPrevious()) {
MinePanelExtension extension = iterator.previous();
try {
extension.onDisable();
} catch (Exception exception) {
plugin.getLogger().warning("Could not disable extension " + extension.id() + ": " + exception.getMessage());
} finally {
context.commandRegistry().unregisterForExtension(extension.id());
}
}
context.commandRegistry().unregisterAll();
for (URLClassLoader classLoader : classLoaders) {
try {
classLoader.close();
} catch (IOException ignored) {
// Best effort cleanup.
}
}
classLoaders.clear();
loadedArtifacts.clear();
loadedIds.clear();
extensionSourceById.clear();
loadedExtensions.clear();
}
private void loadJarExtensions(Path jarFile, List<MinePanelExtension> newlyLoaded) {
URLClassLoader classLoader = null;
try {
URL url = jarFile.toUri().toURL();
classLoader = new URLClassLoader(new URL[]{url}, plugin.getClass().getClassLoader());
ServiceLoader<MinePanelExtension> serviceLoader = ServiceLoader.load(MinePanelExtension.class, classLoader);
int found = 0;
int registered = 0;
for (MinePanelExtension extension : serviceLoader) {
found++;
if (registerLoadedExtension(extension, jarFile.getFileName().toString())) {
registered++;
if (newlyLoaded != null) {
newlyLoaded.add(extension);
}
}
}
if (found == 0) {
plugin.getLogger().warning("No MinePanel extension entry found in " + jarFile.getFileName() + ". Add META-INF/services/" + MinePanelExtension.class.getName());
}
if (registered > 0) {
classLoaders.add(classLoader);
loadedArtifacts.add(jarFile.getFileName().toString());
classLoader = null;
}
} catch (Exception exception) {
plugin.getLogger().warning("Could not load extension jar " + jarFile.getFileName() + ": " + exception.getMessage());
} finally {
if (classLoader != null) {
try {
classLoader.close();
} catch (IOException ignored) {
// Best effort cleanup.
}
}
}
}
private boolean registerLoadedExtension(MinePanelExtension extension, String source) {
if (extension == null || extension.id() == null || extension.id().isBlank()) {
plugin.getLogger().warning("Skipping extension with invalid id from " + source);
return false;
}
String id = extension.id().trim().toLowerCase(Locale.ROOT);
if (!loadedIds.add(id)) {
plugin.getLogger().warning("Skipping duplicate extension id: " + extension.id());
return false;
}
try {
extension.onLoad(context);
loadedExtensions.add(extension);
extensionSourceById.put(id, source);
plugin.getLogger().info("Loaded extension " + extension.id() + " from " + source);
return true;
} catch (Exception exception) {
loadedIds.remove(id);
plugin.getLogger().warning("Could not initialize extension " + extension.id() + ": " + exception.getMessage());
return false;
}
}
public record ReloadResult(int scannedArtifactCount, List<String> loadedExtensionIds, List<String> warnings) {
}
private List<String> scanArtifactFileNames() {
if (extensionsDirectory == null) {
return List.of();
}
try (Stream<Path> files = Files.list(extensionsDirectory)) {
return files
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar"))
.map(path -> path.getFileName().toString())
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
} catch (IOException exception) {
plugin.getLogger().warning("Could not read extension artifacts: " + exception.getMessage());
return List.of();
}
}
private int compareJarsNewestFirst(Path left, Path right) {
long leftModified = lastModifiedMillis(left);
long rightModified = lastModifiedMillis(right);
int byModified = Long.compare(rightModified, leftModified);
if (byModified != 0) {
return byModified;
}
String leftName = left.getFileName().toString().toLowerCase(Locale.ROOT);
String rightName = right.getFileName().toString().toLowerCase(Locale.ROOT);
return rightName.compareTo(leftName);
}
private long lastModifiedMillis(Path file) {
try {
return Files.getLastModifiedTime(file).toMillis();
} catch (IOException ignored) {
return 0L;
}
}
private void cleanupDuplicateArtifactsOnStartup(Path extensionDirectory) {
List<Path> jars;
try (Stream<Path> files = Files.list(extensionDirectory)) {
jars = files
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar"))
.toList();
} catch (IOException exception) {
plugin.getLogger().warning("Could not inspect extension artifacts for startup cleanup: " + exception.getMessage());
return;
}
if (jars.size() < 2) {
return;
}
Map<String, Path> keepByKey = new HashMap<>();
Map<String, ArtifactVersionToken> keepVersionByKey = new HashMap<>();
for (Path jar : jars) {
String fileName = jar.getFileName().toString();
String key = extensionKeyFromArtifact(fileName);
if (key.isBlank()) {
continue;
}
ArtifactVersionToken currentVersion = parseArtifactVersion(fileName);
Path keptPath = keepByKey.get(key);
ArtifactVersionToken keptVersion = keepVersionByKey.get(key);
if (keptPath == null || keptVersion == null) {
keepByKey.put(key, jar);
keepVersionByKey.put(key, currentVersion);
continue;
}
int compare = compareArtifactVersions(currentVersion, keptVersion);
if (compare > 0 || (compare == 0 && compareJarsNewestFirst(jar, keptPath) < 0)) {
keepByKey.put(key, jar);
keepVersionByKey.put(key, currentVersion);
}
}
for (Path jar : jars) {
String fileName = jar.getFileName().toString();
String key = extensionKeyFromArtifact(fileName);
if (key.isBlank()) {
continue;
}
Path kept = keepByKey.get(key);
if (kept == null || kept.equals(jar)) {
continue;
}
try {
Files.deleteIfExists(jar);
plugin.getLogger().info("Removed old extension artifact on startup: " + fileName + " (kept " + kept.getFileName() + ")");
} catch (IOException exception) {
plugin.getLogger().warning("Could not delete old extension artifact " + fileName + " on startup: " + exception.getMessage());
}
}
}
private String extensionKeyFromArtifact(String fileName) {
if (fileName == null || fileName.isBlank()) {
return "";
}
String normalized = fileName.trim().toLowerCase(Locale.ROOT);
if (!normalized.endsWith(".jar") || !normalized.startsWith("minepanel-extension-")) {
return "";
}
normalized = normalized.substring(0, normalized.length() - 4);
normalized = normalized.substring("minepanel-extension-".length());
int alphaSplit = normalized.lastIndexOf("-alpha-");
if (alphaSplit > 0) {
normalized = normalized.substring(0, alphaSplit);
} else {
int semverSplit = normalized.lastIndexOf('-');
if (semverSplit > 0) {
String suffix = normalized.substring(semverSplit + 1);
if (isNumericVersionSuffix(suffix)) {
normalized = normalized.substring(0, semverSplit);
}
}
}
return normalized.replaceAll("[^a-z0-9]", "");
}
private String normalizeExtensionId(String rawId) {
if (rawId == null || rawId.isBlank()) {
return "";
}
return rawId.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
}
private ArtifactVersionToken parseArtifactVersion(String fileName) {
if (fileName == null || fileName.isBlank()) {
return ArtifactVersionToken.unknown();
}
String normalized = fileName.trim().toLowerCase(Locale.ROOT);
if (normalized.endsWith(".jar")) {
normalized = normalized.substring(0, normalized.length() - 4);
}
int alphaSplit = normalized.lastIndexOf("-alpha-");
if (alphaSplit > 0) {
String buildRaw = normalized.substring(alphaSplit + "-alpha-".length());
try {
return ArtifactVersionToken.alpha(Integer.parseInt(buildRaw));
} catch (NumberFormatException ignored) {
return ArtifactVersionToken.unknown();
}
}
int semverSplit = normalized.lastIndexOf('-');
if (semverSplit > 0) {
String suffix = normalized.substring(semverSplit + 1);
if (isNumericVersionSuffix(suffix)) {
List<Integer> parts = new ArrayList<>();
for (String part : suffix.split("\\.")) {
if (part.isBlank()) {
continue;
}
try {
parts.add(Integer.parseInt(part));
} catch (NumberFormatException ignored) {
return ArtifactVersionToken.unknown();
}
}
if (!parts.isEmpty()) {
return ArtifactVersionToken.semver(parts);
}
}
}
return ArtifactVersionToken.unknown();
}
private boolean isNumericVersionSuffix(String value) {
if (value == null || value.isBlank()) {
return false;
}
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (!Character.isDigit(c) && c != '.') {
return false;
}
}
return true;
}
private int compareArtifactVersions(ArtifactVersionToken left, ArtifactVersionToken right) {
if (left.kind() != right.kind()) {
return Integer.compare(left.kind().priority, right.kind().priority);
}
if (left.kind() == ArtifactVersionKind.ALPHA) {
return Integer.compare(left.alphaBuild(), right.alphaBuild());
}
if (left.kind() == ArtifactVersionKind.SEMVER) {
int max = Math.max(left.semverParts().size(), right.semverParts().size());
for (int i = 0; i < max; i++) {
int l = i < left.semverParts().size() ? left.semverParts().get(i) : 0;
int r = i < right.semverParts().size() ? right.semverParts().get(i) : 0;
if (l != r) {
return Integer.compare(l, r);
}
}
}
return 0;
}
private enum ArtifactVersionKind {
UNKNOWN(0),
ALPHA(1),
SEMVER(2);
private final int priority;
ArtifactVersionKind(int priority) {
this.priority = priority;
}
}
private record ArtifactVersionToken(ArtifactVersionKind kind, int alphaBuild, List<Integer> semverParts) {
private static ArtifactVersionToken unknown() {
return new ArtifactVersionToken(ArtifactVersionKind.UNKNOWN, -1, List.of());
}
private static ArtifactVersionToken alpha(int build) {
return new ArtifactVersionToken(ArtifactVersionKind.ALPHA, build, List.of());
}
private static ArtifactVersionToken semver(List<Integer> parts) {
return new ArtifactVersionToken(ArtifactVersionKind.SEMVER, -1, parts == null ? List.of() : List.copyOf(parts));
}
}
}
@@ -0,0 +1,9 @@
package de.winniepat.minePanel.extensions;
public record ExtensionNavigationTab(
String category,
String label,
String path
) {
}
@@ -0,0 +1,11 @@
package de.winniepat.minePanel.extensions;
import de.winniepat.minePanel.users.PanelUser;
import spark.Request;
import spark.Response;
@FunctionalInterface
public interface ExtensionRouteHandler {
Object handle(Request request, Response response, PanelUser user) throws Exception;
}
@@ -0,0 +1,16 @@
package de.winniepat.minePanel.extensions;
import de.winniepat.minePanel.users.PanelPermission;
import spark.Response;
import java.util.Map;
public interface ExtensionWebRegistry {
void get(String path, PanelPermission permission, ExtensionRouteHandler handler);
void post(String path, PanelPermission permission, ExtensionRouteHandler handler);
String json(Response response, int status, Map<String, Object> payload);
}
@@ -0,0 +1,88 @@
package de.winniepat.minePanel.extensions;
import java.util.List;
/**
* Service Provider Interface (SPI) for MinePanel extensions.
* <p>
* Implementations are discovered through Java {@code ServiceLoader} and loaded by the
* extension manager during MinePanel startup.
* </p>
* <p>
* Typical lifecycle:
* </p>
* <ol>
* <li>{@link #onLoad(ExtensionContext)}</li>
* <li>{@link #onEnable()}</li>
* <li>{@link #registerWebRoutes(ExtensionWebRegistry)}</li>
* <li>{@link #navigationTabs()}</li>
* <li>{@link #onDisable()} on shutdown</li>
* </ol>
*/
public interface MinePanelExtension {
/**
* Returns the stable, unique id of this extension.
* <p>
* The id is used for runtime bookkeeping and should remain unchanged across versions.
* </p>
*
* @return extension id (for example {@code "reports"})
*/
String id();
/**
* Returns the human-readable display name shown in UI/status views.
*
* @return extension display name
*/
String displayName();
/**
* Called once when the extension is loaded and before it is enabled.
* <p>
* Use this for initialization that requires MinePanel services, such as setting up
* database schema or caching dependencies from the provided context.
* </p>
*
* @param context runtime context provided by MinePanel
*/
default void onLoad(ExtensionContext context) {
}
/**
* Called after {@link #onLoad(ExtensionContext)} when the extension should become active.
* <p>
* Use this for registering listeners, commands, or background tasks.
* </p>
*/
default void onEnable() {
}
/**
* Called when MinePanel is disabling the extension.
* <p>
* Use this hook to release resources and stop tasks started in {@link #onEnable()}.
* </p>
*/
default void onDisable() {
}
/**
* Allows the extension to register HTTP routes in the MinePanel web server.
*
* @param webRegistry route registration facade
*/
default void registerWebRoutes(ExtensionWebRegistry webRegistry) {
}
/**
* Returns sidebar navigation tabs contributed by this extension.
*
* @return list of extension tabs, or an empty list if none are contributed
*/
default List<ExtensionNavigationTab> navigationTabs() {
return List.of();
}
}
@@ -0,0 +1,233 @@
package de.winniepat.minePanel.extensions.airstrike;
import com.google.gson.Gson;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.persistence.KnownPlayer;
import de.winniepat.minePanel.users.PanelPermission;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.entity.TNTPrimed;
import org.bukkit.util.Vector;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class AirstrikeExtension implements MinePanelExtension {
private static final int DEFAULT_TNT = 5000;
private static final int MAX_TNT = 20000;
private static final int WAVE_COUNT = 20;
private static final long WAVE_INTERVAL_TICKS = 12L;
private static final double DROP_HEIGHT = 52.0;
private static final double HORIZONTAL_SPREAD = 5.0;
private final Gson gson = new Gson();
private ExtensionContext context;
@Override
public String id() {
return "airstrike";
}
@Override
public String displayName() {
return "Airstrike";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.post("/api/extensions/airstrike/launch", PanelPermission.MANAGE_AIRSTRIKE, (request, response, user) -> {
AirstrikePayload payload = gson.fromJson(request.body(), AirstrikePayload.class);
if (payload == null || (isBlank(payload.uuid()) && isBlank(payload.username()))) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
int amount = sanitizeAmount(payload.amount());
TargetSnapshot target;
try {
target = context.schedulerBridge().callGlobal(
() -> resolveOnlineTarget(payload.uuid(), payload.username()),
2,
TimeUnit.SECONDS
);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return webRegistry.json(response, 500, Map.of("error", "interrupted"));
} catch (ExecutionException | TimeoutException exception) {
return webRegistry.json(response, 500, Map.of("error", "lookup_failed"));
}
if (target == null) {
return webRegistry.json(response, 404, Map.of("error", "player_not_online"));
}
try {
launchAirstrike(target.uuid(), target.username(), user.username(), amount);
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "airstrike_schedule_failed"));
}
return webRegistry.json(response, 200, Map.of(
"ok", true,
"targetUuid", target.uuid().toString(),
"targetUsername", target.username(),
"tntCount", amount
));
});
}
private void launchAirstrike(UUID targetUuid, String targetName, String actorName, int totalTnt) {
context.panelLogger().log("AUDIT", actorName, "Triggered airstrike on " + targetName + " with " + totalTnt + " TNT");
final de.winniepat.minePanel.util.ServerSchedulerBridge.CancellableTask[] repeatingTask = new de.winniepat.minePanel.util.ServerSchedulerBridge.CancellableTask[1];
Runnable waveTask = new Runnable() {
private int remainingTnt = totalTnt;
private int remainingWaves = WAVE_COUNT;
@Override
public void run() {
Player onlineTarget = context.plugin().getServer().getPlayer(targetUuid);
if (onlineTarget == null || !onlineTarget.isOnline()) {
context.panelLogger().log("SYSTEM", "airstrike", "Airstrike cancelled because target went offline: " + targetName);
if (repeatingTask[0] != null) {
repeatingTask[0].cancel();
}
return;
}
int tntThisWave = (int) Math.ceil((double) remainingTnt / Math.max(1, remainingWaves));
boolean dispatched = runOnPlayerScheduler(onlineTarget, () -> {
for (int index = 0; index < tntThisWave; index++) {
spawnStrikeTnt(onlineTarget);
}
});
if (!dispatched) {
context.panelLogger().log("SYSTEM", "airstrike", "Airstrike cancelled because player scheduler dispatch failed: " + targetName);
if (repeatingTask[0] != null) {
repeatingTask[0].cancel();
}
return;
}
remainingTnt = Math.max(0, remainingTnt - tntThisWave);
remainingWaves--;
if (remainingWaves <= 0 || remainingTnt <= 0) {
if (repeatingTask[0] != null) {
repeatingTask[0].cancel();
}
}
}
};
repeatingTask[0] = context.schedulerBridge().runRepeatingGlobal(waveTask, 0L, WAVE_INTERVAL_TICKS);
}
private boolean runOnPlayerScheduler(Player player, Runnable task) {
if (!context.schedulerBridge().isFolia()) {
task.run();
return true;
}
try {
Object entityScheduler = player.getClass().getMethod("getScheduler").invoke(player);
Method runMethod = entityScheduler.getClass().getMethod("run", org.bukkit.plugin.Plugin.class, java.util.function.Consumer.class, Runnable.class);
runMethod.invoke(entityScheduler, context.plugin(), (java.util.function.Consumer<Object>) ignored -> task.run(), null);
return true;
} catch (ReflectiveOperationException exception) {
return false;
}
}
private void spawnStrikeTnt(Player target) {
ThreadLocalRandom random = ThreadLocalRandom.current();
Location targetLocation = target.getLocation();
Vector velocity = target.getVelocity();
// Lead the strike slightly so waves keep up with sprinting players.
double leadFactor = 6.0;
double predictedX = targetLocation.getX() + (velocity.getX() * leadFactor);
double predictedZ = targetLocation.getZ() + (velocity.getZ() * leadFactor);
double angle = random.nextDouble(0.0, Math.PI * 2.0);
double radius = random.nextDouble(0.0, HORIZONTAL_SPREAD);
double offsetX = Math.cos(angle) * radius;
double offsetZ = Math.sin(angle) * radius;
Location strikeCenter = new Location(targetLocation.getWorld(), predictedX, targetLocation.getY(), predictedZ);
Location spawnLocation = strikeCenter.add(offsetX, DROP_HEIGHT + random.nextDouble(0.0, 8.0), offsetZ);
TNTPrimed tnt = targetLocation.getWorld().spawn(spawnLocation, TNTPrimed.class);
tnt.setFuseTicks(65 + random.nextInt(20));
Vector tntVelocity = new Vector(
random.nextDouble(-0.08, 0.08),
-1.85 - random.nextDouble(0.0, 0.45),
random.nextDouble(-0.08, 0.08)
);
tnt.setVelocity(tntVelocity);
}
private TargetSnapshot resolveOnlineTarget(String rawUuid, String rawUsername) {
if (!isBlank(rawUuid)) {
try {
Player online = context.plugin().getServer().getPlayer(UUID.fromString(rawUuid.trim()));
if (online != null) {
return new TargetSnapshot(online.getUniqueId(), online.getName());
}
} catch (IllegalArgumentException ignored) {
// Try username fallback.
}
}
if (!isBlank(rawUsername)) {
Player online = context.plugin().getServer().getPlayerExact(rawUsername.trim());
if (online != null) {
return new TargetSnapshot(online.getUniqueId(), online.getName());
}
Optional<KnownPlayer> known = context.knownPlayerRepository().findByUsername(rawUsername.trim());
if (known.isPresent()) {
Player knownOnline = context.plugin().getServer().getPlayer(known.get().uuid());
if (knownOnline != null) {
return new TargetSnapshot(knownOnline.getUniqueId(), knownOnline.getName());
}
}
}
return null;
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private int sanitizeAmount(Integer requestedAmount) {
if (requestedAmount == null) {
return DEFAULT_TNT;
}
return Math.max(1, Math.min(MAX_TNT, requestedAmount));
}
private record AirstrikePayload(String uuid, String username, Integer amount) {
}
private record TargetSnapshot(UUID uuid, String username) {
}
}
@@ -0,0 +1,12 @@
package de.winniepat.minePanel.extensions.announcements;
public record Announcement(
long id,
String message,
boolean enabled,
int sortOrder,
long createdAt,
long updatedAt
) {
}
@@ -0,0 +1,160 @@
package de.winniepat.minePanel.extensions.announcements;
import de.winniepat.minePanel.persistence.Database;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public final class AnnouncementRepository {
private final Database database;
public AnnouncementRepository(Database database) {
this.database = database;
}
public void initializeSchema() {
try (Connection connection = database.getConnection();
Statement statement = connection.createStatement()) {
statement.execute("CREATE TABLE IF NOT EXISTS ext_announcements ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "message TEXT NOT NULL,"
+ "enabled INTEGER NOT NULL DEFAULT 1,"
+ "sort_order INTEGER NOT NULL DEFAULT 0,"
+ "created_at INTEGER NOT NULL,"
+ "updated_at INTEGER NOT NULL"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS ext_announcements_config ("
+ "id INTEGER PRIMARY KEY,"
+ "enabled INTEGER NOT NULL DEFAULT 0,"
+ "interval_seconds INTEGER NOT NULL DEFAULT 300,"
+ "updated_at INTEGER NOT NULL DEFAULT 0"
+ ")");
statement.execute("INSERT OR IGNORE INTO ext_announcements_config(id, enabled, interval_seconds, updated_at) VALUES (1, 0, 300, 0)");
} catch (SQLException exception) {
throw new IllegalStateException("Could not initialize announcements schema", exception);
}
}
public AnnouncementConfig readConfig() {
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT enabled, interval_seconds, updated_at FROM ext_announcements_config WHERE id = 1");
ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next()) {
return new AnnouncementConfig(false, 300, 0L);
}
return new AnnouncementConfig(
resultSet.getInt("enabled") == 1,
Math.max(10, resultSet.getInt("interval_seconds")),
resultSet.getLong("updated_at")
);
} catch (SQLException exception) {
throw new IllegalStateException("Could not read announcements config", exception);
}
}
public void saveConfig(boolean enabled, int intervalSeconds, long updatedAt) {
int normalizedInterval = Math.max(10, Math.min(86_400, intervalSeconds));
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(
"UPDATE ext_announcements_config SET enabled = ?, interval_seconds = ?, updated_at = ? WHERE id = 1"
)) {
statement.setInt(1, enabled ? 1 : 0);
statement.setInt(2, normalizedInterval);
statement.setLong(3, Math.max(0L, updatedAt));
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not save announcements config", exception);
}
}
public List<Announcement> listMessages() {
List<Announcement> messages = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(
"SELECT id, message, enabled, sort_order, created_at, updated_at FROM ext_announcements ORDER BY sort_order ASC, id ASC"
);
ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
messages.add(new Announcement(
resultSet.getLong("id"),
resultSet.getString("message"),
resultSet.getInt("enabled") == 1,
resultSet.getInt("sort_order"),
resultSet.getLong("created_at"),
resultSet.getLong("updated_at")
));
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not list announcements", exception);
}
return messages;
}
public long createMessage(String message, long now) {
int nextSortOrder = nextSortOrder();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO ext_announcements(message, enabled, sort_order, created_at, updated_at) VALUES (?, 1, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
)) {
statement.setString(1, message);
statement.setInt(2, nextSortOrder);
statement.setLong(3, now);
statement.setLong(4, now);
statement.executeUpdate();
try (ResultSet resultSet = statement.getGeneratedKeys()) {
if (resultSet.next()) {
return resultSet.getLong(1);
}
}
return -1L;
} catch (SQLException exception) {
throw new IllegalStateException("Could not create announcement", exception);
}
}
public boolean deleteMessage(long id) {
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement("DELETE FROM ext_announcements WHERE id = ?")) {
statement.setLong(1, id);
return statement.executeUpdate() > 0;
} catch (SQLException exception) {
throw new IllegalStateException("Could not delete announcement", exception);
}
}
public boolean setMessageEnabled(long id, boolean enabled, long updatedAt) {
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement("UPDATE ext_announcements SET enabled = ?, updated_at = ? WHERE id = ?")) {
statement.setInt(1, enabled ? 1 : 0);
statement.setLong(2, updatedAt);
statement.setLong(3, id);
return statement.executeUpdate() > 0;
} catch (SQLException exception) {
throw new IllegalStateException("Could not update announcement", exception);
}
}
private int nextSortOrder() {
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT COALESCE(MAX(sort_order), 0) + 1 AS next_order FROM ext_announcements");
ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return resultSet.getInt("next_order");
}
return 1;
} catch (SQLException exception) {
throw new IllegalStateException("Could not compute announcement sort order", exception);
}
}
public record AnnouncementConfig(boolean enabled, int intervalSeconds, long updatedAt) {
}
}
@@ -0,0 +1,188 @@
package de.winniepat.minePanel.extensions.announcements;
import de.winniepat.minePanel.extensions.ExtensionContext;
import org.bukkit.entity.Player;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
public final class AnnouncementService {
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final ExtensionContext context;
private final AnnouncementRepository repository;
private volatile boolean enabled;
private volatile int intervalSeconds;
private volatile long nextRunAt;
private int rotationCursor;
private de.winniepat.minePanel.util.ServerSchedulerBridge.CancellableTask task;
public AnnouncementService(ExtensionContext context, AnnouncementRepository repository) {
this.context = context;
this.repository = repository;
AnnouncementRepository.AnnouncementConfig config = repository.readConfig();
this.enabled = config.enabled();
this.intervalSeconds = Math.max(10, config.intervalSeconds());
this.nextRunAt = System.currentTimeMillis() + (long) this.intervalSeconds * 1000L;
this.rotationCursor = 0;
}
public void start() {
stop();
this.task = context.schedulerBridge().runRepeatingGlobal(this::tick, 20L, 20L);
}
public void stop() {
if (task != null) {
task.cancel();
task = null;
}
}
public AnnouncementState state() {
return new AnnouncementState(enabled, intervalSeconds, nextRunAt, repository.listMessages());
}
public void updateConfig(boolean enabled, int intervalSeconds) {
this.enabled = enabled;
this.intervalSeconds = Math.max(10, Math.min(86_400, intervalSeconds));
this.nextRunAt = System.currentTimeMillis() + (long) this.intervalSeconds * 1000L;
repository.saveConfig(this.enabled, this.intervalSeconds, System.currentTimeMillis());
}
public boolean sendNow() {
Announcement next = nextAnnouncement();
if (next == null) {
return false;
}
broadcast(next.message());
nextRunAt = System.currentTimeMillis() + (long) intervalSeconds * 1000L;
return true;
}
public long addMessage(String message) {
long id = repository.createMessage(message, System.currentTimeMillis());
if (id > 0) {
nextRunAt = System.currentTimeMillis() + (long) intervalSeconds * 1000L;
}
return id;
}
public boolean deleteMessage(long id) {
boolean deleted = repository.deleteMessage(id);
if (deleted) {
rotationCursor = 0;
}
return deleted;
}
public boolean setMessageEnabled(long id, boolean enabled) {
boolean updated = repository.setMessageEnabled(id, enabled, System.currentTimeMillis());
if (updated) {
rotationCursor = 0;
}
return updated;
}
private void tick() {
if (!enabled) {
return;
}
long now = System.currentTimeMillis();
if (now < nextRunAt) {
return;
}
Announcement next = nextAnnouncement();
if (next == null) {
nextRunAt = now + (long) intervalSeconds * 1000L;
return;
}
broadcast(next.message());
nextRunAt = now + (long) intervalSeconds * 1000L;
}
private Announcement nextAnnouncement() {
List<Announcement> enabledMessages = repository.listMessages().stream()
.filter(Announcement::enabled)
.toList();
if (enabledMessages.isEmpty()) {
rotationCursor = 0;
return null;
}
if (rotationCursor >= enabledMessages.size()) {
rotationCursor = 0;
}
Announcement selected = enabledMessages.get(rotationCursor);
rotationCursor = (rotationCursor + 1) % enabledMessages.size();
return selected;
}
private void broadcast(String message) {
long nowMillis = System.currentTimeMillis();
List<Player> onlinePlayers = List.copyOf(context.plugin().getServer().getOnlinePlayers());
for (Player onlinePlayer : onlinePlayers) {
String resolved = resolvePlaceholders(message, onlinePlayer, nowMillis, onlinePlayers.size());
onlinePlayer.sendMessage(resolved);
}
context.panelLogger().log("SYSTEM", "ANNOUNCEMENTS", "Broadcasted announcement: " + message);
}
private String resolvePlaceholders(String template, Player player, long nowMillis, int onlineCount) {
if (template == null || template.isBlank()) {
return "";
}
ZoneId zoneId = ZoneId.systemDefault();
var now = Instant.ofEpochMilli(nowMillis).atZone(zoneId);
double tps = readPrimaryTps();
String resolved = template;
resolved = resolved.replace("%player%", player == null ? "Player" : player.getName());
resolved = resolved.replace("%time%", TIME_FORMAT.format(now));
resolved = resolved.replace("%date%", DATE_FORMAT.format(now));
resolved = resolved.replace("%datetime%", DATETIME_FORMAT.format(now));
resolved = resolved.replace("%online%", String.valueOf(onlineCount));
resolved = resolved.replace("%max_players%", String.valueOf(context.plugin().getServer().getMaxPlayers()));
resolved = resolved.replace("%world%", player == null || player.getWorld() == null ? "unknown" : player.getWorld().getName());
resolved = resolved.replace("%server%", context.plugin().getServer().getName());
resolved = resolved.replace("%tps%", tps < 0 ? "N/A" : String.format(Locale.US, "%.2f", tps));
return resolved;
}
private double readPrimaryTps() {
try {
Object result = context.plugin().getServer().getClass().getMethod("getTPS").invoke(context.plugin().getServer());
if (result instanceof double[] values && values.length > 0) {
return values[0];
}
} catch (Exception ignored) {
// Keep placeholder available on implementations without Paper TPS API.
}
return -1.0;
}
public record AnnouncementState(
boolean enabled,
int intervalSeconds,
long nextRunAt,
List<Announcement> messages
) {
}
}
@@ -0,0 +1,173 @@
package de.winniepat.minePanel.extensions.announcements;
import com.google.gson.Gson;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.users.PanelPermission;
import java.util.List;
import java.util.Map;
public final class AnnouncementsExtension implements MinePanelExtension {
private final Gson gson = new Gson();
private ExtensionContext context;
private AnnouncementService service;
@Override
public String id() {
return "announcements";
}
@Override
public String displayName() {
return "Announcements";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
AnnouncementRepository repository = new AnnouncementRepository(context.database());
repository.initializeSchema();
this.service = new AnnouncementService(context, repository);
}
@Override
public void onEnable() {
service.start();
}
@Override
public void onDisable() {
if (service != null) {
service.stop();
}
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/announcements", PanelPermission.VIEW_ANNOUNCEMENTS, (request, response, user) -> {
AnnouncementService.AnnouncementState state = service.state();
List<Map<String, Object>> messages = state.messages().stream()
.map(message -> Map.<String, Object>of(
"id", message.id(),
"message", message.message(),
"enabled", message.enabled(),
"sortOrder", message.sortOrder(),
"createdAt", message.createdAt(),
"updatedAt", message.updatedAt()
))
.toList();
return webRegistry.json(response, 200, Map.of(
"enabled", state.enabled(),
"intervalSeconds", state.intervalSeconds(),
"nextRunAt", state.nextRunAt(),
"messages", messages
));
});
webRegistry.post("/api/extensions/announcements/config", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
ConfigPayload payload = gson.fromJson(request.body(), ConfigPayload.class);
if (payload == null || payload.enabled() == null || payload.intervalSeconds() == null) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
service.updateConfig(payload.enabled(), payload.intervalSeconds());
context.panelLogger().log("AUDIT", user.username(), "Updated announcements configuration");
return webRegistry.json(response, 200, Map.of("ok", true));
});
webRegistry.post("/api/extensions/announcements/messages", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
MessagePayload payload = gson.fromJson(request.body(), MessagePayload.class);
String message = payload == null ? "" : sanitizeMessage(payload.message());
if (message.isBlank()) {
return webRegistry.json(response, 400, Map.of("error", "invalid_message"));
}
long id = service.addMessage(message);
if (id <= 0) {
return webRegistry.json(response, 500, Map.of("error", "create_failed"));
}
context.panelLogger().log("AUDIT", user.username(), "Added announcement #" + id);
return webRegistry.json(response, 201, Map.of("ok", true, "id", id));
});
webRegistry.post("/api/extensions/announcements/messages/:id/delete", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
long id = parseId(request.params("id"));
if (id <= 0) {
return webRegistry.json(response, 400, Map.of("error", "invalid_message_id"));
}
if (!service.deleteMessage(id)) {
return webRegistry.json(response, 404, Map.of("error", "message_not_found"));
}
context.panelLogger().log("AUDIT", user.username(), "Deleted announcement #" + id);
return webRegistry.json(response, 200, Map.of("ok", true));
});
webRegistry.post("/api/extensions/announcements/messages/:id/toggle", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
long id = parseId(request.params("id"));
TogglePayload payload = gson.fromJson(request.body(), TogglePayload.class);
if (id <= 0 || payload == null || payload.enabled() == null) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
if (!service.setMessageEnabled(id, payload.enabled())) {
return webRegistry.json(response, 404, Map.of("error", "message_not_found"));
}
context.panelLogger().log("AUDIT", user.username(), "Set announcement #" + id + " enabled=" + payload.enabled());
return webRegistry.json(response, 200, Map.of("ok", true));
});
webRegistry.post("/api/extensions/announcements/send-now", PanelPermission.MANAGE_ANNOUNCEMENTS, (request, response, user) -> {
if (!service.sendNow()) {
return webRegistry.json(response, 400, Map.of("error", "no_enabled_messages"));
}
context.panelLogger().log("AUDIT", user.username(), "Sent announcement manually");
return webRegistry.json(response, 200, Map.of("ok", true));
});
}
@Override
public List<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Announcements", "/dashboard/announcements"));
}
private long parseId(String raw) {
try {
return Long.parseLong(raw);
} catch (Exception ignored) {
return -1L;
}
}
private String sanitizeMessage(String raw) {
if (raw == null) {
return "";
}
String trimmed = raw.trim();
if (trimmed.length() > 220) {
return trimmed.substring(0, 220);
}
return trimmed;
}
private record ConfigPayload(Boolean enabled, Integer intervalSeconds) {
}
private record MessagePayload(String message) {
}
private record TogglePayload(Boolean enabled) {
}
}
@@ -0,0 +1,108 @@
package de.winniepat.minePanel.extensions.luckperms;
import de.winniepat.minePanel.extensions.*;
import de.winniepat.minePanel.users.PanelPermission;
import net.luckperms.api.LuckPerms;
import net.luckperms.api.LuckPermsProvider;
import net.luckperms.api.cacheddata.CachedMetaData;
import net.luckperms.api.model.group.Group;
import net.luckperms.api.model.user.User;
import net.luckperms.api.query.QueryOptions;
import java.util.*;
import java.util.concurrent.TimeUnit;
public final class LuckPermsExtension implements MinePanelExtension {
private ExtensionContext context;
@Override
public String id() {
return "luckperms";
}
@Override
public String displayName() {
return "LuckPerms Integration";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/luckperms/player/:uuid", PanelPermission.VIEW_LUCKPERMS, (request, response, user) -> {
UUID playerUuid;
try {
playerUuid = UUID.fromString(request.params("uuid"));
} catch (IllegalArgumentException exception) {
return webRegistry.json(response, 400, Map.of("error", "invalid_uuid"));
}
if (!isLuckPermsAvailable()) {
return webRegistry.json(response, 200, Map.of(
"available", false,
"error", "luckperms_not_present"
));
}
try {
LuckPerms api = LuckPermsProvider.get();
User lpUser = api.getUserManager().loadUser(playerUuid).get(3, TimeUnit.SECONDS);
QueryOptions queryOptions = api.getContextManager()
.getQueryOptions(lpUser)
.orElse(api.getContextManager().getStaticQueryOptions());
CachedMetaData metaData = lpUser.getCachedData().getMetaData(queryOptions);
String prefix = sanitize(metaData.getPrefix());
String suffix = sanitize(metaData.getSuffix());
List<String> groups = lpUser.getInheritedGroups(queryOptions).stream()
.map(Group::getName)
.distinct()
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
List<String> permissions = lpUser.getCachedData().getPermissionData(queryOptions).getPermissionMap().entrySet().stream()
.filter(entry -> Boolean.TRUE.equals(entry.getValue()))
.map(Map.Entry::getKey)
.distinct()
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
List<String> limitedPermissions = permissions.stream().limit(200).toList();
boolean permissionsTruncated = permissions.size() > limitedPermissions.size();
Map<String, Object> payload = new HashMap<>();
payload.put("available", true);
payload.put("primaryGroup", sanitize(lpUser.getPrimaryGroup()));
payload.put("prefix", prefix);
payload.put("suffix", suffix);
payload.put("groups", groups);
payload.put("permissions", limitedPermissions);
payload.put("permissionsCount", permissions.size());
payload.put("permissionsTruncated", permissionsTruncated);
payload.put("generatedAt", System.currentTimeMillis());
return webRegistry.json(response, 200, payload);
} catch (Exception exception) {
return webRegistry.json(response, 500, Map.of(
"available", false,
"error", "luckperms_lookup_failed",
"details", exception.getClass().getSimpleName()
));
}
});
}
private boolean isLuckPermsAvailable() {
return context.plugin().getServer().getPluginManager().getPlugin("LuckPerms") != null;
}
private String sanitize(String value) {
return value == null || value.isBlank() ? "-" : value;
}
}
@@ -0,0 +1,141 @@
package de.winniepat.minePanel.extensions.maintenance;
import com.google.gson.Gson;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.users.PanelPermission;
import org.bukkit.event.HandlerList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class MaintenanceExtension implements MinePanelExtension {
private final Gson gson = new Gson();
private ExtensionContext context;
private MaintenanceService maintenanceService;
private MaintenanceJoinListener joinListener;
private MaintenanceMotdListener motdListener;
@Override
public String id() {
return "maintenance";
}
@Override
public String displayName() {
return "Maintenance";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
this.maintenanceService = new MaintenanceService(context);
}
@Override
public void onEnable() {
this.joinListener = new MaintenanceJoinListener(maintenanceService);
this.motdListener = new MaintenanceMotdListener(maintenanceService);
context.plugin().getServer().getPluginManager().registerEvents(joinListener, context.plugin());
context.plugin().getServer().getPluginManager().registerEvents(motdListener, context.plugin());
}
@Override
public void onDisable() {
if (joinListener != null) {
HandlerList.unregisterAll(joinListener);
joinListener = null;
}
if (motdListener != null) {
HandlerList.unregisterAll(motdListener);
motdListener = null;
}
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/maintenance/status", PanelPermission.VIEW_MAINTENANCE, (request, response, user) -> {
MaintenanceService.MaintenanceSnapshot snapshot = maintenanceService.snapshot();
return webRegistry.json(response, 200, Map.of(
"enabled", snapshot.enabled(),
"reason", snapshot.reason(),
"motd", snapshot.motd(),
"changedBy", snapshot.changedBy(),
"changedAt", snapshot.changedAt(),
"affectedOnlinePlayers", snapshot.affectedOnlinePlayers(),
"bypassPermission", MaintenanceService.BYPASS_PERMISSION
));
});
webRegistry.post("/api/extensions/maintenance/enable", PanelPermission.MANAGE_MAINTENANCE, (request, response, user) -> {
TogglePayload payload = gson.fromJson(request.body(), TogglePayload.class);
String reason = payload != null ? payload.reason() : null;
String motd = payload != null ? payload.motd() : null;
boolean kickNonStaff = payload != null && Boolean.TRUE.equals(payload.kickNonStaff());
int kicked;
try {
kicked = maintenanceService.enable(user.username(), reason, motd, kickNonStaff);
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "maintenance_enable_failed", "details", exception.getMessage()));
}
context.panelLogger().log(
"AUDIT",
user.username(),
"Enabled maintenance mode" + (kickNonStaff ? " and kicked " + kicked + " player(s)" : "")
);
return webRegistry.json(response, 200, toStatusPayload(true, kicked));
});
webRegistry.post("/api/extensions/maintenance/disable", PanelPermission.MANAGE_MAINTENANCE, (request, response, user) -> {
maintenanceService.disable(user.username());
context.panelLogger().log("AUDIT", user.username(), "Disabled maintenance mode");
return webRegistry.json(response, 200, toStatusPayload(true, 0));
});
webRegistry.post("/api/extensions/maintenance/kick-nonstaff", PanelPermission.MANAGE_MAINTENANCE, (request, response, user) -> {
if (!maintenanceService.isEnabled()) {
return webRegistry.json(response, 400, Map.of("error", "maintenance_not_enabled"));
}
int kicked;
try {
kicked = maintenanceService.kickNonStaffPlayers();
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "maintenance_kick_failed", "details", exception.getMessage()));
}
context.panelLogger().log("AUDIT", user.username(), "Kicked " + kicked + " non-staff player(s) during maintenance");
return webRegistry.json(response, 200, toStatusPayload(true, kicked));
});
}
@Override
public List<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Maintenance", "/dashboard/maintenance"));
}
private Map<String, Object> toStatusPayload(boolean ok, int kicked) {
MaintenanceService.MaintenanceSnapshot snapshot = maintenanceService.snapshot();
Map<String, Object> payload = new HashMap<>();
payload.put("ok", ok);
payload.put("enabled", snapshot.enabled());
payload.put("reason", snapshot.reason());
payload.put("motd", snapshot.motd());
payload.put("changedBy", snapshot.changedBy());
payload.put("changedAt", snapshot.changedAt());
payload.put("affectedOnlinePlayers", snapshot.affectedOnlinePlayers());
payload.put("kicked", kicked);
payload.put("bypassPermission", MaintenanceService.BYPASS_PERMISSION);
return payload;
}
private record TogglePayload(String reason, String motd, Boolean kickNonStaff) {
}
}
@@ -0,0 +1,29 @@
package de.winniepat.minePanel.extensions.maintenance;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerLoginEvent;
public final class MaintenanceJoinListener implements Listener {
private final MaintenanceService maintenanceService;
public MaintenanceJoinListener(MaintenanceService maintenanceService) {
this.maintenanceService = maintenanceService;
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onPlayerLogin(PlayerLoginEvent event) {
if (!maintenanceService.isEnabled()) {
return;
}
if (maintenanceService.canBypass(event.getPlayer())) {
return;
}
event.disallow(PlayerLoginEvent.Result.KICK_OTHER, maintenanceService.kickMessage());
}
}
@@ -0,0 +1,25 @@
package de.winniepat.minePanel.extensions.maintenance;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.server.ServerListPingEvent;
public final class MaintenanceMotdListener implements Listener {
private final MaintenanceService maintenanceService;
public MaintenanceMotdListener(MaintenanceService maintenanceService) {
this.maintenanceService = maintenanceService;
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onServerListPing(ServerListPingEvent event) {
if (!maintenanceService.isEnabled()) {
return;
}
event.setMotd(maintenanceService.motd());
}
}
@@ -0,0 +1,157 @@
package de.winniepat.minePanel.extensions.maintenance;
import de.winniepat.minePanel.extensions.ExtensionContext;
import org.bukkit.entity.Player;
import java.time.Instant;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class MaintenanceService {
public static final String BYPASS_PERMISSION = "minepanel.maintenance.bypass";
private final ExtensionContext context;
private volatile boolean enabled;
private volatile String reason = "§cServer maintenance in progress";
private volatile String motd = "§cMaintenance mode is active";
private volatile String changedBy = "SYSTEM";
private volatile long changedAt = Instant.now().toEpochMilli();
public MaintenanceService(ExtensionContext context) {
this.context = context;
}
public MaintenanceSnapshot snapshot() {
return new MaintenanceSnapshot(enabled, reason, motd, changedBy, changedAt, countAffectedOnlinePlayers());
}
public int enable(String actor, String nextReason, String nextMotd, boolean kickNonStaff) {
enabled = true;
reason = normalizeReason(nextReason);
motd = normalizeMotd(nextMotd);
changedBy = normalizeActor(actor);
changedAt = Instant.now().toEpochMilli();
if (!kickNonStaff) {
return 0;
}
return kickNonStaffPlayers();
}
public void disable(String actor) {
enabled = false;
changedBy = normalizeActor(actor);
changedAt = Instant.now().toEpochMilli();
}
public boolean isEnabled() {
return enabled;
}
public String reason() {
return reason;
}
public String changedBy() {
return changedBy;
}
public String motd() {
return motd;
}
public long changedAt() {
return changedAt;
}
public boolean canBypass(Player player) {
return player != null && (player.isOp() || player.hasPermission(BYPASS_PERMISSION));
}
public int kickNonStaffPlayers() {
return runSync(this::kickNonStaffPlayersSync);
}
private int kickNonStaffPlayersSync() {
int kicked = 0;
for (Player online : context.plugin().getServer().getOnlinePlayers()) {
if (canBypass(online)) {
continue;
}
kicked++;
online.kickPlayer(kickMessage());
}
return kicked;
}
public int countAffectedOnlinePlayers() {
return runSync(this::countAffectedOnlinePlayersSync);
}
private int countAffectedOnlinePlayersSync() {
int affected = 0;
for (Player online : context.plugin().getServer().getOnlinePlayers()) {
if (!canBypass(online)) {
affected++;
}
}
return affected;
}
public String kickMessage() {
return "Maintenance mode is active. " + reason;
}
private String normalizeReason(String value) {
if (value == null || value.isBlank()) {
return "Server maintenance in progress";
}
String trimmed = value.trim();
return trimmed.length() > 180 ? trimmed.substring(0, 180) : trimmed;
}
private String normalizeMotd(String value) {
if (value == null || value.isBlank()) {
return "Maintenance mode is active";
}
String trimmed = value.trim();
return trimmed.length() > 120 ? trimmed.substring(0, 120) : trimmed;
}
private String normalizeActor(String value) {
if (value == null || value.isBlank()) {
return "SYSTEM";
}
return value.trim();
}
private int runSync(SyncIntTask task) {
try {
return context.schedulerBridge().callGlobal(task::run, 10, TimeUnit.SECONDS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("maintenance_task_interrupted", exception);
} catch (ExecutionException | TimeoutException exception) {
throw new IllegalStateException("maintenance_task_failed", exception);
}
}
@FunctionalInterface
private interface SyncIntTask {
int run();
}
public record MaintenanceSnapshot(
boolean enabled,
String reason,
String motd,
String changedBy,
long changedAt,
int affectedOnlinePlayers
) {
}
}
@@ -0,0 +1,308 @@
package de.winniepat.minePanel.extensions.playermanagement;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import de.winniepat.minePanel.extensions.ExtensionConfigurable;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.persistence.ExtensionSettingsRepository;
import de.winniepat.minePanel.persistence.KnownPlayer;
import de.winniepat.minePanel.users.PanelPermission;
import org.bukkit.entity.Player;
import org.bukkit.event.HandlerList;
import java.time.Instant;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public final class PlayerManagementExtension implements MinePanelExtension, ExtensionConfigurable {
private final Gson gson = new Gson();
private ExtensionContext context;
private PlayerMuteRepository muteRepository;
private PlayerMuteListener muteListener;
private ExtensionSettingsRepository extensionSettingsRepository;
@Override
public String id() {
return "player-management";
}
@Override
public String displayName() {
return "Player Management";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
this.muteRepository = new PlayerMuteRepository(context.database());
this.muteRepository.initializeSchema();
this.extensionSettingsRepository = new ExtensionSettingsRepository(context.database());
}
@Override
public void onEnable() {
muteRepository.clearExpired(Instant.now().toEpochMilli());
ChatFilterSettings settings = loadChatFilterSettings();
muteListener = new PlayerMuteListener(
muteRepository,
context.panelLogger(),
settings.enabled(),
settings.badWords(),
settings.autoMuteMinutes(),
settings.autoMuteReason(),
settings.cancelMessage()
);
context.plugin().getServer().getPluginManager().registerEvents(muteListener, context.plugin());
}
@Override
public void onDisable() {
if (muteListener != null) {
HandlerList.unregisterAll(muteListener);
muteListener = null;
}
}
@Override
public void onSettingsUpdated(String settingsJson) {
ChatFilterSettings latest = parseChatFilterSettings(settingsJson);
if (muteListener == null) {
return;
}
muteListener.updateFilterConfig(
latest.enabled(),
latest.badWords(),
latest.autoMuteMinutes(),
latest.autoMuteReason(),
latest.cancelMessage()
);
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.post("/api/extensions/player-management/mute", PanelPermission.MANAGE_PLAYER_MANAGEMENT, (request, response, user) -> {
MutePayload payload = gson.fromJson(request.body(), MutePayload.class);
if (payload == null || (isBlank(payload.uuid()) && isBlank(payload.username()))) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
Optional<KnownPlayer> target = resolveTarget(payload.uuid(), payload.username());
if (target.isEmpty()) {
return webRegistry.json(response, 404, Map.of("error", "player_not_found"));
}
int minutes = payload.durationMinutes() == null ? 0 : Math.max(0, Math.min(43_200, payload.durationMinutes()));
String reason = isBlank(payload.reason()) ? "Muted by panel moderator" : payload.reason().trim();
long now = Instant.now().toEpochMilli();
Long expiresAt = minutes <= 0 ? null : now + minutes * 60_000L;
muteRepository.upsertMute(target.get().uuid(), target.get().username(), reason, user.username(), now, expiresAt);
context.panelLogger().log("AUDIT", user.username(), "Muted " + target.get().username() + (minutes > 0 ? " for " + minutes + " minute(s)" : " permanently") + ": " + reason);
Player online = context.plugin().getServer().getPlayer(target.get().uuid());
if (online != null) {
online.sendMessage("You were muted by a moderator. Reason: " + reason);
}
java.util.Map<String, Object> resultPayload = new java.util.HashMap<>();
resultPayload.put("ok", true);
resultPayload.put("uuid", target.get().uuid().toString());
resultPayload.put("username", target.get().username());
resultPayload.put("expiresAt", expiresAt);
return webRegistry.json(response, 200, resultPayload);
});
webRegistry.post("/api/extensions/player-management/unmute", PanelPermission.MANAGE_PLAYER_MANAGEMENT, (request, response, user) -> {
UnmutePayload payload = gson.fromJson(request.body(), UnmutePayload.class);
if (payload == null || (isBlank(payload.uuid()) && isBlank(payload.username()))) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
Optional<KnownPlayer> target = resolveTarget(payload.uuid(), payload.username());
if (target.isEmpty()) {
return webRegistry.json(response, 404, Map.of("error", "player_not_found"));
}
boolean removed = muteRepository.removeMute(target.get().uuid());
if (!removed) {
return webRegistry.json(response, 404, Map.of("error", "player_not_muted"));
}
context.panelLogger().log("AUDIT", user.username(), "Unmuted " + target.get().username());
return webRegistry.json(response, 200, Map.of("ok", true));
});
webRegistry.get("/api/extensions/player-management/mute/:uuid", PanelPermission.VIEW_PLAYER_MANAGEMENT, (request, response, user) -> {
UUID uuid;
try {
uuid = UUID.fromString(request.params("uuid"));
} catch (IllegalArgumentException exception) {
return webRegistry.json(response, 400, Map.of("error", "invalid_uuid"));
}
muteRepository.clearExpired(Instant.now().toEpochMilli());
Optional<PlayerMute> mute = muteRepository.findByUuid(uuid);
if (mute.isEmpty()) {
return webRegistry.json(response, 200, Map.of("muted", false));
}
return webRegistry.json(response, 200, Map.of(
"muted", true,
"username", mute.get().username(),
"reason", mute.get().reason(),
"mutedBy", mute.get().mutedBy(),
"mutedAt", mute.get().mutedAt(),
"expiresAt", mute.get().expiresAt()
));
});
}
private Optional<KnownPlayer> resolveTarget(String rawUuid, String rawUsername) {
if (!isBlank(rawUuid)) {
try {
UUID uuid = UUID.fromString(rawUuid.trim());
Optional<KnownPlayer> known = context.knownPlayerRepository().findByUuid(uuid);
if (known.isPresent()) {
return known;
}
} catch (IllegalArgumentException ignored) {
// Fallback to username lookup.
}
}
if (!isBlank(rawUsername)) {
Optional<KnownPlayer> knownByName = context.knownPlayerRepository().findByUsername(rawUsername.trim());
if (knownByName.isPresent()) {
return knownByName;
}
Player online = context.plugin().getServer().getPlayerExact(rawUsername.trim());
if (online != null) {
long now = Instant.now().toEpochMilli();
context.knownPlayerRepository().upsert(online.getUniqueId(), online.getName(), now);
return context.knownPlayerRepository().findByUuid(online.getUniqueId());
}
}
return Optional.empty();
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private Set<String> parseBadWords(List<String> configuredWords) {
Set<String> result = new LinkedHashSet<>();
if (configuredWords == null) {
return result;
}
for (String rawWord : configuredWords) {
if (rawWord == null || rawWord.isBlank()) {
continue;
}
result.add(rawWord.trim());
}
return result;
}
private ChatFilterSettings loadChatFilterSettings() {
String settingsJson = extensionSettingsRepository.findSettingsJson(id()).orElse("{}");
return parseChatFilterSettings(settingsJson);
}
private ChatFilterSettings parseChatFilterSettings(String settingsJson) {
try {
JsonElement root = gson.fromJson(settingsJson, JsonElement.class);
if (root == null || !root.isJsonObject()) {
return ChatFilterSettings.defaults();
}
JsonObject rootObject = root.getAsJsonObject();
JsonObject filter = rootObject.has("badWordFilter") && rootObject.get("badWordFilter").isJsonObject()
? rootObject.getAsJsonObject("badWordFilter")
: new JsonObject();
boolean enabled = booleanValue(filter, "enabled", false);
int autoMuteMinutes = intValue(filter, "autoMuteMinutes", 15, 0, 43_200);
String autoMuteReason = stringValue(filter, "autoMuteReason", "Inappropriate language");
boolean cancelMessage = booleanValue(filter, "cancelMessage", true);
Set<String> words = parseBadWords(readWords(filter));
return new ChatFilterSettings(enabled, words, autoMuteMinutes, autoMuteReason, cancelMessage);
} catch (Exception exception) {
context.plugin().getLogger().warning("Could not parse player-management extension settings: " + exception.getMessage());
return ChatFilterSettings.defaults();
}
}
private List<String> readWords(JsonObject filter) {
if (filter == null || !filter.has("words") || !filter.get("words").isJsonArray()) {
return List.of();
}
JsonArray array = filter.getAsJsonArray("words");
java.util.ArrayList<String> words = new java.util.ArrayList<>();
for (JsonElement element : array) {
if (element == null || !element.isJsonPrimitive()) {
continue;
}
words.add(element.getAsString());
}
return words;
}
private boolean booleanValue(JsonObject object, String key, boolean fallback) {
if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()) {
return fallback;
}
try {
return object.get(key).getAsBoolean();
} catch (Exception ignored) {
return fallback;
}
}
private int intValue(JsonObject object, String key, int fallback, int min, int max) {
if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()) {
return fallback;
}
try {
int value = object.get(key).getAsInt();
return Math.max(min, Math.min(max, value));
} catch (Exception ignored) {
return fallback;
}
}
private String stringValue(JsonObject object, String key, String fallback) {
if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()) {
return fallback;
}
String value = object.get(key).getAsString();
return (value == null || value.isBlank()) ? fallback : value.trim();
}
private record ChatFilterSettings(boolean enabled, Set<String> badWords, int autoMuteMinutes, String autoMuteReason, boolean cancelMessage) {
private static ChatFilterSettings defaults() {
return new ChatFilterSettings(false, Set.of(), 15, "Inappropriate language", true);
}
}
private record MutePayload(String uuid, String username, Integer durationMinutes, String reason) {
}
private record UnmutePayload(String uuid, String username) {
}
}
@@ -0,0 +1,21 @@
package de.winniepat.minePanel.extensions.playermanagement;
import java.util.UUID;
public record PlayerMute(
UUID uuid,
String username,
String reason,
String mutedBy,
long mutedAt,
Long expiresAt
) {
public boolean isExpired(long nowMillis) {
return expiresAt != null && expiresAt > 0 && nowMillis >= expiresAt;
}
public boolean isActive(long nowMillis) {
return !isExpired(nowMillis);
}
}
@@ -0,0 +1,164 @@
package de.winniepat.minePanel.extensions.playermanagement;
import de.winniepat.minePanel.logs.PanelLogger;
import io.papermc.paper.event.player.AsyncChatEvent;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.AsyncPlayerChatEvent;
import java.time.Instant;
import java.util.HashSet;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
public final class PlayerMuteListener implements Listener {
private static final PlainTextComponentSerializer PLAIN_TEXT_SERIALIZER = PlainTextComponentSerializer.plainText();
private final PlayerMuteRepository muteRepository;
private final PanelLogger panelLogger;
private volatile boolean badWordFilterEnabled;
private volatile Set<String> badWords;
private volatile int autoMuteMinutes;
private volatile String autoMuteReason;
private volatile boolean cancelBlockedMessage;
public PlayerMuteListener(
PlayerMuteRepository muteRepository,
PanelLogger panelLogger,
boolean badWordFilterEnabled,
Set<String> badWords,
int autoMuteMinutes,
String autoMuteReason,
boolean cancelBlockedMessage
) {
this.muteRepository = muteRepository;
this.panelLogger = panelLogger;
updateFilterConfig(badWordFilterEnabled, badWords, autoMuteMinutes, autoMuteReason, cancelBlockedMessage);
}
public void updateFilterConfig(
boolean badWordFilterEnabled,
Set<String> badWords,
int autoMuteMinutes,
String autoMuteReason,
boolean cancelBlockedMessage
) {
this.badWordFilterEnabled = badWordFilterEnabled;
this.badWords = badWords == null ? Set.of() : new HashSet<>(badWords);
this.autoMuteMinutes = Math.max(0, autoMuteMinutes);
this.autoMuteReason = (autoMuteReason == null || autoMuteReason.isBlank())
? "Inappropriate language"
: autoMuteReason.trim();
this.cancelBlockedMessage = cancelBlockedMessage;
}
@EventHandler(ignoreCancelled = true)
public void onAsyncPlayerChat(AsyncPlayerChatEvent event) {
processChatMessage(event.getPlayer(), event.getMessage(), event::setCancelled);
}
@EventHandler(ignoreCancelled = true)
public void onAsyncChat(AsyncChatEvent event) {
processChatMessage(event.getPlayer(), PLAIN_TEXT_SERIALIZER.serialize(event.message()), event::setCancelled);
}
private void processChatMessage(Player player, String message, CancelHandler cancelHandler) {
long now = Instant.now().toEpochMilli();
Optional<PlayerMute> mute = muteRepository.findByUuid(player.getUniqueId());
if (mute.isEmpty()) {
handleBadWordFilter(player, message, now, cancelHandler);
return;
}
PlayerMute activeMute = mute.get();
if (activeMute.isExpired(now)) {
muteRepository.removeMute(activeMute.uuid());
handleBadWordFilter(player, message, now, cancelHandler);
return;
}
cancelHandler.cancel(true);
if (activeMute.expiresAt() == null) {
player.sendMessage(ChatColor.RED + "You are muted. Reason: " + activeMute.reason());
return;
}
long secondsLeft = Math.max(1L, (activeMute.expiresAt() - now) / 1000L);
player.sendMessage(ChatColor.RED + "You are muted for another " + secondsLeft + "s. Reason: " + activeMute.reason());
}
private void handleBadWordFilter(Player player, String message, long now, CancelHandler cancelHandler) {
if (!badWordFilterEnabled || badWords.isEmpty()) {
return;
}
if (player.hasPermission("minepanel.chatfilter.bypass") || player.isOp()) {
return;
}
String matchedWord = findMatchedBadWord(message);
if (matchedWord == null) {
return;
}
Long expiresAt = autoMuteMinutes <= 0 ? null : now + autoMuteMinutes * 60_000L;
muteRepository.upsertMute(
player.getUniqueId(),
player.getName(),
autoMuteReason,
"AUTO_MOD",
now,
expiresAt
);
if (cancelBlockedMessage) {
cancelHandler.cancel(true);
}
panelLogger.log(
"SECURITY",
"CHAT_FILTER",
"Auto-muted " + player.getName() + " for bad language (matched: " + matchedWord + ")"
);
if (expiresAt == null) {
player.sendMessage(ChatColor.RED + "Your message contained blocked language. You have been muted. Reason: " + autoMuteReason);
return;
}
player.sendMessage(ChatColor.RED + "Your message contained blocked language. You have been muted for " + autoMuteMinutes + " minute(s). Reason: " + autoMuteReason);
}
private String findMatchedBadWord(String message) {
if (message == null || message.isBlank()) {
return null;
}
String lower = message.toLowerCase(Locale.ROOT);
for (String badWord : badWords) {
if (badWord == null || badWord.isBlank()) {
continue;
}
String normalizedBadWord = badWord.toLowerCase(Locale.ROOT).trim();
String pattern = "(^|[^a-z0-9])" + Pattern.quote(normalizedBadWord) + "([^a-z0-9]|$)";
if (Pattern.compile(pattern).matcher(lower).find()) {
return badWord;
}
}
return null;
}
@FunctionalInterface
private interface CancelHandler {
void cancel(boolean cancelled);
}
}
@@ -0,0 +1,112 @@
package de.winniepat.minePanel.extensions.playermanagement;
import de.winniepat.minePanel.persistence.Database;
import java.sql.*;
import java.util.*;
public final class PlayerMuteRepository {
private final Database database;
public PlayerMuteRepository(Database database) {
this.database = database;
}
public void initializeSchema() {
String sql = "CREATE TABLE IF NOT EXISTS player_mutes ("
+ "uuid TEXT PRIMARY KEY,"
+ "username TEXT NOT NULL,"
+ "reason TEXT NOT NULL,"
+ "muted_by TEXT NOT NULL,"
+ "muted_at INTEGER NOT NULL,"
+ "expires_at INTEGER"
+ ")";
try (Connection connection = database.getConnection();
Statement statement = connection.createStatement()) {
statement.execute(sql);
} catch (SQLException exception) {
throw new IllegalStateException("Could not initialize mute schema", exception);
}
}
public void upsertMute(UUID uuid, String username, String reason, String mutedBy, long mutedAt, Long expiresAt) {
String sql = "INSERT INTO player_mutes(uuid, username, reason, muted_by, muted_at, expires_at) VALUES (?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(uuid) DO UPDATE SET "
+ "username = excluded.username, "
+ "reason = excluded.reason, "
+ "muted_by = excluded.muted_by, "
+ "muted_at = excluded.muted_at, "
+ "expires_at = excluded.expires_at";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
statement.setString(2, username == null ? "" : username);
statement.setString(3, reason == null ? "" : reason);
statement.setString(4, mutedBy == null ? "" : mutedBy);
statement.setLong(5, mutedAt);
if (expiresAt == null || expiresAt <= 0) {
statement.setNull(6, Types.BIGINT);
} else {
statement.setLong(6, expiresAt);
}
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not save mute", exception);
}
}
public Optional<PlayerMute> findByUuid(UUID uuid) {
String sql = "SELECT uuid, username, reason, muted_by, muted_at, expires_at FROM player_mutes WHERE uuid = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(read(resultSet));
}
}
return Optional.empty();
} catch (SQLException exception) {
throw new IllegalStateException("Could not read mute", exception);
}
}
public boolean removeMute(UUID uuid) {
String sql = "DELETE FROM player_mutes WHERE uuid = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
return statement.executeUpdate() > 0;
} catch (SQLException exception) {
throw new IllegalStateException("Could not delete mute", exception);
}
}
public int clearExpired(long nowMillis) {
String sql = "DELETE FROM player_mutes WHERE expires_at IS NOT NULL AND expires_at > 0 AND expires_at <= ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, nowMillis);
return statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not clear expired mutes", exception);
}
}
private PlayerMute read(ResultSet resultSet) throws SQLException {
long rawExpires = resultSet.getLong("expires_at");
Long expiresAt = resultSet.wasNull() ? null : rawExpires;
return new PlayerMute(
UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username"),
resultSet.getString("reason"),
resultSet.getString("muted_by"),
resultSet.getLong("muted_at"),
expiresAt
);
}
}
@@ -0,0 +1,182 @@
package de.winniepat.minePanel.extensions.playerstats;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.users.PanelPermission;
import org.bukkit.OfflinePlayer;
import org.bukkit.Statistic;
import java.lang.reflect.Method;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class PlayerStatsExtension implements MinePanelExtension {
private ExtensionContext context;
@Override
public String id() {
return "player-stats";
}
@Override
public String displayName() {
return "Player Stats";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/player-stats/player/:uuid", PanelPermission.VIEW_PLAYER_STATS, (request, response, user) -> {
UUID playerUuid;
try {
playerUuid = UUID.fromString(request.params("uuid"));
} catch (IllegalArgumentException exception) {
return webRegistry.json(response, 400, Map.of("error", "invalid_uuid"));
}
StatsSnapshot snapshot;
try {
snapshot = fetchStats(playerUuid);
} catch (Exception exception) {
return webRegistry.json(response, 500, Map.of(
"available", false,
"error", "player_stats_lookup_failed",
"details", exception.getClass().getSimpleName()
));
}
Map<String, Object> payload = new HashMap<>();
payload.put("available", true);
payload.put("kills", snapshot.kills());
payload.put("deaths", snapshot.deaths());
payload.put("economyAvailable", snapshot.economyAvailable());
payload.put("balance", snapshot.balance());
payload.put("balanceFormatted", snapshot.balanceFormatted());
payload.put("economyProvider", snapshot.economyProvider());
payload.put("generatedAt", System.currentTimeMillis());
return webRegistry.json(response, 200, payload);
});
}
private StatsSnapshot fetchStats(UUID playerUuid) throws ExecutionException, InterruptedException, TimeoutException {
return context.schedulerBridge().callGlobal(() -> {
OfflinePlayer offlinePlayer = context.plugin().getServer().getOfflinePlayer(playerUuid);
int kills = safeStatistic(offlinePlayer, Statistic.PLAYER_KILLS);
int deaths = safeStatistic(offlinePlayer, Statistic.DEATHS);
EconomyLookup economy = lookupEconomy(offlinePlayer);
return new StatsSnapshot(kills, deaths, economy.available(), economy.balance(), economy.formatted(), economy.provider());
}, 2, TimeUnit.SECONDS);
}
private int safeStatistic(OfflinePlayer player, Statistic statistic) {
try {
return player.getStatistic(statistic);
} catch (Exception ignored) {
return 0;
}
}
private EconomyLookup lookupEconomy(OfflinePlayer player) {
try {
Class<?> economyClass = Class.forName("net.milkbowl.vault.economy.Economy");
Object registration = context.plugin().getServer().getServicesManager().getRegistration(economyClass);
if (registration == null) {
return EconomyLookup.unavailable();
}
Method getProvider = registration.getClass().getMethod("getProvider");
Object provider = getProvider.invoke(registration);
if (provider == null) {
return EconomyLookup.unavailable();
}
String providerName;
try {
Method getName = provider.getClass().getMethod("getName");
providerName = String.valueOf(getName.invoke(provider));
} catch (Exception ignored) {
providerName = provider.getClass().getSimpleName();
}
Double balance = invokeBalance(provider, player);
if (balance == null) {
return new EconomyLookup(true, null, "-", providerName);
}
String formatted = formatBalance(provider, balance);
return new EconomyLookup(true, balance, formatted, providerName);
} catch (ClassNotFoundException ignored) {
return EconomyLookup.unavailable();
} catch (Exception ignored) {
return EconomyLookup.unavailable();
}
}
private Double invokeBalance(Object provider, OfflinePlayer player) {
try {
Method getBalanceOffline = provider.getClass().getMethod("getBalance", OfflinePlayer.class);
Object value = getBalanceOffline.invoke(provider, player);
if (value instanceof Number number) {
return number.doubleValue();
}
} catch (Exception ignored) {
// Fallback below.
}
try {
Method getBalanceString = provider.getClass().getMethod("getBalance", String.class);
String playerName = player.getName();
if (playerName == null || playerName.isBlank()) {
return null;
}
Object value = getBalanceString.invoke(provider, playerName);
if (value instanceof Number number) {
return number.doubleValue();
}
} catch (Exception ignored) {
// No compatible method available.
}
return null;
}
private String formatBalance(Object provider, double value) {
try {
Method formatMethod = provider.getClass().getMethod("format", double.class);
Object formatted = formatMethod.invoke(provider, value);
if (formatted != null) {
return String.valueOf(formatted);
}
} catch (Exception ignored) {
// Fallback below.
}
return String.format(Locale.US, "%.2f", value);
}
private record StatsSnapshot(
int kills,
int deaths,
boolean economyAvailable,
Double balance,
String balanceFormatted,
String economyProvider
) {
}
private record EconomyLookup(boolean available, Double balance, String formatted, String provider) {
private static EconomyLookup unavailable() {
return new EconomyLookup(false, null, "-", "");
}
}
}
@@ -0,0 +1,18 @@
package de.winniepat.minePanel.extensions.reports;
import java.util.UUID;
public record PlayerReport(
long id,
UUID reporterUuid,
String reporterName,
UUID suspectUuid,
String suspectName,
String reason,
String status,
long createdAt,
String reviewedBy,
long reviewedAt
) {
}
@@ -0,0 +1,73 @@
package de.winniepat.minePanel.extensions.reports;
import de.winniepat.minePanel.logs.PanelLogger;
import de.winniepat.minePanel.persistence.KnownPlayerRepository;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.time.Instant;
import java.util.Arrays;
public final class ReportCommand implements CommandExecutor {
private final ReportRepository reportRepository;
private final KnownPlayerRepository knownPlayerRepository;
private final PanelLogger panelLogger;
public ReportCommand(ReportRepository reportRepository, KnownPlayerRepository knownPlayerRepository, PanelLogger panelLogger) {
this.reportRepository = reportRepository;
this.knownPlayerRepository = knownPlayerRepository;
this.panelLogger = panelLogger;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player reporter)) {
sender.sendMessage(ChatColor.RED + "Only players can create reports.");
return true;
}
if (args.length < 2) {
sender.sendMessage(ChatColor.YELLOW + "Usage: /report <player> <reason>");
return true;
}
Player suspect = reporter.getServer().getPlayerExact(args[0]);
if (suspect == null) {
sender.sendMessage(ChatColor.RED + "The reported player must currently be online.");
return true;
}
if (suspect.getUniqueId().equals(reporter.getUniqueId())) {
sender.sendMessage(ChatColor.RED + "You cannot report yourself.");
return true;
}
String reason = String.join(" ", Arrays.copyOfRange(args, 1, args.length)).trim();
if (reason.isBlank()) {
sender.sendMessage(ChatColor.RED + "Please provide a reason.");
return true;
}
long now = Instant.now().toEpochMilli();
knownPlayerRepository.upsert(reporter.getUniqueId(), reporter.getName(), now);
knownPlayerRepository.upsert(suspect.getUniqueId(), suspect.getName(), now);
long reportId = reportRepository.createReport(
reporter.getUniqueId(),
reporter.getName(),
suspect.getUniqueId(),
suspect.getName(),
reason,
now
);
sender.sendMessage(ChatColor.GREEN + "Report submitted (#" + reportId + ") for " + suspect.getName() + ".");
panelLogger.log("REPORT", reporter.getName(), "Created report #" + reportId + " against " + suspect.getName() + ": " + reason);
return true;
}
}
@@ -0,0 +1,130 @@
package de.winniepat.minePanel.extensions.reports;
import de.winniepat.minePanel.persistence.Database;
import java.sql.*;
import java.util.*;
public final class ReportRepository {
private final Database database;
public ReportRepository(Database database) {
this.database = database;
}
public void initializeSchema() {
String sql = "CREATE TABLE IF NOT EXISTS player_reports ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "reporter_uuid TEXT NOT NULL,"
+ "reporter_name TEXT NOT NULL,"
+ "suspect_uuid TEXT NOT NULL,"
+ "suspect_name TEXT NOT NULL,"
+ "reason TEXT NOT NULL,"
+ "status TEXT NOT NULL,"
+ "created_at INTEGER NOT NULL,"
+ "reviewed_by TEXT NOT NULL DEFAULT '',"
+ "reviewed_at INTEGER NOT NULL DEFAULT 0"
+ ")";
try (Connection connection = database.getConnection();
Statement statement = connection.createStatement()) {
statement.execute(sql);
} catch (SQLException exception) {
throw new IllegalStateException("Could not initialize report schema", exception);
}
}
public long createReport(UUID reporterUuid, String reporterName, UUID suspectUuid, String suspectName, String reason, long createdAt) {
String sql = "INSERT INTO player_reports(reporter_uuid, reporter_name, suspect_uuid, suspect_name, reason, status, created_at, reviewed_by, reviewed_at) "
+ "VALUES (?, ?, ?, ?, ?, 'OPEN', ?, '', 0)";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
statement.setString(1, reporterUuid.toString());
statement.setString(2, reporterName);
statement.setString(3, suspectUuid.toString());
statement.setString(4, suspectName);
statement.setString(5, reason == null ? "" : reason);
statement.setLong(6, createdAt);
statement.executeUpdate();
try (ResultSet generatedKeys = statement.getGeneratedKeys()) {
if (generatedKeys.next()) {
return generatedKeys.getLong(1);
}
}
return 0L;
} catch (SQLException exception) {
throw new IllegalStateException("Could not create player report", exception);
}
}
public List<PlayerReport> listReports(String status) {
boolean filterStatus = status != null && !status.isBlank();
String sql = filterStatus
? "SELECT * FROM player_reports WHERE status = ? ORDER BY created_at DESC, id DESC"
: "SELECT * FROM player_reports ORDER BY created_at DESC, id DESC";
List<PlayerReport> reports = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
if (filterStatus) {
statement.setString(1, status.trim().toUpperCase(Locale.ROOT));
}
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
reports.add(read(resultSet));
}
}
return reports;
} catch (SQLException exception) {
throw new IllegalStateException("Could not list reports", exception);
}
}
public Optional<PlayerReport> findById(long id) {
String sql = "SELECT * FROM player_reports WHERE id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, id);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(read(resultSet));
}
}
return Optional.empty();
} catch (SQLException exception) {
throw new IllegalStateException("Could not read report", exception);
}
}
public boolean markResolved(long id, String reviewedBy, long reviewedAt) {
String sql = "UPDATE player_reports SET status = 'RESOLVED', reviewed_by = ?, reviewed_at = ? WHERE id = ? AND status = 'OPEN'";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, reviewedBy == null ? "" : reviewedBy);
statement.setLong(2, reviewedAt);
statement.setLong(3, id);
return statement.executeUpdate() > 0;
} catch (SQLException exception) {
throw new IllegalStateException("Could not resolve report", exception);
}
}
private PlayerReport read(ResultSet resultSet) throws SQLException {
return new PlayerReport(
resultSet.getLong("id"),
UUID.fromString(resultSet.getString("reporter_uuid")),
resultSet.getString("reporter_name"),
UUID.fromString(resultSet.getString("suspect_uuid")),
resultSet.getString("suspect_name"),
resultSet.getString("reason"),
resultSet.getString("status"),
resultSet.getLong("created_at"),
resultSet.getString("reviewed_by"),
resultSet.getLong("reviewed_at")
);
}
}
@@ -0,0 +1,182 @@
package de.winniepat.minePanel.extensions.reports;
import com.google.gson.Gson;
import de.winniepat.minePanel.extensions.*;
import de.winniepat.minePanel.logs.PanelLogger;
import de.winniepat.minePanel.persistence.KnownPlayerRepository;
import de.winniepat.minePanel.users.PanelPermission;
import org.bukkit.BanList;
import org.bukkit.entity.Player;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class ReportSystemExtension implements MinePanelExtension {
private final Gson gson = new Gson();
private ExtensionContext context;
private ReportRepository reportRepository;
@Override
public String id() {
return "report-system";
}
@Override
public String displayName() {
return "Report System";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
this.reportRepository = new ReportRepository(context.database());
this.reportRepository.initializeSchema();
}
@Override
public void onEnable() {
KnownPlayerRepository knownPlayerRepository = context.knownPlayerRepository();
PanelLogger panelLogger = context.panelLogger();
boolean registered = context.commandRegistry().register(
id(),
"report",
"Report a player to MinePanel moderators",
"/report <player> <reason>",
"minepanel.report",
List.of(),
new ReportCommand(reportRepository, knownPlayerRepository, panelLogger)
);
if (!registered) {
context.plugin().getLogger().warning("Could not register /report command for report extension.");
}
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/reports", PanelPermission.VIEW_REPORTS, (request, response, user) -> {
String status = request.queryParams("status");
List<Map<String, Object>> reports = reportRepository.listReports(status).stream().map(this::toReportPayload).toList();
return webRegistry.json(response, 200, Map.of("reports", reports));
});
webRegistry.post("/api/extensions/reports/:id/resolve", PanelPermission.MANAGE_REPORTS, (request, response, user) -> {
long reportId = parseReportId(request.params("id"));
if (reportId <= 0) {
return webRegistry.json(response, 400, Map.of("error", "invalid_report_id"));
}
boolean updated = reportRepository.markResolved(reportId, user.username(), Instant.now().toEpochMilli());
if (!updated) {
return webRegistry.json(response, 404, Map.of("error", "report_not_found_or_closed"));
}
context.panelLogger().log("AUDIT", user.username(), "Resolved player report #" + reportId);
return webRegistry.json(response, 200, Map.of("ok", true));
});
webRegistry.post("/api/extensions/reports/:id/ban", PanelPermission.MANAGE_REPORTS, (request, response, user) -> {
long reportId = parseReportId(request.params("id"));
if (reportId <= 0) {
return webRegistry.json(response, 400, Map.of("error", "invalid_report_id"));
}
Optional<PlayerReport> report = reportRepository.findById(reportId);
if (report.isEmpty()) {
return webRegistry.json(response, 404, Map.of("error", "report_not_found"));
}
BanPayload payload = gson.fromJson(request.body(), BanPayload.class);
Integer durationMinutes = payload == null ? null : payload.durationMinutes();
String reason = payload == null || isBlank(payload.reason())
? "Banned by report review (#" + reportId + ")"
: payload.reason().trim();
BanResult banResult = banPlayer(report.get(), durationMinutes, reason, user.username());
if (!banResult.success()) {
return webRegistry.json(response, 500, Map.of("error", "ban_failed", "details", banResult.error()));
}
reportRepository.markResolved(reportId, user.username(), Instant.now().toEpochMilli());
context.panelLogger().log("AUDIT", user.username(), "Banned " + report.get().suspectName() + " from report #" + reportId + ": " + reason);
java.util.Map<String, Object> resultPayload = new java.util.HashMap<>();
resultPayload.put("ok", true);
resultPayload.put("username", report.get().suspectName());
resultPayload.put("expiresAt", banResult.expiresAt());
return webRegistry.json(response, 200, resultPayload);
});
}
@Override
public List<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Reports", "/dashboard/reports"));
}
private Map<String, Object> toReportPayload(PlayerReport report) {
return Map.of(
"id", report.id(),
"reporterUuid", report.reporterUuid().toString(),
"reporterName", report.reporterName(),
"suspectUuid", report.suspectUuid().toString(),
"suspectName", report.suspectName(),
"reason", report.reason(),
"status", report.status(),
"createdAt", report.createdAt(),
"reviewedBy", report.reviewedBy(),
"reviewedAt", report.reviewedAt()
);
}
private long parseReportId(String rawId) {
try {
return Long.parseLong(rawId);
} catch (Exception ignored) {
return -1L;
}
}
private BanResult banPlayer(PlayerReport report, Integer durationMinutes, String reason, String actor) {
try {
return context.schedulerBridge().callGlobal(() -> {
Date expiresAt = null;
if (durationMinutes != null && durationMinutes > 0) {
int clampedMinutes = Math.min(durationMinutes, 43_200);
expiresAt = Date.from(Instant.now().plusSeconds(clampedMinutes * 60L));
}
context.plugin().getServer().getBanList(BanList.Type.NAME)
.addBan(report.suspectName(), reason, expiresAt, "MinePanelReport:" + actor);
Player online = context.plugin().getServer().getPlayer(report.suspectUuid());
if (online == null) {
online = context.plugin().getServer().getPlayerExact(report.suspectName());
}
if (online != null) {
online.kickPlayer("Banned. Reason: " + reason);
}
return new BanResult(true, expiresAt == null ? null : expiresAt.getTime(), "");
}, 2, TimeUnit.SECONDS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return new BanResult(false, null, "interrupted");
} catch (ExecutionException | TimeoutException exception) {
return new BanResult(false, null, exception.getClass().getSimpleName());
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private record BanPayload(Integer durationMinutes, String reason) {
}
private record BanResult(boolean success, Long expiresAt, String error) {
}
}
@@ -0,0 +1,18 @@
package de.winniepat.minePanel.extensions.tickets;
import java.util.UUID;
public record PlayerTicket(
long id,
UUID creatorUuid,
String creatorName,
String category,
String description,
String status,
long createdAt,
long updatedAt,
String handledBy,
long handledAt
) {
}
@@ -0,0 +1,28 @@
package de.winniepat.minePanel.extensions.tickets;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public final class TicketCommand implements CommandExecutor {
private final TicketMenuListener menuListener;
public TicketCommand(TicketMenuListener menuListener) {
this.menuListener = menuListener;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage(ChatColor.RED + "Only players can create support tickets.");
return true;
}
menuListener.openMenu(player);
return true;
}
}
@@ -0,0 +1,107 @@
package de.winniepat.minePanel.extensions.tickets;
import de.winniepat.minePanel.logs.PanelLogger;
import de.winniepat.minePanel.persistence.KnownPlayerRepository;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.time.Instant;
import java.util.List;
import java.util.Map;
public final class TicketMenuListener implements Listener {
private static final String MENU_TITLE = ChatColor.DARK_AQUA + "MinePanel Tickets";
private final TicketRepository ticketRepository;
private final KnownPlayerRepository knownPlayerRepository;
private final PanelLogger panelLogger;
public TicketMenuListener(TicketRepository ticketRepository, KnownPlayerRepository knownPlayerRepository, PanelLogger panelLogger) {
this.ticketRepository = ticketRepository;
this.knownPlayerRepository = knownPlayerRepository;
this.panelLogger = panelLogger;
}
public void openMenu(Player player) {
Inventory menu = Bukkit.createInventory(null, 27, MENU_TITLE);
menu.setItem(11, createTicketItem(Material.REDSTONE, ChatColor.RED + "Technical Issue", List.of(ChatColor.GRAY + "Server lag, crashes or bugs")));
menu.setItem(13, createTicketItem(Material.PAPER, ChatColor.AQUA + "Question / Help", List.of(ChatColor.GRAY + "Need support from the team")));
menu.setItem(15, createTicketItem(Material.BOOK, ChatColor.GREEN + "Other", List.of(ChatColor.GRAY + "General support request")));
menu.setItem(22, createTicketItem(Material.BARRIER, ChatColor.RED + "Close", List.of(ChatColor.GRAY + "Close this menu")));
player.openInventory(menu);
}
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)) {
return;
}
if (!MENU_TITLE.equals(event.getView().getTitle())) {
return;
}
event.setCancelled(true);
int slot = event.getRawSlot();
if (slot < 0 || slot >= event.getView().getTopInventory().getSize()) {
return;
}
Map<Integer, TicketTemplate> templates = Map.of(
11, new TicketTemplate("Technical Issue", "Reported from in-game menu: technical issue"),
13, new TicketTemplate("Question / Help", "Reported from in-game menu: question or support needed"),
15, new TicketTemplate("Other", "Reported from in-game menu: general support request")
);
if (slot == 22) {
player.closeInventory();
return;
}
TicketTemplate template = templates.get(slot);
if (template == null) {
return;
}
long now = Instant.now().toEpochMilli();
knownPlayerRepository.upsert(player.getUniqueId(), player.getName(), now);
long ticketId = ticketRepository.createTicket(
player.getUniqueId(),
player.getName(),
template.category(),
template.description(),
now
);
player.closeInventory();
player.sendMessage(ChatColor.GREEN + "Ticket submitted (#" + ticketId + ") in category " + template.category() + ".");
panelLogger.log("TICKET", player.getName(), "Created ticket #" + ticketId + " [" + template.category() + "] " + template.description());
}
private ItemStack createTicketItem(Material material, String name, List<String> lore) {
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName(name);
meta.setLore(lore);
item.setItemMeta(meta);
}
return item;
}
private record TicketTemplate(String category, String description) {
}
}
@@ -0,0 +1,120 @@
package de.winniepat.minePanel.extensions.tickets;
import de.winniepat.minePanel.persistence.Database;
import java.sql.*;
import java.util.*;
public final class TicketRepository {
private final Database database;
public TicketRepository(Database database) {
this.database = database;
}
public void initializeSchema() {
String sql = "CREATE TABLE IF NOT EXISTS player_tickets ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "creator_uuid TEXT NOT NULL,"
+ "creator_name TEXT NOT NULL,"
+ "category TEXT NOT NULL,"
+ "description TEXT NOT NULL,"
+ "status TEXT NOT NULL,"
+ "created_at INTEGER NOT NULL,"
+ "updated_at INTEGER NOT NULL,"
+ "handled_by TEXT NOT NULL DEFAULT '',"
+ "handled_at INTEGER NOT NULL DEFAULT 0"
+ ")";
try (Connection connection = database.getConnection();
Statement statement = connection.createStatement()) {
statement.execute(sql);
} catch (SQLException exception) {
throw new IllegalStateException("Could not initialize ticket schema", exception);
}
}
public long createTicket(UUID creatorUuid, String creatorName, String category, String description, long createdAt) {
String sql = "INSERT INTO player_tickets(creator_uuid, creator_name, category, description, status, created_at, updated_at, handled_by, handled_at) "
+ "VALUES (?, ?, ?, ?, 'OPEN', ?, ?, '', 0)";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
statement.setString(1, creatorUuid.toString());
statement.setString(2, creatorName == null ? "Unknown" : creatorName);
statement.setString(3, category == null || category.isBlank() ? "Other" : category.trim());
statement.setString(4, description == null ? "" : description.trim());
statement.setLong(5, createdAt);
statement.setLong(6, createdAt);
statement.executeUpdate();
try (ResultSet generatedKeys = statement.getGeneratedKeys()) {
if (generatedKeys.next()) {
return generatedKeys.getLong(1);
}
}
return 0L;
} catch (SQLException exception) {
throw new IllegalStateException("Could not create ticket", exception);
}
}
public List<PlayerTicket> listTickets(String status) {
boolean filterStatus = status != null && !status.isBlank();
String sql = filterStatus
? "SELECT * FROM player_tickets WHERE status = ? ORDER BY created_at DESC, id DESC"
: "SELECT * FROM player_tickets ORDER BY created_at DESC, id DESC";
List<PlayerTicket> tickets = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
if (filterStatus) {
statement.setString(1, status.trim().toUpperCase(Locale.ROOT));
}
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
tickets.add(read(resultSet));
}
}
return tickets;
} catch (SQLException exception) {
throw new IllegalStateException("Could not list tickets", exception);
}
}
public boolean updateStatus(long id, String status, String handledBy, long handledAt) {
if (id <= 0 || status == null || status.isBlank()) {
return false;
}
String sql = "UPDATE player_tickets SET status = ?, updated_at = ?, handled_by = ?, handled_at = ? WHERE id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, status.trim().toUpperCase(Locale.ROOT));
statement.setLong(2, handledAt);
statement.setString(3, handledBy == null ? "" : handledBy);
statement.setLong(4, handledAt);
statement.setLong(5, id);
return statement.executeUpdate() > 0;
} catch (SQLException exception) {
throw new IllegalStateException("Could not update ticket status", exception);
}
}
private PlayerTicket read(ResultSet resultSet) throws SQLException {
return new PlayerTicket(
resultSet.getLong("id"),
UUID.fromString(resultSet.getString("creator_uuid")),
resultSet.getString("creator_name"),
resultSet.getString("category"),
resultSet.getString("description"),
resultSet.getString("status"),
resultSet.getLong("created_at"),
resultSet.getLong("updated_at"),
resultSet.getString("handled_by"),
resultSet.getLong("handled_at")
);
}
}
@@ -0,0 +1,131 @@
package de.winniepat.minePanel.extensions.tickets;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.users.PanelPermission;
import org.bukkit.event.HandlerList;
import java.time.Instant;
import java.util.*;
public final class TicketSystemExtension implements MinePanelExtension {
private ExtensionContext context;
private TicketRepository ticketRepository;
private TicketMenuListener menuListener;
@Override
public String id() {
return "ticket-system";
}
@Override
public String displayName() {
return "Ticket System";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
this.ticketRepository = new TicketRepository(context.database());
this.ticketRepository.initializeSchema();
}
@Override
public void onEnable() {
this.menuListener = new TicketMenuListener(ticketRepository, context.knownPlayerRepository(), context.panelLogger());
context.plugin().getServer().getPluginManager().registerEvents(menuListener, context.plugin());
boolean registered = context.commandRegistry().register(
id(),
"ticket",
"Create a support ticket for server staff",
"/ticket",
"minepanel.ticket",
List.of("support"),
new TicketCommand(menuListener)
);
if (!registered) {
context.plugin().getLogger().warning("Could not register /ticket command for ticket extension.");
}
}
@Override
public void onDisable() {
if (menuListener != null) {
HandlerList.unregisterAll(menuListener);
menuListener = null;
}
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/tickets", PanelPermission.VIEW_TICKETS, (request, response, user) -> {
String status = request.queryParams("status");
List<Map<String, Object>> tickets = ticketRepository.listTickets(status).stream().map(this::toPayload).toList();
return webRegistry.json(response, 200, Map.of("tickets", tickets));
});
webRegistry.post("/api/extensions/tickets/:id/close", PanelPermission.MANAGE_TICKETS, (request, response, user) -> {
long ticketId = parseTicketId(request.params("id"));
if (ticketId <= 0) {
return webRegistry.json(response, 400, Map.of("error", "invalid_ticket_id"));
}
boolean updated = ticketRepository.updateStatus(ticketId, "CLOSED", user.username(), Instant.now().toEpochMilli());
if (!updated) {
return webRegistry.json(response, 404, Map.of("error", "ticket_not_found"));
}
context.panelLogger().log("AUDIT", user.username(), "Closed ticket #" + ticketId);
return webRegistry.json(response, 200, Map.of("ok", true));
});
webRegistry.post("/api/extensions/tickets/:id/reopen", PanelPermission.MANAGE_TICKETS, (request, response, user) -> {
long ticketId = parseTicketId(request.params("id"));
if (ticketId <= 0) {
return webRegistry.json(response, 400, Map.of("error", "invalid_ticket_id"));
}
boolean updated = ticketRepository.updateStatus(ticketId, "OPEN", user.username(), Instant.now().toEpochMilli());
if (!updated) {
return webRegistry.json(response, 404, Map.of("error", "ticket_not_found"));
}
context.panelLogger().log("AUDIT", user.username(), "Reopened ticket #" + ticketId);
return webRegistry.json(response, 200, Map.of("ok", true));
});
}
@Override
public List<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Tickets", "/dashboard/tickets"));
}
private long parseTicketId(String rawId) {
try {
return Long.parseLong(rawId);
} catch (Exception ignored) {
return -1L;
}
}
private Map<String, Object> toPayload(PlayerTicket ticket) {
return Map.of(
"id", ticket.id(),
"creatorUuid", ticket.creatorUuid().toString(),
"creatorName", ticket.creatorName(),
"category", ticket.category(),
"description", ticket.description(),
"status", ticket.status(),
"createdAt", ticket.createdAt(),
"updatedAt", ticket.updatedAt(),
"handledBy", ticket.handledBy(),
"handledAt", ticket.handledAt()
);
}
}
@@ -0,0 +1,139 @@
package de.winniepat.minePanel.extensions.whitelist;
import com.google.gson.Gson;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.users.PanelPermission;
import java.util.List;
import java.util.Map;
public final class WhitelistExtension implements MinePanelExtension {
private final Gson gson = new Gson();
private ExtensionContext context;
private WhitelistService whitelistService;
@Override
public String id() {
return "whitelist";
}
@Override
public String displayName() {
return "Whitelist";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
this.whitelistService = new WhitelistService(context);
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/whitelist/status", PanelPermission.VIEW_WHITELIST, (request, response, user) -> {
try {
boolean enabled = whitelistService.isWhitelistEnabled();
return webRegistry.json(response, 200, Map.of("enabled", enabled));
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "whitelist_status_failed", "details", exception.getMessage()));
}
});
webRegistry.post("/api/extensions/whitelist/toggle", PanelPermission.MANAGE_WHITELIST, (request, response, user) -> {
TogglePayload payload = gson.fromJson(request.body(), TogglePayload.class);
if (payload == null || payload.enabled() == null) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
boolean changed;
try {
changed = whitelistService.setWhitelistEnabled(payload.enabled());
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "whitelist_toggle_failed", "details", exception.getMessage()));
}
context.panelLogger().log(
"AUDIT",
user.username(),
(payload.enabled() ? "Enabled" : "Disabled") + " server whitelist" + (changed ? "" : " (no change)")
);
return webRegistry.json(response, 200, Map.of("ok", true, "enabled", payload.enabled(), "changed", changed));
});
webRegistry.get("/api/extensions/whitelist", PanelPermission.VIEW_WHITELIST, (request, response, user) -> {
try {
List<Map<String, Object>> entries = whitelistService.listEntries();
return webRegistry.json(response, 200, Map.of("entries", entries, "count", entries.size()));
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "whitelist_list_failed", "details", exception.getMessage()));
}
});
webRegistry.post("/api/extensions/whitelist/add", PanelPermission.MANAGE_WHITELIST, (request, response, user) -> {
UsernamePayload payload = gson.fromJson(request.body(), UsernamePayload.class);
String username = payload == null ? null : payload.username();
WhitelistService.ChangeResult result;
try {
result = whitelistService.addByUsername(username);
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "whitelist_add_failed", "details", exception.getMessage()));
}
if (!result.success()) {
return webRegistry.json(response, 400, Map.of("error", result.error()));
}
context.panelLogger().log("AUDIT", user.username(),
(result.changed() ? "Added " : "Kept ") + result.username() + " on whitelist");
return webRegistry.json(response, 200, Map.of(
"ok", true,
"changed", result.changed(),
"uuid", result.uuid() == null ? "" : result.uuid().toString(),
"username", result.username()
));
});
webRegistry.post("/api/extensions/whitelist/remove", PanelPermission.MANAGE_WHITELIST, (request, response, user) -> {
UsernamePayload payload = gson.fromJson(request.body(), UsernamePayload.class);
String username = payload == null ? null : payload.username();
WhitelistService.ChangeResult result;
try {
result = whitelistService.removeByUsername(username);
} catch (IllegalStateException exception) {
return webRegistry.json(response, 500, Map.of("error", "whitelist_remove_failed", "details", exception.getMessage()));
}
if (!result.success()) {
int status = "not_whitelisted".equals(result.error()) ? 404 : 400;
return webRegistry.json(response, status, Map.of("error", result.error()));
}
context.panelLogger().log("AUDIT", user.username(), "Removed " + result.username() + " from whitelist");
return webRegistry.json(response, 200, Map.of(
"ok", true,
"changed", result.changed(),
"uuid", result.uuid() == null ? "" : result.uuid().toString(),
"username", result.username()
));
});
}
@Override
public List<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Whitelist", "/dashboard/whitelist"));
}
private record UsernamePayload(String username) {
}
private record TogglePayload(Boolean enabled) {
}
}
@@ -0,0 +1,155 @@
package de.winniepat.minePanel.extensions.whitelist;
import de.winniepat.minePanel.extensions.ExtensionContext;
import org.bukkit.OfflinePlayer;
import java.util.*;
import java.util.concurrent.*;
public final class WhitelistService {
private final ExtensionContext context;
public WhitelistService(ExtensionContext context) {
this.context = context;
}
public List<Map<String, Object>> listEntries() {
return runSync(() -> {
List<Map<String, Object>> entries = new ArrayList<>();
for (OfflinePlayer player : context.plugin().getServer().getWhitelistedPlayers()) {
String name = player.getName();
String username = (name == null || name.isBlank()) ? player.getUniqueId().toString() : name;
entries.add(Map.of(
"uuid", player.getUniqueId().toString(),
"username", username,
"online", player.isOnline()
));
}
entries.sort(Comparator.comparing(entry -> String.valueOf(entry.get("username")), String.CASE_INSENSITIVE_ORDER));
return entries;
});
}
public boolean isWhitelistEnabled() {
return runSync(() -> context.plugin().getServer().hasWhitelist());
}
public boolean setWhitelistEnabled(boolean enabled) {
return runSync(() -> {
boolean before = context.plugin().getServer().hasWhitelist();
if (before == enabled) {
return false;
}
context.plugin().getServer().setWhitelist(enabled);
return true;
});
}
public ChangeResult addByUsername(String rawUsername) {
String username = normalizeUsername(rawUsername);
if (username.isBlank()) {
return ChangeResult.invalid("invalid_username");
}
return runSync(() -> {
OfflinePlayer target = resolvePlayerByUsername(username);
if (target == null) {
return ChangeResult.invalid("player_not_found");
}
if (target.isWhitelisted()) {
return ChangeResult.ok(target, false);
}
target.setWhitelisted(true);
return ChangeResult.ok(target, true);
});
}
public ChangeResult removeByUsername(String rawUsername) {
String username = normalizeUsername(rawUsername);
if (username.isBlank()) {
return ChangeResult.invalid("invalid_username");
}
return runSync(() -> {
OfflinePlayer target = findWhitelistedByUsername(username);
if (target == null) {
return ChangeResult.invalid("not_whitelisted");
}
target.setWhitelisted(false);
if (target.isOnline() && target.getPlayer() != null) {
target.getPlayer().kickPlayer("You have been removed from the whitelist.");
}
return ChangeResult.ok(target, true);
});
}
private OfflinePlayer resolvePlayerByUsername(String username) {
OfflinePlayer online = context.plugin().getServer().getPlayerExact(username);
if (online != null) {
return online;
}
for (OfflinePlayer offline : context.plugin().getServer().getOfflinePlayers()) {
if (offline.getName() != null && offline.getName().equalsIgnoreCase(username)) {
return offline;
}
}
OfflinePlayer fallback = context.plugin().getServer().getOfflinePlayer(username);
return (fallback.getName() == null && !fallback.hasPlayedBefore()) ? null : fallback;
}
private OfflinePlayer findWhitelistedByUsername(String username) {
for (OfflinePlayer player : context.plugin().getServer().getWhitelistedPlayers()) {
String name = player.getName();
if (name != null && name.equalsIgnoreCase(username)) {
return player;
}
}
return null;
}
private String normalizeUsername(String value) {
if (value == null) {
return "";
}
String username = value.trim();
if (username.length() < 3 || username.length() > 16) {
return "";
}
if (!username.matches("^[A-Za-z0-9_]+$")) {
return "";
}
return username;
}
private <T> T runSync(Callable<T> task) {
try {
return context.schedulerBridge().callGlobal(task, 10, TimeUnit.SECONDS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("whitelist_task_interrupted", exception);
} catch (ExecutionException | TimeoutException exception) {
throw new IllegalStateException("whitelist_task_failed", exception);
}
}
public record ChangeResult(boolean success, boolean changed, String error, UUID uuid, String username) {
private static ChangeResult ok(OfflinePlayer player, boolean changed) {
String username = player.getName() == null || player.getName().isBlank()
? player.getUniqueId().toString()
: player.getName();
return new ChangeResult(true, changed, "", player.getUniqueId(), username);
}
private static ChangeResult invalid(String error) {
return new ChangeResult(false, false, error, null, "");
}
}
}
@@ -0,0 +1,389 @@
package de.winniepat.minePanel.extensions.worldbackups;
import de.winniepat.minePanel.MinePanel;
import org.bukkit.World;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
public final class WorldBackupService {
private static final DateTimeFormatter FILE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss").withZone(ZoneOffset.UTC);
private static final Set<String> WORLD_KEYS = Set.of("overworld", "nether", "end");
private final MinePanel plugin;
private final Path backupsRoot;
public WorldBackupService(MinePanel plugin, Path backupsRoot) {
this.plugin = plugin;
this.backupsRoot = backupsRoot;
}
public List<String> supportedWorldKeys() {
return List.of("overworld", "nether", "end");
}
public BackupCreateResult createBackup(String worldKey, String requestedName) {
String normalizedWorldKey = normalizeWorldKey(worldKey);
if (normalizedWorldKey == null) {
return BackupCreateResult.error("invalid_world");
}
String safeName = sanitizeBackupName(requestedName);
if (safeName.isBlank()) {
return BackupCreateResult.error("invalid_name");
}
WorldSnapshot snapshot = captureWorldSnapshot(normalizedWorldKey);
if (snapshot == null) {
return BackupCreateResult.error("world_not_found");
}
Path worldBackupDir = backupsRoot.resolve(normalizedWorldKey);
String fileName = FILE_TIMESTAMP.format(Instant.now()) + "__" + safeName + ".zip";
Path tempZip = null;
try {
Files.createDirectories(backupsRoot);
Files.createDirectories(worldBackupDir);
Path zipFile = resolveUniqueZipPath(worldBackupDir, fileName);
tempZip = resolveUniqueTempPath(worldBackupDir);
zipDirectory(snapshot.worldFolder(), tempZip);
try {
Files.move(tempZip, zipFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException ignored) {
Files.move(tempZip, zipFile, StandardCopyOption.REPLACE_EXISTING);
}
long sizeBytes = Files.size(zipFile);
long createdAt = Files.getLastModifiedTime(zipFile).toMillis();
return BackupCreateResult.success(new WorldBackupEntry(zipFile.getFileName().toString(), safeName, createdAt, sizeBytes, normalizedWorldKey));
} catch (Exception exception) {
return BackupCreateResult.error("backup_failed", buildErrorDetails(exception));
} finally {
if (tempZip != null) {
try {
Files.deleteIfExists(tempZip);
} catch (IOException ignored) {
// Best effort cleanup.
}
}
}
}
private Path resolveUniqueZipPath(Path worldBackupDir, String baseFileName) throws IOException {
Path initial = worldBackupDir.resolve(baseFileName).normalize();
if (!Files.exists(initial)) {
return initial;
}
String base = baseFileName;
String suffix = ".zip";
if (baseFileName.toLowerCase(Locale.ROOT).endsWith(suffix)) {
base = baseFileName.substring(0, baseFileName.length() - suffix.length());
}
for (int i = 2; i <= 9999; i++) {
Path candidate = worldBackupDir.resolve(base + "-" + i + suffix).normalize();
if (!Files.exists(candidate)) {
return candidate;
}
}
throw new IOException("could_not_allocate_unique_backup_file_name");
}
private Path resolveUniqueTempPath(Path worldBackupDir) throws IOException {
for (int i = 0; i < 10_000; i++) {
String candidateName = "backup-" + UUID.randomUUID() + ".tmp";
Path candidate = worldBackupDir.resolve(candidateName).normalize();
if (!Files.exists(candidate)) {
return candidate;
}
}
throw new IOException("could_not_allocate_unique_temp_file_name");
}
private String buildErrorDetails(Exception exception) {
String message = exception.getMessage();
if (message == null || message.isBlank()) {
return exception.getClass().getSimpleName();
}
return exception.getClass().getSimpleName() + ": " + message;
}
public List<WorldBackupEntry> listBackups(String worldKey) {
String normalizedWorldKey = normalizeWorldKey(worldKey);
if (normalizedWorldKey == null) {
return List.of();
}
Path worldBackupDir = backupsRoot.resolve(normalizedWorldKey);
if (!Files.isDirectory(worldBackupDir)) {
return List.of();
}
try (var files = Files.list(worldBackupDir)) {
return files
.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".zip"))
.map(path -> toEntry(path, normalizedWorldKey))
.filter(Objects::nonNull)
.sorted(Comparator.comparingLong(WorldBackupEntry::createdAt).reversed())
.toList();
} catch (IOException exception) {
return List.of();
}
}
public BackupDeleteResult deleteBackup(String worldKey, String fileName) {
String normalizedWorldKey = normalizeWorldKey(worldKey);
if (normalizedWorldKey == null) {
return BackupDeleteResult.error("invalid_world");
}
if (fileName == null || fileName.isBlank()) {
return BackupDeleteResult.error("invalid_file_name");
}
String trimmedFileName = fileName.trim();
if (!trimmedFileName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
return BackupDeleteResult.error("invalid_file_name");
}
if (trimmedFileName.contains("/") || trimmedFileName.contains("\\") || trimmedFileName.contains("..")) {
return BackupDeleteResult.error("invalid_file_name");
}
Path worldBackupDir = backupsRoot.resolve(normalizedWorldKey).normalize();
Path target = worldBackupDir.resolve(trimmedFileName).normalize();
if (!target.startsWith(worldBackupDir)) {
return BackupDeleteResult.error("invalid_file_name");
}
if (!Files.exists(target) || !Files.isRegularFile(target)) {
return BackupDeleteResult.error("backup_not_found");
}
try {
Files.delete(target);
return BackupDeleteResult.success(trimmedFileName, normalizedWorldKey);
} catch (Exception exception) {
return BackupDeleteResult.error("delete_failed", buildErrorDetails(exception));
}
}
private WorldSnapshot captureWorldSnapshot(String worldKey) {
try {
return plugin.schedulerBridge().callGlobal(() -> {
World world = resolveWorld(worldKey);
if (world == null) {
return null;
}
world.save();
return new WorldSnapshot(world.getName(), world.getWorldFolder().toPath());
}, 3, TimeUnit.SECONDS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return null;
} catch (ExecutionException | TimeoutException exception) {
return null;
}
}
private World resolveWorld(String worldKey) {
String baseLevelName = plugin.getServer().getWorlds().isEmpty()
? "world"
: plugin.getServer().getWorlds().get(0).getName();
return switch (worldKey) {
case "overworld" -> firstWorldByNames(List.of(baseLevelName, "world"), World.Environment.NORMAL);
case "nether" -> firstWorldByNames(List.of(baseLevelName + "_nether", "world_nether"), World.Environment.NETHER);
case "end" -> firstWorldByNames(List.of(baseLevelName + "_the_end", "world_the_end", baseLevelName + "_end"), World.Environment.THE_END);
default -> null;
};
}
private World firstWorldByNames(List<String> candidates, World.Environment fallbackEnvironment) {
for (String candidate : candidates) {
if (candidate == null || candidate.isBlank()) {
continue;
}
World world = plugin.getServer().getWorld(candidate);
if (world != null) {
return world;
}
}
return plugin.getServer().getWorlds().stream()
.filter(world -> world.getEnvironment() == fallbackEnvironment)
.findFirst()
.orElse(null);
}
private WorldBackupEntry toEntry(Path file, String worldKey) {
try {
String fileName = file.getFileName().toString();
String displayName = parseNameFromFileName(fileName);
long createdAt = Files.getLastModifiedTime(file).toMillis();
long sizeBytes = Files.size(file);
return new WorldBackupEntry(fileName, displayName, createdAt, sizeBytes, worldKey);
} catch (IOException exception) {
return null;
}
}
private String parseNameFromFileName(String fileName) {
int separatorIndex = fileName.indexOf("__");
if (separatorIndex < 0) {
return fileName;
}
String withoutPrefix = fileName.substring(separatorIndex + 2);
if (withoutPrefix.toLowerCase(Locale.ROOT).endsWith(".zip")) {
return withoutPrefix.substring(0, withoutPrefix.length() - 4);
}
return withoutPrefix;
}
private void zipDirectory(Path sourceDirectory, Path zipFile) throws IOException {
try (ZipOutputStream zipStream = new ZipOutputStream(Files.newOutputStream(zipFile, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE))) {
Files.walkFileTree(sourceDirectory, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attrs) throws IOException {
Path relative = sourceDirectory.relativize(directory);
if (!relative.toString().isEmpty()) {
String entryName = normalizeEntryName(relative) + "/";
try {
zipStream.putNextEntry(new ZipEntry(entryName));
zipStream.closeEntry();
} catch (IOException ignored) {
// Ignore duplicate/invalid directory entries and continue.
}
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
if (Files.isSymbolicLink(file)) {
return FileVisitResult.CONTINUE;
}
Path relative = sourceDirectory.relativize(file);
if (shouldSkipFile(relative)) {
return FileVisitResult.CONTINUE;
}
if (!Files.isReadable(file)) {
return FileVisitResult.CONTINUE;
}
String entryName = normalizeEntryName(relative);
try {
zipStream.putNextEntry(new ZipEntry(entryName));
Files.copy(file, zipStream);
zipStream.closeEntry();
} catch (IOException ignored) {
// Skip files that are temporarily locked by the OS or server process.
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
// Continue if a file cannot be read due to locks/permissions.
return FileVisitResult.CONTINUE;
}
});
} catch (UncheckedIOException exception) {
throw exception.getCause();
}
}
private boolean shouldSkipFile(Path relativePath) {
String name = relativePath.getFileName() == null ? "" : relativePath.getFileName().toString().toLowerCase(Locale.ROOT);
return "session.lock".equals(name);
}
private String normalizeEntryName(Path relativePath) {
return relativePath.toString().replace('\\', '/');
}
private String sanitizeBackupName(String rawName) {
if (rawName == null) {
return "";
}
StringBuilder sanitized = new StringBuilder();
String trimmed = rawName.trim();
for (int i = 0; i < trimmed.length(); i++) {
char c = trimmed.charAt(i);
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
sanitized.append(c);
} else if (c == ' ' || c == '.') {
sanitized.append('_');
}
}
String value = sanitized.toString().replaceAll("_+", "_");
if (value.length() > 48) {
return value.substring(0, 48);
}
return value;
}
private String normalizeWorldKey(String worldKey) {
if (worldKey == null) {
return null;
}
String normalized = worldKey.trim().toLowerCase(Locale.ROOT);
return WORLD_KEYS.contains(normalized) ? normalized : null;
}
public record WorldBackupEntry(String fileName, String name, long createdAt, long sizeBytes, String world) {
}
public record BackupCreateResult(boolean success, String error, String details, WorldBackupEntry backup) {
static BackupCreateResult success(WorldBackupEntry backup) {
return new BackupCreateResult(true, "", "", backup);
}
static BackupCreateResult error(String error) {
return new BackupCreateResult(false, error, "", null);
}
static BackupCreateResult error(String error, String details) {
return new BackupCreateResult(false, error, details == null ? "" : details, null);
}
}
public record BackupDeleteResult(boolean success, String error, String details, String fileName, String world) {
static BackupDeleteResult success(String fileName, String world) {
return new BackupDeleteResult(true, "", "", fileName, world);
}
static BackupDeleteResult error(String error) {
return new BackupDeleteResult(false, error, "", "", "");
}
static BackupDeleteResult error(String error, String details) {
return new BackupDeleteResult(false, error, details == null ? "" : details, "", "");
}
}
private record WorldSnapshot(String worldName, Path worldFolder) {
}
}
@@ -0,0 +1,145 @@
package de.winniepat.minePanel.extensions.worldbackups;
import com.google.gson.Gson;
import de.winniepat.minePanel.extensions.ExtensionContext;
import de.winniepat.minePanel.extensions.ExtensionNavigationTab;
import de.winniepat.minePanel.extensions.ExtensionWebRegistry;
import de.winniepat.minePanel.extensions.MinePanelExtension;
import de.winniepat.minePanel.users.PanelPermission;
import java.nio.file.Path;
import java.util.*;
public final class WorldBackupsExtension implements MinePanelExtension {
private final Gson gson = new Gson();
private ExtensionContext context;
private WorldBackupService backupService;
@Override
public String id() {
return "world-backups";
}
@Override
public String displayName() {
return "World Backups";
}
@Override
public void onLoad(ExtensionContext context) {
this.context = context;
Path backupRoot = context.plugin().getDataFolder().toPath().resolve("backups");
this.backupService = new WorldBackupService(context.plugin(), backupRoot);
}
@Override
public void registerWebRoutes(ExtensionWebRegistry webRegistry) {
webRegistry.get("/api/extensions/world-backups", PanelPermission.VIEW_BACKUPS, (request, response, user) -> {
String world = request.queryParams("world");
if (world != null && !world.isBlank()) {
List<Map<String, Object>> backups = backupService.listBackups(world).stream().map(this::toPayload).toList();
return webRegistry.json(response, 200, Map.of("world", world, "backups", backups));
}
Map<String, Object> payload = new HashMap<>();
for (String worldKey : backupService.supportedWorldKeys()) {
List<Map<String, Object>> backups = backupService.listBackups(worldKey).stream().map(this::toPayload).toList();
payload.put(worldKey, backups);
}
payload.put("worlds", backupService.supportedWorldKeys());
return webRegistry.json(response, 200, payload);
});
webRegistry.post("/api/extensions/world-backups/create", PanelPermission.MANAGE_BACKUPS, (request, response, user) -> {
CreateBackupPayload payload = gson.fromJson(request.body(), CreateBackupPayload.class);
if (payload == null || isBlank(payload.world()) || isBlank(payload.name())) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
WorldBackupService.BackupCreateResult result = backupService.createBackup(payload.world(), payload.name());
if (!result.success()) {
Map<String, Object> errorPayload = new HashMap<>();
errorPayload.put("error", result.error());
if (!isBlank(result.details())) {
errorPayload.put("details", result.details());
}
context.plugin().getLogger().warning("World backup failed for world='" + payload.world()
+ "', name='" + payload.name() + "': "
+ (isBlank(result.details()) ? result.error() : result.details()));
int status;
if ("invalid_world".equals(result.error()) || "invalid_name".equals(result.error())) {
status = 400;
} else if ("world_not_found".equals(result.error())) {
status = 404;
} else {
status = 500;
}
return webRegistry.json(response, status, errorPayload);
}
context.panelLogger().log("AUDIT", user.username(), "Created " + result.backup().world() + " backup: " + result.backup().fileName());
return webRegistry.json(response, 200, Map.of("ok", true, "backup", toPayload(result.backup())));
});
webRegistry.post("/api/extensions/world-backups/delete", PanelPermission.MANAGE_BACKUPS, (request, response, user) -> {
DeleteBackupPayload payload = gson.fromJson(request.body(), DeleteBackupPayload.class);
if (payload == null || isBlank(payload.world()) || isBlank(payload.fileName())) {
return webRegistry.json(response, 400, Map.of("error", "invalid_payload"));
}
WorldBackupService.BackupDeleteResult result = backupService.deleteBackup(payload.world(), payload.fileName());
if (!result.success()) {
Map<String, Object> errorPayload = new HashMap<>();
errorPayload.put("error", result.error());
if (!isBlank(result.details())) {
errorPayload.put("details", result.details());
}
int status;
if ("invalid_world".equals(result.error()) || "invalid_file_name".equals(result.error())) {
status = 400;
} else if ("backup_not_found".equals(result.error())) {
status = 404;
} else {
status = 500;
}
return webRegistry.json(response, status, errorPayload);
}
context.panelLogger().log("AUDIT", user.username(), "Deleted " + result.world() + " backup: " + result.fileName());
return webRegistry.json(response, 200, Map.of("ok", true, "fileName", result.fileName(), "world", result.world()));
});
}
@Override
public List<ExtensionNavigationTab> navigationTabs() {
return List.of(new ExtensionNavigationTab("server", "Backups", "/dashboard/world-backups"));
}
private Map<String, Object> toPayload(WorldBackupService.WorldBackupEntry backup) {
return Map.of(
"fileName", backup.fileName(),
"name", backup.name(),
"createdAt", backup.createdAt(),
"sizeBytes", backup.sizeBytes(),
"world", backup.world()
);
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private record CreateBackupPayload(String world, String name) {
}
private record DeleteBackupPayload(String world, String fileName) {
}
}
@@ -0,0 +1,37 @@
package de.winniepat.minePanel.integrations;
public record DiscordWebhookConfig(
boolean enabled,
String webhookUrl,
boolean useEmbed,
String botName,
String messageTemplate,
String embedTitleTemplate,
boolean logChat,
boolean logCommands,
boolean logAuth,
boolean logAudit,
boolean logSecurity,
boolean logConsoleResponse,
boolean logSystem
) {
public static DiscordWebhookConfig defaults() {
return new DiscordWebhookConfig(
false,
"",
true,
"MinePanel",
"[{timestamp}] [{kind}] [{source}] {message}",
"MinePanel {kind}",
true,
true,
true,
true,
true,
true,
true
);
}
}
@@ -0,0 +1,94 @@
package de.winniepat.minePanel.integrations;
import de.winniepat.minePanel.persistence.Database;
import java.sql.*;
public final class DiscordWebhookRepository {
private final Database database;
public DiscordWebhookRepository(Database database) {
this.database = database;
}
public DiscordWebhookConfig load() {
String sql = "SELECT enabled, webhook_url, use_embed, bot_name, message_template, embed_title_template, "
+ "log_chat, log_commands, log_auth, log_audit, log_security, log_console_response, log_system "
+ "FROM discord_webhook_config WHERE id = 1";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return new DiscordWebhookConfig(
resultSet.getInt("enabled") == 1,
resultSet.getString("webhook_url"),
resultSet.getInt("use_embed") == 1,
resultSet.getString("bot_name"),
resultSet.getString("message_template"),
resultSet.getString("embed_title_template"),
resultSet.getInt("log_chat") == 1,
resultSet.getInt("log_commands") == 1,
resultSet.getInt("log_auth") == 1,
resultSet.getInt("log_audit") == 1,
resultSet.getInt("log_security") == 1,
resultSet.getInt("log_console_response") == 1,
resultSet.getInt("log_system") == 1
);
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not load Discord webhook config", exception);
}
DiscordWebhookConfig defaults = DiscordWebhookConfig.defaults();
save(defaults);
return defaults;
}
public void save(DiscordWebhookConfig config) {
String sql = "INSERT INTO discord_webhook_config("
+ "id, enabled, webhook_url, use_embed, bot_name, message_template, embed_title_template, "
+ "log_chat, log_commands, log_auth, log_audit, log_security, log_console_response, log_system"
+ ") VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(id) DO UPDATE SET "
+ "enabled=excluded.enabled, "
+ "webhook_url=excluded.webhook_url, "
+ "use_embed=excluded.use_embed, "
+ "bot_name=excluded.bot_name, "
+ "message_template=excluded.message_template, "
+ "embed_title_template=excluded.embed_title_template, "
+ "log_chat=excluded.log_chat, "
+ "log_commands=excluded.log_commands, "
+ "log_auth=excluded.log_auth, "
+ "log_audit=excluded.log_audit, "
+ "log_security=excluded.log_security, "
+ "log_console_response=excluded.log_console_response, "
+ "log_system=excluded.log_system";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, config.enabled() ? 1 : 0);
statement.setString(2, nullSafe(config.webhookUrl()));
statement.setInt(3, config.useEmbed() ? 1 : 0);
statement.setString(4, nullSafe(config.botName()));
statement.setString(5, nullSafe(config.messageTemplate()));
statement.setString(6, nullSafe(config.embedTitleTemplate()));
statement.setInt(7, config.logChat() ? 1 : 0);
statement.setInt(8, config.logCommands() ? 1 : 0);
statement.setInt(9, config.logAuth() ? 1 : 0);
statement.setInt(10, config.logAudit() ? 1 : 0);
statement.setInt(11, config.logSecurity() ? 1 : 0);
statement.setInt(12, config.logConsoleResponse() ? 1 : 0);
statement.setInt(13, config.logSystem() ? 1 : 0);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not save Discord webhook config", exception);
}
}
private String nullSafe(String value) {
return value == null ? "" : value;
}
}
@@ -0,0 +1,136 @@
package de.winniepat.minePanel.integrations;
import com.google.gson.Gson;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;
public final class DiscordWebhookService {
private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ISO_INSTANT;
private final Logger logger;
private final DiscordWebhookRepository repository;
private final AtomicReference<DiscordWebhookConfig> configReference;
private final ExecutorService senderExecutor;
private final HttpClient httpClient;
private final Gson gson;
public DiscordWebhookService(Logger logger, DiscordWebhookRepository repository) {
this.logger = logger;
this.repository = repository;
this.configReference = new AtomicReference<>(repository.load());
this.senderExecutor = Executors.newSingleThreadExecutor(task -> {
Thread thread = new Thread(task, "minepanel-discord-webhook");
thread.setDaemon(true);
return thread;
});
this.httpClient = HttpClient.newHttpClient();
this.gson = new Gson();
}
public DiscordWebhookConfig getConfig() {
return configReference.get();
}
public DiscordWebhookConfig updateConfig(DiscordWebhookConfig config) {
repository.save(config);
configReference.set(config);
return config;
}
public void handlePanelLog(String kind, String source, String message, Instant timestamp) {
DiscordWebhookConfig config = configReference.get();
if (!config.enabled() || config.webhookUrl().isBlank()) {
return;
}
if (!shouldSend(config, kind)) {
return;
}
String renderedMessage = renderTemplate(config.messageTemplate(), kind, source, message, timestamp);
String botName = config.botName().isBlank() ? "MinePanel" : config.botName();
senderExecutor.execute(() -> send(config, botName, kind, renderedMessage, source, timestamp));
}
public void shutdown() {
senderExecutor.shutdownNow();
}
private boolean shouldSend(DiscordWebhookConfig config, String kind) {
return switch (kind) {
case "CHAT" -> config.logChat();
case "COMMAND", "CONSOLE_COMMAND" -> config.logCommands();
case "AUTH" -> config.logAuth();
case "AUDIT" -> config.logAudit();
case "SECURITY" -> config.logSecurity();
case "CONSOLE_RESPONSE" -> config.logConsoleResponse();
case "SYSTEM" -> config.logSystem();
default -> false;
};
}
private void send(DiscordWebhookConfig config, String botName, String kind, String renderedMessage, String source, Instant timestamp) {
try {
Map<String, Object> payload;
if (config.useEmbed()) {
String title = renderTemplate(config.embedTitleTemplate(), kind, source, renderedMessage, timestamp);
payload = Map.of(
"username", botName,
"embeds", List.of(Map.of(
"title", truncate(title, 256),
"description", truncate(renderedMessage, 4096),
"timestamp", timestamp.toString(),
"color", 3447003
))
);
} else {
payload = Map.of(
"username", botName,
"content", truncate(renderedMessage, 2000)
);
}
HttpRequest request = HttpRequest.newBuilder(URI.create(config.webhookUrl()))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload), StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() >= 300) {
logger.warning("Discord webhook returned status " + response.statusCode());
}
} catch (Exception exception) {
logger.warning("Discord webhook send failed: " + exception.getMessage());
}
}
private String renderTemplate(String template, String kind, String source, String message, Instant timestamp) {
String rawTemplate = (template == null || template.isBlank())
? "[{timestamp}] [{kind}] [{source}] {message}"
: template;
return rawTemplate
.replace("{timestamp}", TIMESTAMP_FORMAT.format(timestamp))
.replace("{kind}", kind == null ? "" : kind)
.replace("{source}", source == null ? "" : source)
.replace("{message}", message == null ? "" : message);
}
private String truncate(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
return value == null ? "" : value;
}
return value.substring(0, maxLength - 1) + "...";
}
}
@@ -0,0 +1,136 @@
package de.winniepat.minePanel.lifecycle;
import de.winniepat.minePanel.MinePanel;
import de.winniepat.minePanel.logs.*;
import de.winniepat.minePanel.persistence.*;
import de.winniepat.minePanel.web.BootstrapService;
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.*;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.logging.*;
public final class PluginLifecycleSupport {
private static final DateTimeFormatter EXPORT_FILE_NAME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
private PluginLifecycleSupport() {
}
@SuppressWarnings("all")
public static void configureThirdPartyStartupLogging() {
System.setProperty("org.eclipse.jetty.util.log.announce", "false");
System.setProperty("org.eclipse.jetty.LEVEL", "WARN");
Logger.getLogger("spark").setLevel(Level.WARNING);
Logger.getLogger("org.eclipse.jetty").setLevel(Level.WARNING);
Logger.getLogger("org.eclipse.jetty.util.log").setLevel(Level.WARNING);
try {
Class<?> levelClass = Class.forName("org.apache.logging.log4j.Level");
Class<?> configuratorClass = Class.forName("org.apache.logging.log4j.core.config.Configurator");
Method setLevelMethod = configuratorClass.getMethod("setLevel", String.class, levelClass);
Object warnLevel = levelClass.getField("WARN").get(null);
setLevelMethod.invoke(null, "spark", warnLevel);
setLevelMethod.invoke(null, "org.eclipse.jetty", warnLevel);
setLevelMethod.invoke(null, "org.eclipse.jetty.util.log", warnLevel);
} catch (ReflectiveOperationException ignored) {
}
}
public static void announceMinePanelBanner(JavaPlugin plugin, ComponentLogger componentLogger, MiniMessage miniMessage) {
plugin.getLogger().info("");
componentLogger.info(miniMessage.deserialize("<gold> __ __ ____ </gold>"));
componentLogger.info(miniMessage.deserialize("<gold>| \\/ | | _ \\ </gold>"));
componentLogger.info("{}{}", miniMessage.deserialize("<gold>| |\\/| | | |_) | MinePanel: </gold>"), miniMessage.deserialize("<green>" + plugin.getDescription().getVersion() + "</green>"));
componentLogger.info("{}{}", miniMessage.deserialize("<gold>| | | | | __/ Running on: </gold>"), miniMessage.deserialize("<aqua>" + plugin.getServer().getName() + "</aqua>" + "(" + plugin.getServer().getMinecraftVersion() + ")"));
componentLogger.info(miniMessage.deserialize("<gold>|_| |_| |_| </gold>"));
plugin.getLogger().info("");
}
public static void announceBootstrapToken(BootstrapService bootstrapService, ComponentLogger componentLogger, MiniMessage miniMessage) {
bootstrapService.getBootstrapToken().ifPresent(token -> {
componentLogger.info("{}{}", miniMessage.deserialize("<dark_green>First launch setup token: </dark_green>"), token);
componentLogger.info("");
});
}
public static void registerPluginListeners(
MinePanel plugin,
PanelLogger panelLogger,
KnownPlayerRepository knownPlayerRepository,
PlayerActivityRepository playerActivityRepository,
JoinLeaveEventRepository joinLeaveEventRepository
) {
plugin.getServer().getPluginManager().registerEvents(new ChatCaptureListener(panelLogger), plugin);
plugin.getServer().getPluginManager().registerEvents(new CommandCaptureListener(panelLogger), plugin);
plugin.getServer().getPluginManager().registerEvents(new PlayerActivityListener(plugin, knownPlayerRepository, playerActivityRepository, joinLeaveEventRepository, panelLogger), plugin);
}
public static void synchronizeKnownPlayers(Server server, KnownPlayerRepository knownPlayerRepository, PlayerActivityRepository playerActivityRepository) {
for (OfflinePlayer offlinePlayer : server.getOfflinePlayers()) {
if (offlinePlayer.getName() != null) {
knownPlayerRepository.upsert(offlinePlayer.getUniqueId(), offlinePlayer.getName());
playerActivityRepository.ensureFromOffline(
offlinePlayer.getUniqueId(),
Math.max(0L, offlinePlayer.getFirstPlayed()),
Math.max(0L, offlinePlayer.getLastPlayed())
);
}
}
server.getOnlinePlayers().forEach(player -> {
knownPlayerRepository.upsert(player.getUniqueId(), player.getName());
long now = Instant.now().toEpochMilli();
playerActivityRepository.onJoin(
player.getUniqueId(),
now,
player.getAddress() != null && player.getAddress().getAddress() != null
? player.getAddress().getAddress().getHostAddress()
: ""
);
});
}
public static void exportPanelLogsToServerLogsDirectory(Path dataFolder, LogRepository logRepository, Logger logger) {
try {
Path logsDirectory = dataFolder.resolve("logs");
Files.createDirectories(logsDirectory);
String timestamp = EXPORT_FILE_NAME_FORMAT.format(Instant.now().atZone(ZoneOffset.UTC));
Path exportFile = logsDirectory.resolve("minepanel-panel-" + timestamp + ".log");
StringBuilder output = new StringBuilder();
for (var entry : logRepository.allLogsAscending()) {
output.append(Instant.ofEpochMilli(entry.createdAt()))
.append(" [")
.append(entry.kind())
.append("] [")
.append(entry.source())
.append("] ")
.append(entry.message())
.append(System.lineSeparator());
}
if (output.isEmpty()) {
output.append(Instant.now()).append(" [SYSTEM] [PLUGIN] No panel logs available during shutdown export").append(System.lineSeparator());
}
Files.writeString(exportFile, output, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
logger.info("Exported panel logs to " + exportFile.toAbsolutePath());
} catch (IOException | IllegalStateException exception) {
logger.warning("Could not export panel logs on disable: " + exception.getMessage());
}
}
}
@@ -0,0 +1,23 @@
package de.winniepat.minePanel.logs;
import io.papermc.paper.event.player.AsyncChatEvent;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.event.*;
public final class ChatCaptureListener implements Listener {
private static final PlainTextComponentSerializer PLAIN_TEXT_SERIALIZER = PlainTextComponentSerializer.plainText();
private final PanelLogger panelLogger;
public ChatCaptureListener(PanelLogger panelLogger) {
this.panelLogger = panelLogger;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onChat(AsyncChatEvent event) {
String message = PLAIN_TEXT_SERIALIZER.serialize(event.message());
panelLogger.log("CHAT", event.getPlayer().getName(), message);
}
}
@@ -0,0 +1,25 @@
package de.winniepat.minePanel.logs;
import org.bukkit.event.*;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
import org.bukkit.event.server.ServerCommandEvent;
public final class CommandCaptureListener implements Listener {
private final PanelLogger panelLogger;
public CommandCaptureListener(PanelLogger panelLogger) {
this.panelLogger = panelLogger;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPlayerCommand(PlayerCommandPreprocessEvent event) {
panelLogger.log("COMMAND", event.getPlayer().getName(), event.getMessage());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onServerCommand(ServerCommandEvent event) {
panelLogger.log("CONSOLE_COMMAND", "CONSOLE", event.getCommand());
}
}
@@ -0,0 +1,11 @@
package de.winniepat.minePanel.logs;
public record PanelLogEntry(
long id,
String kind,
String source,
String message,
long createdAt
) {
}
@@ -0,0 +1,26 @@
package de.winniepat.minePanel.logs;
import de.winniepat.minePanel.integrations.DiscordWebhookService;
import de.winniepat.minePanel.persistence.LogRepository;
import java.time.Instant;
public final class PanelLogger {
private final LogRepository logRepository;
private final DiscordWebhookService discordWebhookService;
public PanelLogger(LogRepository logRepository, DiscordWebhookService discordWebhookService) {
this.logRepository = logRepository;
this.discordWebhookService = discordWebhookService;
}
public synchronized void log(String kind, String source, String message) {
Instant now = Instant.now();
String safeMessage = message == null ? "" : message;
logRepository.appendLog(kind, source, safeMessage);
discordWebhookService.handlePanelLog(kind, source, safeMessage, now);
}
}
@@ -0,0 +1,144 @@
package de.winniepat.minePanel.logs;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import de.winniepat.minePanel.MinePanel;
import de.winniepat.minePanel.persistence.*;
import org.bukkit.entity.Player;
import org.bukkit.event.*;
import org.bukkit.event.player.*;
import java.net.*;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.UUID;
public final class PlayerActivityListener implements Listener {
private final MinePanel plugin;
private final KnownPlayerRepository knownPlayerRepository;
private final PlayerActivityRepository playerActivityRepository;
private final JoinLeaveEventRepository joinLeaveEventRepository;
private final PanelLogger panelLogger;
private final HttpClient httpClient = HttpClient.newHttpClient();
private final Gson gson = new Gson();
public PlayerActivityListener(
MinePanel plugin,
KnownPlayerRepository knownPlayerRepository,
PlayerActivityRepository playerActivityRepository,
JoinLeaveEventRepository joinLeaveEventRepository,
PanelLogger panelLogger
) {
this.plugin = plugin;
this.knownPlayerRepository = knownPlayerRepository;
this.playerActivityRepository = playerActivityRepository;
this.joinLeaveEventRepository = joinLeaveEventRepository;
this.panelLogger = panelLogger;
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
UUID uuid = player.getUniqueId();
long now = Instant.now().toEpochMilli();
knownPlayerRepository.upsert(uuid, player.getName(), now);
joinLeaveEventRepository.appendJoinEvent(uuid, player.getName(), now);
String ip = "";
if (player.getAddress() != null && player.getAddress().getAddress() != null) {
ip = player.getAddress().getAddress().getHostAddress();
}
playerActivityRepository.onJoin(uuid, now, ip);
if (!ip.isBlank()) {
Player matchingOnlinePlayer = findOtherOnlinePlayerWithSameIp(player, ip);
if (matchingOnlinePlayer != null) {
panelLogger.log(
"SECURITY",
"ALT_DETECT",
"Possible alt account: " + player.getName()
+ " joined with IP " + ip
+ " already used by online player " + matchingOnlinePlayer.getName()
);
}
}
if (!ip.isBlank()) {
String ipAddress = ip;
plugin.schedulerBridge().runAsync(() -> {
String country = resolveCountry(ipAddress);
playerActivityRepository.updateCountry(uuid, country);
});
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerQuit(PlayerQuitEvent event) {
Player player = event.getPlayer();
long now = Instant.now().toEpochMilli();
knownPlayerRepository.upsert(player.getUniqueId(), player.getName(), now);
playerActivityRepository.onQuit(player.getUniqueId(), now);
joinLeaveEventRepository.appendLeaveEvent(player.getUniqueId(), player.getName(), now);
}
private String resolveCountry(String ipAddress) {
if (isLocalAddress(ipAddress)) {
return "Local";
}
try {
String encodedIp = URLEncoder.encode(ipAddress, StandardCharsets.UTF_8);
String url = "http://ip-api.com/json/" + encodedIp + "?fields=status,country";
HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() >= 300) {
return "Unknown";
}
JsonObject payload = gson.fromJson(response.body(), JsonObject.class);
if (payload == null || !payload.has("status")) {
return "Unknown";
}
if (!"success".equalsIgnoreCase(payload.get("status").getAsString())) {
return "Unknown";
}
if (!payload.has("country") || payload.get("country").isJsonNull()) {
return "Unknown";
}
return payload.get("country").getAsString();
} catch (Exception ignored) {
return "Unknown";
}
}
private boolean isLocalAddress(String ipAddress) {
try {
InetAddress address = InetAddress.getByName(ipAddress);
return address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isSiteLocalAddress();
} catch (Exception ignored) {
return true;
}
}
private Player findOtherOnlinePlayerWithSameIp(Player joinedPlayer, String ipAddress) {
for (Player online : plugin.getServer().getOnlinePlayers()) {
if (online.getUniqueId().equals(joinedPlayer.getUniqueId())) {
continue;
}
String onlineIp = "";
if (online.getAddress() != null && online.getAddress().getAddress() != null) {
onlineIp = online.getAddress().getAddress().getHostAddress();
}
if (!onlineIp.isBlank() && onlineIp.equals(ipAddress)) {
return online;
}
}
return null;
}
}
@@ -0,0 +1,88 @@
package de.winniepat.minePanel.logs;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.stream.*;
public final class ServerLogService {
private final Path logDirectory;
private final Path latestLogFile;
public ServerLogService(Path pluginDataFolder) {
Path pluginsFolder = pluginDataFolder.getParent();
Path serverRoot = pluginsFolder == null ? pluginDataFolder : pluginsFolder.getParent();
if (serverRoot == null) {
serverRoot = pluginDataFolder;
}
this.logDirectory = serverRoot.resolve("logs");
this.latestLogFile = logDirectory.resolve("latest.log");
}
public List<String> readLatestLines(int lines) {
return readLogLines("latest.log", lines);
}
public List<String> readLogLines(String logFileName, int lines) {
Path targetFile = resolveLogFile(logFileName);
if (targetFile == null || !Files.exists(targetFile)) {
return Collections.emptyList();
}
int safeLines = Math.max(1, Math.min(lines, 2000));
Deque<String> queue = new ArrayDeque<>(safeLines);
try {
for (String line : Files.readAllLines(targetFile, StandardCharsets.UTF_8)) {
if (queue.size() == safeLines) {
queue.removeFirst();
}
queue.addLast(line);
}
} catch (IOException exception) {
return List.of("Unable to read " + targetFile.getFileName() + ": " + exception.getMessage());
}
return new ArrayList<>(queue);
}
public List<String> listLogFiles() {
if (!Files.exists(logDirectory) || !Files.isDirectory(logDirectory)) {
return Collections.emptyList();
}
try (Stream<Path> files = Files.list(logDirectory)) {
return files
.filter(Files::isRegularFile)
.sorted(Comparator.comparingLong(this::lastModifiedSafe).reversed())
.map(path -> path.getFileName().toString())
.collect(Collectors.toList());
} catch (IOException exception) {
return Collections.emptyList();
}
}
private Path resolveLogFile(String logFileName) {
String selected = (logFileName == null || logFileName.isBlank()) ? "latest.log" : logFileName;
if (selected.contains("/") || selected.contains("\\")) {
return null;
}
Path target = logDirectory.resolve(selected).normalize();
if (!target.startsWith(logDirectory)) {
return null;
}
return target;
}
private long lastModifiedSafe(Path path) {
try {
return Files.getLastModifiedTime(path).toMillis();
} catch (IOException ignored) {
return Long.MIN_VALUE;
}
}
}
@@ -0,0 +1,143 @@
package de.winniepat.minePanel.persistence;
import java.nio.file.*;
import java.sql.*;
public final class Database {
private final Path databaseFile;
public Database(Path databaseFile) {
this.databaseFile = databaseFile;
}
public void initialize() {
try {
Files.createDirectories(databaseFile.getParent());
} catch (Exception exception) {
throw new IllegalStateException("Could not create database directory", exception);
}
try (Connection connection = getConnection();
Statement statement = connection.createStatement()) {
statement.execute("CREATE TABLE IF NOT EXISTS users ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "username TEXT NOT NULL UNIQUE,"
+ "password_hash TEXT NOT NULL,"
+ "role TEXT NOT NULL,"
+ "created_at INTEGER NOT NULL"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS user_permissions ("
+ "user_id INTEGER NOT NULL,"
+ "permission TEXT NOT NULL,"
+ "PRIMARY KEY(user_id, permission),"
+ "FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS sessions ("
+ "token TEXT PRIMARY KEY,"
+ "user_id INTEGER NOT NULL,"
+ "created_at INTEGER NOT NULL,"
+ "expires_at INTEGER NOT NULL,"
+ "FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS oauth_accounts ("
+ "user_id INTEGER NOT NULL,"
+ "provider TEXT NOT NULL,"
+ "provider_user_id TEXT NOT NULL,"
+ "display_name TEXT NOT NULL DEFAULT '',"
+ "email TEXT NOT NULL DEFAULT '',"
+ "avatar_url TEXT NOT NULL DEFAULT '',"
+ "linked_at INTEGER NOT NULL,"
+ "updated_at INTEGER NOT NULL,"
+ "PRIMARY KEY(user_id, provider),"
+ "UNIQUE(provider, provider_user_id),"
+ "FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS oauth_states ("
+ "state TEXT PRIMARY KEY,"
+ "provider TEXT NOT NULL,"
+ "mode TEXT NOT NULL,"
+ "user_id INTEGER,"
+ "created_at INTEGER NOT NULL,"
+ "expires_at INTEGER NOT NULL"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS panel_logs ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "kind TEXT NOT NULL,"
+ "source TEXT NOT NULL,"
+ "message TEXT NOT NULL,"
+ "created_at INTEGER NOT NULL"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS known_players ("
+ "uuid TEXT PRIMARY KEY,"
+ "username TEXT NOT NULL,"
+ "last_seen_at INTEGER NOT NULL"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS player_activity ("
+ "uuid TEXT PRIMARY KEY,"
+ "first_joined INTEGER NOT NULL DEFAULT 0,"
+ "last_seen INTEGER NOT NULL DEFAULT 0,"
+ "total_playtime_seconds INTEGER NOT NULL DEFAULT 0,"
+ "total_sessions INTEGER NOT NULL DEFAULT 0,"
+ "current_session_start INTEGER NOT NULL DEFAULT 0,"
+ "last_ip TEXT NOT NULL DEFAULT '',"
+ "last_country TEXT NOT NULL DEFAULT ''"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS join_leave_events ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "event_type TEXT NOT NULL,"
+ "player_uuid TEXT NOT NULL,"
+ "player_name TEXT NOT NULL,"
+ "created_at INTEGER NOT NULL"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS discord_webhook_config ("
+ "id INTEGER PRIMARY KEY,"
+ "enabled INTEGER NOT NULL,"
+ "webhook_url TEXT NOT NULL,"
+ "use_embed INTEGER NOT NULL,"
+ "bot_name TEXT NOT NULL,"
+ "message_template TEXT NOT NULL,"
+ "embed_title_template TEXT NOT NULL,"
+ "log_chat INTEGER NOT NULL,"
+ "log_commands INTEGER NOT NULL,"
+ "log_auth INTEGER NOT NULL,"
+ "log_audit INTEGER NOT NULL,"
+ "log_security INTEGER NOT NULL DEFAULT 1,"
+ "log_console_response INTEGER NOT NULL,"
+ "log_system INTEGER NOT NULL"
+ ")");
statement.execute("CREATE TABLE IF NOT EXISTS extension_settings ("
+ "extension_id TEXT PRIMARY KEY,"
+ "settings_json TEXT NOT NULL DEFAULT '{}',"
+ "updated_at INTEGER NOT NULL DEFAULT 0"
+ ")");
try {
statement.execute("ALTER TABLE discord_webhook_config ADD COLUMN log_security INTEGER NOT NULL DEFAULT 1");
} catch (SQLException ignored) {
// Column already exists on upgraded installs.
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not initialize database", exception);
}
}
public Connection getConnection() throws SQLException {
return DriverManager.getConnection("jdbc:sqlite:" + databaseFile.toAbsolutePath());
}
public void close() {
}
}
@@ -0,0 +1,64 @@
package de.winniepat.minePanel.persistence;
import java.sql.*;
import java.util.Optional;
public final class ExtensionSettingsRepository {
private final Database database;
public ExtensionSettingsRepository(Database database) {
this.database = database;
}
public Optional<String> findSettingsJson(String extensionId) {
String normalizedId = normalizeExtensionId(extensionId);
if (normalizedId.isBlank()) {
return Optional.empty();
}
String sql = "SELECT settings_json FROM extension_settings WHERE extension_id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, normalizedId);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.ofNullable(resultSet.getString("settings_json"));
}
}
return Optional.empty();
} catch (SQLException exception) {
throw new IllegalStateException("Could not read extension settings", exception);
}
}
public void saveSettingsJson(String extensionId, String settingsJson, long updatedAtMillis) {
String normalizedId = normalizeExtensionId(extensionId);
if (normalizedId.isBlank()) {
throw new IllegalArgumentException("invalid_extension_id");
}
String sql = "INSERT INTO extension_settings(extension_id, settings_json, updated_at) VALUES (?, ?, ?) "
+ "ON CONFLICT(extension_id) DO UPDATE SET "
+ "settings_json = excluded.settings_json, "
+ "updated_at = excluded.updated_at";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, normalizedId);
statement.setString(2, settingsJson == null || settingsJson.isBlank() ? "{}" : settingsJson);
statement.setLong(3, Math.max(0L, updatedAtMillis));
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not save extension settings", exception);
}
}
private String normalizeExtensionId(String extensionId) {
if (extensionId == null || extensionId.isBlank()) {
return "";
}
return extensionId.trim().toLowerCase();
}
}
@@ -0,0 +1,66 @@
package de.winniepat.minePanel.persistence;
import java.sql.*;
import java.util.*;
public final class JoinLeaveEventRepository {
private final Database database;
public JoinLeaveEventRepository(Database database) {
this.database = database;
}
public void appendJoinEvent(UUID uuid, String username, long createdAt) {
appendEvent("JOIN", uuid, username, createdAt);
}
public void appendLeaveEvent(UUID uuid, String username, long createdAt) {
appendEvent("LEAVE", uuid, username, createdAt);
}
public List<JoinLeaveEvent> listEventsSince(long sinceMillis) {
String sql = "SELECT event_type, player_uuid, player_name, created_at "
+ "FROM join_leave_events WHERE created_at >= ? ORDER BY created_at ASC";
List<JoinLeaveEvent> events = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, Math.max(0L, sinceMillis));
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
events.add(new JoinLeaveEvent(
resultSet.getString("event_type"),
UUID.fromString(resultSet.getString("player_uuid")),
resultSet.getString("player_name"),
resultSet.getLong("created_at")
));
}
}
return events;
} catch (SQLException exception) {
throw new IllegalStateException("Could not list join/leave events", exception);
}
}
private void appendEvent(String eventType, UUID uuid, String username, long createdAt) {
if (uuid == null || username == null || username.isBlank()) {
return;
}
String sql = "INSERT INTO join_leave_events(event_type, player_uuid, player_name, created_at) VALUES (?, ?, ?, ?)";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, eventType);
statement.setString(2, uuid.toString());
statement.setString(3, username);
statement.setLong(4, createdAt);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not append join/leave event", exception);
}
}
public record JoinLeaveEvent(String eventType, UUID playerUuid, String playerName, long createdAt) {
}
}
@@ -0,0 +1,11 @@
package de.winniepat.minePanel.persistence;
import java.util.UUID;
public record KnownPlayer(
UUID uuid,
String username,
long lastSeenAt
) {
}
@@ -0,0 +1,100 @@
package de.winniepat.minePanel.persistence;
import java.sql.*;
import java.time.Instant;
import java.util.*;
public final class KnownPlayerRepository {
private final Database database;
public KnownPlayerRepository(Database database) {
this.database = database;
}
public void upsert(UUID uuid, String username) {
upsert(uuid, username, Instant.now().toEpochMilli());
}
public void upsert(UUID uuid, String username, long lastSeenAt) {
if (uuid == null || username == null || username.isBlank()) {
return;
}
String sql = "INSERT INTO known_players(uuid, username, last_seen_at) VALUES (?, ?, ?) "
+ "ON CONFLICT(uuid) DO UPDATE SET username = excluded.username, last_seen_at = excluded.last_seen_at";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
statement.setString(2, username);
statement.setLong(3, lastSeenAt);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not upsert known player", exception);
}
}
public List<KnownPlayer> findAll() {
String sql = "SELECT uuid, username, last_seen_at FROM known_players";
List<KnownPlayer> players = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
players.add(new KnownPlayer(
UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username"),
resultSet.getLong("last_seen_at")
));
}
return players;
} catch (SQLException exception) {
throw new IllegalStateException("Could not list known players", exception);
}
}
public Optional<KnownPlayer> findByUuid(UUID uuid) {
String sql = "SELECT uuid, username, last_seen_at FROM known_players WHERE uuid = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(new KnownPlayer(
UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username"),
resultSet.getLong("last_seen_at")
));
}
return Optional.empty();
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not find known player", exception);
}
}
public Optional<KnownPlayer> findByUsername(String username) {
if (username == null || username.isBlank()) {
return Optional.empty();
}
String sql = "SELECT uuid, username, last_seen_at FROM known_players WHERE LOWER(username) = LOWER(?) LIMIT 1";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, username.trim());
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(new KnownPlayer(
UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username"),
resultSet.getLong("last_seen_at")
));
}
return Optional.empty();
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not find known player by username", exception);
}
}
}
@@ -0,0 +1,97 @@
package de.winniepat.minePanel.persistence;
import de.winniepat.minePanel.logs.PanelLogEntry;
import java.sql.*;
import java.time.Instant;
import java.util.*;
public final class LogRepository {
private final Database database;
public LogRepository(Database database) {
this.database = database;
}
public void appendLog(String kind, String source, String message) {
String sql = "INSERT INTO panel_logs(kind, source, message, created_at) VALUES (?, ?, ?, ?)";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, kind);
statement.setString(2, source);
statement.setString(3, message);
statement.setLong(4, Instant.now().toEpochMilli());
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not append panel log", exception);
}
}
public List<PanelLogEntry> recentLogs(int limit) {
int safeLimit = Math.max(1, Math.min(limit, 1000));
String sql = "SELECT id, kind, source, message, created_at FROM panel_logs ORDER BY id DESC LIMIT ?";
List<PanelLogEntry> entries = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, safeLimit);
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
entries.add(new PanelLogEntry(
resultSet.getLong("id"),
resultSet.getString("kind"),
resultSet.getString("source"),
resultSet.getString("message"),
resultSet.getLong("created_at")
));
}
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not query panel logs", exception);
}
return entries;
}
public List<PanelLogEntry> allLogsAscending() {
String sql = "SELECT id, kind, source, message, created_at FROM panel_logs ORDER BY id ASC";
List<PanelLogEntry> entries = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
entries.add(new PanelLogEntry(
resultSet.getLong("id"),
resultSet.getString("kind"),
resultSet.getString("source"),
resultSet.getString("message"),
resultSet.getLong("created_at")
));
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not query all panel logs", exception);
}
return entries;
}
public long latestLogId() {
String sql = "SELECT COALESCE(MAX(id), 0) AS latest_id FROM panel_logs";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
return resultSet.getLong("latest_id");
} catch (SQLException exception) {
throw new IllegalStateException("Could not get latest panel log id", exception);
}
}
public void clearLogs() {
String sql = "DELETE FROM panel_logs";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not clear panel logs", exception);
}
}
}
@@ -0,0 +1,153 @@
package de.winniepat.minePanel.persistence;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
public final class OAuthAccountRepository {
private final Database database;
public OAuthAccountRepository(Database database) {
this.database = database;
}
public Optional<Long> findUserIdByProviderSubject(String provider, String providerUserId) {
String normalizedProvider = normalizeProvider(provider);
String sql = "SELECT user_id FROM oauth_accounts WHERE provider = ? AND provider_user_id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, normalizedProvider);
statement.setString(2, providerUserId);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(resultSet.getLong("user_id"));
}
}
return Optional.empty();
} catch (SQLException exception) {
throw new IllegalStateException("Could not query OAuth account by provider subject", exception);
}
}
public Optional<OAuthAccountLink> findByUserAndProvider(long userId, String provider) {
String normalizedProvider = normalizeProvider(provider);
String sql = "SELECT user_id, provider, provider_user_id, display_name, email, avatar_url, linked_at, updated_at "
+ "FROM oauth_accounts WHERE user_id = ? AND provider = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
statement.setString(2, normalizedProvider);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(map(resultSet));
}
}
return Optional.empty();
} catch (SQLException exception) {
throw new IllegalStateException("Could not query OAuth account by user and provider", exception);
}
}
public List<OAuthAccountLink> listByUserId(long userId) {
String sql = "SELECT user_id, provider, provider_user_id, display_name, email, avatar_url, linked_at, updated_at "
+ "FROM oauth_accounts WHERE user_id = ? ORDER BY provider ASC";
List<OAuthAccountLink> links = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
links.add(map(resultSet));
}
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not list OAuth links for user", exception);
}
return links;
}
public void upsertLink(long userId, String provider, String providerUserId, String displayName, String email, String avatarUrl) {
String normalizedProvider = normalizeProvider(provider);
long now = Instant.now().toEpochMilli();
String sql = "INSERT INTO oauth_accounts(user_id, provider, provider_user_id, display_name, email, avatar_url, linked_at, updated_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(user_id, provider) DO UPDATE SET "
+ "provider_user_id = excluded.provider_user_id, "
+ "display_name = excluded.display_name, "
+ "email = excluded.email, "
+ "avatar_url = excluded.avatar_url, "
+ "updated_at = excluded.updated_at";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
statement.setString(2, normalizedProvider);
statement.setString(3, providerUserId);
statement.setString(4, safe(displayName));
statement.setString(5, safe(email));
statement.setString(6, safe(avatarUrl));
statement.setLong(7, now);
statement.setLong(8, now);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not upsert OAuth account link", exception);
}
}
public boolean unlink(long userId, String provider) {
String normalizedProvider = normalizeProvider(provider);
String sql = "DELETE FROM oauth_accounts WHERE user_id = ? AND provider = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
statement.setString(2, normalizedProvider);
return statement.executeUpdate() > 0;
} catch (SQLException exception) {
throw new IllegalStateException("Could not unlink OAuth account", exception);
}
}
private OAuthAccountLink map(ResultSet resultSet) throws SQLException {
return new OAuthAccountLink(
resultSet.getLong("user_id"),
resultSet.getString("provider"),
resultSet.getString("provider_user_id"),
resultSet.getString("display_name"),
resultSet.getString("email"),
resultSet.getString("avatar_url"),
resultSet.getLong("linked_at"),
resultSet.getLong("updated_at")
);
}
private String normalizeProvider(String provider) {
return provider == null ? "" : provider.trim().toLowerCase(Locale.ROOT);
}
private String safe(String value) {
return value == null ? "" : value.trim();
}
public record OAuthAccountLink(
long userId,
String provider,
String providerUserId,
String displayName,
String email,
String avatarUrl,
long linkedAt,
long updatedAt
) {
}
}
@@ -0,0 +1,122 @@
package de.winniepat.minePanel.persistence;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.Locale;
import java.util.Optional;
public final class OAuthStateRepository {
private final Database database;
public OAuthStateRepository(Database database) {
this.database = database;
}
public void createState(String state, String provider, String mode, Long userId, long expiresAtMillis) {
cleanupExpired();
String sql = "INSERT INTO oauth_states(state, provider, mode, user_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)";
long now = Instant.now().toEpochMilli();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, state);
statement.setString(2, normalize(provider));
statement.setString(3, normalize(mode));
if (userId == null) {
statement.setNull(4, java.sql.Types.INTEGER);
} else {
statement.setLong(4, userId);
}
statement.setLong(5, now);
statement.setLong(6, expiresAtMillis);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not create OAuth state", exception);
}
}
public Optional<OAuthState> consumeState(String state, String provider, String mode) {
String normalizedProvider = normalize(provider);
String normalizedMode = normalize(mode);
String selectSql = "SELECT state, provider, mode, user_id, created_at, expires_at FROM oauth_states WHERE state = ? AND provider = ? AND mode = ?";
String deleteSql = "DELETE FROM oauth_states WHERE state = ?";
try (Connection connection = database.getConnection();
PreparedStatement selectStatement = connection.prepareStatement(selectSql);
PreparedStatement deleteStatement = connection.prepareStatement(deleteSql)) {
connection.setAutoCommit(false);
selectStatement.setString(1, state);
selectStatement.setString(2, normalizedProvider);
selectStatement.setString(3, normalizedMode);
OAuthState oauthState = null;
try (ResultSet resultSet = selectStatement.executeQuery()) {
if (resultSet.next()) {
Long userId = null;
long rawUserId = resultSet.getLong("user_id");
if (!resultSet.wasNull()) {
userId = rawUserId;
}
oauthState = new OAuthState(
resultSet.getString("state"),
resultSet.getString("provider"),
resultSet.getString("mode"),
userId,
resultSet.getLong("created_at"),
resultSet.getLong("expires_at")
);
}
}
if (oauthState == null) {
connection.rollback();
return Optional.empty();
}
deleteStatement.setString(1, state);
deleteStatement.executeUpdate();
if (oauthState.expiresAtMillis() <= Instant.now().toEpochMilli()) {
connection.commit();
return Optional.empty();
}
connection.commit();
return Optional.of(oauthState);
} catch (SQLException exception) {
throw new IllegalStateException("Could not consume OAuth state", exception);
}
}
public void cleanupExpired() {
String sql = "DELETE FROM oauth_states WHERE expires_at <= ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, Instant.now().toEpochMilli());
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not cleanup OAuth states", exception);
}
}
private String normalize(String raw) {
return raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT);
}
public record OAuthState(
String state,
String provider,
String mode,
Long userId,
long createdAtMillis,
long expiresAtMillis
) {
}
}
@@ -0,0 +1,16 @@
package de.winniepat.minePanel.persistence;
import java.util.UUID;
public record PlayerActivity(
UUID uuid,
long firstJoined,
long lastSeen,
long totalPlaytimeSeconds,
long totalSessions,
long currentSessionStart,
String lastIp,
String lastCountry
) {
}
@@ -0,0 +1,152 @@
package de.winniepat.minePanel.persistence;
import java.sql.*;
import java.util.*;
public final class PlayerActivityRepository {
private final Database database;
public PlayerActivityRepository(Database database) {
this.database = database;
}
public void ensureFromOffline(UUID uuid, long firstJoined, long lastSeen) {
if (uuid == null) {
return;
}
long safeFirstJoined = Math.max(0L, firstJoined);
long safeLastSeen = Math.max(0L, lastSeen);
String sql = "INSERT INTO player_activity(uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country) "
+ "VALUES (?, ?, ?, 0, 0, 0, '', '') "
+ "ON CONFLICT(uuid) DO UPDATE SET "
+ "first_joined = CASE WHEN player_activity.first_joined = 0 THEN excluded.first_joined ELSE player_activity.first_joined END, "
+ "last_seen = MAX(player_activity.last_seen, excluded.last_seen)";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
statement.setLong(2, safeFirstJoined);
statement.setLong(3, safeLastSeen);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not seed player activity", exception);
}
}
public void onJoin(UUID uuid, long joinedAt, String ipAddress) {
if (uuid == null) {
return;
}
String safeIp = ipAddress == null ? "" : ipAddress;
String sql = "INSERT INTO player_activity(uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country) "
+ "VALUES (?, ?, ?, 0, 1, ?, ?, '') "
+ "ON CONFLICT(uuid) DO UPDATE SET "
+ "first_joined = CASE WHEN player_activity.first_joined = 0 THEN excluded.first_joined ELSE player_activity.first_joined END, "
+ "last_seen = excluded.last_seen, "
+ "total_sessions = player_activity.total_sessions + 1, "
+ "current_session_start = CASE WHEN player_activity.current_session_start > 0 THEN player_activity.current_session_start ELSE excluded.current_session_start END, "
+ "last_ip = excluded.last_ip";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
statement.setLong(2, joinedAt);
statement.setLong(3, joinedAt);
statement.setLong(4, joinedAt);
statement.setString(5, safeIp);
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not update player activity on join", exception);
}
}
public void onQuit(UUID uuid, long quitAt) {
if (uuid == null) {
return;
}
String sql = "UPDATE player_activity SET "
+ "last_seen = ?, "
+ "total_playtime_seconds = total_playtime_seconds + CASE "
+ "WHEN current_session_start > 0 AND ? > current_session_start THEN (? - current_session_start) / 1000 ELSE 0 END, "
+ "current_session_start = 0 "
+ "WHERE uuid = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, quitAt);
statement.setLong(2, quitAt);
statement.setLong(3, quitAt);
statement.setString(4, uuid.toString());
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not update player activity on quit", exception);
}
}
public void updateCountry(UUID uuid, String country) {
if (uuid == null) {
return;
}
String sql = "UPDATE player_activity SET last_country = ? WHERE uuid = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, country == null ? "" : country);
statement.setString(2, uuid.toString());
statement.executeUpdate();
} catch (SQLException exception) {
throw new IllegalStateException("Could not update player country", exception);
}
}
public Optional<PlayerActivity> findByUuid(UUID uuid) {
String sql = "SELECT uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country "
+ "FROM player_activity WHERE uuid = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(read(resultSet));
}
}
return Optional.empty();
} catch (SQLException exception) {
throw new IllegalStateException("Could not read player activity", exception);
}
}
public Map<UUID, PlayerActivity> findAllByUuid() {
String sql = "SELECT uuid, first_joined, last_seen, total_playtime_seconds, total_sessions, current_session_start, last_ip, last_country FROM player_activity";
Map<UUID, PlayerActivity> players = new HashMap<>();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
PlayerActivity entry = read(resultSet);
players.put(entry.uuid(), entry);
}
return players;
} catch (SQLException exception) {
throw new IllegalStateException("Could not list player activity", exception);
}
}
private PlayerActivity read(ResultSet resultSet) throws SQLException {
return new PlayerActivity(
UUID.fromString(resultSet.getString("uuid")),
resultSet.getLong("first_joined"),
resultSet.getLong("last_seen"),
resultSet.getLong("total_playtime_seconds"),
resultSet.getLong("total_sessions"),
resultSet.getLong("current_session_start"),
resultSet.getString("last_ip"),
resultSet.getString("last_country")
);
}
}
@@ -0,0 +1,317 @@
package de.winniepat.minePanel.persistence;
import de.winniepat.minePanel.users.*;
import java.sql.*;
import java.time.Instant;
import java.util.*;
public final class UserRepository {
private final Database database;
public UserRepository(Database database) {
this.database = database;
}
public long countUsers() {
String sql = "SELECT COUNT(*) AS amount FROM users";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
return resultSet.getLong("amount");
} catch (SQLException exception) {
throw new IllegalStateException("Could not count users", exception);
}
}
public long countOwners() {
String sql = "SELECT COUNT(*) AS amount FROM users WHERE role = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, UserRole.OWNER.name());
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.getLong("amount");
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not count owners", exception);
}
}
public int demoteExtraOwnersToAdmin() {
String selectSql = "SELECT id FROM users WHERE role = ? ORDER BY id ASC";
String updateSql = "UPDATE users SET role = ? WHERE id = ?";
try (Connection connection = database.getConnection();
PreparedStatement selectStatement = connection.prepareStatement(selectSql);
PreparedStatement updateStatement = connection.prepareStatement(updateSql)) {
connection.setAutoCommit(false);
selectStatement.setString(1, UserRole.OWNER.name());
List<Long> ownerIds = new ArrayList<>();
try (ResultSet resultSet = selectStatement.executeQuery()) {
while (resultSet.next()) {
ownerIds.add(resultSet.getLong("id"));
}
}
if (ownerIds.size() <= 1) {
connection.rollback();
return 0;
}
int updated = 0;
for (int i = 1; i < ownerIds.size(); i++) {
long userId = ownerIds.get(i);
updateStatement.setString(1, UserRole.ADMIN.name());
updateStatement.setLong(2, userId);
if (updateStatement.executeUpdate() > 0) {
savePermissions(connection, userId, UserRole.ADMIN.defaultPermissions());
updated++;
}
}
connection.commit();
return updated;
} catch (SQLException exception) {
throw new IllegalStateException("Could not normalize owner accounts", exception);
}
}
public PanelUser createUser(String username, String passwordHash, UserRole role) {
return createUser(username, passwordHash, role, role.defaultPermissions());
}
public PanelUser createUser(String username, String passwordHash, UserRole role, Set<PanelPermission> permissions) {
String sql = "INSERT INTO users(username, password_hash, role, created_at) VALUES (?, ?, ?, ?)";
long createdAt = Instant.now().toEpochMilli();
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS)) {
connection.setAutoCommit(false);
statement.setString(1, username);
statement.setString(2, passwordHash);
statement.setString(3, role.name());
statement.setLong(4, createdAt);
statement.executeUpdate();
long userId;
try (ResultSet keys = statement.getGeneratedKeys()) {
if (keys.next()) {
userId = keys.getLong(1);
} else {
throw new IllegalStateException("Could not create user (missing generated key)");
}
}
Set<PanelPermission> sanitized = sanitizePermissions(permissions, role);
savePermissions(connection, userId, sanitized);
connection.commit();
return new PanelUser(userId, username, role, sanitized, createdAt);
} catch (SQLException exception) {
throw new IllegalStateException("Could not create user", exception);
}
}
public Optional<PanelUserAuth> findByUsername(String username) {
String sql = "SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, username);
try (ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next()) {
return Optional.empty();
}
PanelUser user = mapUser(connection, resultSet);
String passwordHash = resultSet.getString("password_hash");
return Optional.of(new PanelUserAuth(user, passwordHash));
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not find user by username", exception);
}
}
public Optional<PanelUser> findById(long userId) {
String sql = "SELECT id, username, role, created_at FROM users WHERE id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return Optional.of(mapUser(connection, resultSet));
}
return Optional.empty();
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not find user by id", exception);
}
}
public List<PanelUser> findAllUsers() {
String sql = "SELECT id, username, role, created_at FROM users ORDER BY id ASC";
String permissionSql = "SELECT user_id, permission FROM user_permissions ORDER BY user_id ASC";
List<PanelUser> users = new ArrayList<>();
try (Connection connection = database.getConnection();
PreparedStatement permissionStatement = connection.prepareStatement(permissionSql);
ResultSet permissionResultSet = permissionStatement.executeQuery();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
Map<Long, Set<PanelPermission>> permissionsByUser = new HashMap<>();
while (permissionResultSet.next()) {
long userId = permissionResultSet.getLong("user_id");
String rawPermission = permissionResultSet.getString("permission");
PanelPermission permission;
try {
permission = PanelPermission.valueOf(rawPermission);
} catch (IllegalArgumentException ignored) {
continue;
}
permissionsByUser.computeIfAbsent(userId, ignored -> EnumSet.noneOf(PanelPermission.class)).add(permission);
}
while (resultSet.next()) {
long userId = resultSet.getLong("id");
UserRole role = UserRole.fromString(resultSet.getString("role"));
Set<PanelPermission> permissions = permissionsByUser.getOrDefault(userId, role.defaultPermissions());
users.add(new PanelUser(
userId,
resultSet.getString("username"),
role,
EnumSet.copyOf(permissions),
resultSet.getLong("created_at")
));
}
return users;
} catch (SQLException exception) {
throw new IllegalStateException("Could not list users", exception);
}
}
public boolean updateRole(long userId, UserRole role) {
String sql = "UPDATE users SET role = ? WHERE id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
connection.setAutoCommit(false);
statement.setString(1, role.name());
statement.setLong(2, userId);
boolean changed = statement.executeUpdate() > 0;
if (!changed) {
connection.rollback();
return false;
}
savePermissions(connection, userId, role.defaultPermissions());
connection.commit();
return true;
} catch (SQLException exception) {
throw new IllegalStateException("Could not update user role", exception);
}
}
public boolean updatePermissions(long userId, Set<PanelPermission> permissions) {
Optional<PanelUser> target = findById(userId);
if (target.isEmpty()) {
return false;
}
Set<PanelPermission> sanitized = sanitizePermissions(permissions, target.get().role());
String sql = "SELECT id FROM users WHERE id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
connection.setAutoCommit(false);
statement.setLong(1, userId);
try (ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next()) {
connection.rollback();
return false;
}
}
savePermissions(connection, userId, sanitized);
connection.commit();
return true;
} catch (SQLException exception) {
throw new IllegalStateException("Could not update user permissions", exception);
}
}
public boolean deleteUser(long userId) {
String sql = "DELETE FROM users WHERE id = ?";
try (Connection connection = database.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
return statement.executeUpdate() > 0;
} catch (SQLException exception) {
throw new IllegalStateException("Could not delete user", exception);
}
}
private PanelUser mapUser(Connection connection, ResultSet resultSet) throws SQLException {
long userId = resultSet.getLong("id");
UserRole role = UserRole.fromString(resultSet.getString("role"));
return new PanelUser(
userId,
resultSet.getString("username"),
role,
loadPermissions(connection, userId, role),
resultSet.getLong("created_at")
);
}
private Set<PanelPermission> loadPermissions(Connection connection, long userId, UserRole role) throws SQLException {
String sql = "SELECT permission FROM user_permissions WHERE user_id = ?";
Set<PanelPermission> permissions = EnumSet.noneOf(PanelPermission.class);
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, userId);
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
String rawPermission = resultSet.getString("permission");
try {
permissions.add(PanelPermission.valueOf(rawPermission));
} catch (IllegalArgumentException ignored) {
// Ignore unknown permissions from older/newer versions.
}
}
}
}
if (permissions.isEmpty()) {
return role.defaultPermissions();
}
return permissions;
}
private void savePermissions(Connection connection, long userId, Set<PanelPermission> permissions) throws SQLException {
try (PreparedStatement deleteStatement = connection.prepareStatement("DELETE FROM user_permissions WHERE user_id = ?")) {
deleteStatement.setLong(1, userId);
deleteStatement.executeUpdate();
}
String insertSql = "INSERT INTO user_permissions(user_id, permission) VALUES (?, ?)";
try (PreparedStatement insertStatement = connection.prepareStatement(insertSql)) {
for (PanelPermission permission : permissions) {
insertStatement.setLong(1, userId);
insertStatement.setString(2, permission.name());
insertStatement.addBatch();
}
insertStatement.executeBatch();
}
}
private Set<PanelPermission> sanitizePermissions(Set<PanelPermission> permissions, UserRole role) {
if (role == UserRole.OWNER) {
return EnumSet.allOf(PanelPermission.class);
}
Set<PanelPermission> source = permissions == null || permissions.isEmpty() ? role.defaultPermissions() : permissions;
Set<PanelPermission> sanitized = EnumSet.noneOf(PanelPermission.class);
for (PanelPermission permission : source) {
if (permission == null || !permission.assignable()) {
continue;
}
sanitized.add(permission);
}
sanitized.add(PanelPermission.ACCESS_PANEL);
return sanitized;
}
}
@@ -0,0 +1,77 @@
package de.winniepat.minePanel.users;
public enum PanelPermission {
// Legacy keys kept for compatibility with older route guards.
VIEW_DASHBOARD("Legacy", "View Dashboard", null, false),
VIEW_LOGS("Legacy", "View Logs", null, false),
ACCESS_PANEL("Panel", "Access Panel", null, true),
VIEW_OVERVIEW("Server", "View Overview", null, true),
VIEW_CONSOLE("Server", "View Console", null, true),
SEND_CONSOLE("Server", "Send Console Commands", null, true),
VIEW_RESOURCES("Server", "View Resources", null, true),
VIEW_PLAYERS("Server", "View Players", null, true),
MANAGE_PLAYERS("Server", "Manage Players", null, true),
VIEW_BANS("Server", "View Bans", null, true),
MANAGE_BANS("Server", "Manage Bans", null, true),
VIEW_PLUGINS("Server", "View Plugins", null, true),
MANAGE_PLUGINS("Server", "Install Plugins", null, true),
VIEW_USERS("Panel", "View Users", null, true),
MANAGE_USERS("Panel", "Manage Users", null, true),
VIEW_DISCORD_WEBHOOK("Panel", "View Discord Webhook", null, true),
MANAGE_DISCORD_WEBHOOK("Panel", "Manage Discord Webhook", null, true),
VIEW_THEMES("Panel", "View Themes", null, true),
MANAGE_THEMES("Panel", "Manage Themes", null, true),
VIEW_EXTENSIONS("Panel", "View Extensions", null, true),
MANAGE_EXTENSIONS("Panel", "Manage Extensions", null, true),
VIEW_BACKUPS("Extensions", "View Backups", "world-backups", true),
MANAGE_BACKUPS("Extensions", "Manage Backups", "world-backups", true),
VIEW_REPORTS("Extensions", "View Reports", "report-system", true),
MANAGE_REPORTS("Extensions", "Manage Reports", "report-system", true),
VIEW_TICKETS("Extensions", "View Tickets", "ticket-system", true),
MANAGE_TICKETS("Extensions", "Manage Tickets", "ticket-system", true),
VIEW_PLAYER_MANAGEMENT("Extensions", "View Player Management", "player-management", true),
MANAGE_PLAYER_MANAGEMENT("Extensions", "Manage Player Management", "player-management", true),
VIEW_PLAYER_STATS("Extensions", "View Player Stats", "player-stats", true),
VIEW_LUCKPERMS("Extensions", "View LuckPerms Data", "luckperms", true),
VIEW_AIRSTRIKE("Extensions", "View Airstrike", "airstrike", true),
MANAGE_AIRSTRIKE("Extensions", "Launch Airstrike", "airstrike", true),
VIEW_MAINTENANCE("Extensions", "View Maintenance", "maintenance", true),
MANAGE_MAINTENANCE("Extensions", "Manage Maintenance", "maintenance", true),
VIEW_WHITELIST("Extensions", "View Whitelist", "whitelist", true),
MANAGE_WHITELIST("Extensions", "Manage Whitelist", "whitelist", true),
VIEW_ANNOUNCEMENTS("Extensions", "View Announcements", "announcements", true),
MANAGE_ANNOUNCEMENTS("Extensions", "Manage Announcements", "announcements", true);
private final String category;
private final String label;
private final String extensionId;
private final boolean assignable;
PanelPermission(String category, String label, String extensionId, boolean assignable) {
this.category = category;
this.label = label;
this.extensionId = extensionId;
this.assignable = assignable;
}
public String category() {
return category;
}
public String label() {
return label;
}
public String extensionId() {
return extensionId;
}
public boolean assignable() {
return assignable;
}
}
@@ -0,0 +1,41 @@
package de.winniepat.minePanel.users;
import java.util.Set;
public record PanelUser(
long id,
String username,
UserRole role,
Set<PanelPermission> permissions,
long createdAt
) {
public boolean hasPermission(PanelPermission permission) {
if (role == UserRole.OWNER || permissions.contains(permission)) {
return true;
}
if (permission != null) {
String name = permission.name();
if (name.startsWith("VIEW_")) {
String manageName = "MANAGE_" + name.substring("VIEW_".length());
try {
PanelPermission managePermission = PanelPermission.valueOf(manageName);
if (permissions.contains(managePermission)) {
return true;
}
} catch (IllegalArgumentException ignored) {
// ignored
}
}
}
if (permission == PanelPermission.VIEW_DASHBOARD) {
return permissions.contains(PanelPermission.ACCESS_PANEL);
}
if (permission == PanelPermission.VIEW_LOGS) {
return permissions.contains(PanelPermission.VIEW_CONSOLE);
}
return false;
}
}
@@ -0,0 +1,8 @@
package de.winniepat.minePanel.users;
public record PanelUserAuth(
PanelUser user,
String passwordHash
) {
}
@@ -0,0 +1,82 @@
package de.winniepat.minePanel.users;
import java.util.*;
public enum UserRole {
OWNER(EnumSet.allOf(PanelPermission.class)),
ADMIN(EnumSet.of(
PanelPermission.ACCESS_PANEL,
PanelPermission.VIEW_OVERVIEW,
PanelPermission.VIEW_CONSOLE,
PanelPermission.SEND_CONSOLE,
PanelPermission.VIEW_RESOURCES,
PanelPermission.VIEW_PLAYERS,
PanelPermission.MANAGE_PLAYERS,
PanelPermission.VIEW_BANS,
PanelPermission.MANAGE_BANS,
PanelPermission.VIEW_PLUGINS,
PanelPermission.MANAGE_PLUGINS,
PanelPermission.VIEW_USERS,
PanelPermission.VIEW_DISCORD_WEBHOOK,
PanelPermission.MANAGE_DISCORD_WEBHOOK,
PanelPermission.VIEW_THEMES,
PanelPermission.VIEW_EXTENSIONS,
PanelPermission.VIEW_BACKUPS,
PanelPermission.MANAGE_BACKUPS,
PanelPermission.VIEW_REPORTS,
PanelPermission.MANAGE_REPORTS,
PanelPermission.VIEW_TICKETS,
PanelPermission.MANAGE_TICKETS,
PanelPermission.VIEW_PLAYER_MANAGEMENT,
PanelPermission.MANAGE_PLAYER_MANAGEMENT,
PanelPermission.VIEW_PLAYER_STATS,
PanelPermission.VIEW_LUCKPERMS,
PanelPermission.VIEW_AIRSTRIKE,
PanelPermission.MANAGE_AIRSTRIKE,
PanelPermission.VIEW_MAINTENANCE,
PanelPermission.MANAGE_MAINTENANCE,
PanelPermission.VIEW_WHITELIST,
PanelPermission.MANAGE_WHITELIST,
PanelPermission.VIEW_ANNOUNCEMENTS,
PanelPermission.MANAGE_ANNOUNCEMENTS
)),
VIEWER(EnumSet.of(
PanelPermission.ACCESS_PANEL,
PanelPermission.VIEW_OVERVIEW,
PanelPermission.VIEW_CONSOLE,
PanelPermission.VIEW_RESOURCES,
PanelPermission.VIEW_PLAYERS,
PanelPermission.VIEW_BANS,
PanelPermission.VIEW_PLUGINS,
PanelPermission.VIEW_THEMES,
PanelPermission.VIEW_BACKUPS,
PanelPermission.VIEW_REPORTS,
PanelPermission.VIEW_TICKETS,
PanelPermission.VIEW_PLAYER_MANAGEMENT,
PanelPermission.VIEW_PLAYER_STATS,
PanelPermission.VIEW_LUCKPERMS,
PanelPermission.VIEW_AIRSTRIKE,
PanelPermission.VIEW_MAINTENANCE,
PanelPermission.VIEW_WHITELIST,
PanelPermission.VIEW_ANNOUNCEMENTS
));
private final Set<PanelPermission> permissions;
UserRole(Set<PanelPermission> permissions) {
this.permissions = permissions;
}
public boolean hasPermission(PanelPermission permission) {
return permissions.contains(permission);
}
public Set<PanelPermission> defaultPermissions() {
return EnumSet.copyOf(permissions);
}
public static UserRole fromString(String raw) {
return UserRole.valueOf(raw.toUpperCase(Locale.ROOT));
}
}
@@ -0,0 +1,184 @@
package de.winniepat.minePanel.util;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitTask;
import java.lang.reflect.Method;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
public final class ServerSchedulerBridge {
private final JavaPlugin plugin;
private final boolean folia;
private final Object globalRegionScheduler;
private final Method globalRunMethod;
private final Method globalRunDelayedMethod;
private final Method globalRunAtFixedRateMethod;
private final Object asyncScheduler;
private final Method asyncRunNowMethod;
public ServerSchedulerBridge(JavaPlugin plugin) {
this.plugin = plugin;
Object resolvedGlobalScheduler = null;
Method resolvedGlobalRun = null;
Method resolvedGlobalRunDelayed = null;
Method resolvedGlobalRunAtFixedRate = null;
Object resolvedAsyncScheduler = null;
Method resolvedAsyncRunNow = null;
try {
resolvedGlobalScheduler = plugin.getServer().getClass().getMethod("getGlobalRegionScheduler").invoke(plugin.getServer());
resolvedGlobalRun = resolvedGlobalScheduler.getClass().getMethod("run", Plugin.class, Consumer.class);
resolvedGlobalRunDelayed = resolvedGlobalScheduler.getClass().getMethod("runDelayed", Plugin.class, Consumer.class, long.class);
resolvedGlobalRunAtFixedRate = resolvedGlobalScheduler.getClass().getMethod("runAtFixedRate", Plugin.class, Consumer.class, long.class, long.class);
resolvedAsyncScheduler = plugin.getServer().getClass().getMethod("getAsyncScheduler").invoke(plugin.getServer());
resolvedAsyncRunNow = resolvedAsyncScheduler.getClass().getMethod("runNow", Plugin.class, Consumer.class);
} catch (ReflectiveOperationException ignored) {
// Paper/Spigot fallback uses BukkitScheduler APIs.
}
this.globalRegionScheduler = resolvedGlobalScheduler;
this.globalRunMethod = resolvedGlobalRun;
this.globalRunDelayedMethod = resolvedGlobalRunDelayed;
this.globalRunAtFixedRateMethod = resolvedGlobalRunAtFixedRate;
this.asyncScheduler = resolvedAsyncScheduler;
this.asyncRunNowMethod = resolvedAsyncRunNow;
this.folia = this.globalRegionScheduler != null && this.globalRunMethod != null;
}
public boolean isFolia() {
return folia;
}
public CancellableTask runRepeatingGlobal(Runnable task, long initialDelayTicks, long periodTicks) {
if (!folia) {
BukkitTask bukkitTask = plugin.getServer().getScheduler().runTaskTimer(plugin, task, initialDelayTicks, periodTicks);
return bukkitTask::cancel;
}
if (globalRunDelayedMethod == null) {
try {
Object scheduledTask = globalRunAtFixedRateMethod.invoke(
globalRegionScheduler,
plugin,
(Consumer<Object>) ignored -> task.run(),
initialDelayTicks,
periodTicks
);
return () -> cancelFoliaTask(scheduledTask);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("could_not_schedule_global_repeating_task", exception);
}
}
long safeInitialDelay = Math.max(0L, initialDelayTicks);
long safePeriod = Math.max(1L, periodTicks);
AtomicBoolean cancelled = new AtomicBoolean(false);
AtomicReference<Object> scheduledTaskRef = new AtomicReference<>();
class FoliaRepeatingState {
void scheduleNext(long delayTicks) {
if (cancelled.get()) {
return;
}
try {
Object scheduledTask = globalRunDelayedMethod.invoke(
globalRegionScheduler,
plugin,
(Consumer<Object>) ignored -> {
if (cancelled.get()) {
return;
}
task.run();
scheduleNext(safePeriod);
},
Math.max(0L, delayTicks)
);
scheduledTaskRef.set(scheduledTask);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("could_not_schedule_global_repeating_task", exception);
}
}
}
FoliaRepeatingState repeatingState = new FoliaRepeatingState();
// Do not block startup waiting for first tick; on Folia this can deadlock when called during enable.
repeatingState.scheduleNext(safeInitialDelay);
return () -> {
cancelled.set(true);
cancelFoliaTask(scheduledTaskRef.get());
};
}
public void runGlobal(Runnable task) {
if (!folia) {
plugin.getServer().getScheduler().runTask(plugin, task);
return;
}
try {
globalRunMethod.invoke(globalRegionScheduler, plugin, (Consumer<Object>) ignored -> task.run());
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("could_not_schedule_global_task", exception);
}
}
public void runAsync(Runnable task) {
if (!folia || asyncScheduler == null || asyncRunNowMethod == null) {
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, task);
return;
}
try {
asyncRunNowMethod.invoke(asyncScheduler, plugin, (Consumer<Object>) ignored -> task.run());
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("could_not_schedule_async_task", exception);
}
}
public <T> T callGlobal(Callable<T> task, long timeout, TimeUnit timeUnit)
throws InterruptedException, ExecutionException, TimeoutException {
CompletableFuture<T> future = new CompletableFuture<>();
runGlobal(() -> {
try {
future.complete(task.call());
} catch (Throwable throwable) {
future.completeExceptionally(throwable);
}
});
return future.get(timeout, timeUnit);
}
private void cancelFoliaTask(Object task) {
if (task == null) {
return;
}
try {
Method cancelMethod = task.getClass().getMethod("cancel");
cancelMethod.invoke(task);
} catch (ReflectiveOperationException ignored) {
// Best-effort cancellation.
}
}
@FunctionalInterface
public interface CancellableTask {
void cancel();
}
}
@@ -0,0 +1,41 @@
package de.winniepat.minePanel.web;
import de.winniepat.minePanel.persistence.UserRepository;
import java.security.SecureRandom;
import java.util.*;
public final class BootstrapService {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final UserRepository userRepository;
private final String bootstrapToken;
public BootstrapService(UserRepository userRepository, int tokenLength) {
this.userRepository = userRepository;
this.bootstrapToken = generateToken(Math.max(16, tokenLength));
}
public boolean needsBootstrap() {
return userRepository.countUsers() == 0;
}
public Optional<String> getBootstrapToken() {
if (!needsBootstrap()) {
return Optional.empty();
}
return Optional.of(bootstrapToken);
}
public boolean verifyToken(String token) {
return needsBootstrap() && token != null && bootstrapToken.equals(token);
}
private String generateToken(int length) {
byte[] bytes = new byte[length];
SECURE_RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes).substring(0, length);
}
}
@@ -0,0 +1,22 @@
package de.winniepat.minePanel.web;
import java.io.*;
import java.nio.charset.StandardCharsets;
public final class ResourceLoader {
private ResourceLoader() {
}
public static String loadUtf8Text(String resourcePath) {
try (InputStream inputStream = ResourceLoader.class.getResourceAsStream(resourcePath)) {
if (inputStream == null) {
throw new IllegalStateException("Resource not found: " + resourcePath);
}
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException exception) {
throw new IllegalStateException("Could not read resource: " + resourcePath, exception);
}
}
}
@@ -0,0 +1,166 @@
package de.winniepat.minePanel.web;
import de.winniepat.minePanel.MinePanel;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
public final class WebAssetService {
private static final List<String> BUNDLED_WEB_FILES = List.of(
"dashboard-account.html",
"dashboard-announcements.html",
"dashboard-bans.html",
"dashboard-console.html",
"dashboard-discord-webhook.html",
"dashboard-extension-config.html",
"dashboard-extensions.html",
"dashboard-maintenance.html",
"dashboard-overview.html",
"dashboard-players.html",
"dashboard-plugins.html",
"dashboard-reports.html",
"dashboard-resources.html",
"dashboard-tickets.html",
"dashboard-world-backups.html",
"dashboard-whitelist.html",
"dashboard-themes.html",
"dashboard-users.html",
"login.html",
"panel.css",
"setup.html",
"theme.js"
);
private static final String LEGACY_EXTENSIONS_DOWNLOAD_MARKER = "href=\"${downloadUrl}\"";
private static final String CURRENT_EXTENSIONS_INSTALL_MARKER = "data-install-extension";
private static final long VERSION_CACHE_TTL_MILLIS = TimeUnit.SECONDS.toMillis(2);
private final MinePanel plugin;
private final Path webDirectory;
private final Map<String, CachedTextAsset> cachedTextAssets = new ConcurrentHashMap<>();
private volatile long cachedVersion = 0L;
private volatile long cachedVersionExpiresAt = 0L;
public WebAssetService(MinePanel plugin, Path webDirectory) {
this.plugin = plugin;
this.webDirectory = webDirectory;
}
public void ensureSeeded() {
try {
Files.createDirectories(webDirectory);
} catch (IOException exception) {
throw new IllegalStateException("Could not create web directory: " + webDirectory, exception);
}
for (String fileName : BUNDLED_WEB_FILES) {
Path target = webDirectory.resolve(fileName).normalize();
if (!target.startsWith(webDirectory)) {
continue;
}
if (Files.exists(target)) {
migrateLegacyAssets(fileName, target);
continue;
}
plugin.saveResource("web/" + fileName, false);
}
}
private void migrateLegacyAssets(String fileName, Path target) {
if (!"dashboard-extensions.html".equals(fileName)) {
return;
}
try {
String current = Files.readString(target, StandardCharsets.UTF_8);
if (current.contains(CURRENT_EXTENSIONS_INSTALL_MARKER)) {
return;
}
if (!current.contains(LEGACY_EXTENSIONS_DOWNLOAD_MARKER)) {
return;
}
String bundled = ResourceLoader.loadUtf8Text("/web/" + fileName);
Files.writeString(target, bundled, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
plugin.getLogger().info("Updated legacy web template: " + fileName);
} catch (Exception exception) {
plugin.getLogger().warning("Could not migrate legacy web template " + fileName + ": " + exception.getMessage());
}
}
public String readText(String fileName) {
Path target = resolveFile(fileName);
try {
long lastModified = Files.exists(target) ? Files.getLastModifiedTime(target).toMillis() : 0L;
CachedTextAsset cached = cachedTextAssets.get(fileName);
if (cached != null && cached.lastModifiedMillis() == lastModified) {
return cached.content();
}
String content = Files.readString(target, StandardCharsets.UTF_8);
cachedTextAssets.put(fileName, new CachedTextAsset(lastModified, content));
return content;
} catch (IOException exception) {
throw new IllegalStateException("Could not read web asset: " + fileName, exception);
}
}
public long currentVersion() {
long now = System.currentTimeMillis();
if (now < cachedVersionExpiresAt) {
return cachedVersion;
}
long newest = 0L;
for (String fileName : BUNDLED_WEB_FILES) {
Path target = resolveFile(fileName);
if (!Files.exists(target)) {
continue;
}
try {
newest = Math.max(newest, Files.getLastModifiedTime(target).toMillis());
} catch (IOException ignored) {
// Ignore broken files so the rest of the panel can continue serving assets.
}
}
cachedVersion = newest;
cachedVersionExpiresAt = now + VERSION_CACHE_TTL_MILLIS;
return newest;
}
public long lastModifiedMillis(String fileName) {
Path target = resolveFile(fileName);
try {
if (!Files.exists(target)) {
return 0L;
}
return Files.getLastModifiedTime(target).toMillis();
} catch (IOException exception) {
return 0L;
}
}
private Path resolveFile(String fileName) {
Path target = webDirectory.resolve(fileName).normalize();
if (!target.startsWith(webDirectory)) {
throw new IllegalArgumentException("Invalid web asset path: " + fileName);
}
return target;
}
public Path webDirectory() {
return webDirectory;
}
private record CachedTextAsset(long lastModifiedMillis, String content) {
}
}
File diff suppressed because it is too large Load Diff