diff --git a/build.gradle.kts b/build.gradle.kts index f966ddb..f2bbd04 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,6 +25,8 @@ dependencies { compileOnly("me.clip:placeholderapi:2.11.6") implementation("com.github.cryptomorin:XSeries:13.7.0") + implementation("com.zaxxer:HikariCP:4.0.3") + implementation("com.mysql:mysql-connector-j:8.0.33") implementation("net.kyori:adventure-text-minimessage:4.17.0") implementation("net.kyori:adventure-text-serializer-legacy:4.17.0") @@ -51,6 +53,7 @@ tasks { withType { relocate("com.cryptomorin.xseries", "me.clip.deluxetags.libs.xseries") + relocate("com.zaxxer.hikari", "me.clip.deluxetags.libs.hikari") relocate("net.kyori", "me.clip.deluxetags.libs.kyori") archiveFileName.set("DeluxeTags-${project.version}.jar") } diff --git a/src/main/java/me/clip/deluxetags/DeluxeTags.java b/src/main/java/me/clip/deluxetags/DeluxeTags.java index c897e12..1b6abc0 100644 --- a/src/main/java/me/clip/deluxetags/DeluxeTags.java +++ b/src/main/java/me/clip/deluxetags/DeluxeTags.java @@ -1,7 +1,13 @@ package me.clip.deluxetags; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; import me.clip.deluxetags.commands.TagCommand; import me.clip.deluxetags.config.ConfigWrapper; @@ -15,6 +21,10 @@ import me.clip.deluxetags.listeners.JoinListener; import me.clip.deluxetags.listeners.PlayerListener; import me.clip.deluxetags.placeholders.TagPlaceholders; +import me.clip.deluxetags.storage.MysqlPlayerDataStorage; +import me.clip.deluxetags.storage.PlayerData; +import me.clip.deluxetags.storage.PlayerDataStorage; +import me.clip.deluxetags.storage.YamlPlayerDataStorage; import me.clip.deluxetags.tags.DeluxeTag; import me.clip.deluxetags.tags.DeluxeTagCategory; import me.clip.deluxetags.tags.DeluxeTagsHandler; @@ -42,6 +52,9 @@ public class DeluxeTags extends JavaPlugin { private TagConfig cfg; private ConfigWrapper messages; private ConfigWrapper playerFile; + private PlayerDataStorage playerDataStorage; + private final Map playerDataCache = new ConcurrentHashMap<>(); + private ExecutorService storageExecutor; private GUIHandler guiHandler; private GUIOptions guiOptions; private BukkitTask cleanupTask = null; @@ -68,6 +81,25 @@ public void onEnable() { playerFile = new ConfigWrapper(this, "userdata", "player_tags.yml"); playerFile.createNewFile(null, "DeluxeTags player_tags.yml\nDo not edit this file!"); + + try { + playerDataStorage = createPlayerDataStorage(); + playerDataStorage.initialize(); + getLogger().info("Player data storage initialized: " + (cfg.mysqlStorageEnabled() ? "MYSQL" : "YAML")); + } catch (Exception exception) { + getLogger().log(Level.SEVERE, "Could not initialize MySQL player data storage. Falling back to YAML", exception); + if (playerDataStorage != null) { + playerDataStorage.close(); + } + playerDataStorage = new YamlPlayerDataStorage(playerFile); + try { + playerDataStorage.initialize(); + } catch (Exception fallbackException) { + getLogger().log(Level.SEVERE, "Could not initialize fallback YAML player data storage", fallbackException); + Bukkit.getPluginManager().disablePlugin(this); + return; + } + } cleanupTask = Bukkit.getScheduler().runTaskTimerAsynchronously(this, new CleanupTask(this), 20L*300, 20L*300); @@ -82,9 +114,7 @@ public void onEnable() { Bukkit.getPluginManager().registerEvents(new PlayerListener(this), this); - if (cfg.loadTagOnJoin()) { - Bukkit.getPluginManager().registerEvents(new JoinListener(this), this); - } + Bukkit.getPluginManager().registerEvents(new JoinListener(this), this); messages = new ConfigWrapper(this, null, "messages.yml"); @@ -125,6 +155,11 @@ public void onDisable() { } this.getTagsHandler().unloadData(); + shutdownStorageExecutor(); + if (playerDataStorage != null) { + playerDataStorage.close(); + } + playerDataCache.clear(); TagGUI.unload(); guiOptions = null; @@ -163,45 +198,111 @@ public static boolean papi() { } public String getSavedTagIdentifier(String uuid) { - FileConfiguration c = playerFile.getConfig(); - if (c.contains(uuid) && c.isString(uuid) && c.getString(uuid) != null) { - return c.getString(uuid); - } - return null; + PlayerData playerData = playerDataCache.get(UUID.fromString(uuid)); + return playerData == null ? null : playerData.getTagIdentifier(); } public void saveTagIdentifier(String uuid, String tagIdentifier) { - FileConfiguration c = playerFile.getConfig(); - c.set(uuid, tagIdentifier); - playerFile.saveConfig(); + UUID playerUuid = UUID.fromString(uuid); + PlayerData oldData = playerDataCache.get(playerUuid); + PlayerData playerData = new PlayerData(tagIdentifier, oldData == null ? null : oldData.getData()); + playerDataCache.put(playerUuid, playerData); + runStorageTask(() -> playerDataStorage.save(playerUuid, playerData)); } public void removeSavedTag(String uuid) { - FileConfiguration c = playerFile.getConfig(); - if (c.contains(uuid)) { - c.set(uuid, null); - playerFile.saveConfig(); - } + UUID playerUuid = UUID.fromString(uuid); + playerDataCache.remove(playerUuid); + runStorageTask(() -> playerDataStorage.delete(playerUuid)); } public void removeSavedTags(List uuids) { - boolean requiresSave = false; - FileConfiguration config = playerFile.getConfig(); - if (uuids == null || uuids.isEmpty()) { return; } for (UUID uuid : uuids) { - if (config.contains(uuid.toString())) { - config.set(uuid.toString(), null); - requiresSave = true; + playerDataCache.remove(uuid); + runStorageTask(() -> playerDataStorage.delete(uuid)); + } + } + + public void loadPlayerData(Player player) { + UUID uuid = player.getUniqueId(); + runStorageTask(() -> { + PlayerData playerData = playerDataStorage.load(uuid); + if (playerData == null) { + playerDataCache.remove(uuid); + } else { + playerDataCache.put(uuid, playerData); } + if (!cfg.loadTagOnJoin()) { + return; + } + Bukkit.getScheduler().runTask(this, () -> { + Player onlinePlayer = Bukkit.getPlayer(uuid); + if (onlinePlayer != null) { + getTagsHandler().updateTagForPlayer(onlinePlayer); + } + }); + }); + } + + public String getPlayerData(UUID uuid) { + PlayerData playerData = playerDataCache.get(uuid); + return playerData == null ? null : playerData.getData(); + } + + public void savePlayerData(UUID uuid, String data) { + PlayerData oldData = playerDataCache.get(uuid); + PlayerData playerData = new PlayerData(oldData == null ? null : oldData.getTagIdentifier(), data); + playerDataCache.put(uuid, playerData); + runStorageTask(() -> playerDataStorage.save(uuid, playerData)); + } + + private PlayerDataStorage createPlayerDataStorage() { + if (cfg.mysqlStorageEnabled()) { + return new MysqlPlayerDataStorage(getConfig().getConfigurationSection("storage.mysql")); + } + return new YamlPlayerDataStorage(playerFile); + } + + private void runStorageTask(StorageTask task) { + if (storageExecutor == null) { + storageExecutor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "DeluxeTags-Storage"); + thread.setDaemon(true); + return thread; + }); } + storageExecutor.execute(() -> { + try { + task.run(); + } catch (Exception exception) { + getLogger().log(Level.SEVERE, "Player data storage operation failed", exception); + } + }); + } - if (requiresSave) { - playerFile.saveConfig(); + private void shutdownStorageExecutor() { + if (storageExecutor == null) { + return; } + storageExecutor.shutdown(); + try { + if (!storageExecutor.awaitTermination(10, TimeUnit.SECONDS)) { + getLogger().warning("Timed out while waiting for player data to save"); + storageExecutor.shutdownNow(); + } + } catch (InterruptedException exception) { + storageExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + storageExecutor = null; + } + + private interface StorageTask { + void run() throws Exception; } public void loadMessages() { diff --git a/src/main/java/me/clip/deluxetags/config/TagConfig.java b/src/main/java/me/clip/deluxetags/config/TagConfig.java index 06ea35e..2ab671d 100644 --- a/src/main/java/me/clip/deluxetags/config/TagConfig.java +++ b/src/main/java/me/clip/deluxetags/config/TagConfig.java @@ -38,6 +38,7 @@ public class TagConfig { "papi_chat", "format_chat", "load_tag_on_join", + "storage", "gui", "categories", "deluxetags" @@ -141,6 +142,15 @@ public void loadDefConfig() { config.set("force_tag_on_join", null); } addDefault("load_tag_on_join", true); + addDefault("storage.mysql.enabled", false); + addDefault("storage.mysql.host", "localhost"); + addDefault("storage.mysql.port", 3306); + addDefault("storage.mysql.database", "deluxetags"); + addDefault("storage.mysql.username", "root"); + addDefault("storage.mysql.password", "password"); + addDefault("storage.mysql.table_prefix", "deluxetags_"); + addDefault("storage.mysql.use_ssl", false); + addDefault("storage.mysql.pool_size", 10); migrateTagAvailabilityPlaceholder(config); @@ -283,10 +293,15 @@ private void addDefault(String path, Object value) { } static void applySectionComments(FileConfiguration config) { - config.setComments("use_minimessage", Collections.singletonList("Main Options")); - config.setComments("gui", Arrays.asList(null, "GUI layout and buttons")); - config.setComments("categories", Arrays.asList(null, "Tag category menus")); - config.setComments("deluxetags", Arrays.asList(null, "Tags")); + try { + java.lang.reflect.Method setComments = config.getClass().getMethod("setComments", String.class, List.class); + setComments.invoke(config, "use_minimessage", Collections.singletonList("Main Options")); + setComments.invoke(config, "gui", Arrays.asList(null, "GUI layout and buttons")); + setComments.invoke(config, "categories", Arrays.asList(null, "Tag category menus")); + setComments.invoke(config, "deluxetags", Arrays.asList(null, "Tags")); + } catch (ReflectiveOperationException ignored) { + // Configuration comments are unavailable on legacy Bukkit versions. + } } static void migrateTagAvailabilityPlaceholder(FileConfiguration config) { @@ -448,6 +463,10 @@ public boolean loadTagOnJoin() { return config.getBoolean("load_tag_on_join"); } + public boolean mysqlStorageEnabled() { + return config.getBoolean("storage.mysql.enabled", false); + } + public boolean forceTags() { return config.getBoolean("force_tags"); } diff --git a/src/main/java/me/clip/deluxetags/listeners/JoinListener.java b/src/main/java/me/clip/deluxetags/listeners/JoinListener.java index e279639..c701048 100644 --- a/src/main/java/me/clip/deluxetags/listeners/JoinListener.java +++ b/src/main/java/me/clip/deluxetags/listeners/JoinListener.java @@ -16,6 +16,6 @@ public JoinListener(DeluxeTags instance) { @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onChat(final PlayerJoinEvent event) { - plugin.getTagsHandler().updateTagForPlayer(event.getPlayer()); + plugin.loadPlayerData(event.getPlayer()); } } \ No newline at end of file diff --git a/src/main/java/me/clip/deluxetags/storage/MysqlPlayerDataStorage.java b/src/main/java/me/clip/deluxetags/storage/MysqlPlayerDataStorage.java new file mode 100644 index 0000000..4c25fc3 --- /dev/null +++ b/src/main/java/me/clip/deluxetags/storage/MysqlPlayerDataStorage.java @@ -0,0 +1,97 @@ +package me.clip.deluxetags.storage; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.bukkit.configuration.ConfigurationSection; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.UUID; + +public class MysqlPlayerDataStorage implements PlayerDataStorage { + + private final ConfigurationSection config; + private final String tableName; + private HikariDataSource dataSource; + + public MysqlPlayerDataStorage(ConfigurationSection config) { + this.config = config; + this.tableName = validateTableName(config.getString("table_prefix", "deluxetags_") + "player_data"); + } + + @Override + public void initialize() throws Exception { + HikariConfig hikariConfig = new HikariConfig(); + String host = config.getString("host", "localhost"); + int port = config.getInt("port", 3306); + String database = config.getString("database", "deluxetags"); + boolean useSsl = config.getBoolean("use_ssl", false); + hikariConfig.setJdbcUrl("jdbc:mysql://" + host + ":" + port + "/" + database + + "?useSSL=" + useSsl + "&allowPublicKeyRetrieval=true&serverTimezone=UTC&characterEncoding=utf8"); + hikariConfig.setUsername(config.getString("username", "root")); + hikariConfig.setPassword(config.getString("password", "password")); + hikariConfig.setMaximumPoolSize(Math.max(1, config.getInt("pool_size", 10))); + hikariConfig.setMinimumIdle(1); + hikariConfig.setPoolName("DeluxeTags-MySQL"); + hikariConfig.setConnectionTimeout(10000L); + dataSource = new HikariDataSource(hikariConfig); + + try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { + statement.executeUpdate("CREATE TABLE IF NOT EXISTS `" + tableName + "` (" + + "`player_uuid` CHAR(36) NOT NULL," + + "`tag_identifier` VARCHAR(255) NULL," + + "`data` JSON NULL," + + "`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + + "PRIMARY KEY (`player_uuid`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + } + } + + @Override + public PlayerData load(UUID uuid) throws Exception { + String sql = "SELECT `tag_identifier`, `data` FROM `" + tableName + "` WHERE `player_uuid` = ?"; + try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + try (ResultSet result = statement.executeQuery()) { + return result.next() ? new PlayerData(result.getString("tag_identifier"), result.getString("data")) : null; + } + } + } + + @Override + public void save(UUID uuid, PlayerData playerData) throws Exception { + String sql = "INSERT INTO `" + tableName + "` (`player_uuid`, `tag_identifier`, `data`) VALUES (?, ?, ?) " + + "ON DUPLICATE KEY UPDATE `tag_identifier` = VALUES(`tag_identifier`), `data` = VALUES(`data`)"; + try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + statement.setString(2, playerData.getTagIdentifier()); + statement.setString(3, playerData.getData()); + statement.executeUpdate(); + } + } + + @Override + public void delete(UUID uuid) throws Exception { + String sql = "DELETE FROM `" + tableName + "` WHERE `player_uuid` = ?"; + try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + statement.executeUpdate(); + } + } + + @Override + public void close() { + if (dataSource != null) { + dataSource.close(); + } + } + + private static String validateTableName(String tableName) { + if (!tableName.matches("[A-Za-z0-9_]+")) { + throw new IllegalArgumentException("MySQL table prefix may only contain letters, numbers, and underscores"); + } + return tableName; + } +} \ No newline at end of file diff --git a/src/main/java/me/clip/deluxetags/storage/PlayerData.java b/src/main/java/me/clip/deluxetags/storage/PlayerData.java new file mode 100644 index 0000000..ebf5860 --- /dev/null +++ b/src/main/java/me/clip/deluxetags/storage/PlayerData.java @@ -0,0 +1,20 @@ +package me.clip.deluxetags.storage; + +public class PlayerData { + + private final String tagIdentifier; + private final String data; + + public PlayerData(String tagIdentifier, String data) { + this.tagIdentifier = tagIdentifier; + this.data = data; + } + + public String getTagIdentifier() { + return tagIdentifier; + } + + public String getData() { + return data; + } +} \ No newline at end of file diff --git a/src/main/java/me/clip/deluxetags/storage/PlayerDataStorage.java b/src/main/java/me/clip/deluxetags/storage/PlayerDataStorage.java new file mode 100644 index 0000000..6c3607d --- /dev/null +++ b/src/main/java/me/clip/deluxetags/storage/PlayerDataStorage.java @@ -0,0 +1,17 @@ +package me.clip.deluxetags.storage; + +import java.util.UUID; + +public interface PlayerDataStorage extends AutoCloseable { + + void initialize() throws Exception; + + PlayerData load(UUID uuid) throws Exception; + + void save(UUID uuid, PlayerData playerData) throws Exception; + + void delete(UUID uuid) throws Exception; + + @Override + void close(); +} \ No newline at end of file diff --git a/src/main/java/me/clip/deluxetags/storage/YamlPlayerDataStorage.java b/src/main/java/me/clip/deluxetags/storage/YamlPlayerDataStorage.java new file mode 100644 index 0000000..6f234cb --- /dev/null +++ b/src/main/java/me/clip/deluxetags/storage/YamlPlayerDataStorage.java @@ -0,0 +1,43 @@ +package me.clip.deluxetags.storage; + +import me.clip.deluxetags.config.ConfigWrapper; +import org.bukkit.configuration.file.FileConfiguration; + +import java.util.UUID; + +public class YamlPlayerDataStorage implements PlayerDataStorage { + + private final ConfigWrapper playerFile; + + public YamlPlayerDataStorage(ConfigWrapper playerFile) { + this.playerFile = playerFile; + } + + @Override + public void initialize() { + } + + @Override + public synchronized PlayerData load(UUID uuid) { + FileConfiguration config = playerFile.getConfig(); + String path = uuid.toString(); + String tagIdentifier = config.isString(path) ? config.getString(path) : null; + return tagIdentifier == null ? null : new PlayerData(tagIdentifier, null); + } + + @Override + public synchronized void save(UUID uuid, PlayerData playerData) { + playerFile.getConfig().set(uuid.toString(), playerData.getTagIdentifier()); + playerFile.saveConfig(); + } + + @Override + public synchronized void delete(UUID uuid) { + playerFile.getConfig().set(uuid.toString(), null); + playerFile.saveConfig(); + } + + @Override + public void close() { + } +} \ No newline at end of file