forked from winnie/MinePanel
first commit
This commit is contained in:
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
+160
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+188
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
+173
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+141
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
+308
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+164
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+112
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+182
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+145
@@ -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
Reference in New Issue
Block a user